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' })
}