diff --git a/hasura/metadata/databases/default/tables/public_tournaments.yaml b/hasura/metadata/databases/default/tables/public_tournaments.yaml index 69f1701e..2311fb31 100644 --- a/hasura/metadata/databases/default/tables/public_tournaments.yaml +++ b/hasura/metadata/databases/default/tables/public_tournaments.yaml @@ -128,7 +128,9 @@ insert_permissions: columns: - auto_start - description + - discord_guild_id - discord_notifications_enabled + - discord_voice_enabled - discord_notify_Canceled - discord_notify_Finished - discord_notify_Forfeit @@ -178,7 +180,9 @@ insert_permissions: columns: - auto_start - description + - discord_guild_id - discord_notifications_enabled + - discord_voice_enabled - discord_notify_Canceled - discord_notify_Finished - discord_notify_Forfeit @@ -227,7 +231,9 @@ insert_permissions: columns: - auto_start - description + - discord_guild_id - discord_notifications_enabled + - discord_voice_enabled - discord_notify_Canceled - discord_notify_Finished - discord_notify_Forfeit @@ -278,7 +284,9 @@ insert_permissions: columns: - auto_start - description + - discord_guild_id - discord_notifications_enabled + - discord_voice_enabled - discord_notify_Canceled - discord_notify_Finished - discord_notify_Forfeit @@ -325,7 +333,9 @@ insert_permissions: columns: - auto_start - description + - discord_guild_id - discord_notifications_enabled + - discord_voice_enabled - discord_notify_Canceled - discord_notify_Finished - discord_notify_Forfeit @@ -373,7 +383,9 @@ insert_permissions: columns: - auto_start - description + - discord_guild_id - discord_notifications_enabled + - discord_voice_enabled - discord_notify_Canceled - discord_notify_Finished - discord_notify_Forfeit @@ -429,7 +441,9 @@ select_permissions: - auto_start - created_at - description + - discord_guild_id - discord_notifications_enabled + - discord_voice_enabled - discord_notify_Canceled - discord_notify_Finished - discord_notify_Forfeit @@ -477,7 +491,9 @@ update_permissions: columns: - auto_start - description + - discord_guild_id - discord_notifications_enabled + - discord_voice_enabled - discord_notify_Canceled - discord_notify_Finished - discord_notify_Forfeit @@ -502,3 +518,15 @@ update_permissions: is_organizer: _eq: true comment: "" +event_triggers: + - name: tournament_events + definition: + enable_manual: true + update: + columns: + - status + retry_conf: + interval_sec: 10 + num_retries: 3 + timeout_sec: 60 + webhook: '{{HASURA_GRAPHQL_EVENT_HOOK}}' diff --git a/hasura/migrations/default/1772556093000_add_tournament_discord_voice/down.sql b/hasura/migrations/default/1772556093000_add_tournament_discord_voice/down.sql new file mode 100644 index 00000000..a435bea9 --- /dev/null +++ b/hasura/migrations/default/1772556093000_add_tournament_discord_voice/down.sql @@ -0,0 +1,3 @@ +ALTER TABLE public.tournaments + DROP COLUMN IF EXISTS discord_guild_id, + DROP COLUMN IF EXISTS discord_voice_enabled; diff --git a/hasura/migrations/default/1772556093000_add_tournament_discord_voice/up.sql b/hasura/migrations/default/1772556093000_add_tournament_discord_voice/up.sql new file mode 100644 index 00000000..45462bbc --- /dev/null +++ b/hasura/migrations/default/1772556093000_add_tournament_discord_voice/up.sql @@ -0,0 +1,3 @@ +ALTER TABLE public.tournaments + ADD COLUMN IF NOT EXISTS discord_guild_id text, + ADD COLUMN IF NOT EXISTS discord_voice_enabled boolean NOT NULL DEFAULT false; diff --git a/src/discord-bot/discord-bot-messaging/discord-bot-messaging.service.ts b/src/discord-bot/discord-bot-messaging/discord-bot-messaging.service.ts index c6d2c5f8..5ac7642e 100644 --- a/src/discord-bot/discord-bot-messaging/discord-bot-messaging.service.ts +++ b/src/discord-bot/discord-bot-messaging/discord-bot-messaging.service.ts @@ -12,6 +12,7 @@ import { } from "discord.js"; import { ConfigService } from "@nestjs/config"; import { AppConfig } from "src/configs/types/AppConfig"; +import { HasuraService } from "../../hasura/hasura.service"; @Injectable() export class DiscordBotMessagingService { @@ -21,6 +22,7 @@ export class DiscordBotMessagingService { @Inject(forwardRef(() => DiscordBotService)) private readonly bot: DiscordBotService, protected readonly config: ConfigService, + private readonly hasura: HasuraService, ) {} public async getMatchChannel(matchId: string): Promise { @@ -46,7 +48,7 @@ export class DiscordBotMessagingService { if (channel) { const categoryChannel = await this.getCategory( - this.getArchiveCategoryName(), + await this.getArchiveCategoryName(), channel.guild, ); await channel.setParent(categoryChannel); @@ -204,8 +206,23 @@ export class DiscordBotMessagingService { return `bot:${matchId}:thread`; } - private getArchiveCategoryName() { - return `${this.config.get("app").name} Matches Archive`; + private async getArchiveCategoryName() { + const brandName = await this.cache.remember( + "settings:brand_name", + async () => { + const { settings_by_pk } = await this.hasura.query({ + settings_by_pk: { + __args: { + name: "public.brand_name", + }, + value: true, + }, + }); + return settings_by_pk?.value || this.config.get("app").name; + }, + 60 * 60, + ); + return `${brandName} Matches Archive`; } public async removeArchivedThreads() { @@ -219,7 +236,7 @@ export class DiscordBotMessagingService { for (const guild of guilds) { const categoryChannel = await this.getCategory( - this.getArchiveCategoryName(), + await this.getArchiveCategoryName(), guild, ); const channels = categoryChannel.children.cache.values(); diff --git a/src/discord-bot/discord-bot-voice-channels/discord-bot-voice-channels.service.ts b/src/discord-bot/discord-bot-voice-channels/discord-bot-voice-channels.service.ts index 9c075193..885d232b 100644 --- a/src/discord-bot/discord-bot-voice-channels/discord-bot-voice-channels.service.ts +++ b/src/discord-bot/discord-bot-voice-channels/discord-bot-voice-channels.service.ts @@ -19,11 +19,12 @@ export class DiscordBotVoiceChannelsService { originalChannelId: string, categoryChannelId: string, lineupId: string, + channelName?: string, ) { const guild = await this.getGuild(guildId); const voiceChannel = await guild.channels.create({ - name: `${lineupId} [${matchId}]`, + name: channelName || `${lineupId} [${matchId}]`, parent: categoryChannelId, type: ChannelType.GuildVoice, permissionOverwrites: [ diff --git a/src/discord-bot/discord-tournament-voice/discord-tournament-voice.module.ts b/src/discord-bot/discord-tournament-voice/discord-tournament-voice.module.ts new file mode 100644 index 00000000..ef09a9dd --- /dev/null +++ b/src/discord-bot/discord-tournament-voice/discord-tournament-voice.module.ts @@ -0,0 +1,17 @@ +import { forwardRef, Module } from "@nestjs/common"; +import { DiscordTournamentVoiceService } from "./discord-tournament-voice.service"; +import { DiscordBotModule } from "../discord-bot.module"; +import { HasuraModule } from "../../hasura/hasura.module"; +import { CacheModule } from "../../cache/cache.module"; +import { loggerFactory } from "../../utilities/LoggerFactory"; + +@Module({ + imports: [ + forwardRef(() => DiscordBotModule), + HasuraModule, + CacheModule, + ], + providers: [DiscordTournamentVoiceService, loggerFactory()], + exports: [DiscordTournamentVoiceService], +}) +export class DiscordTournamentVoiceModule {} diff --git a/src/discord-bot/discord-tournament-voice/discord-tournament-voice.service.ts b/src/discord-bot/discord-tournament-voice/discord-tournament-voice.service.ts new file mode 100644 index 00000000..15c059b8 --- /dev/null +++ b/src/discord-bot/discord-tournament-voice/discord-tournament-voice.service.ts @@ -0,0 +1,411 @@ +import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common"; +import { + CategoryChannel, + ChannelType, + PermissionsBitField, +} from "discord.js"; +import { CacheService } from "../../cache/cache.service"; +import { HasuraService } from "../../hasura/hasura.service"; +import { DiscordBotService } from "../discord-bot.service"; +import { DiscordBotMessagingService } from "../discord-bot-messaging/discord-bot-messaging.service"; +import { DiscordBotVoiceChannelsService } from "../discord-bot-voice-channels/discord-bot-voice-channels.service"; +import { getBracketRoundLabel } from "./utilities/getBracketRoundLabel"; + +interface TournamentVoiceCache { + guildId: string; + categoryId: string; + readyRoomId: string; +} + +@Injectable() +export class DiscordTournamentVoiceService { + constructor( + private readonly logger: Logger, + private readonly cache: CacheService, + private readonly hasura: HasuraService, + @Inject(forwardRef(() => DiscordBotService)) + private readonly bot: DiscordBotService, + @Inject(forwardRef(() => DiscordBotMessagingService)) + private readonly messaging: DiscordBotMessagingService, + @Inject(forwardRef(() => DiscordBotVoiceChannelsService)) + private readonly voiceChannels: DiscordBotVoiceChannelsService, + ) {} + + public async createTournamentReadyRoom(tournamentId: string) { + const { tournaments_by_pk: tournament } = await this.hasura.query({ + tournaments_by_pk: { + __args: { + id: tournamentId, + }, + discord_guild_id: true, + discord_voice_enabled: true, + name: true, + }, + }); + + if (!tournament || !tournament.discord_voice_enabled || !tournament.discord_guild_id) { + return; + } + + const guildId = tournament.discord_guild_id as string; + const tournamentName = tournament.name as string; + + const existing = await this.getVoiceCache(tournamentId); + if (existing) { + return; + } + + try { + const guild = await this.bot.client.guilds.fetch(guildId); + + const category = await guild.channels.create({ + name: tournamentName, + type: ChannelType.GuildCategory, + }); + + const readyRoom = await guild.channels.create({ + name: "Ready Room", + type: ChannelType.GuildVoice, + parent: category.id, + permissionOverwrites: [ + { + id: guild.id, + allow: [ + PermissionsBitField.Flags.ViewChannel, + PermissionsBitField.Flags.Connect, + ], + deny: [PermissionsBitField.Flags.Speak], + }, + { + id: this.bot.client.user.id, + allow: [ + PermissionsBitField.Flags.ViewChannel, + PermissionsBitField.Flags.Connect, + PermissionsBitField.Flags.Speak, + PermissionsBitField.Flags.MoveMembers, + PermissionsBitField.Flags.ManageChannels, + ], + }, + ], + }); + + await this.setVoiceCache(tournamentId, { + guildId, + categoryId: category.id, + readyRoomId: readyRoom.id, + }); + + this.logger.log( + `[${tournamentId}] created tournament ready room and category`, + ); + } catch (error) { + this.logger.error( + `[${tournamentId}] failed to create tournament ready room`, + error, + ); + } + } + + public async createMatchVoiceChannels(matchId: string) { + const { tournament_brackets } = await this.hasura.query({ + tournament_brackets: { + __args: { + where: { + match_id: { _eq: matchId }, + }, + limit: 1, + }, + round: true, + path: true, + group: true, + match_number: true, + stage: { + type: true, + order: true, + max_teams: true, + groups: true, + tournament_id: true, + tournament: { + discord_voice_enabled: true, + discord_guild_id: true, + stages: { + order: true, + }, + }, + }, + match: { + lineup_1: { + id: true, + name: true, + }, + lineup_2: { + id: true, + name: true, + }, + }, + }, + }); + + const bracket = tournament_brackets?.at(0); + if (!bracket?.stage?.tournament?.discord_voice_enabled) { + return; + } + + const tournamentId = bracket.stage.tournament_id as string; + const voiceCache = await this.getVoiceCache(tournamentId); + + if (!voiceCache) { + this.logger.warn( + `[${matchId}] no tournament voice cache found for tournament ${tournamentId}`, + ); + return; + } + + const lineup1 = bracket.match?.lineup_1; + + const existingMatchVoice = await this.voiceChannels.getVoiceCache(matchId, lineup1?.id as string); + if (existingMatchVoice) { + return; // Already created (e.g., from WaitingForCheckIn trigger) + } + + const totalStages = bracket.stage.tournament.stages?.length || 1; + const stageOrder = bracket.stage.order as number; + const bracketRound = bracket.round as number; + const bracketPath = bracket.path as string | null; + const stageType = bracket.stage.type as string | null; + const isFinalStage = stageOrder === totalStages; + + const bracketsInSameRound = await this.hasura.query({ + tournament_brackets_aggregate: { + __args: { + where: { + stage: { + tournament_id: { _eq: tournamentId }, + order: { _eq: stageOrder }, + }, + round: { _eq: bracketRound }, + path: { _eq: bracketPath }, + }, + }, + aggregate: { + count: true, + }, + }, + }); + + const totalMatchesInRound = + (bracketsInSameRound.tournament_brackets_aggregate.aggregate.count as number) || 1; + + const maxRound = await this.hasura.query({ + tournament_brackets_aggregate: { + __args: { + where: { + stage: { + tournament_id: { _eq: tournamentId }, + order: { _eq: stageOrder }, + }, + path: { _eq: bracketPath }, + }, + }, + aggregate: { + max: { + round: true, + }, + }, + }, + }); + + const highestRound = + (maxRound.tournament_brackets_aggregate.aggregate.max?.round as number) || bracketRound; + const isLastRound = bracketRound === highestRound; + const isLoserBracket = bracketPath === "loser"; + + const roundLabel = getBracketRoundLabel( + bracketRound, + stageOrder, + isFinalStage, + totalMatchesInRound, + isLoserBracket, + stageType, + isLastRound, + ); + + const lineup2 = bracket.match?.lineup_2; + + if (!lineup1 || !lineup2) { + this.logger.warn(`[${matchId}] match lineups not found`); + return; + } + + try { + const team1Name = (lineup1.name as string) || "Team 1"; + const team2Name = (lineup2.name as string) || "Team 2"; + + await this.voiceChannels.createMatchVoiceChannel( + matchId, + voiceCache.guildId, + voiceCache.readyRoomId, + voiceCache.categoryId, + lineup1.id as string, + `${team1Name} - ${roundLabel}`, + ); + + await this.voiceChannels.createMatchVoiceChannel( + matchId, + voiceCache.guildId, + voiceCache.readyRoomId, + voiceCache.categoryId, + lineup2.id as string, + `${team2Name} - ${roundLabel}`, + ); + + this.logger.log( + `[${matchId}] created tournament match voice channels for ${roundLabel}`, + ); + } catch (error) { + this.logger.error( + `[${matchId}] failed to create tournament match voice channels`, + error, + ); + } + } + + public async movePlayersToMatchChannels(matchId: string) { + const { matches_by_pk: match } = await this.hasura.query({ + matches_by_pk: { + __args: { + id: matchId, + }, + lineup_1: { + id: true, + lineup_players: { + steam_id: true, + player: { + discord_id: true, + name: true, + }, + }, + }, + lineup_2: { + id: true, + lineup_players: { + steam_id: true, + player: { + discord_id: true, + name: true, + }, + }, + }, + }, + }); + + if (!match) { + return; + } + + const unlinkedPlayers: string[] = []; + + for (const lineup of [match.lineup_1, match.lineup_2]) { + for (const lineupPlayer of lineup.lineup_players) { + const discordId = lineupPlayer.player?.discord_id as string | undefined; + const playerName = lineupPlayer.player?.name as string | undefined; + + if (!discordId) { + unlinkedPlayers.push( + playerName || (lineupPlayer.steam_id as string), + ); + continue; + } + + try { + await this.voiceChannels.moveMemberToTeamChannel( + matchId, + lineup.id as string, + { + id: discordId, + username: playerName || "Unknown", + globalName: playerName || "Unknown", + }, + ); + } catch (error) { + this.logger.warn( + `[${matchId}] failed to move player ${lineupPlayer.steam_id} to voice channel`, + error, + ); + } + } + } + + if (unlinkedPlayers.length > 0) { + try { + await this.messaging.sendToMatchThread(matchId, { + content: `The following players do not have Discord linked and could not be moved to voice channels: ${unlinkedPlayers.join(", ")}`, + }); + } catch (error) { + this.logger.warn( + `[${matchId}] failed to send unlinked players warning`, + error, + ); + } + } + } + + public async removeTournamentVoice(tournamentId: string) { + const voiceCache = await this.getVoiceCache(tournamentId); + + if (!voiceCache) { + return; + } + + try { + const guild = await this.bot.client.guilds.fetch(voiceCache.guildId); + + try { + const category = await guild.channels.fetch(voiceCache.categoryId); + if (category && category.type === ChannelType.GuildCategory) { + for (const [, child] of (category as CategoryChannel).children.cache) { + await child.delete().catch(() => {}); + } + await category.delete(); + } + } catch (error) { + this.logger.warn( + `[${tournamentId}] unable to delete category`, + error, + ); + } + + await this.cache.forget(this.getVoiceCacheKey(tournamentId)); + + this.logger.log( + `[${tournamentId}] removed tournament voice channels`, + ); + } catch (error) { + this.logger.error( + `[${tournamentId}] failed to remove tournament voice channels`, + error, + ); + } + } + + private async getVoiceCache( + tournamentId: string, + ): Promise { + return await this.cache.get(this.getVoiceCacheKey(tournamentId)); + } + + private async setVoiceCache( + tournamentId: string, + data: TournamentVoiceCache, + ) { + await this.cache.put( + this.getVoiceCacheKey(tournamentId), + data, + 30 * 24 * 60 * 60, + ); + } + + private getVoiceCacheKey(tournamentId: string) { + return `tournament:${tournamentId}:voice`; + } +} diff --git a/src/discord-bot/discord-tournament-voice/utilities/getBracketRoundLabel.ts b/src/discord-bot/discord-tournament-voice/utilities/getBracketRoundLabel.ts new file mode 100644 index 00000000..d258f47a --- /dev/null +++ b/src/discord-bot/discord-tournament-voice/utilities/getBracketRoundLabel.ts @@ -0,0 +1,45 @@ +export function getBracketRoundLabel( + roundNumber: number, + stage: number, + isFinalStage: boolean, + totalMatchesInRound: number, + isLoserBracket: boolean, + stageType: string | null | undefined, + isLastRound: boolean, +): string { + if (stageType === "RoundRobin" || stageType === "Swiss") { + return `Round ${roundNumber}`; + } + + const isDE = stageType === "DoubleElimination"; + + if (isLoserBracket) { + if (isLastRound) { + return "LB Final"; + } + return `LB Round ${roundNumber}`; + } + + if (stage === 1 && roundNumber === 1) { + return isDE ? "WB Opening Round" : "Opening Round"; + } + + if (isFinalStage) { + if (totalMatchesInRound === 4) { + return isDE ? "WB Quarter-Finals" : "Quarter-Finals"; + } + + if (totalMatchesInRound === 2) { + return isDE ? "WB Semi-Finals" : "Semi-Finals"; + } + + if (totalMatchesInRound === 1) { + if (isDE && !isLastRound) { + return "WB Final"; + } + return isDE ? "Grand Final" : "Final"; + } + } + + return isDE ? `WB Round ${roundNumber}` : `Round ${roundNumber}`; +} diff --git a/src/matches/matches.controller.ts b/src/matches/matches.controller.ts index 3b052d6e..fc6cc5ac 100644 --- a/src/matches/matches.controller.ts +++ b/src/matches/matches.controller.ts @@ -32,6 +32,7 @@ import { S3Service } from "src/s3/s3.service"; import { ChatService } from "src/chat/chat.service"; import { ChatLobbyType } from "src/chat/enums/ChatLobbyTypes"; import { MatchRelayService } from "./match-relay/match-relay.service"; +import { DiscordTournamentVoiceService } from "../discord-bot/discord-tournament-voice/discord-tournament-voice.service"; @Controller("matches") export class MatchesController { @@ -62,6 +63,7 @@ export class MatchesController { private scheduledMatchesQueue: Queue, private s3: S3Service, private readonly matchRelayService: MatchRelayService, + private readonly tournamentVoice: DiscordTournamentVoiceService, ) { this.appConfig = this.configService.get("app"); } @@ -323,6 +325,25 @@ export class MatchesController { ); } + if ( + data.op === "UPDATE" && + data.new.status === "WaitingForCheckIn" && + data.old.status !== "WaitingForCheckIn" + ) { + await this.tournamentVoice.createMatchVoiceChannels(matchId); + await this.tournamentVoice.movePlayersToMatchChannels(matchId); + } + + // Also create voice channels on Veto or Live (fallback for skipped check-in) + if ( + data.op === "UPDATE" && + (data.new.status === "Veto" || data.new.status === "Live") && + data.old.status !== data.new.status + ) { + await this.tournamentVoice.createMatchVoiceChannels(matchId); + await this.tournamentVoice.movePlayersToMatchChannels(matchId); + } + if (data.op === "DELETE") { await this.chatService.removeLobby(ChatLobbyType.Match, matchId); } diff --git a/src/matches/matches.module.ts b/src/matches/matches.module.ts index ccd63b50..3a6f31fa 100644 --- a/src/matches/matches.module.ts +++ b/src/matches/matches.module.ts @@ -48,6 +48,7 @@ import { MatchRelayController } from "./match-relay/match-relay.controller"; import { MatchRelayService } from "./match-relay/match-relay.service"; import { MatchRelayAuthMiddleware } from "./match-relay/match-relay-auth-middleware"; import { K8sModule } from "src/k8s/k8s.module"; +import { DiscordTournamentVoiceModule } from "../discord-bot/discord-tournament-voice/discord-tournament-voice.module"; @Module({ imports: [ @@ -62,6 +63,7 @@ import { K8sModule } from "src/k8s/k8s.module"; NotificationsModule, K8sModule, forwardRef(() => DiscordBotModule), + DiscordTournamentVoiceModule, MatchMaking, ChatModule, BullModule.registerQueue( diff --git a/src/tournaments/tournaments.controller.ts b/src/tournaments/tournaments.controller.ts index 99448dff..821d3cbc 100644 --- a/src/tournaments/tournaments.controller.ts +++ b/src/tournaments/tournaments.controller.ts @@ -1,8 +1,11 @@ import { Controller, Logger } from "@nestjs/common"; -import { HasuraAction } from "../hasura/hasura.controller"; +import { HasuraAction, HasuraEvent } from "../hasura/hasura.controller"; import { HasuraService } from "../hasura/hasura.service"; +import { HasuraEventData } from "../hasura/types/HasuraEventData"; import { S3Service } from "../s3/s3.service"; import { User } from "../auth/types/User"; +import { DiscordTournamentVoiceService } from "../discord-bot/discord-tournament-voice/discord-tournament-voice.service"; +import { tournaments_set_input } from "../../generated"; @Controller("tournaments") export class TournamentsController { @@ -10,8 +13,30 @@ export class TournamentsController { private readonly logger: Logger, private readonly hasura: HasuraService, private readonly s3: S3Service, + private readonly tournamentVoice: DiscordTournamentVoiceService, ) {} + @HasuraEvent() + public async tournament_events( + data: HasuraEventData, + ) { + const tournamentId = (data.new.id || data.old.id) as string; + const status = data.new.status as string; + + if ( + status === "Live" && + data.old.status !== "Live" + ) { + await this.tournamentVoice.createTournamentReadyRoom(tournamentId); + } + + if ( + ["Finished", "Cancelled", "CancelledMinTeams"].includes(status) + ) { + await this.tournamentVoice.removeTournamentVoice(tournamentId); + } + } + @HasuraAction() public async deleteTournament(data: { user: User; tournament_id: string }) { const { tournament_id } = data; diff --git a/src/tournaments/tournaments.module.ts b/src/tournaments/tournaments.module.ts index f0d8e20c..8ecc0dbf 100644 --- a/src/tournaments/tournaments.module.ts +++ b/src/tournaments/tournaments.module.ts @@ -2,10 +2,11 @@ import { Module } from "@nestjs/common"; import { TournamentsController } from "./tournaments.controller"; import { HasuraModule } from "../hasura/hasura.module"; import { S3Module } from "../s3/s3.module"; +import { DiscordTournamentVoiceModule } from "../discord-bot/discord-tournament-voice/discord-tournament-voice.module"; import { loggerFactory } from "../utilities/LoggerFactory"; @Module({ - imports: [HasuraModule, S3Module], + imports: [HasuraModule, S3Module, DiscordTournamentVoiceModule], controllers: [TournamentsController], providers: [loggerFactory()], })