From b66f2b121f0a561180e1e9a2e4f399725d4a670b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Garapich?= Date: Sun, 1 Dec 2024 02:59:42 +0100 Subject: [PATCH] feat: mumble integration initial --- .github/workflows/test.yml | 16 +++ Dockerfile | 1 + docker-compose.yml | 10 ++ package.json | 4 + pnpm-lock.yaml | 108 ++++++++++++++++++ src/admin/discord/index.ts | 2 +- src/admin/games/index.ts | 2 +- src/admin/index.ts | 4 + src/admin/map-pool/index.ts | 2 +- src/admin/player-restrictions/index.ts | 2 +- src/admin/plugins/sync-clients.ts | 9 ++ .../{plugins => }/standard-admin-page.ts | 4 +- src/admin/voice-server/index.ts | 2 +- .../views/html/mumble-client-status.tsx | 26 +++++ .../views/html/voice-server.page.tsx | 17 ++- src/certificates/get.ts | 32 ++++++ src/certificates/index.ts | 5 + src/configuration/reset.ts | 2 + src/configuration/set.ts | 2 + src/database/collections.ts | 2 + src/database/models/certificate.model.ts | 5 + src/database/models/game-slot.model.ts | 1 + src/events.ts | 10 ++ src/games/plugins/sync-clients.ts | 17 ++- src/games/views/html/connect-info.tsx | 2 + src/games/views/html/game-list-item.tsx | 4 +- src/games/views/html/join-game-button.tsx | 8 ++ src/games/views/html/join-voice-button.tsx | 44 +++++++ src/keys/get.ts | 2 +- src/main.ts | 6 +- src/mumble/assert-client-is-connected.ts | 9 ++ src/mumble/client.ts | 71 ++++++++++++ src/mumble/index.ts | 18 +++ src/mumble/link-channels.ts | 26 +++++ src/mumble/move-to-target-channel.ts | 24 ++++ src/mumble/mumble-direct-url.ts | 33 ++++++ src/mumble/plugins/create-channels.ts | 54 +++++++++ src/mumble/plugins/link-channels.ts | 21 ++++ src/mumble/plugins/reconnect-client.ts | 34 ++++++ src/mumble/setup-game-channels.ts | 43 +++++++ src/mumble/status.ts | 19 +++ src/version.ts | 13 +++ tests/20-game/10-setup-mumble-server.spec.ts | 28 +++++ tests/fixtures/configure-mumble-server.ts | 52 +++++++++ tests/mumble-data/mumble-server.sqlite | Bin 0 -> 135168 bytes tests/mumble-data/mumble_server_config.ini | 12 ++ tests/pages/admin.page.ts | 12 +- tests/pages/game.page.ts | 4 + 48 files changed, 807 insertions(+), 17 deletions(-) create mode 100644 src/admin/plugins/sync-clients.ts rename src/admin/{plugins => }/standard-admin-page.ts (92%) create mode 100644 src/admin/voice-server/views/html/mumble-client-status.tsx create mode 100644 src/certificates/get.ts create mode 100644 src/certificates/index.ts create mode 100644 src/database/models/certificate.model.ts create mode 100644 src/games/views/html/join-voice-button.tsx create mode 100644 src/mumble/assert-client-is-connected.ts create mode 100644 src/mumble/client.ts create mode 100644 src/mumble/index.ts create mode 100644 src/mumble/link-channels.ts create mode 100644 src/mumble/move-to-target-channel.ts create mode 100644 src/mumble/mumble-direct-url.ts create mode 100644 src/mumble/plugins/create-channels.ts create mode 100644 src/mumble/plugins/link-channels.ts create mode 100644 src/mumble/plugins/reconnect-client.ts create mode 100644 src/mumble/setup-game-channels.ts create mode 100644 src/mumble/status.ts create mode 100644 src/version.ts create mode 100644 tests/20-game/10-setup-mumble-server.spec.ts create mode 100644 tests/fixtures/configure-mumble-server.ts create mode 100644 tests/mumble-data/mumble-server.sqlite create mode 100644 tests/mumble-data/mumble_server_config.ini diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 149f3734..1c995a80 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -106,6 +106,21 @@ jobs: - uses: pnpm/action-setup@v2 with: version: 8 + + - name: Start mumble server + run: | + docker run \ + --rm --detach \ + --name mumble-server \ + --network=${{ job.services.mongo.network }} \ + -p 64738:64738/tcp \ + -p 64738:64738/udp \ + -e MUMBLE_CONFIG_AUTOBAN_ATTEMPTS=0 \ + -e MUMBLE_SUPERUSER_PASSWORD=123456 \ + --volume ${{ github.workspace }}/tests/mumble-data:/data \ + --user root \ + mumblevoip/mumble-server:latest + - uses: actions/setup-node@v4 with: node-version: 20 @@ -161,6 +176,7 @@ jobs: env: STEAM_USERNAME: ${{ secrets.TEST_USER_USERNAME }} STEAM_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }} + TEST_MUMBLE_SERVER_HOST: 'localhost' - name: Stop app if: ${{ always() }} diff --git a/Dockerfile b/Dockerfile index 4a5cbb63..f457d369 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile RUN pnpm run build FROM base +RUN apt update && apt install -y --no-install-recommends openssl COPY package.json /app COPY --from=prod-deps /app/node_modules /app/node_modules COPY --from=build /app/dist /app/dist diff --git a/docker-compose.yml b/docker-compose.yml index 5fd7e426..da5a7408 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,5 +23,15 @@ services: profiles: - dev + mumble: + image: mumblevoip/mumble-server:latest + environment: + - MUMBLE_SUPERUSER_PASSWORD=123456 + ports: + - 64738:64738/tcp + - 64738:64738/udp + volumes: + - ./tests/mumble-data/:/data:rw + volumes: mongo: diff --git a/package.json b/package.json index 711ed2e8..4766bc7f 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@kitajs/ts-html-plugin": "4.1.1", "@tailwindcss/typography": "0.5.15", "@tf2pickup-org/serveme-tf-client": "0.1.2", + "@tf2pickup-org/mumble-client": "0.8.2", "async-mutex": "0.5.0", "autoprefixer": "10.4.20", "country-flag-icons": "1.5.13", @@ -48,6 +49,8 @@ "mongodb": "6.12.0", "nanoid": "5.0.9", "openid": "2.0.12", + "package-up": "5.0.0", + "pem": "1.14.8", "pino": "9.6.0", "postcss": "8.4.49", "postcss-import": "16.1.0", @@ -70,6 +73,7 @@ "@types/lodash-es": "4.17.12", "@types/node": "22.10.2", "@types/openid": "2.0.5", + "@types/pem": "1.14.4", "@types/postcss-import": "14.0.3", "@types/steamid": "2.0.3", "@types/ws": "8.5.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e281b734..5c2a65a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@tailwindcss/typography': specifier: 0.5.15 version: 0.5.15(tailwindcss@3.4.17) + '@tf2pickup-org/mumble-client': + specifier: 0.8.2 + version: 0.8.2 '@tf2pickup-org/serveme-tf-client': specifier: 0.1.2 version: 0.1.2 @@ -104,6 +107,12 @@ importers: openid: specifier: 2.0.12 version: 2.0.12 + package-up: + specifier: 5.0.0 + version: 5.0.0 + pem: + specifier: 1.14.8 + version: 1.14.8 pino: specifier: 9.6.0 version: 9.6.0 @@ -165,6 +174,9 @@ importers: '@types/openid': specifier: 2.0.5 version: 2.0.5 + '@types/pem': + specifier: 1.14.4 + version: 1.14.4 '@types/postcss-import': specifier: 14.0.3 version: 14.0.3 @@ -698,6 +710,9 @@ packages: engines: {node: '>=18'} hasBin: true + '@protobuf-ts/runtime@2.9.4': + resolution: {integrity: sha512-vHRFWtJJB/SiogWDF0ypoKfRIZ41Kq+G9cEFj6Qm1eQaAhJ1LDFvgZ7Ja4tb3iLOQhz0PaoPnnOijF1qmEqTxg==} + '@rollup/rollup-android-arm-eabi@4.25.0': resolution: {integrity: sha512-CC/ZqFZwlAIbU1wUPisHyV/XRc5RydFrNLtgl3dGYskdwPZdt4HERtKm50a/+DtTlKeCq9IXFEWR+P6blwjqBA==} cpu: [arm] @@ -816,6 +831,12 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20' + '@tf2pickup-org/mumble-client@0.8.2': + resolution: {integrity: sha512-MCus1YyYWRJLcWFjhtSHr0I9MSe+cdKW8s61lTYOVBapEkrtsB648RHQuXR/ILOxJrC03E3yz5PjTcVxXuJNIQ==} + + '@tf2pickup-org/mumble-protocol@1.0.5': + resolution: {integrity: sha512-nqFc6QskL6+w+A6w3j2Pa5na7Xy9Yij6bM0RUtnL+5t59QAFrBSHASHbdS0Yhdb9xhAApTrwcxob590oQhwy2g==} + '@tf2pickup-org/serveme-tf-client@0.1.2': resolution: {integrity: sha512-cVEEbUwfc9ooVHLsjirXzHgWfzwjJ7seXy6blzxgKv2KpcB59iGDyzQq80Hu4b6C6OXOQctQCndfZqupKTednQ==} @@ -859,6 +880,9 @@ packages: '@types/openid@2.0.5': resolution: {integrity: sha512-8IAPfHrjtytw4/SLInJ9ZUE3JE/GuoPhoXZlG/uCeCXOOvLMM0FlKS95IIiXn8dW6fCfzJD2Rlvmv+zTgGxANw==} + '@types/pem@1.14.4': + resolution: {integrity: sha512-Xt6qY6kX1RD4UmYNhWCCf3OSJrRcwbQIaJ/mQSjjAHxIjXMHx/vHNPOgEU3HdVKS1k/U5CZ6ClQlRo8egkl8xg==} + '@types/postcss-import@14.0.3': resolution: {integrity: sha512-raZhRVTf6Vw5+QbmQ7LOHSDML71A5rj4+EqDzAbrZPfxfoGzFxMHRCq16VlddGIZpHELw0BG4G0YE2ANkdZiIQ==} @@ -1128,6 +1152,9 @@ packages: resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + charenc@0.0.2: + resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + chart.js@4.4.7: resolution: {integrity: sha512-pwkcKfdzTMAU/+jNosKhNL2bHtJc/sSmYgVbuGTEDhzkrhmyihmP7vUc/5ZK9WopidMDHNe3Wm7jOd/WhuHWuw==} engines: {pnpm: '>=8'} @@ -1241,6 +1268,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crypt@0.0.2: + resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} + css-declaration-sorter@7.2.0: resolution: {integrity: sha512-h70rUM+3PNFuaBDTLe8wF/cdWu+dOZmb7pJt8Z2sedYbAcQVQV/tEchueg3GWxwqS0cxtbxmaHEdkNACqcvsow==} engines: {node: ^14 || ^16 || >=18} @@ -1412,6 +1442,10 @@ packages: es-module-lexer@1.5.4: resolution: {integrity: sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==} + es6-promisify@7.0.0: + resolution: {integrity: sha512-ginqzK3J90Rd4/Yz7qRrqUeIpe3TwSXTPPZtPne7tGBPeAaQiU8qt4fpKApnxHcq1AwtUdHVg5P77x/yrggG8Q==} + engines: {node: '>=6'} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -1552,6 +1586,10 @@ packages: resolution: {integrity: sha512-/5NN/R0pFWuff16TMajeKt2JyiW+/OE8nOO8vo1DwZTxLaIURb7lcBYPIgRPh61yCNh9l8voeKwcrkUzmB00vw==} engines: {node: '>=14'} + find-up-simple@1.0.0: + resolution: {integrity: sha512-q7Us7kcjj2VMePAa02hDAF6d+MzsdsAWEwYyOpwUtlerRBkOEPBCRZrAV4XfcSN8fHAgaD0hP7miwoay6DCprw==} + engines: {node: '>=18'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1754,6 +1792,9 @@ packages: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} + is-buffer@1.1.6: + resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} + is-core-module@2.15.0: resolution: {integrity: sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==} engines: {node: '>= 0.4'} @@ -1949,6 +1990,9 @@ packages: engines: {node: '>= 18'} hasBin: true + md5@2.3.0: + resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==} + mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} @@ -2120,6 +2164,10 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + p-event@5.0.1: resolution: {integrity: sha512-dd589iCQ7m1L0bmC5NLlVYfy3TbBEsMUfWx9PyAgPeIcFZ/E2yaTZ4Rz4MiBmmJShviiftHVXOqfnfzJ6kyMrQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2151,6 +2199,10 @@ packages: package-json-from-dist@1.0.0: resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==} + package-up@5.0.0: + resolution: {integrity: sha512-MQEgDUvXCa3sGvqHg3pzHO8e9gqTCMPVrWUko3vPQGntwegmFo52mZb2abIVTjFnUcW0BcPz0D93jV5Cas1DWA==} + engines: {node: '>=18'} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2206,6 +2258,10 @@ packages: resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} engines: {node: '>= 14.16'} + pem@1.14.8: + resolution: {integrity: sha512-ZpbOf4dj9/fQg5tQzTqv4jSKJQsK7tPl0pm4/pvPcZVjZcJg7TMfr3PBk6gJH97lnpJDu4e4v8UUqEz5daipCg==} + engines: {node: '>=14.0.0'} + picocolors@1.0.1: resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} @@ -2668,6 +2724,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -3503,6 +3562,8 @@ snapshots: dependencies: playwright: 1.49.1 + '@protobuf-ts/runtime@2.9.4': {} + '@rollup/rollup-android-arm-eabi@4.25.0': optional: true @@ -3596,6 +3657,16 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.17 + '@tf2pickup-org/mumble-client@0.8.2': + dependencies: + '@protobuf-ts/runtime': 2.9.4 + '@tf2pickup-org/mumble-protocol': 1.0.5 + rxjs: 7.8.1 + + '@tf2pickup-org/mumble-protocol@1.0.5': + dependencies: + '@protobuf-ts/runtime': 2.9.4 + '@tf2pickup-org/serveme-tf-client@0.1.2': dependencies: date-fns: 4.1.0 @@ -3638,6 +3709,10 @@ snapshots: '@types/openid@2.0.5': {} + '@types/pem@1.14.4': + dependencies: + '@types/node': 22.10.2 + '@types/postcss-import@14.0.3': dependencies: postcss: 8.4.49 @@ -3950,6 +4025,8 @@ snapshots: chalk@5.3.0: {} + charenc@0.0.2: {} + chart.js@4.4.7: dependencies: '@kurkle/color': 0.3.2 @@ -4073,6 +4150,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypt@0.0.2: {} + css-declaration-sorter@7.2.0(postcss@8.4.49): dependencies: postcss: 8.4.49 @@ -4246,6 +4325,8 @@ snapshots: es-module-lexer@1.5.4: {} + es6-promisify@7.0.0: {} + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -4469,6 +4550,8 @@ snapshots: fast-querystring: 1.1.2 safe-regex2: 4.0.0 + find-up-simple@1.0.0: {} + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -4668,6 +4751,8 @@ snapshots: dependencies: binary-extensions: 2.3.0 + is-buffer@1.1.6: {} + is-core-module@2.15.0: dependencies: hasown: 2.0.2 @@ -4844,6 +4929,12 @@ snapshots: marked@15.0.4: {} + md5@2.3.0: + dependencies: + charenc: 0.0.2 + crypt: 0.0.2 + is-buffer: 1.1.6 + mdn-data@2.0.28: {} mdn-data@2.0.30: {} @@ -4964,6 +5055,8 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + os-tmpdir@1.0.2: {} + p-event@5.0.1: dependencies: p-timeout: 5.1.0 @@ -4990,6 +5083,10 @@ snapshots: package-json-from-dist@1.0.0: {} + package-up@5.0.0: + dependencies: + find-up-simple: 1.0.0 + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5035,6 +5132,13 @@ snapshots: pathval@2.0.0: {} + pem@1.14.8: + dependencies: + es6-promisify: 7.0.0 + md5: 2.3.0 + os-tmpdir: 1.0.2 + which: 2.0.2 + picocolors@1.0.1: {} picocolors@1.1.1: {} @@ -5447,6 +5551,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rxjs@7.8.1: + dependencies: + tslib: 2.8.1 + safe-buffer@5.2.1: {} safe-regex2@4.0.0: diff --git a/src/admin/discord/index.ts b/src/admin/discord/index.ts index 9de752c5..45c0662b 100644 --- a/src/admin/discord/index.ts +++ b/src/admin/discord/index.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { standardAdminPage } from '../plugins/standard-admin-page' +import { standardAdminPage } from '../standard-admin-page' import { DiscordPage } from './views/html/discord.page' export default standardAdminPage({ diff --git a/src/admin/games/index.ts b/src/admin/games/index.ts index e6c3eee2..a6281344 100644 --- a/src/admin/games/index.ts +++ b/src/admin/games/index.ts @@ -2,7 +2,7 @@ import { GamesPage } from './views/html/games.page' import { z } from 'zod' import { LogsTfUploadMethod } from '../../shared/types/logs-tf-upload-method' import { configuration } from '../../configuration' -import { standardAdminPage } from '../plugins/standard-admin-page' +import { standardAdminPage } from '../standard-admin-page' export default standardAdminPage({ path: '/admin/games', diff --git a/src/admin/index.ts b/src/admin/index.ts index b9092764..a3de822d 100644 --- a/src/admin/index.ts +++ b/src/admin/index.ts @@ -1,7 +1,11 @@ import fp from 'fastify-plugin' +import { resolve } from 'node:path' export default fp( async app => { + await app.register((await import('@fastify/autoload')).default, { + dir: resolve(import.meta.dirname, 'plugins'), + }) await app.register((await import('./routes')).default) }, { diff --git a/src/admin/map-pool/index.ts b/src/admin/map-pool/index.ts index 1460c70a..2f427988 100644 --- a/src/admin/map-pool/index.ts +++ b/src/admin/map-pool/index.ts @@ -1,6 +1,6 @@ import fp from 'fastify-plugin' import { z } from 'zod' -import { standardAdminPage } from '../plugins/standard-admin-page' +import { standardAdminPage } from '../standard-admin-page' import { MapPoolEntry as MapPoolEntryCmp, MapPoolPage } from './views/html/map-pool.page' import type { MapPoolEntry } from '../../database/models/map-pool-entry.model' import { mapPool } from '../../queue/map-pool' diff --git a/src/admin/player-restrictions/index.ts b/src/admin/player-restrictions/index.ts index 9a2732d3..5364e36f 100644 --- a/src/admin/player-restrictions/index.ts +++ b/src/admin/player-restrictions/index.ts @@ -1,7 +1,7 @@ import { PlayerRestrictionsPage } from './views/html/player-restrictions.page' import { z } from 'zod' import { configuration } from '../../configuration' -import { standardAdminPage } from '../plugins/standard-admin-page' +import { standardAdminPage } from '../standard-admin-page' export default standardAdminPage({ path: '/admin/player-restrictions', diff --git a/src/admin/plugins/sync-clients.ts b/src/admin/plugins/sync-clients.ts new file mode 100644 index 00000000..ddf8910b --- /dev/null +++ b/src/admin/plugins/sync-clients.ts @@ -0,0 +1,9 @@ +import fp from 'fastify-plugin' +import { events } from '../../events' +import { MumbleClientStatus } from '../voice-server/views/html/mumble-client-status' + +export default fp(async app => { + events.on('mumble/connectionStatusChanged', () => { + app.gateway.to({ url: '/admin/voice-server' }).send(() => MumbleClientStatus()) + }) +}) diff --git a/src/admin/plugins/standard-admin-page.ts b/src/admin/standard-admin-page.ts similarity index 92% rename from src/admin/plugins/standard-admin-page.ts rename to src/admin/standard-admin-page.ts index 019de63e..03bf6208 100644 --- a/src/admin/plugins/standard-admin-page.ts +++ b/src/admin/standard-admin-page.ts @@ -1,8 +1,8 @@ import type { ZodSchema, ZodTypeDef } from 'zod' import fp from 'fastify-plugin' import type { ZodTypeProvider } from 'fastify-type-provider-zod' -import { PlayerRole } from '../../database/models/player.model' -import type { User } from '../../auth/types/user' +import { PlayerRole } from '../database/models/player.model' +import type { User } from '../auth/types/user' import { requestContext } from '@fastify/request-context' interface StandardAdminPageArgs { diff --git a/src/admin/voice-server/index.ts b/src/admin/voice-server/index.ts index e5ed3ee5..664c1015 100644 --- a/src/admin/voice-server/index.ts +++ b/src/admin/voice-server/index.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { standardAdminPage } from '../plugins/standard-admin-page' +import { standardAdminPage } from '../standard-admin-page' import { VoiceServerPage } from './views/html/voice-server.page' import { VoiceServerType } from '../../shared/types/voice-server-type' import { configuration } from '../../configuration' diff --git a/src/admin/voice-server/views/html/mumble-client-status.tsx b/src/admin/voice-server/views/html/mumble-client-status.tsx new file mode 100644 index 00000000..b994755e --- /dev/null +++ b/src/admin/voice-server/views/html/mumble-client-status.tsx @@ -0,0 +1,26 @@ +import { mumble } from '../../../../mumble' +import { MumbleClientStatus as Status } from '../../../../mumble/status' + +export function MumbleClientStatus() { + let status = <> + switch (mumble.getStatus()) { + case Status.disconnected: + status = disconnected + break + case Status.connecting: + status = connecting... + break + case Status.connected: + status = connected + break + case Status.error: + status = error + break + } + + return ( +
+ {status} +
+ ) +} diff --git a/src/admin/voice-server/views/html/voice-server.page.tsx b/src/admin/voice-server/views/html/voice-server.page.tsx index e0c89d18..89c1716f 100644 --- a/src/admin/voice-server/views/html/voice-server.page.tsx +++ b/src/admin/voice-server/views/html/voice-server.page.tsx @@ -3,6 +3,7 @@ import { configuration } from '../../../../configuration' import { VoiceServerType } from '../../../../shared/types/voice-server-type' import { Admin } from '../../../views/html/admin' import { SaveButton } from '../../../views/html/save-button' +import { MumbleClientStatus } from './mumble-client-status' export async function VoiceServerPage(props: { user: User }) { const type = await configuration.get('games.voice_server_type') @@ -118,7 +119,12 @@ export async function VoiceServerPage(props: { user: User }) {
- +
@@ -129,9 +135,16 @@ export async function VoiceServerPage(props: { user: User }) {
- +
+ +

diff --git a/src/certificates/get.ts b/src/certificates/get.ts new file mode 100644 index 00000000..74efd0c2 --- /dev/null +++ b/src/certificates/get.ts @@ -0,0 +1,32 @@ +import { yearsToDays } from 'date-fns' +import { collections } from '../database/collections' +import { logger } from '../logger' +import pem from 'pem' +import type { CertificateModel } from '../database/models/certificate.model' + +const createCertificate = (options: pem.CertificateCreationOptions) => + new Promise((resolve, reject) => { + pem.createCertificate(options, (error, result) => { + if (error) { + reject(error) + } else { + resolve(result) + } + }) + }) + +export async function get(purpose: string): Promise { + logger.trace(`certificates.get(purpose=${purpose})`) + const c = await collections.certificates.findOne({ purpose }) + if (c !== null) { + return c + } + + const { clientKey, certificate } = await createCertificate({ + days: yearsToDays(10), + selfSigned: true, + }) + await collections.certificates.insertOne({ purpose, clientKey, certificate }) + logger.info({ purpose }, `certificate created`) + return { purpose, clientKey, certificate } +} diff --git a/src/certificates/index.ts b/src/certificates/index.ts new file mode 100644 index 00000000..058c548f --- /dev/null +++ b/src/certificates/index.ts @@ -0,0 +1,5 @@ +import { get } from './get' + +export const certificates = { + get, +} as const diff --git a/src/configuration/reset.ts b/src/configuration/reset.ts index 836ddfd3..f3c3eb88 100644 --- a/src/configuration/reset.ts +++ b/src/configuration/reset.ts @@ -1,8 +1,10 @@ import { collections } from '../database/collections' import type { Configuration } from '../database/models/configuration-entry.model' +import { events } from '../events' import { get } from './get' export async function reset(key: T): Promise { await collections.configuration.deleteOne({ key }) + events.emit('configuration:updated', { key }) return await get(key) } diff --git a/src/configuration/set.ts b/src/configuration/set.ts index ca7d6351..9053eef7 100644 --- a/src/configuration/set.ts +++ b/src/configuration/set.ts @@ -3,6 +3,7 @@ import { configurationSchema, type Configuration, } from '../database/models/configuration-entry.model' +import { events } from '../events' export async function set( key: T, @@ -10,4 +11,5 @@ export async function set( ): Promise { configurationSchema.parse({ key, value }) await collections.configuration.updateOne({ key }, { $set: { value } }, { upsert: true }) + events.emit('configuration:updated', { key }) } diff --git a/src/database/collections.ts b/src/database/collections.ts index 58a827e9..1b3b9ae0 100644 --- a/src/database/collections.ts +++ b/src/database/collections.ts @@ -16,8 +16,10 @@ import type { SecretModel } from './models/secret.model' import type { StaticGameServerModel } from './models/static-game-server.model' import type { TaskModel } from './models/task.model' import type { StreamModel } from './models/stream.model' +import type { CertificateModel } from './models/certificate.model' export const collections = { + certificates: database.collection('certificates'), configuration: database.collection('configuration'), documents: database.collection('documents'), gameLogs: database.collection('gamelogs'), diff --git a/src/database/models/certificate.model.ts b/src/database/models/certificate.model.ts new file mode 100644 index 00000000..9da315d0 --- /dev/null +++ b/src/database/models/certificate.model.ts @@ -0,0 +1,5 @@ +export interface CertificateModel { + purpose: string + clientKey: string + certificate: string +} diff --git a/src/database/models/game-slot.model.ts b/src/database/models/game-slot.model.ts index 37d8de3f..e7f1d90d 100644 --- a/src/database/models/game-slot.model.ts +++ b/src/database/models/game-slot.model.ts @@ -21,4 +21,5 @@ export interface GameSlotModel { connectionStatus: PlayerConnectionStatus skill?: number shouldJoinBy?: Date + voiceServerUrl?: string } diff --git a/src/events.ts b/src/events.ts index b4fa2e1e..4875f7d5 100644 --- a/src/events.ts +++ b/src/events.ts @@ -13,8 +13,14 @@ import type { Tf2Team } from './shared/types/tf2-team' import type { StreamModel } from './database/models/stream.model' import type { Bot } from './shared/types/bot' import type { PlayerConnectionStatus } from './database/models/game-slot.model' +import type { Configuration } from './database/models/configuration-entry.model' +import type { MumbleClientStatus } from './mumble/status' export interface Events { + 'configuration:updated': { + key: keyof Configuration + } + 'game:created': { game: GameModel } @@ -101,6 +107,10 @@ export interface Events { demoUrl: string } + 'mumble/connectionStatusChanged': { + status: MumbleClientStatus + } + 'player:connected': { steamId: SteamId64 metadata: UserMetadata diff --git a/src/games/plugins/sync-clients.ts b/src/games/plugins/sync-clients.ts index 64657597..d13e2cd2 100644 --- a/src/games/plugins/sync-clients.ts +++ b/src/games/plugins/sync-clients.ts @@ -13,6 +13,8 @@ import { whenGameEnds } from '../when-game-ends' import { GamesLink } from '../../html/components/games-link' import { safe } from '../../utils/safe' import { GameScore } from '../views/html/game-score' +import { JoinVoiceButton } from '../views/html/join-voice-button' +import { JoinGameButton } from '../views/html/join-game-button' // eslint-disable-next-line @typescript-eslint/require-await export default fp(async app => { @@ -44,7 +46,7 @@ export default fp(async app => { ) { app.gateway .to({ url: `/games/${after.number}` }) - .send(async actor => await ConnectInfo({ game: after, actor })) + .send(async actor => await JoinGameButton({ game: after, actor })) } if (before.logsUrl !== after.logsUrl) { @@ -80,7 +82,14 @@ export default fp(async app => { app.gateway .to({ url: `/games/${after.number}` }) .to({ player: slot.player }) - .send(async actor => await ConnectInfo({ game: after, actor })) + .send(async actor => await JoinGameButton({ game: after, actor })) + } + + if (beforeSlot.voiceServerUrl !== slot.voiceServerUrl) { + app.gateway + .to({ url: `/games/${after.number}` }) + .to({ player: slot.player }) + .send(async actor => await JoinVoiceButton({ game: after, actor })) } }), ) @@ -93,9 +102,9 @@ export default fp(async app => { events.on( 'game:updated', - whenGameEnds(async ({ after }) => { + whenGameEnds(async () => { const cmp = await GamesLink() - app.gateway.to({ url: `/games/${after.number}` }).send(() => cmp) + app.gateway.broadcast(() => cmp) }), ) diff --git a/src/games/views/html/connect-info.tsx b/src/games/views/html/connect-info.tsx index 4078674e..96646dcf 100644 --- a/src/games/views/html/connect-info.tsx +++ b/src/games/views/html/connect-info.tsx @@ -2,6 +2,7 @@ import { GameState, type GameModel } from '../../../database/models/game.model' import { IconCopy } from '../../../html/components/icons' import type { SteamId64 } from '../../../shared/types/steam-id-64' import { JoinGameButton } from './join-game-button' +import { JoinVoiceButton } from './join-voice-button' export function ConnectInfo(props: { game: GameModel; actor: SteamId64 | undefined }) { const connectInfoVisible = [ @@ -17,6 +18,7 @@ export function ConnectInfo(props: { game: GameModel; actor: SteamId64 | undefin <> + ) } diff --git a/src/games/views/html/game-list-item.tsx b/src/games/views/html/game-list-item.tsx index 408ec6fc..9021770c 100644 --- a/src/games/views/html/game-list-item.tsx +++ b/src/games/views/html/game-list-item.tsx @@ -35,7 +35,9 @@ export function GameListItem(props: { game: GameModel; classPlayed?: Tf2ClassNam return (

- #{props.game.number} + + #{props.game.number} + {props.game.map} diff --git a/src/games/views/html/join-game-button.tsx b/src/games/views/html/join-game-button.tsx index 0d4ab861..bab1cf48 100644 --- a/src/games/views/html/join-game-button.tsx +++ b/src/games/views/html/join-game-button.tsx @@ -9,6 +9,14 @@ import type { SteamId64 } from '../../../shared/types/steam-id-64' import { connectStringToLink } from '../../connect-string-to-link' export async function JoinGameButton(props: { game: GameModel; actor: SteamId64 | undefined }) { + return ( +
+ +
+ ) +} + +async function JoinGameButtonContent(props: { game: GameModel; actor: SteamId64 | undefined }) { let btnContent: JSX.Element let connectLink: string | undefined if ([GameState.created, GameState.configuring].includes(props.game.state)) { diff --git a/src/games/views/html/join-voice-button.tsx b/src/games/views/html/join-voice-button.tsx new file mode 100644 index 00000000..f1807a32 --- /dev/null +++ b/src/games/views/html/join-voice-button.tsx @@ -0,0 +1,44 @@ +import { SlotStatus } from '../../../database/models/game-slot.model' +import type { GameModel } from '../../../database/models/game.model' +import { IconHeadset } from '../../../html/components/icons' +import type { SteamId64 } from '../../../shared/types/steam-id-64' + +export async function JoinVoiceButton(props: { game: GameModel; actor: SteamId64 | undefined }) { + return ( +
+ +
+ ) +} + +async function JoinVoiceButtonContent(props: { game: GameModel; actor: SteamId64 | undefined }) { + if (!props.actor) { + return <> + } + + const slot = await getPlayerSlot(props.game, props.actor) + if (!slot) { + return <> + } + + if (!slot.voiceServerUrl) { + return <> + } + + return ( +
+ + join voice + + ) +} + +async function getPlayerSlot(game: GameModel, actor?: SteamId64) { + if (!actor) { + return undefined + } + + return game.slots + .filter(slot => [SlotStatus.active, SlotStatus.waitingForSubstitute].includes(slot.status)) + .find(slot => slot.player === actor) +} diff --git a/src/keys/get.ts b/src/keys/get.ts index 42633be6..51f553b9 100644 --- a/src/keys/get.ts +++ b/src/keys/get.ts @@ -34,7 +34,7 @@ export async function get(keyName: string): Promise { }) .toString(), }) - logger.info(`key pair "${keyName} generated`) + logger.info({ name: keyName }, `key pair "${keyName} generated`) return keyPair } else { const privateKey = createPrivateKey({ diff --git a/src/main.ts b/src/main.ts index 661980cb..ec03adcc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,16 +2,19 @@ import './migrate' import fastify from 'fastify' import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod' import { resolve } from 'node:path' -import { logger as loggerInstance } from './logger' +import { logger, logger as loggerInstance } from './logger' import { secrets } from './secrets' import { hoursToSeconds } from 'date-fns' import { environment } from './environment' +import { version } from './version' const app = fastify({ loggerInstance }) app.setSerializerCompiler(serializerCompiler) app.setValidatorCompiler(validatorCompiler) +logger.info(`starting tf2pickup.org ${version}`) + await app.register(await import('@fastify/sensible')) await app.register(await import('@fastify/formbody')) await app.register(await import('@fastify/cookie'), { @@ -52,5 +55,6 @@ await app.register((await import('./admin')).default) await app.register((await import('./hall-of-game')).default) await app.register((await import('./pre-ready')).default) await app.register((await import('./serveme-tf')).default) +await app.register((await import('./mumble')).default) await app.listen({ host: environment.APP_HOST, port: environment.APP_PORT }) diff --git a/src/mumble/assert-client-is-connected.ts b/src/mumble/assert-client-is-connected.ts new file mode 100644 index 00000000..d62881ef --- /dev/null +++ b/src/mumble/assert-client-is-connected.ts @@ -0,0 +1,9 @@ +import type { Client, User } from '@tf2pickup-org/mumble-client' + +export function assertClientIsConnected( + client: Client | undefined, +): asserts client is Client & { user: User } { + if (!client?.user) { + throw new Error(`mumble client not connected`) + } +} diff --git a/src/mumble/client.ts b/src/mumble/client.ts new file mode 100644 index 00000000..746507e4 --- /dev/null +++ b/src/mumble/client.ts @@ -0,0 +1,71 @@ +import { Client } from '@tf2pickup-org/mumble-client' +import { configuration } from '../configuration' +import { VoiceServerType } from '../shared/types/voice-server-type' +import { logger } from '../logger' +import { certificates } from '../certificates' +import { moveToTargetChannel } from './move-to-target-channel' +import { assertClientIsConnected } from './assert-client-is-connected' +import { version } from '../version' +import { MumbleClientStatus, setStatus } from './status' + +export let client: Client | undefined + +export async function tryConnect() { + client?.disconnect() + setStatus(MumbleClientStatus.disconnected) + + const type = await configuration.get('games.voice_server_type') + if (type !== VoiceServerType.mumble) { + return + } + + const [host, port, channelName, password] = await Promise.all([ + configuration.get('games.voice_server.mumble.url'), + configuration.get('games.voice_server.mumble.port'), + configuration.get('games.voice_server.mumble.channel_name'), + configuration.get('games.voice_server.mumble.password'), + ]) + if (!host) { + throw new Error(`mumble configuration malformed`) + } + + logger.info({ host, port, channelName }, `connecting to mumble server...`) + setStatus(MumbleClientStatus.connecting) + try { + const { clientKey, certificate } = await certificates.get('mumble') + client = new Client({ + host, + port, + username: 'tf2pickup.org bot', + ...(password ? { password } : {}), + clientName: `tf2pickup.org ${version}`, + key: clientKey, + cert: certificate, + rejectUnauthorized: false, + }) + + await client.connect() + assertClientIsConnected(client) + logger.info( + { + mumbleUser: { + name: client.user!.name, + }, + welcomeText: client.welcomeText, + }, + `connected to the mumble server`, + ) + + await client.user.setSelfDeaf(true) + await moveToTargetChannel() + + const permissions = await client.user.channel.getPermissions() + if (!permissions.canCreateChannel) { + logger.warn(`bot ${client.user!.name} does not have permissions to create new channels`) + } + setStatus(MumbleClientStatus.connected) + } catch (error) { + setStatus(MumbleClientStatus.error) + throw error + } +} diff --git a/src/mumble/index.ts b/src/mumble/index.ts new file mode 100644 index 00000000..39179223 --- /dev/null +++ b/src/mumble/index.ts @@ -0,0 +1,18 @@ +import fp from 'fastify-plugin' +import { resolve } from 'node:path' +import { getStatus } from './status' + +export const mumble = { + getStatus, +} as const + +export default fp( + async app => { + await app.register((await import('@fastify/autoload')).default, { + dir: resolve(import.meta.dirname, 'plugins'), + }) + }, + { + name: 'mumble', + }, +) diff --git a/src/mumble/link-channels.ts b/src/mumble/link-channels.ts new file mode 100644 index 00000000..a8b0f87c --- /dev/null +++ b/src/mumble/link-channels.ts @@ -0,0 +1,26 @@ +import type { GameModel } from '../database/models/game.model' +import { logger } from '../logger' +import { client } from './client' + +export async function linkChannels(game: GameModel) { + if (!client || !client.user) { + return + } + + const channelName = `${game.number}` + const gameChannel = client.user.channel.subChannels.find(channel => channel.name === channelName) + if (!gameChannel) { + throw new Error('channel does not exist') + } + + const [red, blu] = [ + gameChannel.subChannels.find(channel => channel.name?.toUpperCase() === 'RED'), + gameChannel.subChannels.find(channel => channel.name?.toUpperCase() === 'BLU'), + ] + if (red && blu) { + await red.link(blu) + logger.info({ game }, `channels linked`) + } else { + throw new Error('BLU or RED subchannel does not exist') + } +} diff --git a/src/mumble/move-to-target-channel.ts b/src/mumble/move-to-target-channel.ts new file mode 100644 index 00000000..305a31aa --- /dev/null +++ b/src/mumble/move-to-target-channel.ts @@ -0,0 +1,24 @@ +import type { Channel } from '@tf2pickup-org/mumble-client' +import { configuration } from '../configuration' +import { logger } from '../logger' +import { assertClientIsConnected } from './assert-client-is-connected' +import { client } from './client' + +export async function moveToTargetChannel() { + assertClientIsConnected(client) + let channel: Channel | undefined + + const channelName = await configuration.get('games.voice_server.mumble.channel_name') + if (!channelName) { + channel = client.channels.byId(0) // 0 is the root channel + } else { + channel = client.channels.byName(channelName) + } + + if (!channel) { + throw new Error(`channel does not exist: ${channelName}`) + } + + logger.trace({ channel: { id: channel.id, name: channel.name } }, 'mumble channel found') + await client.user!.moveToChannel(channel.id) +} diff --git a/src/mumble/mumble-direct-url.ts b/src/mumble/mumble-direct-url.ts new file mode 100644 index 00000000..1e792303 --- /dev/null +++ b/src/mumble/mumble-direct-url.ts @@ -0,0 +1,33 @@ +import { configuration } from '../configuration' +import { collections } from '../database/collections' +import type { GameModel } from '../database/models/game.model' +import type { SteamId64 } from '../shared/types/steam-id-64' + +export async function mumbleDirectUrl(game: GameModel, player: SteamId64): Promise { + const p = await collections.players.findOne({ steamId: player }) + if (p === null) { + throw new Error(`player not found: ${p}`) + } + + const slot = game.slots.find(s => s.player === player) + if (!slot) { + throw new Error(`player is not in the game`) + } + + const [host, port, rootChannelName, password] = await Promise.all([ + configuration.get('games.voice_server.mumble.url'), + configuration.get('games.voice_server.mumble.port'), + configuration.get('games.voice_server.mumble.channel_name'), + configuration.get('games.voice_server.mumble.password'), + ]) + + const url = new URL(`mumble://${host}`) + url.pathname = [rootChannelName, String(game.number), slot.team.toUpperCase()].join('/') + url.username = p.name.replace(/\s+/g, '_') + if (password) { + url.password = password + } + url.protocol = 'mumble:' + url.port = `${port}` + return url +} diff --git a/src/mumble/plugins/create-channels.ts b/src/mumble/plugins/create-channels.ts new file mode 100644 index 00000000..b201881f --- /dev/null +++ b/src/mumble/plugins/create-channels.ts @@ -0,0 +1,54 @@ +import fp from 'fastify-plugin' +import { events } from '../../events' +import { safe } from '../../utils/safe' +import { setupGameChannels } from '../setup-game-channels' +import { logger } from '../../logger' +import { games } from '../../games' +import { mumbleDirectUrl } from '../mumble-direct-url' +import { collections } from '../../database/collections' +import { client } from '../client' + +export default fp( + async () => { + events.on( + 'game:created', + safe(async ({ game }) => { + if (client) { + await setupGameChannels(game) + logger.info({ game }, 'channels for game created') + } + }), + ) + + events.on( + 'game:playerReplaced', + safe(async ({ game, replacement }) => { + if (!client) { + return + } + + const r = await collections.players.findOne({ steamId: replacement }) + if (!r) { + throw new Error(`replacee player not found: ${r}`) + } + + await games.update( + game.number, + { + $set: { + 'slots.$[slot].voiceServerUrl': (await mumbleDirectUrl(game, replacement)).toString(), + }, + }, + { + arrayFilters: [ + { + 'slot.player': { $eq: r._id }, + }, + ], + }, + ) + }), + ) + }, + { name: 'auto create channels' }, +) diff --git a/src/mumble/plugins/link-channels.ts b/src/mumble/plugins/link-channels.ts new file mode 100644 index 00000000..8bad40a6 --- /dev/null +++ b/src/mumble/plugins/link-channels.ts @@ -0,0 +1,21 @@ +import fp from 'fastify-plugin' +import { events } from '../../events' +import { whenGameEnds } from '../../games/when-game-ends' +import { safe } from '../../utils/safe' +import { linkChannels } from '../link-channels' + +export default fp( + async () => { + events.on( + 'game:updated', + safe( + whenGameEnds(async ({ after }) => { + await linkChannels(after) + }), + ), + ) + }, + { + name: 'auto link channels', + }, +) diff --git a/src/mumble/plugins/reconnect-client.ts b/src/mumble/plugins/reconnect-client.ts new file mode 100644 index 00000000..674c7919 --- /dev/null +++ b/src/mumble/plugins/reconnect-client.ts @@ -0,0 +1,34 @@ +import fp from 'fastify-plugin' +import { events } from '../../events' +import { debounce } from 'lodash-es' +import { tryConnect } from '../client' +import { safe } from '../../utils/safe' +import { secondsToMilliseconds } from 'date-fns' + +export default fp( + async () => { + const tryConnectDebounced = debounce(safe(tryConnect), secondsToMilliseconds(1)) + + events.on( + 'configuration:updated', + safe(async ({ key }) => { + if ( + ![ + 'games.voice_server_type', + 'games.voice_server.mumble.url', + 'games.voice_server.mumble.port', + 'games.voice_server.mumble.channel_name', + 'games.voice_server.mumble.password', + ].includes(key) + ) { + return + } + + tryConnectDebounced() + }), + ) + + setImmediate(safe(tryConnect)) + }, + { name: 'auto reconnect mumble client' }, +) diff --git a/src/mumble/setup-game-channels.ts b/src/mumble/setup-game-channels.ts new file mode 100644 index 00000000..25623de7 --- /dev/null +++ b/src/mumble/setup-game-channels.ts @@ -0,0 +1,43 @@ +import { toUpper } from 'lodash-es' +import type { GameModel } from '../database/models/game.model' +import { assertClientIsConnected } from './assert-client-is-connected' +import { client } from './client' +import { moveToTargetChannel } from './move-to-target-channel' +import { Tf2Team } from '../shared/types/tf2-team' +import { games } from '../games' +import { collections } from '../database/collections' +import { mumbleDirectUrl } from './mumble-direct-url' + +// subchannels for each game +const subChannelNames = [toUpper(Tf2Team.blu), toUpper(Tf2Team.red)] + +export async function setupGameChannels(game: GameModel) { + assertClientIsConnected(client) + await moveToTargetChannel() + const channelName = `${game.number}` + const channel = await client.user.channel.createSubChannel(channelName) + await Promise.all( + subChannelNames.map(async subChannelName => await channel.createSubChannel(subChannelName)), + ) + + // update game + await games.update(game.number, { + $set: ( + await Promise.all( + game.slots.map(async slot => { + const player = await collections.players.findOne({ steamId: slot.player }) + if (player === null) { + throw new Error(`no such player: ${slot.player.toString()}`) + } + return await mumbleDirectUrl(game, player.steamId) + }), + ) + ).reduce( + (acc, url, i) => { + acc[`slots.${i}.voiceServerUrl`] = url.toString() + return acc + }, + {} as Record<`slots.${number}.voiceServerUrl`, string>, + ), + }) +} diff --git a/src/mumble/status.ts b/src/mumble/status.ts new file mode 100644 index 00000000..3f07e638 --- /dev/null +++ b/src/mumble/status.ts @@ -0,0 +1,19 @@ +import { events } from '../events' + +export const enum MumbleClientStatus { + disconnected = 'disconnected', + connecting = 'connecting', + connected = 'connected', + error = 'error', +} + +let status: MumbleClientStatus = MumbleClientStatus.disconnected + +export function setStatus(newStatus: MumbleClientStatus) { + status = newStatus + events.emit('mumble/connectionStatusChanged', { status }) +} + +export function getStatus() { + return status +} diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 00000000..7bc58e85 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,13 @@ +import { readFile } from 'node:fs/promises' +import { packageUp } from 'package-up' +import { z } from 'zod' + +const packageJsonPath = await packageUp() +if (!packageJsonPath) { + throw new Error(`cannot find package.json in ${import.meta.dirname}`) +} + +const file = await readFile(packageJsonPath) +const packageJsonSchema = z.object({ version: z.string() }) + +export const { version } = packageJsonSchema.parse(JSON.parse(file.toString())) diff --git a/tests/20-game/10-setup-mumble-server.spec.ts b/tests/20-game/10-setup-mumble-server.spec.ts new file mode 100644 index 00000000..927672af --- /dev/null +++ b/tests/20-game/10-setup-mumble-server.spec.ts @@ -0,0 +1,28 @@ +import { mergeTests } from '@playwright/test' +import { expect, launchGame } from '../fixtures/launch-game' +import { configureMumbleServer } from '../fixtures/configure-mumble-server' + +const test = mergeTests(launchGame, configureMumbleServer) + +test('renders join voice button', async ({ + gameNumber, + users, + mumbleConfiguration, + mumbleClient, +}) => { + const page = await users.byName('Astropower').gamePage(gameNumber) + + const joinVoiceButton = page.joinVoiceButton() + await expect(joinVoiceButton).toBeVisible() + await expect(joinVoiceButton).toHaveAttribute( + 'href', + `mumble://Astropower@${mumbleConfiguration.host}:${mumbleConfiguration.port}/${mumbleConfiguration.channelName}/${gameNumber}/RED`, + ) + + expect( + mumbleClient.channels.byPath(mumbleConfiguration.channelName, gameNumber.toString(), 'RED'), + ).toBeTruthy() + expect( + mumbleClient.channels.byPath(mumbleConfiguration.channelName, gameNumber.toString(), 'BLU'), + ).toBeTruthy() +}) diff --git a/tests/fixtures/configure-mumble-server.ts b/tests/fixtures/configure-mumble-server.ts new file mode 100644 index 00000000..2a7de48d --- /dev/null +++ b/tests/fixtures/configure-mumble-server.ts @@ -0,0 +1,52 @@ +import { Client } from '@tf2pickup-org/mumble-client' +import { authUsers } from './auth-users' + +interface MumbleConfiguration { + host: string + port: number + channelName: string +} + +export const configureMumbleServer = authUsers.extend<{ + mumbleConfiguration: MumbleConfiguration + mumbleClient: Client + mumbleServerConfigured: void +}>({ + mumbleConfiguration: async ({}, use) => { + if (!('TEST_MUMBLE_SERVER_HOST' in process.env)) { + throw new Error('TEST_MUMBLE_SERVER_HOST is required to run this test') + } + + await use({ + host: process.env['TEST_MUMBLE_SERVER_HOST']!, + port: 64738, + channelName: 'tf2pickup-tests', + }) + }, + mumbleClient: async ({}, use) => { + const client = new Client({ + host: 'localhost', + port: 64738, + username: 'superuser', + password: '123456', + rejectUnauthorized: false, + }) + await client.connect() + await use(client) + client.disconnect() + }, + mumbleServerConfigured: [ + async ({ users, mumbleConfiguration }, use) => { + const { channelName } = mumbleConfiguration + + const admin = await users.getAdmin().adminPage() + await admin.configureVoiceServer({ + host: mumbleConfiguration.host, + password: '', + channelName, + }) + await use() + }, + { auto: true }, + ], +}) diff --git a/tests/mumble-data/mumble-server.sqlite b/tests/mumble-data/mumble-server.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..9a0735497c7a7eea8d820f134039b69d5a9d7d8a GIT binary patch literal 135168 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 literal 0 HcmV?d00001 diff --git a/tests/mumble-data/mumble_server_config.ini b/tests/mumble-data/mumble_server_config.ini new file mode 100644 index 00000000..d94e701a --- /dev/null +++ b/tests/mumble-data/mumble_server_config.ini @@ -0,0 +1,12 @@ +# Config file automatically generated from the MUMBLE_CONFIG_* environment variables +# or secrets in /run/secrets/MUMBLE_CONFIG_* files + +database=/data/mumble-server.sqlite +ice="tcp -h 127.0.0.1 -p 6502" +welcometext="
Welcome to this server, running the official Mumble Docker image.
Enjoy your stay!
" +port=64738 +users=100 + +[Ice] +Ice.Warn.UnknownProperties=1 +Ice.MessageSizeMax=65536 diff --git a/tests/pages/admin.page.ts b/tests/pages/admin.page.ts index f8583161..06e4a776 100644 --- a/tests/pages/admin.page.ts +++ b/tests/pages/admin.page.ts @@ -1,4 +1,4 @@ -import type { Page } from '@playwright/test' +import { expect, type Page } from '@playwright/test' import { secondsToMilliseconds } from 'date-fns' export class AdminPage { @@ -32,4 +32,14 @@ export class AdminPage { .click({ timeout: secondsToMilliseconds(1) }) } catch (error) {} } + + async configureVoiceServer(props: { host: string; password: string; channelName: string }) { + await this.page.goto('/admin/voice-server') + await this.page.getByLabel('Mumble').click() + await this.page.getByLabel('Server URL').fill(props.host) + await this.page.getByLabel('Server password').fill(props.password) + await this.page.getByLabel('Channel name').fill(props.channelName) + await this.page.getByRole('button', { name: 'Save' }).click() + await expect(this.page.getByText('connected', { exact: true })).toBeVisible() + } } diff --git a/tests/pages/game.page.ts b/tests/pages/game.page.ts index d02a8233..df7be236 100644 --- a/tests/pages/game.page.ts +++ b/tests/pages/game.page.ts @@ -65,6 +65,10 @@ export class GamePage { return this.page.getByRole('link', { name: /join (game|in \d{1,2}\:\d{2})/i }) } + joinVoiceButton() { + return this.page.getByRole('link', { name: 'join voice' }) + } + watchStvButton() { return this.page.getByRole('link', { name: 'watch stv' }) }