import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, Client, CommandInteraction, Events, GatewayIntentBits, PermissionFlagsBits, REST, Routes, SlashCommandBuilder, SlashCommandStringOption, type ButtonInteraction, type ChatInputCommandInteraction, } from "discord.js"; function requireEnv(key: string): string { let value = process.env[key]; if (!value) { throw new Error(`Missing ${key} environment variable.`); } return value; } let token = requireEnv("DISCORD_TOKEN"); let clientId = requireEnv("DISCORD_CLIENT_ID"); let guildId = requireEnv("DISCORD_GUILD_ID"); let client = new Client({ intents: [GatewayIntentBits.Guilds] }); let commands = [ { slashCommand: new SlashCommandBuilder() .setName("new") .setDescription("Create a new CTF") .addStringOption( new SlashCommandStringOption() .setName("name") .setDescription("Name for the new CTF") .setRequired(true) ), handler: newCtf, }, { slashCommand: new SlashCommandBuilder() .setName("archive") .setDescription("Make this CTF category read-only for everyone"), handler: makeCtfCategoryReadonly, }, ]; let CATEGORY_BUTTON_PREFIX = "ctf-category:"; async function newCtf(interaction: ChatInputCommandInteraction) { if (!interaction.guild) { await interaction.reply({ content: "This command can only be used inside a server.", flags: "Ephemeral", }); return; } let name = interaction.options.getString("name", true); await interaction.deferReply({ flags: "Ephemeral" }); let category = await interaction.guild.channels.create({ name, type: ChannelType.GuildCategory, permissionOverwrites: [ { id: interaction.guild.roles.everyone, deny: [PermissionFlagsBits.ViewChannel], } ], }); await Promise.all([ await interaction.guild.channels.create({ name: "info", type: ChannelType.GuildText, parent: category.id, position: 1, }), await interaction.guild.channels.create({ name: "general", type: ChannelType.GuildText, parent: category.id, position: 2, }), await interaction.guild.channels.create({ name: "challs", type: ChannelType.GuildForum, parent: category.id, position: 3, }), ]); let commandChannel = (interaction.channelId && await interaction.guild.channels.fetch(interaction.channelId)); if (!commandChannel || !commandChannel.isTextBased()) throw new Error("Wth is this channel?"); await commandChannel.send({ components: [ new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`${CATEGORY_BUTTON_PREFIX}${category.id}`) .setLabel(`Join ${name}`) .setStyle(ButtonStyle.Primary), ), ], }); await interaction.editReply( `Created ${category.name}.`, ); } async function makeCtfCategoryReadonly(interaction: ChatInputCommandInteraction) { if (!interaction.guild) { await interaction.reply({ content: "This command can only be used inside a server.", flags: "Ephemeral", }); return; } if (!interaction.channel || interaction.channel.type !== ChannelType.GuildText || !["general", "info"].includes(interaction.channel.name) || !interaction.channel.parentId ) { await interaction.reply({ content: "This command can only be used in the `general` or `info` channel of a CTF category.", flags: "Ephemeral", }); return; } let category = interaction.channel.parentId && await interaction.guild.channels.fetch(interaction.channel.parentId); if (!category || category.type !== ChannelType.GuildCategory) { await interaction.reply({ content: "This command can only be used in the `general` or `info` channel of a CTF category.", flags: "Ephemeral", }); return; } await interaction.deferReply({ flags: "Ephemeral" }); let allChannels = await interaction.guild.channels.fetch(); let categoryChildren = [...allChannels.values()].filter((channel) => { return channel !== null && "parentId" in channel && channel.parentId === category.id; }); let infoChannel = categoryChildren.find((channel) => channel?.name === "info"); let generalChannel = categoryChildren.find((channel) => channel?.name === "general"); let challsChannel = categoryChildren.find((channel) => channel?.name === "challs"); if ( !infoChannel || !generalChannel || !challsChannel || infoChannel.type !== ChannelType.GuildText || generalChannel.type !== ChannelType.GuildText || challsChannel.type !== ChannelType.GuildForum ) { await interaction.editReply("This command can only be used in the `general` or `info` channel of a CTF category."); return; } let everyoneRoleId = interaction.guild.roles.everyone.id; await category.permissionOverwrites.edit(everyoneRoleId, { ViewChannel: true, ReadMessageHistory: true, SendMessages: false, SendMessagesInThreads: false, CreatePublicThreads: false, CreatePrivateThreads: false, }); await Promise.all( category.permissionOverwrites.cache .filter(overwrite => overwrite.id !== everyoneRoleId) .map(overwrite => category.permissionOverwrites.delete(overwrite.id)) ); for (let channel of [infoChannel, generalChannel, challsChannel]) { if (channel && "lockPermissions" in channel && typeof channel.lockPermissions === "function") { await channel.lockPermissions(); } } await interaction.editReply( `Updated ${category.name}: @everyone can read, (mostly) no-one can write.`, ); } async function handleJoinCtfButton(interaction: ButtonInteraction) { if (!interaction.guild) { await interaction.reply({ content: "This button only works inside a server.", flags: "Ephemeral", }); return; } let categoryId = interaction.customId.slice(CATEGORY_BUTTON_PREFIX.length); let categoryChannel = await interaction.guild.channels.fetch(categoryId); if (!categoryChannel || categoryChannel.type !== ChannelType.GuildCategory) { await interaction.reply({ content: "That category no longer exists.", flags: "Ephemeral", }); return; } await categoryChannel.permissionOverwrites.edit(interaction.user.id, { ViewChannel: true, SendMessages: true, }); await interaction.reply({ content: `Granted you access to ${categoryChannel.name}.`, flags: "Ephemeral", }); } let rest = new REST({ version: "10" }).setToken(token); async function registerSlashCommands() { await rest.put(Routes.applicationGuildCommands(clientId, guildId), { body: commands.map(command => command.slashCommand.toJSON()), }); } client.once(Events.ClientReady, (readyClient) => { console.log(`Logged in as ${readyClient.user.tag}`); }); client.on(Events.InteractionCreate, async (interaction) => { try { if (interaction.isChatInputCommand()) { for (let command of commands) { if (interaction.commandName === command.slashCommand.name) { await command.handler(interaction); break; } } } else if (interaction.isButton() && interaction.customId.startsWith(CATEGORY_BUTTON_PREFIX)) { await handleJoinCtfButton(interaction); } } catch (error) { if (interaction instanceof CommandInteraction) { console.error("Error while handling interaction", error); if (interaction.deferred || interaction.replied) { await interaction.followUp({ content: "Something went wrong! :(", flags: "Ephemeral", }); } else { await interaction.reply({ content: "Something went wrong! :(", flags: "Ephemeral", }); } } } }); try { await registerSlashCommands(); await client.login(token); } catch (error) { console.error("Discord bot failed to start", error); process.exit(1); }