From f370ce8fea59557bdb7a2b563b59e8e58a7151ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Garapich?= Date: Fri, 6 Mar 2026 18:54:54 +0100 Subject: [PATCH 1/3] feat: voice on discord support --- sample.env | 2 + .../views/html/voice-server.page.tsx | 82 ++++++++++ src/auth/plugins/steam.ts | 1 + src/auth/types/user.ts | 1 + .../models/configuration-entry.model.ts | 12 ++ src/database/models/game.model.ts | 10 ++ src/database/models/player.model.ts | 8 + src/discord-voice/create-postgame-lobby.ts | 103 +++++++++++++ src/discord-voice/deep-link.ts | 3 + src/discord-voice/get-config.ts | 28 ++++ src/discord-voice/get-guild.ts | 40 +++++ src/discord-voice/plugins/create-channels.ts | 29 ++++ src/discord-voice/plugins/link-channels.ts | 17 +++ src/discord-voice/plugins/remove-channels.ts | 38 +++++ src/discord-voice/remove-game-channels.ts | 37 +++++ src/discord-voice/setup-game-channels.ts | 68 +++++++++ src/discord-voice/sync-game-channels.ts | 144 ++++++++++++++++++ src/discord/client.ts | 2 + src/discord/fetch-user-access-token.ts | 34 +++++ src/discord/fetch-user.ts | 21 +++ src/discord/index.ts | 8 + src/discord/make-oauth-redirect-url.ts | 19 +++ src/discord/return-url.ts | 3 + src/discord/save-user-profile.ts | 31 ++++ src/discord/state.ts | 32 ++++ .../html/discord-settings-entry.test.tsx | 25 +++ .../views/html/discord-settings-entry.tsx | 43 ++++++ src/environment.ts | 2 + src/games/plugins/sync-clients.ts | 3 +- src/games/views/html/connect-info.tsx | 2 + .../views/html/discord-voice-status.test.tsx | 75 +++++++++ src/games/views/html/discord-voice-status.tsx | 56 +++++++ .../views/html/player-settings.page.tsx | 3 + src/routes/admin/voice-server/index.ts | 15 ++ src/routes/discord/auth/index.ts | 24 +++ src/routes/discord/auth/return/index.ts | 47 ++++++ src/routes/discord/index.ts | 28 ++++ src/shared/types/voice-server-type.ts | 1 + src/tasks/tasks.ts | 6 + 39 files changed, 1101 insertions(+), 2 deletions(-) create mode 100644 src/discord-voice/create-postgame-lobby.ts create mode 100644 src/discord-voice/deep-link.ts create mode 100644 src/discord-voice/get-config.ts create mode 100644 src/discord-voice/get-guild.ts create mode 100644 src/discord-voice/plugins/create-channels.ts create mode 100644 src/discord-voice/plugins/link-channels.ts create mode 100644 src/discord-voice/plugins/remove-channels.ts create mode 100644 src/discord-voice/remove-game-channels.ts create mode 100644 src/discord-voice/setup-game-channels.ts create mode 100644 src/discord-voice/sync-game-channels.ts create mode 100644 src/discord/fetch-user-access-token.ts create mode 100644 src/discord/fetch-user.ts create mode 100644 src/discord/make-oauth-redirect-url.ts create mode 100644 src/discord/return-url.ts create mode 100644 src/discord/save-user-profile.ts create mode 100644 src/discord/state.ts create mode 100644 src/discord/views/html/discord-settings-entry.test.tsx create mode 100644 src/discord/views/html/discord-settings-entry.tsx create mode 100644 src/games/views/html/discord-voice-status.test.tsx create mode 100644 src/games/views/html/discord-voice-status.tsx create mode 100644 src/routes/discord/auth/index.ts create mode 100644 src/routes/discord/auth/return/index.ts create mode 100644 src/routes/discord/index.ts diff --git a/sample.env b/sample.env index f69a729df..7ccb1ac1c 100644 --- a/sample.env +++ b/sample.env @@ -60,6 +60,8 @@ SERVEME_TF_API_KEY= # Discord (optional) DISCORD_BOT_TOKEN= +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= # twitch.tv integration (optional) # https://dev.twitch.tv/console 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 75132f80c..36c01cb5f 100644 --- a/src/admin/voice-server/views/html/voice-server.page.tsx +++ b/src/admin/voice-server/views/html/voice-server.page.tsx @@ -15,6 +15,11 @@ export async function VoiceServerPage() { configuration.get('games.voice_server.mumble.password'), configuration.get('games.voice_server.mumble.channel_name'), ]) + const [discordGuildId, discordCategoryId, discordPostgameCategoryId] = await Promise.all([ + configuration.get('games.voice_server.discord.guild_id'), + configuration.get('games.voice_server.discord.category_id'), + configuration.get('games.voice_server.discord.postgame_category_id'), + ]) return ( @@ -82,6 +87,21 @@ export async function VoiceServerPage() {

+
+ + +

+ The Discord bot will create private team voice channels for each game and move players + into a shared post-game lobby after the match. +

+
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+

