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/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
Admin notifications channel:
-
Substitute notifications channel:
-
Queue prompts 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 (
-
- disabled
-
- {Array.from(textChannels, ([parent, channels]) => (
-
- {channels.map(({ id, name }) => (
-
- {name}
-
- ))}
-
- ))}
-
- )
-}
-
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 (
+
+ {guildId ? 'guild not found' : 'select guild first'}
+ {current && (
+
+ {current}
+
+ )}
+
+ )
+ }
+
+ 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 (
+
+ disabled
+
+ {categories.map(({ id, name }) => (
+
+ {name}
+
+ ))}
+
+ )
+ }
+
+ 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 (
+
+ 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 75132f80c..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')
@@ -15,6 +17,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 (
@@ -35,23 +42,24 @@ export async function VoiceServerPage() {
-
-
+
+
+
+
+
+
+
+
+
+ Guild
+
+
+
+
+
+
+
+
+
+
+ Team channel category
+
+
+
+
+
+
+
+
+
+
+ Post-game category
+
+
+
+
+
+
+
+
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}
+
+
+
+ Disconnect
+
+ >
+ ) : (
+ <>
+
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/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() {
{!!user &&
}
+
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({}),