diff --git a/.gitignore b/.gitignore index 6bdcd15..91561b6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ __pycache__/ data.json .env +temp.md +solves.csv \ No newline at end of file diff --git a/README.md b/README.md index 00739fa..08bc08e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,87 @@ # CTF-Bot -This is a Discord bot that is used to parse ctftime.org and make managing CTF channels easier. \ No newline at end of file +This is a Discord bot that is used to parse ctftime.org and make managing CTF channels easier. + +## Testing +### Enviornment Variables +- To test the bot, first declare the following enivornment variables in a `.env` file in the root directory +The following variables need to be defined: +``` +TOKEN= +CTF_VERIFIED_ROLE_ID= +OFFICER_ROLE_ID= +ARCHIVE_CATEGORY_ID= +CTF_CATEGORY_POS= +``` +- A token for the bot can be created by going to the Discord Developer Portal and [creating a new bot](https://discord.com/developers/applications?new_application=true) + - You must then invite your bot to your server +- The role IDs can be created in a Discord and copied as follows + - ![role_ids](documentation/images/role_ids.png) +- The `CTF_CATEGORY_POS` is the position you want new categories to be made in the server +- The `ARCHIVE_CATEGORY_ID` is the category ID where you want the bot to archive channels. Right click the category to get the category ID + +### Running Docker +- Use the following commands to build the docker image +- `docker build -t ctf-bot .` to build the image +- `docker run --name ctf-bot-container` to run the container + - `-v /path/to/directory:/mnt/host/ctfbot -w /mnt/host/ctfbot` to mount the current directory to the container so that it will be easier to access the `data.json` file for debugging +- You can use `docker rm $(docker ps -a -q)` to remove all containers if you run into an issue + +### Testing in Discord +- Use the `/register` command to create a new CTF +- You can insert [this command](documentation/delete_channels.py) into the `cog.py` file to delete all channels in the server if you find yourself testing `/register` often +- You will need to delete `data.json` manually to reset the bot's saved data, or use `/end_ctf` +- If you edit a command's name or parameters, you need to restart Discord to use it + +## Commands +### /upcoming +- Gets upcoming CTFs from the CTFd API +- ![upcoming](documentation/images/upcoming.png) + +### /event +- Gets the event from the CTFd API using the event ID +- ![event](documentation/images/events.png) + +### /connect_to_ctfd +- Lets users input their CTFd username to be used in the CSV at the end of the CTF. This is to award CTFd points to users who solve challenges +- ![ctfd](documentation/images/ctfd.png) + +### /register +- Only an officer can use this command +- Register for a CTF and create the channels +- ![register_cmd](documentation/images/register_cmd.png) +- ![register](documentation/images/register.png) +- React to the join message to get access to the CTF channels +- ![join_ctf](documentation/images/join_ctf.png) +- When someone reacts or unreacts to the join message, the bot will send a message in the logs channel +- ![logs](documentation/images/logs.png) + +### /challenge +- To create a new challenge, use the **#general** channel in the CTF category to create a new thread. This will let others know that you are working on that challenge, and will let you document your progress! +- ![challenge_cmd](documentation/images/challenge_cmd.png) +- ![challenge](documentation/images/challenge.png) +- This will create a new entry in the **#challenges** channel +- ![challenges_1](documentation/images/challenges_1.png) + +### /solve +- To solve a challenge, use the **#challenges** channel to find the the challenge you want to solve. Join the thread and use the `/solve` *after* you have submitted the flag +- ![solve](documentation/images/solve.png) +- This will send a message in the **#general** channel letting everyone know your accomplishment, and update the challenge board +- ![solve_board](documentation/images/solve_board.png) + +### /hide +- Lets say you created a challenge by mistake. You can use `/hide` to cross it out from the challenge board and close the thread. Use `/hide` again if you want to unhide it. +- ![hide](documentation/images/hide.png) +- ![hide_board](documentation/images/hide_board.png) + +### /end_ctf +- Only an officer can use this command +- This will end the CTF and move the challenge board to the general channel +- It will also send a CSV file with the solves and users who joined the CTF (marked by 0 solves) +- It will then rename the general channel and move it to the archive category +- This method allows all the CTF data to be condensed into one channel +- It does not delete the channels but marks them as "To be deleted". This is to prevent accidental deletion of channels +- ![end_ctf_1](documentation/images/end_ctf_1.png) +- ![end_ctf_2](documentation/images/end_ctf_2.png) + +## Known Issues +- Line length does not meet PEP8 standards diff --git a/ctfbot/cog.py b/ctfbot/cog.py index 8f65f54..bf1c04e 100644 --- a/ctfbot/cog.py +++ b/ctfbot/cog.py @@ -1,17 +1,20 @@ from collections import defaultdict -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path +import math import discord +from discord.ext import commands import jsonpickle - from decouple import config -from discord.ext import commands from ctfbot import ctftime -from ctfbot.data import GlobalData +from ctfbot.data import Chall_board_indicies, Category_and_challenge, Challenges, Event, GlobalData +from ctfbot.helpers import get_event_ctx, get_event_from_channel, update_indicies, move_board, get_embed_from_index, gen_csv_of_solves JSON_DATA_FILE = Path.cwd() / 'data.json' +MAX_FIELDS = 25 +OFFICER_ROLE_ID = int(config('OFFICER_ROLE_ID')) def iso_to_pretty(iso): @@ -43,16 +46,20 @@ async def on_command_error(self, ctx, error): await ctx.respond("An internal error occurred") def write_data(self): - JSON_DATA_FILE.write_text(jsonpickle.encode(self.data, indent=4)) + if not JSON_DATA_FILE.exists(): + JSON_DATA_FILE.touch() + JSON_DATA_FILE.write_text( + jsonpickle.encode( + self.data, + indent=4, + keys=True)) def load_data(self): try: - self.data = jsonpickle.decode(JSON_DATA_FILE.read_text()) - # for server in self.data.servers.values(): - # for reminder in server.reminders.values(): - # self.scheduler.enter(reminder.utcnow(), 1, lambda: self.) + self.data = jsonpickle.decode( + JSON_DATA_FILE.read_text(), keys=True) + except OSError: - print("Couldn't read default data file") self.data = GlobalData() self.write_data() @@ -64,18 +71,10 @@ async def upcoming(self, ctx: discord.ApplicationContext): await ctx.respond(embed=self.create_event_embed(event)) @commands.slash_command() - async def schedule(self, ctx: discord.ApplicationContext): - events = self.data.servers[ctx.guild_id].events - if events: - description = '\n'.join(ctx.bot.get_channel(int(channel_id)).mention - for channel_id in events.values()) - embed = discord.Embed(title='Upcoming registered events', description=description) - await ctx.respond(embed=embed) - else: - await ctx.respond('No upcoming events at the moment') - - @commands.slash_command() - async def event(self, ctx: discord.ApplicationContext, event_id: discord.Option(int)): + async def event( + self, + ctx: discord.ApplicationContext, + event_id: discord.Option(int)): event = ctftime.get_event(event_id) if event is None: await ctx.respond("Event not found") @@ -83,26 +82,38 @@ async def event(self, ctx: discord.ApplicationContext, event_id: discord.Option( await ctx.respond(embed=self.create_event_embed(event)) @commands.slash_command() - @commands.has_permissions(administrator=True) - async def register(self, ctx: discord.ApplicationContext, - event_id: discord.Option(int), category_name: discord.Option(str)): + @commands.has_role(OFFICER_ROLE_ID) + async def register( + self, + ctx: discord.ApplicationContext, + event_id: discord.Option(int), + category_name: discord.Option(str), + ctf_verified_required: discord.Option(bool)): data = self.data.servers[ctx.guild_id] - if str(event_id) in data.events or str(event_id) in data.archived_events: + if str(event_id) in data.events or str( + event_id) in data.archived_events: await ctx.respond('You have already registered/played this event!') return event = ctftime.get_event(event_id) + if event is None: await ctx.respond('Event not found') return + guild: discord.Guild = ctx.guild - category: discord.CategoryChannel = await guild.create_category(name=category_name, + category: discord.CategoryChannel = await guild.create_category(name=category_name + "🚩", position=config('CTF_CATEGORY_POS')) - data.events[event_id] = category.id - overwrites = {guild.default_role: discord.PermissionOverwrite(send_messages=False, - add_reactions=False, manage_threads=False)} + data.event_categories[category.id] = event_id + overwrites = { + guild.default_role: discord.PermissionOverwrite( + send_messages=False, + add_reactions=False, + manage_threads=False)} channel_join: discord.TextChannel = await guild.create_text_channel(name='join-ctf', category=category, overwrites=overwrites) - overwrites = {guild.default_role: discord.PermissionOverwrite(read_messages=False)} + overwrites = { + guild.default_role: discord.PermissionOverwrite( + read_messages=False)} channel_join_logs: discord.TextChannel = await guild.create_text_channel(name='logs', category=category, overwrites=overwrites) @@ -112,44 +123,111 @@ async def register(self, ctx: discord.ApplicationContext, channel_general: discord.TextChannel = await guild.create_text_channel(name='general', category=category, overwrites=overwrites) - message_challenges_embed = discord.Embed( + message_chall_board_embed = discord.Embed( title=f"{category_name} Challenges", description="Current and solved challenges. To add a challenge, use the command /challenge in" + f"{channel_general.mention}. To solve a challenge, use the command /solve in a challenge thread." + - "If you created a challenge by mistake, contact an admin to use /remove", + " If you created a challenge by mistake, use the /hide command", color=discord.Colour.yellow(), ) - message_challenges: discord.Message = await channel_challenges.send(embed=message_challenges_embed) + message_chall_board: discord.Message = await channel_challenges.send(embed=message_chall_board_embed) message_info: discord.Message = await channel_join.send(embed=self.create_event_embed(event)) message_join_embed = discord.Embed( title=f"Join {category_name}", - description="To join the CTF, react with the :white_check_mark: emoji below!" + - " You must have the CTF-Verified role to join. Please message an admin/officer to obtain this role.", + description="To join the CTF, react with the :white_check_mark: emoji below!", color=discord.Colour.green(), ) + message_join: discord.Message = await channel_join.send(embed=message_join_embed) await message_join.add_reaction("✅") + if ctf_verified_required: + message_verified_embed = discord.Embed( + title=f"This is a CTF-Verified ✅ event", + description="You must have the CTF-Verified role to join. " + + "Please message an admin/officer to obtain this role.", + color=discord.Colour.green(), + ) + message_verified: discord.Message = await channel_join.send(embed=message_verified_embed) + + challenges_instance = Challenges() + + data.events[event_id] = Event( + ctf_verified=ctf_verified_required, + channel_join=channel_join.id, + channel_logs=channel_join_logs.id, + channel_challenges=channel_challenges.id, + channel_general=channel_general.id, + join_message=message_join.id, + challenges=challenges_instance + ) await ctx.respond(f"Event Created! Join at {channel_join.mention}") self.write_data() @commands.slash_command() - @commands.has_permissions(administrator=True) - async def archive(self, ctx: discord.ApplicationContext): - guild: discord.Guild = ctx.guild - category: discord.CategoryChannel = guild.get_channel(config('ARCHIVE_CATEGORY_ID', cast=int)) + async def connect_to_ctfd(self, ctx: discord.ApplicationContext, + username: discord.Option(str)): data = self.data.servers[ctx.guild_id] - for event_id, channel_id in data.events.items(): - if int(channel_id) == ctx.channel_id: - await ctx.channel.edit(category=category) - del data.events[event_id] - data.archived_events.append(event_id) - self.write_data() - await ctx.respond('Done!') - return - await ctx.respond('Current channel is not an active CTF') + data.user_to_ctfd[ctx.author.id] = username + await ctx.respond(ctx.author.mention + + 'has connected to the [CTFd](https://ctfd.wolvsec.org/) with username ' + + username + ' to get CTF solve points! (/connect_to_ctfd)') + self.write_data() @commands.slash_command() - async def team(self, ctx: discord.ApplicationContext, team_id: discord.Option(int)): + @commands.has_role(OFFICER_ROLE_ID) + async def end_ctf(self, ctx: discord.ApplicationContext): + data = self.data.servers[ctx.guild_id] + guild: discord.Guild = ctx.guild + if (event := get_event_ctx(data, ctx)) is None: + await ctx.respond('Current channel is not an active CTF') + return + join_channel = guild.get_channel(event.channel_join) + logs_channel = guild.get_channel(event.channel_logs) + challenge_channel = guild.get_channel(event.channel_challenges) + general_channel = guild.get_channel(event.channel_general) + message_end = discord.Embed( + title=f"This CTF has been ended! 🛑", + description=f"Thank you for playing! This channel is now publicly viewable.", + color=discord.Colour.red(), + ) + await general_channel.send(embed=message_end) + for message_id in event.challenges.chall_board_msg_ids: + message_chall_board: discord.Message = await challenge_channel.fetch_message(message_id) + await general_channel.send(embed=message_chall_board.embeds[0]) + filename = "solves.csv" + gen_csv_of_solves(event, data, filename) + with open(filename, 'rb') as file: + await general_channel.send("Here is the CSV file of solves for each user:", file=discord.File(file, filename)) + category_id = ctx.channel.category_id + category = guild.get_channel(category_id) + await general_channel.edit(name=category.name) + + delete_string = "TO BE DELETED ❌" + await category.edit(name=delete_string) + await join_channel.edit(name=delete_string) + await logs_channel.edit(name=delete_string) + await challenge_channel.edit(name=delete_string) + + archive_category_id = config('ARCHIVE_CATEGORY_ID', cast=int) + archive_category = guild.get_channel(archive_category_id) + + try: + await general_channel.edit(category=archive_category) + except Exception as e: + await general_channel.send("ERROR: Unable to move to archive category") + + event_id = data.event_categories[category_id] + data.archived_events.append(event_id) + del data.events[event_id] + del data.event_categories[category_id] + await ctx.respond('Event has been sucessfully ended!') + self.write_data() + + @commands.slash_command() + async def team( + self, + ctx: discord.ApplicationContext, + team_id: discord.Option(int)): team = ctftime.get_team(team_id) if team is None: await ctx.respond("Team not found") @@ -171,180 +249,194 @@ async def team(self, ctx: discord.ApplicationContext, team_id: discord.Option(in await ctx.respond(embed=embed) @commands.slash_command() - async def reminder(self, ctx: discord.ApplicationContext): - data = self.data.servers[ctx.guild_id] - if ctx.channel_id in data.reminders: - await ctx.respond('Removed reminder for this event') - data.reminders[ctx.channel_id] = datetime.utcnow() - else: - await ctx.respond('Added reminder for this event') - del data.reminders[ctx.channel_id] - - @commands.slash_command() - async def challenge(self, ctx: discord.ApplicationContext, chal_category: discord.Option(str), - chal_name: discord.Option(str)): + async def challenge( + self, + ctx: discord.ApplicationContext, + chal_category: discord.Option(str), + chal_name: discord.Option(str)): banned_strings = ['→', '**', '~~', '@'] - if any(banned_string in chal_category or banned_string in chal_name for banned_string in banned_strings): + if any( + banned_string in chal_category or banned_string in chal_name for banned_string in banned_strings): await ctx.respond('Invalid character in challenge name/category') return - guild: discord.Guild = ctx.guild + if len(chal_name) > 50 or len(chal_category) > 50: + await ctx.respond('Challenge name or can not be longer than 50 characters') + return data = self.data.servers[ctx.guild_id] - for event_id, category_id in data.events.items(): - category = guild.get_channel(category_id) - if category and ctx.channel_id == category.channels[3].id: - break - else: - await ctx.respond('This is not a CTF channel') + guild: discord.Guild = ctx.guild + if (event := get_event_ctx(data, ctx)) is None: + await ctx.respond('Current channel is not an active CTF') + return + challenges = event.challenges + challenge_channel = guild.get_channel(event.channel_challenges) + if (chal_category, chal_name) in challenges.category_challenge_to_chall_board: + await ctx.respond('Challenge already exists') return - channel_challenges: discord.TextChannel = category.channels[2] - channel_general: discord.TextChannel = category.channels[3] - message_challs: discord.Message = await channel_challenges.fetch_message(channel_challenges.last_message_id) - embed = message_challs.embeds[0] - thread = None - for index, field in enumerate(embed.fields): - if field.name == f'**{chal_category}**': - for i in range(index + 1, len(embed.fields)): - if embed.fields[i].value.startswith(chal_name): - await ctx.respond('Challenge already exists') - return - elif embed.fields[i].name.startswith('**'): - break - else: - i = len(embed.fields) - break - else: - embed.add_field(name=f'**{chal_category}**', value='') - i = len(embed.fields) - thread = await channel_general.create_thread(name=chal_category + '/' + chal_name, type=discord.ChannelType.public_thread) - embed.insert_field_at(i, name='', value=chal_name + ' → ' + thread.mention, inline=False) + space_allocated = 2 if chal_category not in challenges.category_to_chall_board else 1 + if (math.floor((challenges.chall_board_field_count - 1 + space_allocated) / + MAX_FIELDS) > len(challenges.chall_board_msg_ids) - 1): + message_chall_board_embed = discord.Embed( + description="Challenges", color=discord.Colour.yellow()) + message_chall_board: discord.Message = await challenge_channel.send( + embed=message_chall_board_embed) + challenges.chall_board_msg_ids.append(message_chall_board.id) - await message_challs.edit(embed=embed) + if chal_category not in challenges.category_to_chall_board: + last_board_pos = len(challenges.chall_board_msg_ids) - 1 + challenges.category_to_chall_board[chal_category] = Chall_board_indicies( + challenges.chall_board_field_count, challenges.chall_board_field_count + 1) + message_chall_board: discord.Message = await challenge_channel.fetch_message( + challenges.chall_board_msg_ids[last_board_pos]) + embed = message_chall_board.embeds[0] + embed.add_field(name=f'__**{chal_category}**__', value='') + challenges.chall_board_field_count += 1 + await message_chall_board.edit(embed=embed) + index_to_insert = challenges.chall_board_field_count + else: + index_to_insert = challenges.category_to_chall_board[ + chal_category].last_challenge_index + 1 + challenges.category_to_chall_board[chal_category].last_challenge_index += 1 + await update_indicies(event, index_to_insert + 1) + board_shift_needed = ( + math.floor( + index_to_insert / + MAX_FIELDS) + + 1) != len( + challenges.chall_board_msg_ids) + if (board_shift_needed): + await move_board(event, index_to_insert, challenge_channel) + + message_chall_board: discord.Message = await challenge_channel.fetch_message( + challenges.chall_board_msg_ids[math.floor(index_to_insert / MAX_FIELDS)]) + embed = message_chall_board.embeds[0] + challenges.category_challenge_to_chall_board[( + chal_category, chal_name)] = index_to_insert + thread = await guild.get_channel(event.channel_general).create_thread( + name=chal_category + '/' + chal_name, type=discord.ChannelType.public_thread) + embed.insert_field_at( + index_to_insert % + MAX_FIELDS, + name='', + value=chal_name + + ' → ' + + thread.mention, + inline=False) + challenges.chall_board_field_count += 1 + challenges.thread_id_to_challenge[thread.id] = Category_and_challenge( + chal_category, chal_name) + await message_chall_board.edit(embed=embed) await ctx.respond(f'Challenge created {thread.mention}') + self.write_data() @commands.slash_command() - @commands.has_permissions(administrator=True) - async def remove(self, ctx: discord.ApplicationContext, chal_category: discord.Option(str), - chal_name: discord.Option(str)): - guild: discord.Guild = ctx.guild + async def hide(self, ctx: discord.ApplicationContext): data = self.data.servers[ctx.guild_id] - for event_id, category_id in data.events.items(): - category = guild.get_channel(category_id) - if category and ctx.channel_id == category.channels[3].id: - break - else: - await ctx.respond('This is not a CTF channel') - return - channel_challenges: discord.TextChannel = category.channels[2] - message_challs: discord.Message = await channel_challenges.fetch_message(channel_challenges.last_message_id) - embed = message_challenges.embeds[0] - for index, field in enumerate(embed.fields): - if field.name == f'**{chal_category}**': - for i in range(index + 1, len(embed.fields)): - if embed.fields[i].value.startswith(chal_name): - thread_id = int(embed.fields[i].value.split(' → <#')[1].split('>')[0]) - thread: discord.Thread = guild.get_thread(thread_id) - await thread.edit(archived=True) - embed.remove_field(i) - if embed.fields[i - 1].name.startswith('**') and (i == len(embed.fields) or - embed.fields[i].name.startswith('**')): - embed.remove_field(i - 1) - await message_challs.edit(embed=embed) - await ctx.respond('Challenge removed') - return - await ctx.respond('Challenge not found') - - @commands.slash_command() - async def solve(self, ctx: discord.ApplicationContext, flag: discord.Option(str), - display_flag: discord.Option(bool)): guild: discord.Guild = ctx.guild - data = self.data.servers[ctx.guild_id] - found_thread = False - for event_id, category_id in data.events.items(): - category = guild.get_channel(category_id) - channel_challs: discord.TextChannel = category.channels[2] - message_challs: discord.Message = await channel_challs.fetch_message(channel_challs.last_message_id) - embed = message_challs.embeds[0] - for index, field in enumerate(embed.fields): - if not field.name.startswith('**'): - thread_id = int(field.value.split(' → <#')[1].split('>')[0]) - if (ctx.channel_id == thread_id): - embed_field = embed.fields[index] - found_thread = True - break - if found_thread: - break - else: + thread = ctx.channel + if (event := get_event_ctx(data, ctx)) is None: + await ctx.respond('Current channel is not an active CTF') + return + challenges = event.challenges + challenge_channel = guild.get_channel(event.channel_challenges) + if thread.id not in challenges.thread_id_to_challenge: await ctx.respond('This is not a CTF thread') return - if (embed_field.value.startswith('~~')): - await ctx.respond('Challenge already solved') + if thread.id in challenges.solved_challs: + await ctx.respond('Challenge solved, can not hide') return - challenge_name = embed_field.value.split(' → ')[0] - if not display_flag: - flag = "HIDDEN" - message_challenges_embed = discord.Embed( - title=f"{challenge_name} has been solved!", - description=f"{ctx.author.mention} has solved with flag: {flag}", - color=discord.Colour.green(), - ) - message_solve: discord.Message = await category.channels[3].send(embed=message_challenges_embed) - embed.set_field_at(index, name='', value="~~" + embed_field.value + '~~ has been solved by ' + - ctx.author.mention + '!', inline=False) - await message_challs.edit(embed=embed) - await ctx.respond('Flag submitted!') - - @commands.slash_command() - async def solve_backup(self, ctx: discord.ApplicationContext, flag: discord.Option(str), - display_flag: discord.Option(bool), general_channel_id: discord.Option(str), challenge_name: discord.Option(str)): - guild: discord.Guild = ctx.guild - data = self.data.servers[ctx.guild_id] - for event_id, category_id in data.events.items(): - category = guild.get_channel(category_id) - if category and general_channel_id == str(category.channels[3].id): - break + category_and_challenge = challenges.thread_id_to_challenge[ctx.channel_id] + category = category_and_challenge.category + challenge = category_and_challenge.challenge + index = challenges.category_challenge_to_chall_board[( + category, challenge)] + embed, message_chall_board = await get_embed_from_index(index, challenges, challenge_channel) + if thread.id in challenges.hidden_challs: + challenges.hidden_challs.remove(thread.id) + embed.set_field_at( + index % + MAX_FIELDS, + name='', + value=challenge + + ' → ' + + thread.mention, + inline=False) + await thread.edit(archived=False) + await message_chall_board.edit(embed=embed) + await ctx.respond(f'Challenge un-hidden. To hide, use /hide again') else: - await ctx.respond('This is not a CTF channel') - return - channel_general: await guild.get_channel(general_channel_id) - message_challenges_embed = discord.Embed( - title=f"{challenge_name} has been solved!", - description=f"{ctx.author.mention} has solved with flag: {flag}", - color=discord.Colour.green(), - ) - message_solve: discord.Message = await category.channels[3].send(embed=message_challenges_embed) - await ctx.respond('Flag submitted!') + challenges.hidden_challs.add(thread.id) + embed.set_field_at( + index % + MAX_FIELDS, + name='', + value="~~" + + challenge + + ' → ' + + thread.mention + + '~~ is hidden', + inline=False) + await message_chall_board.edit(embed=embed) + await ctx.respond('Challenge hidden. To unhide, use /hide again') + await thread.edit(archived=True) + self.write_data() @commands.slash_command() - @commands.has_permissions(administrator=True) - async def print_events(self, ctx: discord.ApplicationContext): - data = self.data.servers[ctx.guild_id] - if data.events == {}: - await ctx.respond('No events') - return - for event_id, category_id in data.events.items(): - await ctx.respond(f'{event_id}: {category_id}') + async def solve(self, ctx: discord.ApplicationContext, + i_have_submitted_the_flag: discord.Option(bool)): - @commands.slash_command() - @commands.has_permissions(administrator=True) - async def set_event_category_id(self, ctx: discord.ApplicationContext, event_id: discord.Option(int), - category_id: discord.Option(str)): data = self.data.servers[ctx.guild_id] - if event_id in data.events: - await ctx.respond('Event id already exists') + guild: discord.Guild = ctx.guild + thread = ctx.channel + if (event := get_event_ctx(data, ctx)) is None: + await ctx.respond('Current channel is not an active CTF') return - data.events[event_id] = int(category_id) - await ctx.respond(f'Added event {event_id} with category {category_id}') - - @commands.slash_command() - @commands.has_permissions(administrator=True) - async def remove_event(self, ctx: discord.ApplicationContext, event_id: discord.Option(int)): - data = self.data.servers[ctx.guild_id] - if event_id not in data.events: - await ctx.respond('Event id does not exist') + challenges = event.challenges + challenge_channel = guild.get_channel(event.channel_challenges) + general_channel = guild.get_channel(event.channel_general) + if thread.id not in challenges.thread_id_to_challenge: + await ctx.respond('This is not a CTF thread') + return + if thread.id in challenges.solved_challs: + await ctx.respond('Challenge solved already') return - data.events.pop(event_id) - await ctx.respond(f'Removed event {event_id}') + if thread.id in challenges.hidden_challs: + await ctx.respond('Challenge hidden, can not solve') + return + if not i_have_submitted_the_flag: + await ctx.respond('You need to submit the flag first!') + return + category_and_challenge = challenges.thread_id_to_challenge[ctx.channel_id] + category = category_and_challenge.category + challenge = category_and_challenge.challenge + index = challenges.category_challenge_to_chall_board[( + category, challenge)] + embed, message_chall_board = await get_embed_from_index(index, challenges, challenge_channel) + + challenges.solved_challs.add(thread.id) + embed.set_field_at( + index % + MAX_FIELDS, + name='', + value="~~" + + challenge + + ' → ' + + thread.mention + + '~~ has been solved by ' + + ctx.author.mention + + '!', + inline=False) + await message_chall_board.edit(embed=embed) + challenges.solves_per_user[ctx.author.id] += 1 + message_challenges_embed = discord.Embed( + title=f"{category}/{challenge} has been solved! 🎉", + description=f"{ctx.author.mention} has solved the challenge! Total solves this CTF: **{challenges.solves_per_user[ctx.author.id]}**", + color=discord.Colour.green(), + ) + message_solve: discord.Message = await general_channel.send(embed=message_challenges_embed) + await ctx.respond('Challenge has been solved! 🎉') + await thread.edit(archived=True) + self.write_data() @commands.Cog.listener() async def on_raw_reaction_add(self, payload): @@ -352,36 +444,67 @@ async def on_raw_reaction_add(self, payload): return guild = self.bot.get_guild(payload.guild_id) data = self.data.servers[guild.id] - for event_id, category_id in data.events.items(): - category = guild.get_channel(category_id) - if category and payload.channel_id == category.channels[0].id: - break - else: + channel = guild.get_channel(payload.channel_id) + if (event := get_event_from_channel(data, channel)) is None: + return + if (event.join_message != payload.message_id): return player = await guild.fetch_member(payload.user_id) - if discord.utils.get(guild.roles, id=config('CTF_VERIFIED_ROLE_ID', cast=int)) not in player.roles: - player = await bot.fetch_user(payload.user_id) + if event.ctf_verified and discord.utils.get( + guild.roles, + id=config( + 'CTF_VERIFIED_ROLE_ID', + cast=int)) not in player.roles: + player = await self.bot.fetch_user(payload.user_id) await player.send('You do not have the CTF-Verified role! Please contact an admin to get this role.') return - await category.channels[1].set_permissions(player, read_messages=True, send_messages=False, - add_reactions=False, manage_threads=False) - await category.channels[2].set_permissions(player, read_messages=True, send_messages=False, - add_reactions=False, manage_threads=False) - await category.channels[3].set_permissions(player, read_messages=True) - await category.channels[1].send(f'{player.mention} has joined the CTF!') + await guild.get_channel(event.channel_logs).set_permissions(player, read_messages=True, send_messages=False, + add_reactions=False, manage_threads=False) + await guild.get_channel(event.channel_challenges).set_permissions(player, read_messages=True, send_messages=False, + add_reactions=False, manage_threads=False) + await guild.get_channel(event.channel_general).set_permissions(player, read_messages=True) + if payload.user_id not in event.challenges.solves_per_user: + event.challenges.solves_per_user[payload.user_id] = 0 + await guild.get_channel(event.channel_logs).send(f'{player.mention} has joined the CTF!') + self.write_data() @commands.Cog.listener() async def on_raw_reaction_remove(self, payload): guild = self.bot.get_guild(payload.guild_id) data = self.data.servers[guild.id] - for event_id, category_id in data.events.items(): - category = guild.get_channel(category_id) - if category and payload.channel_id == category.channels[0].id: - break - else: + channel = guild.get_channel(payload.channel_id) + if (event := get_event_from_channel(data, channel)) is None: + return + if (event.join_message != payload.message_id): return player = await guild.fetch_member(payload.user_id) - await category.channels[1].set_permissions(player, read_messages=False) - await category.channels[2].set_permissions(player, read_messages=False) - await category.channels[3].set_permissions(player, read_messages=False) - await category.channels[1].send(f'{player.mention} has left the CTF!') + await guild.get_channel(event.channel_logs).set_permissions(player, read_messages=False) + await guild.get_channel(event.channel_join).set_permissions(player, read_messages=False) + await guild.get_channel(event.channel_general).set_permissions(player, read_messages=False) + await guild.get_channel(event.channel_logs).send(f'{player.mention} has left the CTF!') + self.write_data() + + # @commands.slash_command() + # async def schedule(self, ctx: discord.ApplicationContext): + # events = self.data.servers[ctx.guild_id].events + # if events: + # description = '\n'.join( + # ctx.bot.get_channel( + # int(channel_id)).mention for channel_id in events.values()) + # embed = discord.Embed( + # title='Upcoming registered events', + # description=description) + # await ctx.respond(embed=embed) + # else: + # await ctx.respond('No upcoming events at the moment') + + # @commands.slash_command() + # async def reminder(self, ctx: discord.ApplicationContext): + # data = self.data.servers[ctx.guild_id] + # if ctx.channel_id in data.reminders: + # await ctx.respond('Removed reminder for this event') + # data.reminders[ctx.channel_id] = datetime.now(timezone.utc) + # else: + # await ctx.respond('Added reminder for this event') + # del data.reminders[ctx.channel_id] + # self.write_data() \ No newline at end of file diff --git a/ctfbot/data.py b/ctfbot/data.py index ecb14a3..2f61daf 100644 --- a/ctfbot/data.py +++ b/ctfbot/data.py @@ -1,17 +1,64 @@ from collections import defaultdict from dataclasses import dataclass, field from datetime import datetime -from typing import DefaultDict, List +from typing import DefaultDict, List, Set +import sqlite3 + + +@dataclass +class Chall_board_indicies: + category_name_index: int = -1 + last_challenge_index: int = -1 + + +@dataclass +class Category_and_challenge: + category: str = "" + challenge: str = "" + + +@dataclass +class Challenges: + chall_board_msg_ids: List[int] = field(default_factory=list) + chall_board_field_count: int = 0 + category_to_chall_board: DefaultDict[str, Chall_board_indicies] = field( + default_factory=lambda: defaultdict(Chall_board_indicies)) + category_challenge_to_chall_board: DefaultDict[Category_and_challenge, int] = field( + default_factory=lambda: defaultdict(int)) + thread_id_to_challenge: DefaultDict[int, Category_and_challenge] = field( + default_factory=lambda: defaultdict(Category_and_challenge)) + + hidden_challs: Set = field(default_factory=set) + solved_challs: Set = field(default_factory=set) + solves_per_user: DefaultDict[int, int] = field( + default_factory=lambda: defaultdict(int)) + + +@dataclass +class Event: + ctf_verified: bool + channel_join: int + channel_logs: int + channel_challenges: int + channel_general: int + join_message: int + challenges: Challenges @dataclass class ServerData: - events: DefaultDict[int, int] = field(default_factory=lambda: defaultdict(int)) + events: DefaultDict[int, Event] = field( + default_factory=lambda: defaultdict(Event)) + event_categories: DefaultDict[int, int] = field( + default_factory=lambda: defaultdict(int)) + user_to_ctfd: DefaultDict[int, int] = field( + default_factory=lambda: defaultdict(int)) archived_events: List[int] = field(default_factory=list) - challenges: DefaultDict[int, List[int]] = field(default_factory=lambda: defaultdict(list)) - reminders: DefaultDict[int, datetime] = field(default_factory=lambda: defaultdict(datetime)) + # reminders: DefaultDict[int, datetime] = field( + # default_factory=lambda: defaultdict(datetime)) @dataclass class GlobalData: - servers: DefaultDict[int, ServerData] = field(default_factory=lambda: defaultdict(ServerData)) + servers: DefaultDict[int, ServerData] = field( + default_factory=lambda: defaultdict(ServerData)) diff --git a/ctfbot/helpers.py b/ctfbot/helpers.py new file mode 100644 index 0000000..c6ef9cb --- /dev/null +++ b/ctfbot/helpers.py @@ -0,0 +1,82 @@ +import csv +import discord +import math + +MAX_FIELDS = 25 + + +def get_event_ctx(data, ctx): + category_id = ctx.channel.category_id + return get_event(data, category_id) + + +def get_event_from_channel(data, channel): + category_id = channel.category_id if channel else None + return get_event(data, category_id) + + +def get_event(data, category_id): + event_categories = data.event_categories + if category_id is None or category_id not in event_categories: + return None + return data.events[event_categories[category_id]] + + +async def update_indicies(event, index_to_start): + challenges = event.challenges + for _, indices in challenges.category_to_chall_board.items(): + if indices.category_name_index >= index_to_start: + indices.category_name_index += 1 + if indices.last_challenge_index >= index_to_start: + indices.last_challenge_index += 1 + for category_and_challenge, index in challenges.category_challenge_to_chall_board.items(): + if index >= index_to_start: + challenges.category_challenge_to_chall_board[category_and_challenge] += 1 + + +async def move_board(event, index_to_insert, challenge_channel): + challenges = event.challenges + back_chall_board_index = len(challenges.chall_board_msg_ids) - 1 + start_chall_board_index = math.floor(index_to_insert / MAX_FIELDS) + message_chall_board: discord.Message = await challenge_channel.fetch_message( + challenges.chall_board_msg_ids[start_chall_board_index]) + embed = message_chall_board.embeds[0] + field_value = embed.fields[-1].value + embed.remove_field(-1) + await message_chall_board.edit(embed=embed) + + for board in range( + start_chall_board_index + 1, + back_chall_board_index - 1): + message_chall_board: discord.Message = await challenge_channel.fetch_message( + challenges.chall_board_msg_ids[board]) + embed = message_chall_board.embeds[0] + field_value_save = embed.fields[-1].value + embed.remove_field(-1) + embed.insert_field_at(0, name='', value=field_value, inline=False) + field_value = field_value_save + + message_chall_board: discord.Message = await challenge_channel.fetch_message( + challenges.chall_board_msg_ids[back_chall_board_index]) + embed = message_chall_board.embeds[0] + embed.insert_field_at(0, name='', value=field_value, inline=False) + await message_chall_board.edit(embed=embed) + + +async def get_embed_from_index(index, challenges, challenge_channel): + message_chall_board: discord.Message = await challenge_channel.fetch_message( + challenges.chall_board_msg_ids[math.floor(index / MAX_FIELDS)]) + embed = message_chall_board.embeds[0] + return embed, message_chall_board + + +def gen_csv_of_solves(event, data, filename): + user_solves = event.challenges.solves_per_user.items() + data = [ + (user_id, data.user_to_ctfd.get(user_id, 'N/A'), solves) + for user_id, solves in user_solves + ] + with open(filename, mode='w', newline='') as file: + writer = csv.writer(file) + writer.writerow(['User ID', 'CTFd Username', 'Solves']) + writer.writerows(data) diff --git a/documentation/datastructs.md b/documentation/datastructs.md new file mode 100644 index 0000000..bef0e25 --- /dev/null +++ b/documentation/datastructs.md @@ -0,0 +1,59 @@ +# Data Structures +- These can be found in [ctfbot/data.py](../ctfbot/data.py) +- Brief Overview of data: + - Global data keeps a list of all servers + - Server data keeps a list of all events and users + - Event data keeps a list of challenges and channels + - Challenge data keeps a list of challenge names and solves +## Global Data +- `servers`: Mapping of server IDs to `Server` objects + +## Server Data +- `events`: Mapping of event IDs to `Event` objects +- `event_categories`: Mapping of category IDs to event IDs + - Used to get the event object from the `discord.ApplicationContext` of a command +- `user_to_ctfd`: Mapping of user IDs to CTFd usernames + - Used when printing out a the CSV at the end of the event +- `archived_events`: Mapping of event IDs to `Event` objects + - Used to store events that have ended + +## Event Data +- `ctf_verified`: If the event is requires the CTF_VERIFIED_ROLE_ID from the environment variables + - Used when a the join reaction is added to the join message +- `channel_join`: The join channel id + - Used to send and check the join message for new reactions +- `channel_logs`: The logs channel id + - Used to send join/leave logs for the CTF to monitor who has access +- `channel_challenges`: The challenges channel id + - Used to send the challenge board to easily see what challenges are being worked on and in what thread +- `channel_general`: The general channel id + - Used to communicate throughout the CTF and send solve messages +- `join_message`: The join message id + - Used to check for new reactions and add the user to the event +- `challenges`: Holds challenge data in a seperate class to organize the data + +## Challenge Data +- `chall_board_msg_ids`: List of all message ids in order that contain the challenge board + - The embeds can be accessed by accessing the 0th index of each message +- `chall_board_field_count`: The number of fields across all challenge board embeds + - Used to know when we should grow the challenge board with + - From the code: `math.floor((challenges.chall_board_field_count - 1 + space_allocated) / MAX_FIELDS) > len(challenges.chall_board_msg_ids) - 1)` +- `category_to_chall_board`: Mapping of category strings to the start index (where the category name is) and the last index (last challenge in that category) + - Used to know where to insert new challenges into the challenge board + - Position in `chall_board_msg_ids` can be found with index % max_fields +- `category_challenge_to_chall_board`: Mapping of categorys and challenges to their posiition on the challenge board + - Used to easily update solved and hidden challenges + - Position in `chall_board_msg_ids` can be found with index % max_fields +- `thread_id_to_challenge` - Mapping of thread ids to challenge names + - Used to find the challenge a `/solve` or `/hide` command is referencing given the thread context +- Diagram illisturating what a max_fields count of 5 would look like to demonstrate the different mappings of the challenge board +![Diagram](images/challenge_board_funcs.png) + +- `hidden_challs`: Set of challenge names that are hidden +- `solved_challs`: Set of challenge names that are solved +- `solves_per_user`: Mapping of user IDs to the number of solves they have + - Used to print out the CSV at the end of the event + - Used to print the running count of solves after each solve + - Users that joined the CTF at any point but have not solved have a solve count of 0 + - This is so the CSV contains all users who accessed the CTF channels incase of investigation + diff --git a/documentation/delete_channels.py b/documentation/delete_channels.py new file mode 100644 index 0000000..96cf770 --- /dev/null +++ b/documentation/delete_channels.py @@ -0,0 +1,29 @@ +# USE FOR TESTING ONLY AND DELETE AFTER +@commands.slash_command() + @commands.has_permissions(administrator=True) + async def delete_channels(self, ctx: discord.ApplicationContext): + keep_channel_id = 1112433431767437384 + guild: discord.Guild = ctx.guild + keep_channel = guild.get_channel(keep_channel_id) + + if keep_channel is None: + await ctx.respond(f"Channel with ID {keep_channel_id} not found.") + return + for channel in guild.channels: + if channel.id != keep_channel_id: + try: + await channel.delete() + except Exception as e: + await ctx.respond(f"Failed to delete channel: {channel.name}. Error: {e}") + for category in guild.categories: + try: + await category.delete() + except Exception as e: + await ctx.respond(f"Failed to delete category: {category.name}. Error: {e}") + for channel in category.channels: + if channel.id != keep_channel_id: + try: + await channel.delete() + except Exception as e: + await ctx.respond(f"Failed to delete channel in kept category: {channel.name}. Error: {e}") + await ctx.respond("Finished deleting channels and categories.") \ No newline at end of file diff --git a/documentation/images/challenge.png b/documentation/images/challenge.png new file mode 100644 index 0000000..31a8668 Binary files /dev/null and b/documentation/images/challenge.png differ diff --git a/documentation/images/challenge_board_funcs.png b/documentation/images/challenge_board_funcs.png new file mode 100644 index 0000000..531e947 Binary files /dev/null and b/documentation/images/challenge_board_funcs.png differ diff --git a/documentation/images/challenge_cmd.png b/documentation/images/challenge_cmd.png new file mode 100644 index 0000000..81193b5 Binary files /dev/null and b/documentation/images/challenge_cmd.png differ diff --git a/documentation/images/challenge_full.png b/documentation/images/challenge_full.png new file mode 100644 index 0000000..337c21d Binary files /dev/null and b/documentation/images/challenge_full.png differ diff --git a/documentation/images/challenge_full_first.png b/documentation/images/challenge_full_first.png new file mode 100644 index 0000000..255f9f1 Binary files /dev/null and b/documentation/images/challenge_full_first.png differ diff --git a/documentation/images/challenges_1.png b/documentation/images/challenges_1.png new file mode 100644 index 0000000..b9220db Binary files /dev/null and b/documentation/images/challenges_1.png differ diff --git a/documentation/images/ctfd.png b/documentation/images/ctfd.png new file mode 100644 index 0000000..af6e7ac Binary files /dev/null and b/documentation/images/ctfd.png differ diff --git a/documentation/images/end_ctf_1.png b/documentation/images/end_ctf_1.png new file mode 100644 index 0000000..4c90b77 Binary files /dev/null and b/documentation/images/end_ctf_1.png differ diff --git a/documentation/images/end_ctf_2.png b/documentation/images/end_ctf_2.png new file mode 100644 index 0000000..6da5157 Binary files /dev/null and b/documentation/images/end_ctf_2.png differ diff --git a/documentation/images/events.png b/documentation/images/events.png new file mode 100644 index 0000000..af6605e Binary files /dev/null and b/documentation/images/events.png differ diff --git a/documentation/images/full_ctf.png b/documentation/images/full_ctf.png new file mode 100644 index 0000000..48364cd Binary files /dev/null and b/documentation/images/full_ctf.png differ diff --git a/documentation/images/hide.png b/documentation/images/hide.png new file mode 100644 index 0000000..4b321de Binary files /dev/null and b/documentation/images/hide.png differ diff --git a/documentation/images/hide_board.png b/documentation/images/hide_board.png new file mode 100644 index 0000000..1f5bec4 Binary files /dev/null and b/documentation/images/hide_board.png differ diff --git a/documentation/images/join_ctf.png b/documentation/images/join_ctf.png new file mode 100644 index 0000000..a4d3ea7 Binary files /dev/null and b/documentation/images/join_ctf.png differ diff --git a/documentation/images/logs.png b/documentation/images/logs.png new file mode 100644 index 0000000..db79958 Binary files /dev/null and b/documentation/images/logs.png differ diff --git a/documentation/images/register.png b/documentation/images/register.png new file mode 100644 index 0000000..10cd46a Binary files /dev/null and b/documentation/images/register.png differ diff --git a/documentation/images/register_cmd.png b/documentation/images/register_cmd.png new file mode 100644 index 0000000..c544fb1 Binary files /dev/null and b/documentation/images/register_cmd.png differ diff --git a/documentation/images/role_ids.png b/documentation/images/role_ids.png new file mode 100644 index 0000000..e01d232 Binary files /dev/null and b/documentation/images/role_ids.png differ diff --git a/documentation/images/solve.png b/documentation/images/solve.png new file mode 100644 index 0000000..c379265 Binary files /dev/null and b/documentation/images/solve.png differ diff --git a/documentation/images/solve_board.png b/documentation/images/solve_board.png new file mode 100644 index 0000000..3630956 Binary files /dev/null and b/documentation/images/solve_board.png differ diff --git a/documentation/images/upcoming.png b/documentation/images/upcoming.png new file mode 100644 index 0000000..70f6d1f Binary files /dev/null and b/documentation/images/upcoming.png differ diff --git a/requirements.txt b/requirements.txt index 3aa3870..4e6c652 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -py-cord==2.3.2 -python-decouple==3.6 -jsonpickle==3.0.0 -requests==2.28.1 +py-cord==2.6.0 +python-decouple==3.8 +jsonpickle==3.2.2 +requests==2.32.3