summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMathias Magnusson <mathias@magnusson.space>2025-12-12 16:52:27 +0100
committerMathias Magnusson <mathias@magnusson.space>2025-12-13 00:58:39 +0100
commit2dd1bfa26f6cc4dd221891b81e93e91c7636516a (patch)
treed0ab9cd5126e45226c74191f168b85a2516b57ab
parente1f4ffd8b6b4fa8b396a421d470ad51fb3c58236 (diff)
downloadchalle-anka-2dd1bfa26f6cc4dd221891b81e93e91c7636516a.tar.gz
create, join, and archive ctf instances
-rw-r--r--.editorconfig12
-rw-r--r--AGENTS.md16
-rw-r--r--README.md12
-rw-r--r--index.ts247
-rw-r--r--package.json3
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.
diff --git a/README.md b/README.md
index 73bc37c..58dd7ba 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/index.ts b/index.ts
index 913cf55..6728910 100644
--- a/index.ts
+++ b/index.ts
@@ -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"
},