From efdc2667ca56968f62e1b09686fb21978a391c53 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 | 5 + package.json | 4 + pnpm-lock.yaml | 108 ++++++++++++++++++ .../views/html/voice-server.page.tsx | 14 ++- 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 | 5 + .../plugins/redirect-player-to-new-game.ts | 2 +- src/games/plugins/sync-clients.ts | 14 ++- 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 | 62 ++++++++++ src/mumble/index.ts | 13 +++ 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 | 47 ++++++++ src/mumble/plugins/link-channels.ts | 21 ++++ src/mumble/plugins/reconnect-client.ts | 34 ++++++ src/mumble/setup-game-channels.ts | 43 +++++++ src/players/index.ts | 4 - src/version.ts | 13 +++ .../08-configure-mumble-server.spec.ts | 17 +++ tests/pages/admin.page.ts | 9 ++ tests/pages/game.page.ts | 4 + 35 files changed, 613 insertions(+), 13 deletions(-) create mode 100644 src/certificates/get.ts create mode 100644 src/certificates/index.ts create mode 100644 src/database/models/certificate.model.ts rename src/{players => games}/plugins/redirect-player-to-new-game.ts (87%) 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/version.ts create mode 100644 tests/20-game/08-configure-mumble-server.spec.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 149f3734..ad9084c4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -100,6 +100,11 @@ jobs: image: mongo:latest ports: - 27017:27017 + mumble: + image: mumblevoip/mumble-server:latest + ports: + - 64738:64738 + - 64738:64738/udp steps: - uses: actions/checkout@v4 diff --git a/package.json b/package.json index a70ce8c4..076b5217 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@kitajs/html": "4.2.5", "@kitajs/ts-html-plugin": "4.1.1", "@tailwindcss/typography": "0.5.15", + "@tf2pickup-org/mumble-client": "0.8.1", "async-mutex": "0.5.0", "autoprefixer": "10.4.20", "croner": "9.0.0", @@ -46,6 +47,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", @@ -68,6 +71,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 1fa41b9c..d35193de 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.16) + '@tf2pickup-org/mumble-client': + specifier: 0.8.1 + version: 0.8.1 async-mutex: specifier: 0.5.0 version: 0.5.0 @@ -98,6 +101,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 @@ -159,6 +168,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 @@ -692,6 +704,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] @@ -810,6 +825,12 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20' + '@tf2pickup-org/mumble-client@0.8.1': + resolution: {integrity: sha512-FC3LTV1EaeCOvRp0pi0HTfAT4Y9iMU6VzNnop6rhAxrKEetzs8PPriIfTAytIOz96+Z7EIZbmtVushc/u5NkwA==} + + '@tf2pickup-org/mumble-protocol@1.0.5': + resolution: {integrity: sha512-nqFc6QskL6+w+A6w3j2Pa5na7Xy9Yij6bM0RUtnL+5t59QAFrBSHASHbdS0Yhdb9xhAApTrwcxob590oQhwy2g==} + '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -850,6 +871,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==} @@ -1119,6 +1143,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'} @@ -1229,6 +1256,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} @@ -1400,6 +1430,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'} @@ -1540,6 +1574,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'} @@ -1742,6 +1780,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'} @@ -1937,6 +1978,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==} @@ -2108,6 +2152,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} @@ -2139,6 +2187,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'} @@ -2194,6 +2246,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==} @@ -2656,6 +2712,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==} @@ -3494,6 +3553,8 @@ snapshots: dependencies: playwright: 1.49.1 + '@protobuf-ts/runtime@2.9.4': {} + '@rollup/rollup-android-arm-eabi@4.25.0': optional: true @@ -3587,6 +3648,16 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.16 + '@tf2pickup-org/mumble-client@0.8.1': + 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 + '@trysound/sax@0.2.0': {} '@tsconfig/strictest@2.0.5': {} @@ -3624,6 +3695,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 @@ -3936,6 +4011,8 @@ snapshots: chalk@5.3.0: {} + charenc@0.0.2: {} + chart.js@4.4.7: dependencies: '@kurkle/color': 0.3.2 @@ -4057,6 +4134,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 @@ -4230,6 +4309,8 @@ snapshots: es-module-lexer@1.5.4: {} + es6-promisify@7.0.0: {} + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -4453,6 +4534,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 @@ -4652,6 +4735,8 @@ snapshots: dependencies: binary-extensions: 2.3.0 + is-buffer@1.1.6: {} + is-core-module@2.15.0: dependencies: hasown: 2.0.2 @@ -4828,6 +4913,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: {} @@ -4948,6 +5039,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 @@ -4974,6 +5067,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 @@ -5019,6 +5116,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: {} @@ -5431,6 +5535,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/voice-server/views/html/voice-server.page.tsx b/src/admin/voice-server/views/html/voice-server.page.tsx index 70db1cfe..c37b5a7e 100644 --- a/src/admin/voice-server/views/html/voice-server.page.tsx +++ b/src/admin/voice-server/views/html/voice-server.page.tsx @@ -118,7 +118,12 @@ export async function VoiceServerPage(props: { user: User }) {
- +
@@ -129,7 +134,12 @@ 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 974c2905..78d78cec 100644 --- a/src/database/models/game-slot.model.ts +++ b/src/database/models/game-slot.model.ts @@ -22,4 +22,5 @@ export interface GameSlotModel { connectionStatus: PlayerConnectionStatus skill?: number shouldJoinBy?: Date + voiceServerUrl?: string } diff --git a/src/events.ts b/src/events.ts index b4fa2e1e..e3b19a39 100644 --- a/src/events.ts +++ b/src/events.ts @@ -13,8 +13,13 @@ 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' export interface Events { + 'configuration:updated': { + key: keyof Configuration + } + 'game:created': { game: GameModel } diff --git a/src/players/plugins/redirect-player-to-new-game.ts b/src/games/plugins/redirect-player-to-new-game.ts similarity index 87% rename from src/players/plugins/redirect-player-to-new-game.ts rename to src/games/plugins/redirect-player-to-new-game.ts index 8d8017f5..b13ad2a4 100644 --- a/src/players/plugins/redirect-player-to-new-game.ts +++ b/src/games/plugins/redirect-player-to-new-game.ts @@ -1,6 +1,6 @@ import fp from 'fastify-plugin' import { events } from '../../events' -import { GoToGame } from '../views/html/go-to-game' +import { GoToGame } from '../../players/views/html/go-to-game' export default fp( async app => { diff --git a/src/games/plugins/sync-clients.ts b/src/games/plugins/sync-clients.ts index b4e5774c..e1d1a129 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 => { @@ -34,7 +36,7 @@ export default fp(async app => { before.connectString !== after.connectString || before.stvConnectString !== after.stvConnectString ) { - app.gateway.broadcast(async actor => await ConnectInfo({ game: after, actor })) + app.gateway.broadcast(async actor => await JoinGameButton({ game: after, actor })) } if (before.logsUrl !== after.logsUrl) { @@ -62,8 +64,14 @@ export default fp(async app => { if (beforeSlot.shouldJoinBy !== slot.shouldJoinBy) { app.gateway - .toPlayers(slot.player) - .broadcast(async actor => await ConnectInfo({ game: after, actor })) + .toPlayers(beforeSlot.player) + .broadcast(async actor => await JoinGameButton({ game: after, actor })) + } + + if (beforeSlot.voiceServerUrl !== slot.voiceServerUrl) { + app.gateway + .toPlayers(beforeSlot.player) + .broadcast(async actor => await JoinVoiceButton({ game: after, actor })) } }), ) 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 (
{isRunning ? : <>}
- #{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 20d7110a..6a1137a8 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('./twitch-tv')).default) 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('./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..cc6b753d --- /dev/null +++ b/src/mumble/client.ts @@ -0,0 +1,62 @@ +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' + +export let client: Client | undefined + +export async function tryConnect() { + client?.disconnect() + + 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...`) + 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`) + } +} diff --git a/src/mumble/index.ts b/src/mumble/index.ts new file mode 100644 index 00000000..8b121a9a --- /dev/null +++ b/src/mumble/index.ts @@ -0,0 +1,13 @@ +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'), + }) + }, + { + 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..43653fc8 --- /dev/null +++ b/src/mumble/plugins/create-channels.ts @@ -0,0 +1,47 @@ +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' + +export default fp( + async () => { + events.on( + 'game:created', + safe(async ({ game }) => { + await setupGameChannels(game) + logger.info({ game }, 'channels for game created') + }), + ) + + events.on( + 'game:playerReplaced', + safe(async ({ game, replacement }) => { + 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..56ae1f87 --- /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(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 + } + + await 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..de9b9a0b --- /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({ _id: 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/players/index.ts b/src/players/index.ts index 14283164..8969c28a 100644 --- a/src/players/index.ts +++ b/src/players/index.ts @@ -2,7 +2,6 @@ import fp from 'fastify-plugin' import { bySteamId } from './by-steam-id' import { getPlayerGameCountOnClasses } from './get-player-game-count-on-classes' import { update } from './update' -import { resolve } from 'node:path' export const players = { bySteamId, @@ -12,9 +11,6 @@ export const players = { 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) }, { name: 'players' }, 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/08-configure-mumble-server.spec.ts b/tests/20-game/08-configure-mumble-server.spec.ts new file mode 100644 index 00000000..f545f2fd --- /dev/null +++ b/tests/20-game/08-configure-mumble-server.spec.ts @@ -0,0 +1,17 @@ +import { expect, launchGame as test } from '../fixtures/launch-game' + +test.beforeAll(async ({ users }) => { + const admin = await users.getAdmin().adminPage() + await admin.configureVoiceServer({ + host: 'localhost', + password: '', + channelName: 'tf2pickup.org', + }) +}) + +test('renders join voice button', async ({ gameNumber, users }) => { + const page = await users.byName('Astropower').gamePage(gameNumber) + + const joinVoiceButton = page.joinVoiceButton() + await expect(joinVoiceButton).toBeVisible() +}) diff --git a/tests/pages/admin.page.ts b/tests/pages/admin.page.ts index 99ce6a98..293c0ad0 100644 --- a/tests/pages/admin.page.ts +++ b/tests/pages/admin.page.ts @@ -22,4 +22,13 @@ export class AdminPage { await revokeButton.click() } } + + 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() + } } 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' }) }