diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 149f3734..6c3b2428 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: 'mongo' - 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 64c16591..bf40b10d 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.5.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 664f3a00..54109e68 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.5.0 version: 9.5.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==} @@ -3506,6 +3565,8 @@ snapshots: dependencies: playwright: 1.49.1 + '@protobuf-ts/runtime@2.9.4': {} + '@rollup/rollup-android-arm-eabi@4.25.0': optional: true @@ -3599,6 +3660,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 @@ -3641,6 +3712,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 @@ -3953,6 +4028,8 @@ snapshots: chalk@5.3.0: {} + charenc@0.0.2: {} + chart.js@4.4.7: dependencies: '@kurkle/color': 0.3.2 @@ -4076,6 +4153,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 @@ -4249,6 +4328,8 @@ snapshots: es-module-lexer@1.5.4: {} + es6-promisify@7.0.0: {} + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -4472,6 +4553,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 @@ -4671,6 +4754,8 @@ snapshots: dependencies: binary-extensions: 2.3.0 + is-buffer@1.1.6: {} + is-core-module@2.15.0: dependencies: hasown: 2.0.2 @@ -4847,6 +4932,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: {} @@ -4967,6 +5058,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 @@ -4993,6 +5086,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 @@ -5038,6 +5135,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: {} @@ -5450,6 +5554,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 00000000..9a073549 Binary files /dev/null and b/tests/mumble-data/mumble-server.sqlite differ 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' }) }