import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, ForumChannel, PermissionFlagsBits, SlashCommandBuilder, SlashCommandChannelOption, SlashCommandStringOption, SlashCommandUserOption, ChatInputCommandInteraction, type ButtonInteraction, type GuildForumTag, type GuildBasedChannel, } from "discord.js"; export let slashCommands = [ { slashCommand: new SlashCommandBuilder() .setName("new") .setDescription("Create a new CTF") .addStringOption( new SlashCommandStringOption() .setName("name") .setDescription("Name for the new CTF") .setRequired(true) ) .addStringOption( new SlashCommandStringOption() .setName("message") .setDescription("Contents of the initial post in the general thread. Should probably be a link to the CTF platform.") .setRequired(true) ) .addStringOption( new SlashCommandStringOption() .setName("tags") .setDescription("A comma-separated list of tags to add to the forum. Defaults to the most common CTF categories.") ), handler: newCtf, }, { slashCommand: new SlashCommandBuilder() .setName("create-button-message") .setDescription("Re-create a message containing an invite button for this CTF") .addChannelOption( new SlashCommandChannelOption() .setName("channel") .setDescription("The channel in which to create the button") .setRequired(true) ), handler: createButtonMessage, }, { slashCommand: new SlashCommandBuilder() .setName("archive") .setDescription("Make this CTF forum read-only for everyone"), handler: archiveCtf, }, { slashCommand: new SlashCommandBuilder() .setName("add") .setDescription("Add a user to this CTF forum") .addUserOption( new SlashCommandUserOption() .setName("user") .setDescription("The user to add") .setRequired(true) ), handler: addToCtf, }, { slashCommand: new SlashCommandBuilder() .setName("remove") .setDescription("Remove a user from this CTF forum") .addUserOption( new SlashCommandUserOption() .setName("user") .setDescription("The user to remove") .setRequired(true) ), handler: removeFromCtf, }, ]; export let buttonCommands = { join: { prefix: "ctf-forum:", handler: handleJoinCtfButton, }, } as const; 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); let link = interaction.options.getString("message", true); let tags = interaction.options.getString("tags"); await interaction.deferReply({ flags: "Ephemeral" }); let forum = await interaction.guild.channels.create({ name, type: ChannelType.GuildForum, permissionOverwrites: [ { id: interaction.guild.roles.everyone, deny: [PermissionFlagsBits.ViewChannel], } ], }); await forum.setAvailableTags((tags?.split(",") ?? ["web", "pwn", "rev", "crypto"]).map(name => ({ name, moderated: false }))); await ensureUnsolvedTag(forum); let generalThread = await forum.threads.create({ name: "general", message: { content: link, }, }); await generalThread.pin(); 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(`${buttonCommands.join.prefix}${forum.id}`) .setLabel(`Join ${name}`) .setStyle(ButtonStyle.Primary), ), ], }); await interaction.editReply( `Created ${forum.name}.`, ); } async function createButtonMessage(interaction: ChatInputCommandInteraction) { let forum = await getCtfForum(interaction); let channel = interaction.options.getChannel("channel", true, [ChannelType.GuildText]); await channel.send({ components: [ new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`${buttonCommands.join.prefix}${forum.id}`) .setLabel(`Join ${forum.name}`) .setStyle(ButtonStyle.Primary), ), ], }); await interaction.reply({ content: "Done!", flags: "Ephemeral" }); } async function archiveCtf(interaction: ChatInputCommandInteraction) { if (!interaction.guild) { await interaction.reply({ content: "This command can only be used inside a server.", flags: "Ephemeral", }); return; } let forum = await getCtfForum(interaction); let everyoneRoleId = interaction.guild.roles.everyone.id; await forum.permissionOverwrites.edit(everyoneRoleId, { ViewChannel: true, SendMessages: false, SendMessagesInThreads: false, CreatePublicThreads: false, CreatePrivateThreads: false, }); await Promise.all( forum.permissionOverwrites.cache .filter(overwrite => overwrite.id !== everyoneRoleId) .map(overwrite => forum.permissionOverwrites.delete(overwrite.id)) ); let channels = await interaction.guild.channels.fetch(); let archiveCategory = channels.find( channel => channel?.type === ChannelType.GuildCategory && ["Archive", "archive"].includes(channel.name) ); if (archiveCategory) { await forum.setParent(archiveCategory.id, { lockPermissions: false }); } await interaction.reply({ content: `Updated ${forum.name}: @everyone can read, (mostly) no-one can write.`, flags: "Ephemeral", }); } async function addToCtf(interaction: ChatInputCommandInteraction) { if (!interaction.guild) { await interaction.reply({ content: "This command can only be used inside a server.", flags: "Ephemeral", }); return; } let user = interaction.options.getUser("user", true); let forum = await getCtfForum(interaction); let member = await interaction.guild.members.fetch(interaction.user.id); let permissions = forum.permissionsFor(member); if (!permissions?.has(PermissionFlagsBits.ManageChannels)) { await interaction.reply({ content: `You don't have permission to update permissions in ${forum.name}.`, flags: "Ephemeral", }); return; } await forum.permissionOverwrites.edit(user, { ViewChannel: true, CreatePublicThreads: true, SendMessages: true, SendMessagesInThreads: true, }); await interaction.reply({ content: `Granted ${user} access to ${forum.name}.`, flags: "Ephemeral", }); } async function removeFromCtf(interaction: ChatInputCommandInteraction) { if (!interaction.guild) { await interaction.reply({ content: "This command can only be used inside a server.", flags: "Ephemeral", }); return; } let user = interaction.options.getUser("user", true); let forum = await getCtfForum(interaction); let member = await interaction.guild.members.fetch(interaction.user.id); let permissions = forum.permissionsFor(member); if (!permissions?.has(PermissionFlagsBits.ManageChannels)) { await interaction.reply({ content: `You don't have permission to update permissions in ${forum.name}.`, flags: "Ephemeral", }); return; } await forum.permissionOverwrites.delete(user.id); await interaction.reply({ content: `Removed ${user} from ${forum.name}.`, flags: "Ephemeral", }); } async function handleJoinCtfButton(interaction: ButtonInteraction, forumId: string) { if (!interaction.guild) { return await interaction.reply({ content: "This button only works inside a server.", flags: "Ephemeral", }); } let forumChannel = await interaction.guild.channels.fetch(forumId); if (!forumChannel || forumChannel.type !== ChannelType.GuildForum) { await interaction.reply({ content: "That forum no longer exists.", flags: "Ephemeral", }); return; } if (forumChannel.permissionsFor(interaction.guild.roles.everyone).has(PermissionFlagsBits.ViewChannel)) { await interaction.reply({ content: `The CTF has been archived so you can already view it: https://discord.com/channels/${interaction.guild.id}/${forumChannel.id}`, flags: "Ephemeral", }); return; } await forumChannel.permissionOverwrites.edit(interaction.user.id, { ViewChannel: true, CreatePublicThreads: true, SendMessages: true, SendMessagesInThreads: true, }); await interaction.reply({ content: `Granted you access to https://discord.com/channels/${interaction.guild.id}/${forumChannel.id}`, flags: "Ephemeral", }); } /** * Given either a forum, a thread in a forum, or an interaction from a slash command ran in a thread * in a forum, returns that forum if it was created by this bot. */ export async function getCtfForum(interactionOrChannel: ChatInputCommandInteraction | GuildBasedChannel): Promise { let interaction = (interactionOrChannel instanceof ChatInputCommandInteraction) ? interactionOrChannel : null; async function done(): Promise { await interaction?.reply({ content: "This command can only be used in a CTF forum channel.", flags: "Ephemeral" }); throw "done"; } let channel: GuildBasedChannel; if (interactionOrChannel instanceof ChatInputCommandInteraction) { if (!interactionOrChannel.guild) throw await done(); let ch = await interactionOrChannel.guild.channels.fetch(interactionOrChannel.channelId); if (!ch) throw await done(); channel = ch; } else { channel = interactionOrChannel; } if (channel.type === ChannelType.GuildForum) { return channel as ForumChannel; } if (!channel.isThread() || !channel.parentId) throw await done(); let parent = await channel.guild.channels.fetch(channel.parentId); if (!parent || parent.type !== ChannelType.GuildForum) throw await done(); let threads = await parent.threads.fetch(); for (let thread of threads.threads.values()) { if (thread.name !== "general" || thread.ownerId !== channel.client.user?.id || !thread.flags.has("Pinned")) { continue; } return parent as ForumChannel; } throw await done(); } export async function ensureUnsolvedTag(forum: ForumChannel): Promise { let currentTags = forum.availableTags ?? []; let existing = currentTags.find(tag => tag.name === "unsolved"); if (existing) { return existing; } await forum.setAvailableTags([ ...currentTags, { name: "unsolved", moderated: false }, ]); let updated = forum.availableTags?.find(tag => tag.name === "unsolved"); if (!updated) { throw new Error(`Failed to create unsolved tag for ${forum.id}`); } return updated; }