diff --git a/src/auth/plugins/steam.ts b/src/auth/plugins/steam.ts index 9a7956512..cc4691870 100644 --- a/src/auth/plugins/steam.ts +++ b/src/auth/plugins/steam.ts @@ -107,6 +107,7 @@ export default fp( 'hasAcceptedRules', 'activeGame', 'twitchTvProfile', + 'discordProfile', ]) request.user = { player } request.requestContext.set('user', { player }) diff --git a/src/auth/types/user.ts b/src/auth/types/user.ts index df77564dd..7a52b0a8e 100644 --- a/src/auth/types/user.ts +++ b/src/auth/types/user.ts @@ -12,5 +12,6 @@ export interface User { | 'hasAcceptedRules' | 'activeGame' | 'twitchTvProfile' + | 'discordProfile' > } diff --git a/src/database/models/configuration-entry.model.ts b/src/database/models/configuration-entry.model.ts index e12a6e3fc..b2220cd47 100644 --- a/src/database/models/configuration-entry.model.ts +++ b/src/database/models/configuration-entry.model.ts @@ -149,6 +149,18 @@ export const configurationSchema = z.discriminatedUnion('key', [ key: z.literal('games.voice_server.mumble.password'), value: z.string().nullable().default(null), }), + z.object({ + key: z.literal('games.voice_server.discord.guild_id'), + value: z.string().nullable().default(null), + }), + z.object({ + key: z.literal('games.voice_server.discord.category_id'), + value: z.string().nullable().default(null), + }), + z.object({ + key: z.literal('games.voice_server.discord.postgame_category_id'), + value: z.string().nullable().default(null), + }), z.object({ key: z.literal('games.join_queue_cooldown'), value: z diff --git a/src/database/models/game.model.ts b/src/database/models/game.model.ts index c2e21beef..9423454a0 100644 --- a/src/database/models/game.model.ts +++ b/src/database/models/game.model.ts @@ -47,6 +47,15 @@ export interface GameServer { } } +export interface DiscordVoiceChannelSet { + guildId: string + categoryId: string + postgameCategoryId: string + redChannelId: string + bluChannelId: string + postgameChannelId?: string +} + export interface GameModel { number: GameNumber map: string @@ -63,4 +72,5 @@ export interface GameModel { logSecret?: string connectString?: string stvConnectString?: string + discordVoice?: DiscordVoiceChannelSet } diff --git a/src/database/models/player.model.ts b/src/database/models/player.model.ts index 580f1c944..f27715e98 100644 --- a/src/database/models/player.model.ts +++ b/src/database/models/player.model.ts @@ -32,6 +32,13 @@ export interface TwitchTvProfile { profileImageUrl: string } +export interface DiscordProfile { + userId: string + username: string + displayName: string + avatarUrl: string | null +} + export interface Etf2lProfile { id: number name: string @@ -73,5 +80,6 @@ export interface PlayerModel { chatMutes?: PlayerBan[] verified?: boolean twitchTvProfile?: TwitchTvProfile + discordProfile?: DiscordProfile stats: PlayerStats } diff --git a/src/discord-voice/create-postgame-lobby.ts b/src/discord-voice/create-postgame-lobby.ts new file mode 100644 index 000000000..9332fd0d4 --- /dev/null +++ b/src/discord-voice/create-postgame-lobby.ts @@ -0,0 +1,103 @@ +import { ChannelType, PermissionFlagsBits, type VoiceChannel } from 'discord.js' +import type { GameModel } from '../database/models/game.model' +import { discord } from '../discord' +import { assertClient } from '../discord/assert-client' +import { games } from '../games' +import { getGuild } from './get-guild' +import { resolveSlotMembers } from './sync-game-channels' + +export async function createPostgameLobby(game: GameModel) { + const guildContext = await getGuild() + if (!guildContext || !game.discordVoice) { + return game + } + + assertClient(discord.client) + const { guild, postgameCategory } = guildContext + const resolvedExistingChannel = game.discordVoice.postgameChannelId + ? guild.channels.resolve(game.discordVoice.postgameChannelId) + : null + const existingChannel: VoiceChannel | null = + resolvedExistingChannel?.type === ChannelType.GuildVoice ? resolvedExistingChannel : null + + const postgameChannel: VoiceChannel = + existingChannel ?? + (await guild.channels.create({ + name: `${game.number}-POSTGAME`, + type: ChannelType.GuildVoice, + parent: postgameCategory, + permissionOverwrites: [ + { + id: guild.roles.everyone.id, + deny: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect], + }, + { + id: discord.client.user!.id, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.Connect, + PermissionFlagsBits.Speak, + PermissionFlagsBits.MoveMembers, + PermissionFlagsBits.ManageChannels, + ], + }, + ], + })) + + const currentGame = + existingChannel && game.discordVoice.postgameChannelId === postgameChannel.id + ? game + : await games.update(game.number, { + $set: { + 'discordVoice.postgameChannelId': postgameChannel.id, + }, + }) + + const [redChannel, bluChannel] = [ + guild.channels.resolve(currentGame.discordVoice!.redChannelId), + guild.channels.resolve(currentGame.discordVoice!.bluChannelId), + ] + if (redChannel?.type !== ChannelType.GuildVoice || bluChannel?.type !== ChannelType.GuildVoice) { + return currentGame + } + + const resolvedMembers = await resolveSlotMembers(currentGame) + const members = [...redChannel.members.values(), ...bluChannel.members.values()] + const overwriteIds = new Set([ + ...resolvedMembers.map(member => member.userId), + ...members.map(member => member.id), + ]) + + await postgameChannel.permissionOverwrites.set([ + { + id: guild.roles.everyone.id, + deny: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect], + }, + { + id: discord.client.user!.id, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.Connect, + PermissionFlagsBits.Speak, + PermissionFlagsBits.MoveMembers, + PermissionFlagsBits.ManageChannels, + ], + }, + ...Array.from(overwriteIds, id => ({ + id, + allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect], + })), + ]) + + await Promise.all( + members.map(async member => { + try { + await member.voice.setChannel(postgameChannel) + } catch { + // ignore move failures; permission grant is already in place + } + }), + ) + + return currentGame +} diff --git a/src/discord-voice/deep-link.ts b/src/discord-voice/deep-link.ts new file mode 100644 index 000000000..44b23622c --- /dev/null +++ b/src/discord-voice/deep-link.ts @@ -0,0 +1,3 @@ +export function makeDeepLink(guildId: string, channelId: string) { + return `https://discord.com/channels/${guildId}/${channelId}` +} diff --git a/src/discord-voice/get-config.ts b/src/discord-voice/get-config.ts new file mode 100644 index 000000000..cbd4141be --- /dev/null +++ b/src/discord-voice/get-config.ts @@ -0,0 +1,28 @@ +import { errors } from '../errors' +import { configuration } from '../configuration' +import { VoiceServerType } from '../shared/types/voice-server-type' + +export interface DiscordVoiceConfiguration { + guildId: string + categoryId: string + postgameCategoryId: string +} + +export async function getConfig(): Promise { + const type = await configuration.get('games.voice_server_type') + if (type !== VoiceServerType.discord) { + return null + } + + const [guildId, categoryId, postgameCategoryId] = await Promise.all([ + configuration.get('games.voice_server.discord.guild_id'), + configuration.get('games.voice_server.discord.category_id'), + configuration.get('games.voice_server.discord.postgame_category_id'), + ]) + + if (!guildId || !categoryId || !postgameCategoryId) { + throw errors.internalServerError('discord voice configuration malformed') + } + + return { guildId, categoryId, postgameCategoryId } +} diff --git a/src/discord-voice/get-guild.ts b/src/discord-voice/get-guild.ts new file mode 100644 index 000000000..d2fbb07aa --- /dev/null +++ b/src/discord-voice/get-guild.ts @@ -0,0 +1,40 @@ +import { ChannelType, type CategoryChannel, type Guild } from 'discord.js' +import { errors } from '../errors' +import { discord } from '../discord' +import { assertClient } from '../discord/assert-client' +import { getConfig } from './get-config' + +export async function getGuild() { + const config = await getConfig() + if (!config) { + return null + } + + assertClient(discord.client) + const guild = discord.client.guilds.resolve(config.guildId) + if (!guild) { + throw errors.notFound(`discord guild not found`) + } + + const category = guild.channels.resolve(config.categoryId) + if (category?.type !== ChannelType.GuildCategory) { + throw errors.notFound(`discord voice category not found`) + } + + const postgameCategory = guild.channels.resolve(config.postgameCategoryId) + if (postgameCategory?.type !== ChannelType.GuildCategory) { + throw errors.notFound(`discord postgame category not found`) + } + + return { + guild, + config, + category, + postgameCategory, + } satisfies { + guild: Guild + config: NonNullable>> + category: CategoryChannel + postgameCategory: CategoryChannel + } +} diff --git a/src/discord-voice/plugins/create-channels.ts b/src/discord-voice/plugins/create-channels.ts new file mode 100644 index 000000000..5c2046b34 --- /dev/null +++ b/src/discord-voice/plugins/create-channels.ts @@ -0,0 +1,29 @@ +import fp from 'fastify-plugin' +import { events } from '../../events' +import { safe } from '../../utils/safe' +import { logger } from '../../logger' +import { setupGameChannels } from '../setup-game-channels' +import { syncGameChannels } from '../sync-game-channels' + +export default fp( + // eslint-disable-next-line @typescript-eslint/require-await + async () => { + events.on( + 'game:created', + safe(async ({ game }) => { + const updatedGame = await setupGameChannels(game) + if (updatedGame !== game) { + logger.info({ gameNumber: game.number }, 'discord voice channels created') + } + }), + ) + + events.on( + 'game:playerReplaced', + safe(async ({ game }) => { + await syncGameChannels(game) + }), + ) + }, + { name: 'discord voice - auto create channels' }, +) diff --git a/src/discord-voice/plugins/link-channels.ts b/src/discord-voice/plugins/link-channels.ts new file mode 100644 index 000000000..d53d16cd3 --- /dev/null +++ b/src/discord-voice/plugins/link-channels.ts @@ -0,0 +1,17 @@ +import fp from 'fastify-plugin' +import { events } from '../../events' +import { safe } from '../../utils/safe' +import { createPostgameLobby } from '../create-postgame-lobby' + +export default fp( + // eslint-disable-next-line @typescript-eslint/require-await + async () => { + events.on( + 'game:ended', + safe(async ({ game }) => { + await createPostgameLobby(game) + }), + ) + }, + { name: 'discord voice - create postgame lobby' }, +) diff --git a/src/discord-voice/plugins/remove-channels.ts b/src/discord-voice/plugins/remove-channels.ts new file mode 100644 index 000000000..b1f41650c --- /dev/null +++ b/src/discord-voice/plugins/remove-channels.ts @@ -0,0 +1,38 @@ +import fp from 'fastify-plugin' +import { minutesToMilliseconds } from 'date-fns' +import { events } from '../../events' +import { safe } from '../../utils/safe' +import { tasks } from '../../tasks' +import { games } from '../../games' +import { getConfig } from '../get-config' +import { removeGameChannels } from '../remove-game-channels' + +const removeChannelDelay = minutesToMilliseconds(1) + +export default fp( + // eslint-disable-next-line @typescript-eslint/require-await + async () => { + tasks.register('discord.cleanupVoiceChannels', async ({ gameNumber }) => { + const game = await games.findOne({ number: gameNumber }) + const { removed } = await removeGameChannels(game) + if (!removed) { + await tasks.schedule('discord.cleanupVoiceChannels', removeChannelDelay, { gameNumber }) + } + }) + + events.on( + 'game:ended', + safe(async ({ game }) => { + const config = await getConfig() + if (!config) { + return + } + + await tasks.schedule('discord.cleanupVoiceChannels', removeChannelDelay, { + gameNumber: game.number, + }) + }), + ) + }, + { name: 'discord voice - auto remove old channels' }, +) diff --git a/src/discord-voice/remove-game-channels.ts b/src/discord-voice/remove-game-channels.ts new file mode 100644 index 000000000..1b0a96620 --- /dev/null +++ b/src/discord-voice/remove-game-channels.ts @@ -0,0 +1,37 @@ +import { ChannelType, type VoiceChannel } from 'discord.js' +import type { GameModel } from '../database/models/game.model' +import { logger } from '../logger' +import { games } from '../games' +import { getGuild } from './get-guild' + +export async function removeGameChannels(game: GameModel) { + const guildContext = await getGuild() + if (!guildContext || !game.discordVoice) { + return { removed: true } + } + + const { guild } = guildContext + const channels = [ + guild.channels.resolve(game.discordVoice.redChannelId), + guild.channels.resolve(game.discordVoice.bluChannelId), + game.discordVoice.postgameChannelId + ? guild.channels.resolve(game.discordVoice.postgameChannelId) + : null, + ].filter((channel): channel is VoiceChannel => channel?.type === ChannelType.GuildVoice) + + const occupied = channels.some(channel => channel.members.size > 0) + if (occupied) { + logger.debug({ gameNumber: game.number }, 'discord voice channels not empty yet') + return { removed: false } + } + + await Promise.all(channels.map(async channel => await channel.delete())) + await games.update(game.number, { + $unset: { + discordVoice: 1, + ...Object.fromEntries(game.slots.map((_, i) => [`slots.${i}.voiceServerUrl`, 1])), + }, + }) + logger.info({ gameNumber: game.number }, 'discord voice channels removed') + return { removed: true } +} diff --git a/src/discord-voice/setup-game-channels.ts b/src/discord-voice/setup-game-channels.ts new file mode 100644 index 000000000..32e467493 --- /dev/null +++ b/src/discord-voice/setup-game-channels.ts @@ -0,0 +1,68 @@ +import { ChannelType, PermissionFlagsBits } from 'discord.js' +import type { GameModel } from '../database/models/game.model' +import { errors } from '../errors' +import { discord } from '../discord' +import { assertClient } from '../discord/assert-client' +import { games } from '../games' +import { getGuild } from './get-guild' +import { syncGameChannels } from './sync-game-channels' + +export async function setupGameChannels(game: GameModel) { + const guildContext = await getGuild() + if (!guildContext) { + return game + } + + assertClient(discord.client) + const { guild, category, postgameCategory, config } = guildContext + if (!discord.client.user) { + throw errors.internalServerError('discord bot user unavailable') + } + + const everyoneId = guild.roles.everyone.id + const baseOverwrites = [ + { + id: everyoneId, + deny: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect], + }, + { + id: discord.client.user.id, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.Connect, + PermissionFlagsBits.Speak, + PermissionFlagsBits.MoveMembers, + PermissionFlagsBits.ManageChannels, + ], + }, + ] + + const [redChannel, bluChannel] = await Promise.all([ + guild.channels.create({ + name: `${game.number}-RED`, + type: ChannelType.GuildVoice, + parent: category, + permissionOverwrites: baseOverwrites, + }), + guild.channels.create({ + name: `${game.number}-BLU`, + type: ChannelType.GuildVoice, + parent: category, + permissionOverwrites: baseOverwrites, + }), + ]) + + const updatedGame = await games.update(game.number, { + $set: { + discordVoice: { + guildId: config.guildId, + categoryId: category.id, + postgameCategoryId: postgameCategory.id, + redChannelId: redChannel.id, + bluChannelId: bluChannel.id, + }, + }, + }) + + return await syncGameChannels(updatedGame) +} diff --git a/src/discord-voice/sync-game-channels.ts b/src/discord-voice/sync-game-channels.ts new file mode 100644 index 000000000..236707d4b --- /dev/null +++ b/src/discord-voice/sync-game-channels.ts @@ -0,0 +1,144 @@ +import { + ChannelType, + PermissionFlagsBits, + type GuildMember, + type OverwriteResolvable, +} from 'discord.js' +import type { GameModel } from '../database/models/game.model' +import { Tf2Team } from '../shared/types/tf2-team' +import { discord } from '../discord' +import { assertClient } from '../discord/assert-client' +import { collections } from '../database/collections' +import { games } from '../games' +import { makeDeepLink } from './deep-link' +import { getGuild } from './get-guild' + +interface ResolvedSlotMember { + steamId: GameModel['slots'][number]['player'] + userId: string + channelId: string + member: GuildMember +} + +export async function resolveSlotMembers(game: GameModel) { + const guildContext = await getGuild() + if (!guildContext || !game.discordVoice) { + return [] + } + + const { guild } = guildContext + const players = await Promise.all( + game.slots.map(async slot => { + const player = await collections.players.findOne( + { steamId: slot.player }, + { projection: { steamId: 1, discordProfile: 1 } }, + ) + return { slot, player } + }), + ) + + const resolved = await Promise.all( + players.map(async ({ slot, player }) => { + const userId = player?.discordProfile?.userId + if (!userId) { + return null + } + + try { + const member = await guild.members.fetch(userId) + const channelId = + slot.team === Tf2Team.red + ? game.discordVoice!.redChannelId + : game.discordVoice!.bluChannelId + return { steamId: slot.player, userId, channelId, member } satisfies ResolvedSlotMember + } catch { + return null + } + }), + ) + + return resolved.filter(member => member !== null) +} + +function makeChannelOverwrites( + everyoneId: string, + channelMembers: ResolvedSlotMember[], +): OverwriteResolvable[] { + assertClient(discord.client) + + return [ + { + id: everyoneId, + deny: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect], + }, + { + id: discord.client.user!.id, + allow: [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.Connect, + PermissionFlagsBits.MoveMembers, + PermissionFlagsBits.ManageChannels, + PermissionFlagsBits.Speak, + ], + }, + ...channelMembers.map(({ userId }) => ({ + id: userId, + allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect], + })), + ].filter(overwrite => overwrite.id) +} + +export async function syncGameChannels(game: GameModel) { + const guildContext = await getGuild() + if (!guildContext || !game.discordVoice) { + return game + } + + const { guild } = guildContext + const [redChannel, bluChannel] = [ + guild.channels.resolve(game.discordVoice.redChannelId), + guild.channels.resolve(game.discordVoice.bluChannelId), + ] + if (redChannel?.type !== ChannelType.GuildVoice || bluChannel?.type !== ChannelType.GuildVoice) { + return game + } + + const resolvedMembers = await resolveSlotMembers(game) + const redMembers = resolvedMembers.filter(({ channelId }) => channelId === redChannel.id) + const bluMembers = resolvedMembers.filter(({ channelId }) => channelId === bluChannel.id) + + await Promise.all([ + redChannel.permissionOverwrites.set(makeChannelOverwrites(guild.roles.everyone.id, redMembers)), + bluChannel.permissionOverwrites.set(makeChannelOverwrites(guild.roles.everyone.id, bluMembers)), + ]) + + const setDoc: Record = {} + const unsetDoc: Record = {} + + for (const [i, slot] of game.slots.entries()) { + const matchingMember = resolvedMembers.find(member => member.steamId === slot.player) + if (!matchingMember) { + unsetDoc[`slots.${i}.voiceServerUrl`] = 1 + continue + } + + setDoc[`slots.${i}.voiceServerUrl`] = makeDeepLink( + game.discordVoice.guildId, + matchingMember.channelId, + ) + } + + const updateDoc: { $set?: Record; $unset?: Record } = {} + if (Object.keys(setDoc).length > 0) { + updateDoc.$set = setDoc + } + if (Object.keys(unsetDoc).length > 0) { + updateDoc.$unset = unsetDoc + } + + if (Object.keys(updateDoc).length === 0) { + return game + } + + return await games.update(game.number, updateDoc) +} diff --git a/src/discord/client.ts b/src/discord/client.ts index be3ee4be5..f311ef5a0 100644 --- a/src/discord/client.ts +++ b/src/discord/client.ts @@ -13,7 +13,9 @@ async function initializeClient(): Promise { intents: [ IntentsBitField.Flags.Guilds, IntentsBitField.Flags.GuildExpressions, + IntentsBitField.Flags.GuildMembers, IntentsBitField.Flags.GuildMessages, + IntentsBitField.Flags.GuildVoiceStates, ], }) const ready = new Promise(resolve => { diff --git a/src/discord/fetch-user-access-token.ts b/src/discord/fetch-user-access-token.ts new file mode 100644 index 000000000..b6ecfcac5 --- /dev/null +++ b/src/discord/fetch-user-access-token.ts @@ -0,0 +1,34 @@ +import { environment } from '../environment' + +export async function fetchUserAccessToken(code: string): Promise { + if (!environment.DISCORD_CLIENT_ID || !environment.DISCORD_CLIENT_SECRET) { + throw new Error( + `DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET env variables are required to call this function`, + ) + } + + const params = new URLSearchParams() + params.set('client_id', environment.DISCORD_CLIENT_ID) + params.set('client_secret', environment.DISCORD_CLIENT_SECRET) + params.set('grant_type', 'authorization_code') + params.set('code', code) + params.set('redirect_uri', `${environment.WEBSITE_URL}/discord/auth/return`) + + const response = await fetch('https://discord.com/api/oauth2/token', { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + body: params, + }) + if (!response.ok) { + throw new Error(`discord oauth token request failed: ${response.status}`) + } + + const body = (await response.json()) as { access_token?: string } + if (!body.access_token) { + throw new Error('discord oauth token missing access_token') + } + + return body.access_token +} diff --git a/src/discord/fetch-user.ts b/src/discord/fetch-user.ts new file mode 100644 index 000000000..fd65b3491 --- /dev/null +++ b/src/discord/fetch-user.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' + +const discordUserSchema = z.object({ + id: z.string(), + username: z.string(), + global_name: z.string().nullable().optional(), + avatar: z.string().nullable().optional(), +}) + +export async function fetchUser(accessToken: string) { + const response = await fetch('https://discord.com/api/users/@me', { + headers: { + authorization: `Bearer ${accessToken}`, + }, + }) + if (!response.ok) { + throw new Error(`discord user request failed: ${response.status}`) + } + + return discordUserSchema.parse(await response.json()) +} diff --git a/src/discord/index.ts b/src/discord/index.ts index c61b2d2cf..f8e8524fd 100644 --- a/src/discord/index.ts +++ b/src/discord/index.ts @@ -1,5 +1,13 @@ import { client } from './client' +import { environment } from '../environment' +import { makeOauthRedirectUrl } from './make-oauth-redirect-url' +import { saveUserProfile } from './save-user-profile' +import { state } from './state' export const discord = { client, + oauthEnabled: Boolean(environment.DISCORD_CLIENT_ID && environment.DISCORD_CLIENT_SECRET), + makeOauthRedirectUrl, + saveUserProfile, + state, } as const diff --git a/src/discord/make-oauth-redirect-url.ts b/src/discord/make-oauth-redirect-url.ts new file mode 100644 index 000000000..15d5af292 --- /dev/null +++ b/src/discord/make-oauth-redirect-url.ts @@ -0,0 +1,19 @@ +import { environment } from '../environment' +import { errors } from '../errors' +import { returnUrl } from './return-url' + +export function makeOauthRedirectUrl(state: string): string { + if (!environment.DISCORD_CLIENT_ID) { + throw errors.badRequest(`DISCORD_CLIENT_ID env variable is required to call this function`) + } + + const params = new URLSearchParams() + params.set('client_id', environment.DISCORD_CLIENT_ID) + params.set('redirect_uri', returnUrl) + params.set('response_type', 'code') + params.set('scope', 'identify') + params.set('prompt', 'consent') + params.set('state', state) + + return `https://discord.com/oauth2/authorize?${params}` +} diff --git a/src/discord/return-url.ts b/src/discord/return-url.ts new file mode 100644 index 000000000..5abad91b3 --- /dev/null +++ b/src/discord/return-url.ts @@ -0,0 +1,3 @@ +import { environment } from '../environment' + +export const returnUrl = `${environment.WEBSITE_URL}/discord/auth/return` diff --git a/src/discord/save-user-profile.ts b/src/discord/save-user-profile.ts new file mode 100644 index 000000000..f50ba2985 --- /dev/null +++ b/src/discord/save-user-profile.ts @@ -0,0 +1,31 @@ +import type { SteamId64 } from '../shared/types/steam-id-64' +import { collections } from '../database/collections' +import { fetchUser } from './fetch-user' +import { fetchUserAccessToken } from './fetch-user-access-token' + +function makeAvatarUrl(user: { id: string; avatar: string | null | undefined }) { + if (!user.avatar) { + return null + } + + return `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png` +} + +export async function saveUserProfile({ steamId, code }: { steamId: SteamId64; code: string }) { + const token = await fetchUserAccessToken(code) + const user = await fetchUser(token) + + await collections.players.updateOne( + { steamId }, + { + $set: { + discordProfile: { + userId: user.id, + username: user.username, + displayName: user.global_name ?? user.username, + avatarUrl: makeAvatarUrl({ id: user.id, avatar: user.avatar }), + }, + }, + }, + ) +} diff --git a/src/discord/state.ts b/src/discord/state.ts new file mode 100644 index 000000000..4859e0fbe --- /dev/null +++ b/src/discord/state.ts @@ -0,0 +1,32 @@ +import jwt from 'jsonwebtoken' +import { z } from 'zod' +import { keys } from '../keys' +import { steamId64 } from '../shared/schemas/steam-id-64' + +const stateDataSchema = z.object({ + steamId: steamId64, +}) + +type StateData = z.infer + +async function make(data: StateData): Promise { + const { privateKey } = await keys.get('discord state') + return jwt.sign(data, privateKey.export({ format: 'pem', type: 'pkcs8' }), { + algorithm: 'ES512', + expiresIn: '5m', + }) +} + +async function verify(state: string): Promise { + const { publicKey } = await keys.get('discord state') + return stateDataSchema.parse( + jwt.verify(state, publicKey.export({ format: 'pem', type: 'spki' }), { + algorithms: ['ES512'], + }), + ) +} + +export const state = { + make, + verify, +} as const diff --git a/src/discord/views/html/discord-settings-entry.test.tsx b/src/discord/views/html/discord-settings-entry.test.tsx new file mode 100644 index 000000000..e6b5cc516 --- /dev/null +++ b/src/discord/views/html/discord-settings-entry.test.tsx @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { DiscordSettingsEntry } from './discord-settings-entry' + +describe('DiscordSettingsEntry', () => { + it('renders a connect link when no discord profile is linked', () => { + const html = DiscordSettingsEntry({ player: {} }) + expect(html).toContain('href="/discord/auth"') + expect(html).toContain('Connect your Discord account') + }) + + it('renders a disconnect button when a discord profile is linked', () => { + const html = DiscordSettingsEntry({ + player: { + discordProfile: { + userId: '123', + username: 'player', + displayName: 'Player One', + avatarUrl: null, + }, + }, + }) + expect(html).toContain('hx-put="/discord/disconnect"') + expect(html).toContain('Linked as') + }) +}) diff --git a/src/discord/views/html/discord-settings-entry.tsx b/src/discord/views/html/discord-settings-entry.tsx new file mode 100644 index 000000000..93021ef7a --- /dev/null +++ b/src/discord/views/html/discord-settings-entry.tsx @@ -0,0 +1,43 @@ +import type { PlayerModel } from '../../../database/models/player.model' +import { IconBrandDiscord } from '../../../html/components/icons' + +export function DiscordSettingsEntry(props: { player: Pick }) { + return ( +
+ +

discord

+ {props.player.discordProfile ? ( + <> +

+ Linked as {props.player.discordProfile.displayName} +

+
+ + + ) : ( + <> +

Connect your Discord account to get private team voice channels during games

+
+ + Connect + + + )} +
+ ) +} diff --git a/src/environment.ts b/src/environment.ts index d8d58d76e..66f9e59ce 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -31,6 +31,8 @@ const environmentSchema = z.object({ SERVEME_TF_API_KEY: z.string().optional(), DISCORD_BOT_TOKEN: z.string().optional(), + DISCORD_CLIENT_ID: z.string().optional(), + DISCORD_CLIENT_SECRET: z.string().optional(), TWITCH_CLIENT_ID: z.string().optional(), TWITCH_CLIENT_SECRET: z.string().optional(), diff --git a/src/games/plugins/sync-clients.ts b/src/games/plugins/sync-clients.ts index a3d64a1bf..8e232b467 100644 --- a/src/games/plugins/sync-clients.ts +++ b/src/games/plugins/sync-clients.ts @@ -11,7 +11,6 @@ import { GameEventList } from '../views/html/game-event-list' import { GameSlot } from '../views/html/game-slot' import { GamesLink } from '../../html/components/games-link' import { GameScore } from '../views/html/game-score' -import { JoinVoiceButton } from '../views/html/join-voice-button' import { JoinGameButton } from '../views/html/join-game-button' import { Tf2Team } from '../../shared/types/tf2-team' import { ServerReadyNotification } from '../views/html/server-ready-notification' @@ -118,7 +117,7 @@ export default fp(async app => { app.gateway .to({ url: `/games/${after.number}` }) .to({ player: slot.player }) - .send(async actor => await JoinVoiceButton({ game: after, actor })) + .send(async actor => await ConnectInfo({ game: after, actor })) } } }) diff --git a/src/games/views/html/connect-info.tsx b/src/games/views/html/connect-info.tsx index 0a98280d2..bc8c2bbca 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 type { SteamId64 } from '../../../shared/types/steam-id-64' import { ConnectString } from './connect-string' import { JoinGameButton } from './join-game-button' +import { DiscordVoiceStatus } from './discord-voice-status' import { JoinVoiceButton } from './join-voice-button' export function ConnectInfo(props: { @@ -22,6 +23,7 @@ export function ConnectInfo(props: { + ) } diff --git a/src/games/views/html/discord-voice-status.test.tsx b/src/games/views/html/discord-voice-status.test.tsx new file mode 100644 index 000000000..e72042bcb --- /dev/null +++ b/src/games/views/html/discord-voice-status.test.tsx @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DiscordVoiceStatus } from './discord-voice-status' +import { configuration } from '../../../configuration' +import { collections } from '../../../database/collections' +import type { GameNumber } from '../../../database/models/game.model' +import { SlotStatus, PlayerConnectionStatus } from '../../../database/models/game-slot.model' +import type { GameSlotId } from '../../../shared/types/game-slot-id' +import { Tf2ClassName } from '../../../shared/types/tf2-class-name' +import { Tf2Team } from '../../../shared/types/tf2-team' +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 + +const baseGame = { + number: 42 as GameNumber, + slots: [ + { + id: 'red-soldier-0' as GameSlotId, + player: actor, + team: Tf2Team.red, + gameClass: Tf2ClassName.soldier, + status: SlotStatus.active, + connectionStatus: PlayerConnectionStatus.connected, + }, + ], +} + +describe('DiscordVoiceStatus', () => { + beforeEach(() => { + vi.mocked(configuration.get).mockResolvedValue(VoiceServerType.discord) + }) + + it('renders nothing when discord voice is disabled', async () => { + vi.mocked(configuration.get).mockResolvedValue(VoiceServerType.none) + const html = await DiscordVoiceStatus({ game: baseGame, actor }) + expect(html).toBe('') + }) + + it('renders linking instructions when actor has no linked discord profile', async () => { + vi.mocked(collections.players.findOne).mockResolvedValue({ discordProfile: undefined }) + const html = await DiscordVoiceStatus({ game: baseGame, actor }) + expect(html).toContain('Link your Discord account') + expect(html).toContain('href="/settings"') + }) + + it('renders mismatch instructions when the linked discord account is unresolved', async () => { + vi.mocked(collections.players.findOne).mockResolvedValue({ + discordProfile: { userId: '123' }, + }) + const html = await DiscordVoiceStatus({ game: baseGame, actor }) + expect(html).toContain('could not be matched') + }) + + it('renders fallback instructions when a deep link exists', async () => { + const html = await DiscordVoiceStatus({ + game: { + ...baseGame, + slots: [{ ...baseGame.slots[0], voiceServerUrl: 'https://discord.com/channels/1/2' }], + }, + actor, + }) + expect(html).toContain('join your team voice channel manually') + }) +}) diff --git a/src/games/views/html/discord-voice-status.tsx b/src/games/views/html/discord-voice-status.tsx new file mode 100644 index 000000000..16701f81f --- /dev/null +++ b/src/games/views/html/discord-voice-status.tsx @@ -0,0 +1,56 @@ +import { configuration } from '../../../configuration' +import { collections } from '../../../database/collections' +import { SlotStatus } from '../../../database/models/game-slot.model' +import type { GameModel } from '../../../database/models/game.model' +import type { SteamId64 } from '../../../shared/types/steam-id-64' +import { VoiceServerType } from '../../../shared/types/voice-server-type' + +export async function DiscordVoiceStatus(props: { + game: Pick + actor: SteamId64 | undefined +}) { + if (!props.actor) { + return <> + } + + const type = await configuration.get('games.voice_server_type') + if (type !== VoiceServerType.discord) { + return <> + } + + const slot = props.game.slots + .filter(slot => [SlotStatus.active, SlotStatus.waitingForSubstitute].includes(slot.status)) + .find(({ player }) => player === props.actor) + if (!slot) { + return <> + } + + if (slot.voiceServerUrl) { + return ( +

+ If the link does not open Discord, join your team voice channel manually in the configured + Discord server. +

+ ) + } + + const player = await collections.players.findOne( + { steamId: props.actor }, + { projection: { discordProfile: 1 } }, + ) + if (!player?.discordProfile) { + return ( +

+ Link your Discord account in settings to access your team voice + channel. +

+ ) + } + + return ( +

+ Your linked Discord account could not be matched in the configured Discord server. Join the + server manually or ask an admin to verify your membership. +

+ ) +} diff --git a/src/players/views/html/player-settings.page.tsx b/src/players/views/html/player-settings.page.tsx index dee1b273b..5e7f84467 100644 --- a/src/players/views/html/player-settings.page.tsx +++ b/src/players/views/html/player-settings.page.tsx @@ -6,6 +6,8 @@ import { makeTitle } from '../../../html/make-title' import { TwitchTvSettingsEntry } from '../../../twitch-tv/views/html/twitch-tv-settings-entry' import { Footer } from '../../../html/components/footer' import { requestContext } from '@fastify/request-context' +import { DiscordSettingsEntry } from '../../../discord/views/html/discord-settings-entry' +import { discord } from '../../../discord' export async function PlayerSettingsPage() { const user = requestContext.get('user')! @@ -52,6 +54,7 @@ export async function PlayerSettingsPage() {

Linked accounts

+ {discord.oauthEnabled && }
diff --git a/src/routes/admin/voice-server/index.ts b/src/routes/admin/voice-server/index.ts index 6c900682f..737f07307 100644 --- a/src/routes/admin/voice-server/index.ts +++ b/src/routes/admin/voice-server/index.ts @@ -40,6 +40,9 @@ export default routes(async app => { mumblePort: z.coerce.number().gte(0).lte(65535).optional().default(64738), mumblePassword: emptyString, mumbleChannelName: emptyString, + discordGuildId: emptyString, + discordCategoryId: emptyString, + discordPostgameCategoryId: emptyString, }), }, }, @@ -52,6 +55,9 @@ export default routes(async app => { mumblePort, mumblePassword, mumbleChannelName, + discordGuildId, + discordCategoryId, + discordPostgameCategoryId, } = request.body await configuration.set('games.voice_server_type', type) if (type === VoiceServerType.staticLink) { @@ -64,6 +70,15 @@ export default routes(async app => { configuration.set('games.voice_server.mumble.password', mumblePassword), configuration.set('games.voice_server.mumble.channel_name', mumbleChannelName), ]) + } else if (type === VoiceServerType.discord) { + await Promise.all([ + configuration.set('games.voice_server.discord.guild_id', discordGuildId), + configuration.set('games.voice_server.discord.category_id', discordCategoryId), + configuration.set( + 'games.voice_server.discord.postgame_category_id', + discordPostgameCategoryId, + ), + ]) } requestContext.set('messages', { success: ['Configuration saved'] }) reply.status(200).html(await VoiceServerPage()) diff --git a/src/routes/discord/auth/index.ts b/src/routes/discord/auth/index.ts new file mode 100644 index 000000000..7251b5aa4 --- /dev/null +++ b/src/routes/discord/auth/index.ts @@ -0,0 +1,24 @@ +import { discord } from '../../../discord' +import { routes } from '../../../utils/routes' + +// eslint-disable-next-line @typescript-eslint/require-await +export default routes(async app => { + if (!discord.oauthEnabled) { + return + } + + app.get( + '/', + { + config: { + authenticate: true, + }, + }, + async (request, reply) => { + const url = discord.makeOauthRedirectUrl( + await discord.state.make({ steamId: request.user!.player.steamId }), + ) + reply.redirect(url, 302) + }, + ) +}) diff --git a/src/routes/discord/auth/return/index.ts b/src/routes/discord/auth/return/index.ts new file mode 100644 index 000000000..1496ec5a5 --- /dev/null +++ b/src/routes/discord/auth/return/index.ts @@ -0,0 +1,47 @@ +import { z } from 'zod' +import { logger } from '../../../../logger' +import { errors } from '../../../../errors' +import { discord } from '../../../../discord' +import { routes } from '../../../../utils/routes' + +// eslint-disable-next-line @typescript-eslint/require-await +export default routes(async app => { + if (!discord.oauthEnabled) { + return + } + + app.get( + '/', + { + config: { + authenticate: true, + }, + schema: { + querystring: z.intersection( + z.union([ + z.object({ + code: z.string(), + }), + z.object({ + error: z.string(), + error_description: z.string().optional(), + }), + ]), + z.object({ + state: z.string(), + }), + ), + }, + }, + async (request, reply) => { + if ('error' in request.query) { + logger.error({ query: request.query }, `discord auth error`) + throw errors.internalServerError(`discord auth error`) + } + + const { steamId } = await discord.state.verify(request.query.state) + await discord.saveUserProfile({ steamId, code: request.query.code }) + reply.redirect('/settings', 302) + }, + ) +}) diff --git a/src/routes/discord/index.ts b/src/routes/discord/index.ts new file mode 100644 index 000000000..ccd54ff50 --- /dev/null +++ b/src/routes/discord/index.ts @@ -0,0 +1,28 @@ +import { players } from '../../players' +import { discord } from '../../discord' +import { DiscordSettingsEntry } from '../../discord/views/html/discord-settings-entry' +import { routes } from '../../utils/routes' + +// eslint-disable-next-line @typescript-eslint/require-await +export default routes(async app => { + if (!discord.oauthEnabled) { + return + } + + app.put( + '/disconnect', + { + config: { + authenticate: true, + }, + }, + async (request, reply) => { + const player = await players.update(request.user!.player.steamId, { + $unset: { + discordProfile: 1, + }, + }) + reply.html(await DiscordSettingsEntry({ player })) + }, + ) +}) diff --git a/src/shared/types/voice-server-type.ts b/src/shared/types/voice-server-type.ts index d64876e95..e6c343100 100644 --- a/src/shared/types/voice-server-type.ts +++ b/src/shared/types/voice-server-type.ts @@ -2,4 +2,5 @@ export enum VoiceServerType { none = 'none', staticLink = 'static link', mumble = 'mumble', + discord = 'discord', } diff --git a/src/tasks/tasks.ts b/src/tasks/tasks.ts index 379f2acac..0d2918574 100644 --- a/src/tasks/tasks.ts +++ b/src/tasks/tasks.ts @@ -34,6 +34,12 @@ export const tasksSchema = z.discriminatedUnion('name', [ gameNumber, }), }), + z.object({ + name: z.literal('discord.cleanupVoiceChannels'), + args: z.object({ + gameNumber, + }), + }), z.object({ name: z.literal('queue:readyUpTimeout'), args: z.object({}), From 9a8c387851d3adf705e2f38e529be9419cba8505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Garapich?= Date: Mon, 9 Mar 2026 12:43:08 +0100 Subject: [PATCH 2/3] devel --- .../views/html/guild-configuration.tsx | 52 +-- .../views/html/select-discord-channel.tsx | 78 ++++ .../views/html/select-discord-guild.tsx | 37 ++ .../views/html/voice-server.page.tsx | 383 +++++++++--------- 4 files changed, 324 insertions(+), 226 deletions(-) create mode 100644 src/admin/discord/views/html/select-discord-channel.tsx create mode 100644 src/admin/discord/views/html/select-discord-guild.tsx diff --git a/src/admin/discord/views/html/guild-configuration.tsx b/src/admin/discord/views/html/guild-configuration.tsx index cfcdb89f7..084a07fda 100644 --- a/src/admin/discord/views/html/guild-configuration.tsx +++ b/src/admin/discord/views/html/guild-configuration.tsx @@ -1,6 +1,7 @@ -import { TextChannel, type Guild } from 'discord.js' +import { type Guild } from 'discord.js' import { SaveButton } from '../../../views/html/save-button' import { configuration } from '../../../../configuration' +import { SelectDiscordChannel } from './select-discord-channel' export async function GuildConfiguration(props: { guild: Guild; enabled: boolean }) { if (!props.enabled) { @@ -15,8 +16,9 @@ export async function GuildConfiguration(props: { guild: Guild; enabled: boolean - Substitute notifications channel: - - channel instanceof TextChannel).values(), - ).reduce>((prev, curr) => { - if (!prev.has(curr.parent!.name)) { - prev.set(curr.parent!.name, []) - } - - prev.get(curr.parent!.name)!.push(curr) - return prev - }, new Map()) - - textChannels.forEach(value => value.sort((a, b) => a.position - b.position)) - - return ( - - ) -} - function SelectRole(props: { guild: Guild; current: string | undefined } & JSX.HtmlSelectTag) { const { guild, current, ...rest } = props const roles = Array.from(guild.roles.cache.values()).toSorted((a, b) => diff --git a/src/admin/discord/views/html/select-discord-channel.tsx b/src/admin/discord/views/html/select-discord-channel.tsx new file mode 100644 index 000000000..16cd9ae63 --- /dev/null +++ b/src/admin/discord/views/html/select-discord-channel.tsx @@ -0,0 +1,78 @@ +import { ChannelType, TextChannel, VoiceChannel } from 'discord.js' +import { discord } from '../../../../discord' + +type DiscordChannelType = 'text' | 'voice' | 'category' + +export function SelectDiscordChannel( + props: { + guildId?: string | null + current?: string + channelType: DiscordChannelType + } & JSX.HtmlSelectTag, +) { + const { guildId, current, channelType, ...rest } = props + const guild = guildId ? discord.client?.guilds.resolve(guildId) : null + + if (!guild) { + return ( + + ) + } + + if (channelType === 'category') { + const categories = Array.from(guild.channels.cache.values()) + .filter(channel => channel.type === ChannelType.GuildCategory) + .toSorted((a, b) => a.position - b.position) + + return ( + + ) + } + + const channels = Array.from(guild.channels.cache.values()).filter(channel => + channelType === 'text' ? channel instanceof TextChannel : channel instanceof VoiceChannel, + ) + + const groupedChannels = channels.reduce>((prev, curr) => { + const parentName = curr.parent?.name ?? 'No category' + if (!prev.has(parentName)) { + prev.set(parentName, []) + } + + prev.get(parentName)!.push(curr) + return prev + }, new Map()) + + groupedChannels.forEach(group => group.sort((a, b) => a.position - b.position)) + + return ( + + ) +} 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 ( + + ) + } + + return ( + + ) +} 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() {

-
- - - +
+ + +

+ Players will be handed a static link to connect to the voice server +

+
+ +
-

- Players will be handed a static link to connect to the voice server -

+ > + +
-
- - -

- A mumble server will be used for the voice during games. Channels will be managed - automatically. -

-
+
+
+ + +

+ A mumble server will be used for the voice during games. Channels will be managed + automatically. +

+
-
- - -

- The Discord bot will create private team voice channels for each game and move players - into a shared post-game lobby after the match. -

-
+
+
+
+ +
+
+ + +
+
-
-
-
- -
-
- - -
-
+
+
+ +
+
+ +
+
-
-
- -
-
- -
-
+
+
+ +
+
+ +
+
-
-
- -
-
- -
-
+
+
+ +
+
+ +
+
-
-
- -
-
- -
-
+ +
+
- - +
+
+ + +

+ The Discord bot will create private team voice channels for each game and move + players into a shared post-game lobby after the match. +

+
-
-
-
- -
-
- -
-
+
+
+
+ +
+
+ +
+
-
-
- -
-
- -
-
+
+
+ +
+
+ +
+
-
-
- -
-
- -
-
-
+
+
+ +
+
+ +
+
+
+

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 ( + + ) +} 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() {
{!!user && } +