diff options
| -rw-r--r-- | .editorconfig | 12 | ||||
| -rw-r--r-- | AGENTS.md | 16 | ||||
| -rw-r--r-- | README.md | 12 | ||||
| -rw-r--r-- | index.ts | 247 | ||||
| -rw-r--r-- | package.json | 3 |
5 files changed, 266 insertions, 24 deletions
diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..211695e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{json,yml,yaml}] +indent_size = 2 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..641eb22 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ +# Agent Notes (fleg-bout) + +## Commands +- Install deps: `bun install` +- Run bot locally: `timeout 5 bun run .` +- Check types: `bun typecheck` +- Tests: we don't do tests + +## Code Style +- TypeScript ESM (`"type": "module"`); prefer `import type { ... }` for types. +- Use 4-space indentation, double quotes, and semicolons. +- Keep imports grouped and sorted: external first, then local. +- Prefer `let`, early returns, and `async/await` over nested callbacks. +- Handle `strict` TS + `noUncheckedIndexedAccess`: null-check before use. +- Errors: `throw new Error(...)` (avoid throwing strings); log context, don’t log secrets. +- Naming: `camelCase` for vars/functions, `PascalCase` for classes/types, `SCREAMING_SNAKE_CASE` for constants. @@ -1,15 +1,3 @@ # fleg-bout -To install dependencies: - -```bash -bun install -``` - -To run: - -```bash -bun run index.ts -``` - This project was created using `bun init` in bun v1.3.4. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. @@ -1,33 +1,236 @@ 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 { - const value = process.env[key]; + let value = process.env[key]; if (!value) { throw new Error(`Missing ${key} environment variable.`); } return value; } -const token = requireEnv("DISCORD_TOKEN"); -const clientId = requireEnv("DISCORD_CLIENT_ID"); -const guildId = requireEnv("DISCORD_GUILD_ID"); +let token = requireEnv("DISCORD_TOKEN"); +let clientId = requireEnv("DISCORD_CLIENT_ID"); +let guildId = requireEnv("DISCORD_GUILD_ID"); -const client = new Client({ intents: [GatewayIntentBits.Guilds] }); +let client = new Client({ intents: [GatewayIntentBits.Guilds] }); -const commands = [ +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, + }, ]; -const rest = new REST({ version: "10" }).setToken(token); +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<ButtonBuilder>().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), { @@ -40,11 +243,31 @@ client.once(Events.ClientReady, (readyClient) => { }); client.on(Events.InteractionCreate, async (interaction) => { - if (!interaction.isChatInputCommand()) return; - for (const command of commands) { - if (interaction.commandName === command.slashCommand.name) { - await command.handler(interaction); - break; + 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", + }); + } } } }); diff --git a/package.json b/package.json index d66233e..308e456 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "module": "index.ts", "type": "module", "private": true, + "scripts": { + "typecheck": "tsc -p tsconfig.json --noEmit" + }, "devDependencies": { "@types/bun": "latest" }, |
