())
+
+ groupedChannels.forEach(group => group.sort((a, b) => a.position - b.position))
+
+ return (
+
+ disabled
+
+ {Array.from(groupedChannels, ([parent, grouped]) => (
+
+ {grouped.map(({ id, name }) => (
+
+ {name}
+
+ ))}
+
+ ))}
+
+ )
+}
diff --git a/src/admin/discord/views/html/select-discord-guild.tsx b/src/admin/discord/views/html/select-discord-guild.tsx
new file mode 100644
index 000000000..3aa05169d
--- /dev/null
+++ b/src/admin/discord/views/html/select-discord-guild.tsx
@@ -0,0 +1,37 @@
+import { discord } from '../../../../discord'
+
+export function SelectDiscordGuild(
+ props: {
+ current?: string | null
+ } & JSX.HtmlSelectTag,
+) {
+ const { current, ...rest } = props
+ const guilds = Array.from(discord.client?.guilds.cache.values() ?? []).toSorted((a, b) =>
+ a.name.localeCompare(b.name),
+ )
+
+ if (guilds.length === 0) {
+ return (
+
+ no guilds available
+ {current && (
+
+ {current}
+
+ )}
+
+ )
+ }
+
+ return (
+
+ disabled
+
+ {guilds.map(({ id, name }) => (
+
+ {name}
+
+ ))}
+
+ )
+}
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 36c01cb5f..eeafe75d2 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,8 @@ 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'
+import { SelectDiscordChannel } from '../../../discord/views/html/select-discord-channel'
+import { SelectDiscordGuild } from '../../../discord/views/html/select-discord-guild'
export async function VoiceServerPage() {
const type = await configuration.get('games.voice_server_type')
@@ -40,23 +42,24 @@ export async function VoiceServerPage() {
-
-
+
-
-
+
From 34eed79818fe8ee501290fbe7ac84eab061d78e1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C5=82=20Garapich?=
Date: Mon, 9 Mar 2026 15:36:53 +0100
Subject: [PATCH 3/3] devel
---
src/queue/plugins/sync-clients.ts | 28 +++++++
.../views/html/discord-voice-alert.test.tsx | 44 ++++++++++
src/queue/views/html/discord-voice-alert.tsx | 40 ++++++++++
.../queue-slot.discord-requirement.test.tsx | 80 +++++++++++++++++++
src/queue/views/html/queue-slot.tsx | 21 ++++-
src/queue/views/html/queue.page.tsx | 2 +
6 files changed, 213 insertions(+), 2 deletions(-)
create mode 100644 src/queue/views/html/discord-voice-alert.test.tsx
create mode 100644 src/queue/views/html/discord-voice-alert.tsx
create mode 100644 src/queue/views/html/queue-slot.discord-requirement.test.tsx
diff --git a/src/queue/plugins/sync-clients.ts b/src/queue/plugins/sync-clients.ts
index 46c0b4df4..47863af10 100644
--- a/src/queue/plugins/sync-clients.ts
+++ b/src/queue/plugins/sync-clients.ts
@@ -20,6 +20,7 @@ import { ChatMessages } from '../views/html/chat'
import { IsInQueue } from '../views/html/is-in-queue'
import type { PlayerModel } from '../../database/models/player.model'
import { WebSocket } from 'ws'
+import { DiscordVoiceAlert } from '../views/html/discord-voice-alert'
export default fp(
// eslint-disable-next-line @typescript-eslint/require-await
@@ -55,6 +56,7 @@ export default fp(
socket.send(await ChatMessages())
socket.send(await RunningGameSnackbar({ gameNumber: player?.activeGame }))
socket.send(await PreReadyUpButton({ actor: socket.player.steamId }))
+ socket.send(await DiscordVoiceAlert({ actor: socket.player.steamId }))
socket.send(await BanAlerts({ actor: socket.player.steamId }))
}
}
@@ -102,6 +104,32 @@ export default fp(
.to({ url: '/' })
.send(async actor => await PreReadyUpButton({ actor }))
}
+
+ if (before.discordProfile?.userId !== after.discordProfile?.userId) {
+ const cmp = await DiscordVoiceAlert({ actor: after.steamId })
+ app.gateway
+ .to({ player: after.steamId })
+ .to({ url: '/' })
+ .send(() => cmp)
+ await syncAllSlots(after.steamId)
+ }
+ }),
+ )
+
+ events.on(
+ 'configuration:updated',
+ safe(async ({ key }) => {
+ if (key !== 'games.voice_server_type') {
+ return
+ }
+
+ const slots = await collections.queueSlots.find().toArray()
+ app.gateway
+ .to({ url: '/' })
+ .send(async actor => [
+ ...(await Promise.all(slots.map(async slot => await QueueSlot({ slot, actor })))),
+ await DiscordVoiceAlert({ actor }),
+ ])
}),
)
diff --git a/src/queue/views/html/discord-voice-alert.test.tsx b/src/queue/views/html/discord-voice-alert.test.tsx
new file mode 100644
index 000000000..368cbb967
--- /dev/null
+++ b/src/queue/views/html/discord-voice-alert.test.tsx
@@ -0,0 +1,44 @@
+import { describe, expect, it, vi } from 'vitest'
+import { DiscordVoiceAlert } from './discord-voice-alert'
+import { configuration } from '../../../configuration'
+import { collections } from '../../../database/collections'
+import type { SteamId64 } from '../../../shared/types/steam-id-64'
+import { VoiceServerType } from '../../../shared/types/voice-server-type'
+
+vi.mock('../../../configuration', () => ({
+ configuration: { get: vi.fn() },
+}))
+
+vi.mock('../../../database/collections', () => ({
+ collections: {
+ players: { findOne: vi.fn() },
+ },
+}))
+
+const actor = '76561198000000001' as SteamId64
+
+describe('DiscordVoiceAlert', () => {
+ it('renders nothing when discord voice is disabled', async () => {
+ vi.mocked(configuration.get).mockResolvedValue(VoiceServerType.none)
+ const html = await DiscordVoiceAlert({ actor })
+ expect(html).toContain('id="discord-voice-alert"')
+ expect(html).not.toContain('Link your Discord account')
+ })
+
+ it('renders a warning when discord voice is enabled and actor is not linked', async () => {
+ vi.mocked(configuration.get).mockResolvedValue(VoiceServerType.discord)
+ vi.mocked(collections.players.findOne).mockResolvedValue({ discordProfile: undefined })
+ const html = await DiscordVoiceAlert({ actor })
+ expect(html).toContain('Link your Discord account')
+ expect(html).toContain('href="/settings"')
+ })
+
+ it('renders nothing when the actor has linked discord', async () => {
+ vi.mocked(configuration.get).mockResolvedValue(VoiceServerType.discord)
+ vi.mocked(collections.players.findOne).mockResolvedValue({
+ discordProfile: { userId: '123' },
+ })
+ const html = await DiscordVoiceAlert({ actor })
+ expect(html).not.toContain('Link your Discord account')
+ })
+})
diff --git a/src/queue/views/html/discord-voice-alert.tsx b/src/queue/views/html/discord-voice-alert.tsx
new file mode 100644
index 000000000..72cddf94b
--- /dev/null
+++ b/src/queue/views/html/discord-voice-alert.tsx
@@ -0,0 +1,40 @@
+import { configuration } from '../../../configuration'
+import { collections } from '../../../database/collections'
+import type { PlayerModel } from '../../../database/models/player.model'
+import type { SteamId64 } from '../../../shared/types/steam-id-64'
+import { VoiceServerType } from '../../../shared/types/voice-server-type'
+
+export async function DiscordVoiceAlert(props: { actor?: SteamId64 | undefined }) {
+ return (
+
+
+
+ )
+}
+
+async function DiscordVoiceAlertContent(props: { actor?: SteamId64 | undefined }) {
+ if (!props.actor) {
+ return <>>
+ }
+
+ const type = await configuration.get('games.voice_server_type')
+ if (type !== VoiceServerType.discord) {
+ return <>>
+ }
+
+ const actor = await collections.players.findOne>(
+ { steamId: props.actor },
+ { projection: { discordProfile: 1 } },
+ )
+ if (actor?.discordProfile) {
+ return <>>
+ }
+
+ return (
+
+ Link your Discord account in{' '}
+
settings
+ {' '}to join the queue while Discord voice is enabled.
+
+ )
+}
diff --git a/src/queue/views/html/queue-slot.discord-requirement.test.tsx b/src/queue/views/html/queue-slot.discord-requirement.test.tsx
new file mode 100644
index 000000000..81ef97c95
--- /dev/null
+++ b/src/queue/views/html/queue-slot.discord-requirement.test.tsx
@@ -0,0 +1,80 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { QueueSlot } from './queue-slot'
+import { collections } from '../../../database/collections'
+import { configuration } from '../../../configuration'
+import { meetsSkillThreshold } from '../../meets-skill-threshold'
+import { Tf2ClassName } from '../../../shared/types/tf2-class-name'
+import type { SteamId64 } from '../../../shared/types/steam-id-64'
+import { VoiceServerType } from '../../../shared/types/voice-server-type'
+import type { QueueSlotId } from '../../types/queue-slot-id'
+
+vi.mock('../../../database/collections', () => ({
+ collections: {
+ players: { findOne: vi.fn() },
+ queueSlots: { findOne: vi.fn() },
+ queueFriends: { findOne: vi.fn() },
+ },
+}))
+
+vi.mock('../../../configuration', () => ({
+ configuration: { get: vi.fn() },
+}))
+
+vi.mock('../../meets-skill-threshold', () => ({
+ meetsSkillThreshold: vi.fn(),
+}))
+
+const actor = '76561198000000001' as SteamId64
+
+const emptySlot = {
+ id: 'soldier-0' as QueueSlotId,
+ gameClass: Tf2ClassName.soldier,
+ player: null,
+ ready: false,
+}
+
+describe('QueueSlot discord requirement', () => {
+ beforeEach(() => {
+ vi.mocked(meetsSkillThreshold).mockResolvedValue(true)
+ vi.mocked(configuration.get).mockImplementation(async key => {
+ if (key === 'games.voice_server_type') {
+ return VoiceServerType.discord
+ }
+
+ if (key === 'queue.require_player_verification') {
+ return false
+ }
+
+ throw new Error(`unexpected config key ${String(key)}`)
+ })
+ })
+
+ it('locks join when discord voice is enabled and actor is not linked', async () => {
+ vi.mocked(collections.players.findOne).mockResolvedValue({
+ bans: [],
+ activeGame: undefined,
+ skill: undefined,
+ verified: true,
+ discordProfile: undefined,
+ })
+
+ const html = await QueueSlot({ slot: emptySlot, actor })
+ expect(html).toContain('disabled')
+ expect(html).toContain('Link your Discord account in settings to join the queue')
+ })
+
+ it('keeps join enabled when discord voice is enabled and actor is linked', async () => {
+ vi.mocked(collections.players.findOne).mockResolvedValue({
+ bans: [],
+ activeGame: undefined,
+ skill: undefined,
+ verified: true,
+ discordProfile: { userId: '123' },
+ })
+
+ const html = await QueueSlot({ slot: emptySlot, actor })
+ expect(html).toContain('join-queue-button')
+ expect(html).not.toContain('disabled')
+ expect(html).not.toContain('Link your Discord account in settings to join the queue')
+ })
+})
diff --git a/src/queue/views/html/queue-slot.tsx b/src/queue/views/html/queue-slot.tsx
index cf5a3cfd6..dfc4563c1 100644
--- a/src/queue/views/html/queue-slot.tsx
+++ b/src/queue/views/html/queue-slot.tsx
@@ -12,6 +12,7 @@ import {
IconPlus,
} from '../../../html/components/icons'
import type { SteamId64 } from '../../../shared/types/steam-id-64'
+import { VoiceServerType } from '../../../shared/types/voice-server-type'
import { meetsSkillThreshold } from '../../meets-skill-threshold'
import type { QueueSlotId } from '../../types/queue-slot-id'
@@ -28,8 +29,19 @@ export async function QueueSlot(props: { slot: QueueSlotModel; actor?: SteamId64
slotContent =
} else if (props.actor) {
const actor = await collections.players.findOne<
- Pick
- >({ steamId: props.actor }, { projection: { bans: 1, activeGame: 1, skill: 1, verified: 1 } })
+ Pick
+ >(
+ { steamId: props.actor },
+ {
+ projection: {
+ bans: 1,
+ activeGame: 1,
+ skill: 1,
+ verified: 1,
+ discordProfile: 1,
+ },
+ },
+ )
if (!actor) {
throw errors.internalServerError(`actor invalid: ${props.actor}`)
}
@@ -40,6 +52,11 @@ export async function QueueSlot(props: { slot: QueueSlotModel; actor?: SteamId64
disabled = 'You have active bans'
} else if (actor.activeGame) {
disabled = 'You are already in a game'
+ } else if (
+ (await configuration.get('games.voice_server_type')) === VoiceServerType.discord &&
+ !actor.discordProfile
+ ) {
+ disabled = 'Link your Discord account in settings to join the queue'
} else if (!(await meetsSkillThreshold(actor, props.slot))) {
disabled = `You do not meet skill requirements to play ${props.slot.gameClass}`
} else if ((await configuration.get('queue.require_player_verification')) && !actor.verified) {
diff --git a/src/queue/views/html/queue.page.tsx b/src/queue/views/html/queue.page.tsx
index 82d4dfe47..d3af46dd8 100644
--- a/src/queue/views/html/queue.page.tsx
+++ b/src/queue/views/html/queue.page.tsx
@@ -26,6 +26,7 @@ import { IsInQueue } from './is-in-queue'
import { MapVoteSelection } from './map-vote-selection'
import { requestContext } from '@fastify/request-context'
import { Announcements } from './announcements'
+import { DiscordVoiceAlert } from './discord-voice-alert'
export async function QueuePage() {
const slots = await collections.queueSlots.find().toArray()
@@ -49,6 +50,7 @@ export async function QueuePage() {