diff --git a/README.md b/README.md index 6730237..eec6b71 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,13 @@ -# fiskebot -![](images/fiskebot_logo.png) +# Zookeeper + +This bot is still work in process. It is a fork of [fiskebot](https://github.com/dugnadctf/fiskebot/), which is again a fork of [eptbot](https://github.com/ept-team/eptbot), which is again a fork of [igCTF](https://gitlab.com/inequationgroup/igCTF), which is again a fork of [NullCTF](https://github.com/NullPxl/NullCTF). + +NB! The bot will only work inside communitychannels due to the use of forum-channels. + +Zookeeper feels quite organized in the way that it creates challenge-threads, so the discordservers don't flood over with challengechannels. + +Exporting functionality is not avaiable at the moment. -This bot is still work in process. It is a fork of [eptbot](https://github.com/ept-team/eptbot), which is again a fork of [igCTF](https://gitlab.com/inequationgroup/igCTF), which is again a fork of [NullCTF](https://github.com/NullPxl/NullCTF). ## Install @@ -26,16 +32,8 @@ The only required variable is `DISCORD_TOKEN`, the rest will use the default val | `CATEGORY_ARCHIVE_PREFIX` | `archive` | Category to move channels to when the CTF is over. There is a max limit on 50 channels per category. The bot wil automatically move channels to new categories when needed | | `CHANNEL_EXPORT` | `export` | The channel to upload exports to | | `CHANNEL_LOGGING_ID` | | If enabled, will send logging to this channel, based on the `LOGGING_DISCORD_LEVEL` logging level | -| `CHANNEL_NAME_DELIMITER` | `-` | The delimiter for the channel names, must be one of `-` or `_`. Results in `-`: `#ctf-challenge-name`, and `_`: `#ctf_challenge_name` | -| `CTFTIME_TEAM_ID` | | CTFtime ID for the `!ctftime team` command | -| `CTFTIME_TEAM_NAME` | | CTFtime name for the `!ctftime team` command | - -### start - -`docker-compose up --build -d` - -### develop - +| `CHANNEL_NAME_DELIMITER` | ` ` | The delimiter for the channel names, must be one of `-`, or `_`. Results in `-`: `#ctf-challenge-name`, and `_`: `#ctf_challenge_name` | +| `CTFTIME_TEAM_ID` | | CTFtime ID for the `!ctftime team` command |![enter image description here](images/rumble-add1.PNG) The `/bot` folder is mounted into the container, so you just need to restart to get your updated changes. ```bash docker-compose build @@ -74,14 +72,17 @@ $ cp git-hook .git/hooks/pre-commit - `!help` Display the main help commands. -- `!create "ctf name"` This is the command you'll use when you want to begin a new CTF. This command will make a text channel with your supplied name. The bot will also send a message in chat where members can react to join the CTF. -![enter image description here](images/ept-create.PNG) +- `!create "ctf name"` This is the command you'll use when you want to begin a new CTF. This command will make a text channel and a forum-channel with your supplied name. The bot will also send a message in chat where members can react to join the CTF. + ![Create CTF-channels](images/rumble-create.PNG) + +- `!add ` This will create a new thread in the forum-channel for a given challenge. + ![Create challengethreads](images/rumble-add1.PNG) + ![Challengethreads in forumchannel](images/rumble-add2.PNG) -- `!add ` This will create a new channel for a given challenge. -![enter image description here](images/ept-add.PNG) -- `!done [@users ...]` Mark a challenge as done. Needs to be done inside the challenge channel. Optionally specify other users who also contributed to solving the challenge, space separated without the @s. -![enter image description here](images/ept-done.PNG) -- `!ctf archive` Mark the ctf as over and move it to the archive categories (specified in `/bot/config.py`). +- `!done [@users ...]` Mark a challenge as done. Needs to be done inside the challengethread. Optionally specify other users who also contributed to solving the challenge, space separated without the @s. + ![Mark challenges as solved](images/rumble-done.PNG) + +- `!ctf archive` Mark the ctf as over and move it to the archive categories (specified in `/bot/config.py`). --- @@ -102,3 +103,5 @@ $ cp git-hook .git/hooks/pre-commit ![enter image description here](images/ctftime-team.png) > ### Have a feature request? Make a GitHub issue. + +> Please upvote this feature request https://github.com/discord/discord-api-docs/discussions/6084 diff --git a/bot/bot.py b/bot/bot.py index 3d7cc32..0fdfad9 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 import asyncio import logging import os.path @@ -15,14 +16,28 @@ from helpers import helpers from logger import BotLogger +# TODO: Append channel id to naming in db to fix naming conflicts. For both teams and challenges +# Possible naming convention: +# f"{name}-{ctx.channel.id}" + logger = BotLogger("bot") if not config["token"]: logger.error("DISCORD_TOKEN has not been set") exit(1) -client = discord.Client() -bot = commands.Bot(command_prefix=config["prefix"]) +#Need to figure out which intents is needed +intents = discord.Intents.all() +intents.members = True +intents.typing = True +intents.presences = True +intents.messages = True +intents.reactions = True + +client = discord.Client(intents=intents) +bot = commands.Bot(command_prefix=config["prefix"],intents=intents) + + intents = discord.Intents.default() intents.members = True @@ -89,23 +104,32 @@ async def on_raw_reaction_add(payload): guild = bot.get_guild(payload.guild_id) chan = bot.get_channel(payload.channel_id) team = db.teamdb[str(payload.guild_id)].find_one({"msg_id": payload.message_id}) + challenge = db.challdb[str(payload.guild_id)].find_one({"msg_id": payload.message_id}) member = await guild.fetch_member(payload.user_id) - if guild and member and chan: - # logger.debug(f"Added reaction: {payload}") - # logger.debug(f"Guild: {guild}, Channel: {chan}, Team: {team}, Member: {member}") - if not team: + + if guild and member and chan and not member.bot: + if team: + role = guild.get_role(team["role_id"]) + if not role: + logger.error( + f"Not adding role. Could not find role ID {team['role_id']} in Discord" + ) + logger.error(team) + return + + await member.add_roles(role, reason="User wanted to join team") + # elif challenge and config['react_for_challenge']: + # # logger.debug(f"Adding {member.name} to thread") + # thread = guild.get_thread(challenge['thread_id']) + # await thread.add_user(member) + # db.challdb[str(payload.guild_id)].update_one( + # {"msg_id": payload.message_id}, {"$push": {"working": member.id}} + # ) + # logger.debug(f"Added {member.name} to thread") + else: # logger.error(f"Not adding role. Could find team") return - role = guild.get_role(team["role_id"]) - if not role: - logger.error( - f"Not adding role. Could not find role ID {team['role_id']} in Discord" - ) - logger.error(team) - return - await member.add_roles(role, reason="User wanted to join team") - logger.debug(f"Added role {role} to user {member}") @bot.event @@ -113,22 +137,36 @@ async def on_raw_reaction_remove(payload): # check if the user is not the bot guild = bot.get_guild(payload.guild_id) team = db.teamdb[str(payload.guild_id)].find_one({"msg_id": payload.message_id}) + challenge = db.challdb[str(payload.guild_id)].find_one({"msg_id": payload.message_id}) member = await guild.fetch_member(payload.user_id) - if guild and member: + + if guild and member and not member.bot: # logger.debug(f"Removed reaction: {payload}") # logger.debug(f"Guild: {guild}, Team: {team}, Member: {member}") - if not team: + + if team: + role = guild.get_role(team["role_id"]) + if not role: + logger.error(f"Not removing role. Could not find role ID {team['role_id']}") + logger.error(team) + return + await member.remove_roles(role, reason="User wanted to leave team") + # logger.debug(f"Removed role {role} from user {member}") + + # elif challenge and config['react_for_challenge']: + # # logger.debug(f"Removing {member.name} to thread") + # thread = guild.get_thread(challenge['thread_id']) + # await thread.remove_user(member) + # db.challdb[str(payload.guild_id)].update_one( + # {"msg_id": payload.message_id}, {"$pull": {"working": member.id}} + # ) + # logger.debug(f"Removed {member.name} to thread") + else: # logger.error(f"Not removing role. Could find team") return - role = guild.get_role(team["role_id"]) - if not role: - logger.error(f"Not removing role. Could not find role ID {team['role_id']}") - logger.error(team) - return - await member.remove_roles(role, reason="User wanted to leave team") - logger.debug(f"Removed role {role} from user {member}") - - + + + async def embed_help(chan, help_topic, help_text): emb = discord.Embed(description=help_text, colour=4387968) emb.set_author(name=help_topic) @@ -154,26 +192,31 @@ async def help(ctx, category=None): @bot.command() async def request(ctx, feature): - for cid in config["maintainers"]: - creator = bot.get_user(cid) - authors_name = str(ctx.author) - await creator.send(f""":pencil: {authors_name}: {feature}""") - await ctx.send(f""":pencil: Thanks, "{feature}" has been requested!""") + if config["maintainers"]: + for cid in config["maintainers"]: + creator = bot.get_user(cid) + authors_name = str(ctx.author) + await creator.send(f""":pencil: {authors_name}: {feature}""") + await ctx.send(f""":pencil: Thanks, "{feature}" has been requested!""") + else: + await ctx.send(f""":pencil: No maintainers listed in config!""") @bot.command() async def report(ctx, error_report): - for cid in config["maintainers"]: - creator = bot.get_user(cid) - authors_name = str(ctx.author) - await creator.send( - f""":triangular_flag_on_post: {authors_name}: {error_report}""" + if config["maintainers"]: + for cid in config["maintainers"]: + creator = bot.get_user(cid) + authors_name = str(ctx.author) + await creator.send( + f""":triangular_flag_on_post: {authors_name}: {error_report}""" + ) + await ctx.send( + f""":triangular_flag_on_post: Thanks for the help, "{error_report}" has been reported!""" ) - await ctx.send( - f""":triangular_flag_on_post: Thanks for the help, "{error_report}" has been reported!""" - ) - - + else: + await ctx.send(f""":pencil: No maintainers listed in config!""") + @bot.command() async def setup(ctx): if ctx.author.id not in config["maintainers"]: @@ -224,7 +267,17 @@ async def exit(ctx): # ------------------- +async def loadExtras(bot): + await bot.load_extension("ctftime") + await bot.load_extension("ctfs") + +async def main(): + + await loadExtras(bot) + + async with bot: + await bot.start(config["token"]) + + if __name__ == "__main__": - bot.load_extension("ctftime") - bot.load_extension("ctfs") - bot.run(config["token"]) + asyncio.run(main()) \ No newline at end of file diff --git a/bot/config.py b/bot/config.py index f4ac454..fd60364 100644 --- a/bot/config.py +++ b/bot/config.py @@ -1,6 +1,5 @@ import os - def parse_variable(variable, default=None, valid=None): value = os.getenv(variable, None) if default and valid and variable not in valid: @@ -76,11 +75,14 @@ def parse_int_list(variable): }, # The delimiter for the channel names, must be one of "-" or "_". i.e. "-": "#ctf-challenge-name", "_": "#ctf_challenge_name" "challenge_name_delimiter": parse_variable( - "CHALLENGE_NAME_DELIMITER", "-", valid=["-", "_"] + "CHALLENGE_NAME_DELIMITER", " ", valid=["-", "_", " "] ), # CTFtime id for the default team to lookup using the `!ctftime team` command "team": { "id": parse_variable("CTFTIME_TEAM_ID", -1), "name": parse_variable("CTFTIME_TEAM_NAME"), }, + # If enabled, users must react to message to make bot add them to thread. Should be visible in threads list anyways. + # Should be True if numbers of participants is high to reduce resourceuse on bot and spam in threads + "react_for_challenge": parse_variable("REACT_FOR_CHALLENGE", False) } diff --git a/bot/constants.py b/bot/constants.py index 0ba01a8..bd8ddc7 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -3,4 +3,5 @@ SOURCE_FORK1 = "https://github.com/NullPxl/NullCTF" SOURCE_FORK2 = "https://gitlab.com/inequationgroup/igCTF" SOURCE_FORK3 = "https://github.com/ept-team/eptbot" -SOURCES_TEXT = f"Source: https://github.com/ekofiskctf/fiskebot\nForked from: {SOURCE_FORK3}\nWho again forked from: {SOURCE_FORK2}\nWho again forked from: {SOURCE_FORK1}" +SOURCE_FORK4 = "https://github.com/ekofiskctf/fiskebot" +SOURCES_TEXT = f"Source: https://github.com/RumbleJungleCTF/RumbleBot\nForked from: {SOURCE_FORK4}\nWho again forked from: {SOURCE_FORK3}\nWho again forked from: {SOURCE_FORK2}\nWho again forked from: {SOURCE_FORK1}" diff --git a/bot/ctf_model.py b/bot/ctf_model.py index 8706346..f351516 100644 --- a/bot/ctf_model.py +++ b/bot/ctf_model.py @@ -1,33 +1,36 @@ import functools import json import os +import datetime +import asyncio import bson import db +# import ctfs import discord import discord.member from config import config from discord.ext import commands -from exceptions import ChannelDeleteFailedException, ChannelNotFoundException +from exceptions import ChannelDeleteFailedException, ChannelNotFoundException, ThreadNotFoundException CATEGORY_CHANNEL_LIMIT = 50 - # Globals # > done_id # > archive_id > working_id # CtfTeam # [gid...] -# > challenges [array of chan IDs] +# > challenges [array of thread IDs] # > name -# > chan_id +# > cmd_chan_id +# > forum_chan_id # > role_id # # Challenge # [gid...] # > name -# > ctf_id [int, channel ID] -# > chan_id +# > ctf_id [int, thread ID] +# > thread_id # > finished [bool] # > solvers (by id) # @@ -64,26 +67,30 @@ def format_role_name(ctf_name): return f"{ctf_name}_team".lower() -async def _find_available_archive_category(guild, current_channel_count, start): - archive_number = 0 - for i in range(start, 15): - category_name = f"{config['categories']['archive-prefix']}-{i}" - archive_number = i +async def _find_available_archive_category(guild): + archive_number = 0 + + for i in range(0, 15): + year = str(datetime.date.today().year) + + archive_number = "{:02}".format(i) + + category_name = f"{year}-{config['categories']['archive-prefix']}-{archive_number}" + try: category_archive = [ category for category in guild.categories if category.name == category_name ][0] - current_category_channels = len(category_archive.channels) - if CATEGORY_CHANNEL_LIMIT - current_category_channels >= ( - current_channel_count - ): + + if len(category_archive.channels) < 49: break - except IndexError: + # Will go here if no archive categories is created that year + except: category_archive = await guild.create_category(category_name) break - return category_archive, archive_number + return category_archive find_category = functools.partial(_find_chan, "categories") @@ -95,6 +102,7 @@ def load_category(guild, catg): return find_category(guild, catg) + basic_read_send = discord.PermissionOverwrite( add_reactions=True, read_messages=True, @@ -123,6 +131,24 @@ def load_category(guild, catg): read_message_history=True, ) +basic_allow_forum = discord.PermissionOverwrite( + add_reactions=True, + read_messages=True, + send_messages=True, + read_message_history=True, + create_public_threads=False, + create_private_threads=False, +) + +basic_disallow_forum = discord.PermissionOverwrite( + add_reactions=False, + read_messages=False, + send_messages=False, + read_message_history=False, + create_public_threads=False, + create_private_threads=False, +) + def chk_upd(ctx_name, update_res): if not update_res.matched_count: @@ -182,7 +208,7 @@ class CtfTeam: @staticmethod async def create(guild, name): - names = [role.name for role in guild.roles] + guild.channels + names = [role.name for role in guild.roles] + [channel.name for channel in guild.channels] if name in names: return [(ValueError, f"`{name}` already exists as a role :grimacing:")] @@ -196,65 +222,132 @@ async def create(guild, name): guild.me: basic_allow, role: basic_allow, } + + forumOverwrites = { + guild.default_role: basic_disallow, + guild.me: basic_allow, + role: basic_allow_forum, + } if discord.utils.get(guild.text_channels, name=name) is not None: return [(ValueError, f"`{name}` already exists as a channel :grimacing:")] - - chan = await guild.create_text_channel( - name=name, - overwrites=overwrites, - topic=f"General talk for {name} CTF event.", - ) + + #Becuase we use threads, it will be more clean to have a full category for ongoing ctfs + workingCategoryName = config["categories"]["working"] + workingCategoryId = False + + workingCategoryId = discord.utils.get(guild.categories, name=workingCategoryName) + + if workingCategoryId: + cmd_chan = await guild.create_text_channel( + name="cmd"+"-"+name, + overwrites=overwrites, + position=0, + topic=f"Command and general talk channel for {name} CTF event", + category=workingCategoryId + ) + + forum_chan = await guild.create_forum( + name="chals"+"-"+name, + overwrites=forumOverwrites, + position=0, + topic=f"Forum channel for {name} CTF event", + category=workingCategoryId + ) + + + else: + existing_categories = [category.name for category in guild.categories] + category = config["categories"]["working"] + if category not in existing_categories: + cat = await guild.create_category(category) + + cmd_chan = await guild.create_text_channel( + name="cmd"+"-"+name, + overwrites=overwrites, + position=0, + category=cat, + topic=f"Command and general talk channel for {name} CTF event", + ) + + forum_chan = await guild.create_forum( + name="chals"+"-"+name, + overwrites=forumOverwrites, + position=0, + topic=f"Forum channel for {name} CTF event", + category=workingCategoryId + ) + + await forum_chan.create_tag(name="Unsolved") + await forum_chan.create_tag(name="Solved") # Update database db.teamdb[str(guild.id)].insert_one( { "archived": False, - "name": name, - "chan_id": chan.id, + "name": name+"-"+str(cmd_chan.id), + "cmd_chan_id": cmd_chan.id, + "forum_chan_id":forum_chan.id, "role_id": role.id, "msg_id": 0, - "chals": [], } ) - CtfTeam.__teams__[chan.id] = CtfTeam(guild, chan.id) + CtfTeam.__teams__[cmd_chan.id] = CtfTeam(guild, cmd_chan.id, forum_chan.id) + return [ ( - None, - f"<#{chan.id}> (`{name}`) has been created! :tada:! React to this message to join.", + cmd_chan.id, + f"<#{cmd_chan.id}> (`{name}`) has been created! :tada:! React to this message to join.", ) ] @staticmethod - def fetch(guild, chan_id): - if chan_id not in CtfTeam.__teams__: - if not db.teamdb[str(guild.id)].find_one({"chan_id": chan_id}): - return None - CtfTeam.__teams__[chan_id] = CtfTeam(guild, chan_id) + def fetch(guild, cmd_chan_id): + """Fetch based on cmd_chan_id""" + print("CtfTeam.fetch") + if cmd_chan_id not in CtfTeam.__teams__: + temp = db.teamdb[str(guild.id)].find_one({"cmd_chan_id": cmd_chan_id}) + if not temp: + temp2 = db.teamdb[str(guild.id)].find_one({"forum_chan_id": cmd_chan_id}) + if not temp2: + return None + CtfTeam.__teams__[cmd_chan_id] = CtfTeam(guild, temp['cmd_chan_id'], cmd_chan_id) + else: + CtfTeam.__teams__[cmd_chan_id] = CtfTeam(guild, cmd_chan_id, temp['forum_chan_id']) else: - CtfTeam.__teams__[chan_id].refresh() + CtfTeam.__teams__[cmd_chan_id].refresh() # TODO: check guild is same - return CtfTeam.__teams__[chan_id] + return CtfTeam.__teams__[cmd_chan_id] - def __init__(self, guild, chan_id): + def __init__(self, guild, cmd_chan_id, forum_chan_id): + + print("CtfTeam.__init__") self.__guild = guild - self.__chan_id = chan_id + self.__cmd_chan_id = cmd_chan_id + self.__forum_chan_id = forum_chan_id self.__teams = db.teamdb[str(guild.id)] self.refresh() + #Something weird with the 'chals' key here @property def challenges(self): - return [Challenge.fetch(self.__guild, cid) for cid in self.__teamdata["chals"]] + # Temporary fix? Had a problem where ctfs with no challenges didn't archive correctly + self.refresh() + return [Challenge.fetch(self.__guild, tid) for tid in self.__teamdata["chals"]] @property def name(self): - return self.__teamdata["name"] + return "-".join(self.__teamdata["name"].split("-")[:-1]) @property - def chan_id(self): - return self.__chan_id + def cmd_chan_id(self): + return self.__cmd_chan_id + + @property + def forum_chan_id(self): + return self.__forum_chan_id @property def guild(self): @@ -273,142 +366,241 @@ def team_data(self): return self.__teamdata @chk_archive - async def add_chal(self, name): - cid = self.__chan_id + async def add_chal(self,name): + cmd_cid = self.__cmd_chan_id + forum_cid = self.__forum_chan_id guild = self.__guild teams = self.__teams team = self.__teamdata + forum_channel = guild.get_channel(forum_cid) - catg_working = load_category(guild, config["categories"]["working"]) + print("add_chal") + + # catg_working = load_category(guild, config["categories"]["working"]) if self.find_chal(name, False): raise TaskFailed(f'Challenge "{name}" already exists!') - # Create a secret channel, initially only with us added. - fullname = f"{self.name}-{name}" - - role = chk_get_role(guild, team["role_id"]) - overwrites = { - guild.default_role: basic_disallow, - guild.me: basic_allow, - role: basic_allow, - } - chan = await catg_working.create_text_channel( - name=fullname, overwrites=overwrites - ) - Challenge.create(guild, cid, chan.id, name) + # Create a public thread, initially only with us added. + fullname = f"❌ {name}" + + print("Fullname: " + fullname) + + tag = [tag for tag in forum_channel.available_tags if tag.name == "Unsolved"][0] + + print(forum_channel.available_tags) + + thread = await forum_channel.create_thread( + name=fullname, + content=f"Started challenge thread for challenge {name}", + applied_tags=[tag] + ) + + # try: + # team = db.teamdb[str(guild.id)].find_one({"forum_chan_id": forum_cid}) + # role = guild.get_role(team["role_id"]) + + # threadMembers = [member for member in thread[0].members] + # teamMembers = [user for user in role.members] + + # for user in teamMembers: + # print(f"Checking if {user.name} in {thread[0].name}") + # if user not in threadMembers: + # print(f"{user.name} not in {thread[0].name}, adding now") + # await thread[0].add_user(user) + # except Exception as e: + # print(e) + # pass + + + Challenge.create(guild=guild,ctf_id=cmd_cid,thread_id=thread[0].id,name=name,forum_id=forum_cid) chk_upd( - fullname, teams.update_one({"chan_id": cid}, {"$push": {"chals": chan.id}}) + fullname, teams.update_one({"cmd_chan_id": cmd_cid}, {"$push": {"chals": thread[0].id}}) ) self.refresh() - + + print("Returning from add_chal") + return [ ( None, - f"Challenge `{name}` has been added! React to this message to work on <#{chan.id}>!", + f"<#{thread[0].id}> (`{name}`) has been added!", ) ] + # Makes a lot of noise if react_for_challenge is False, but is needed to show all challenges in channel-list. + # Added a feature request to allow the bot to silently add members to thread + # Other methods is highly wanted + + # TODO: Figure this out + # Unsure if this is necessary when using forum channels + # if not config['react_for_challenge']: + # try: + # team = db.teamdb[str(guild.id)].find_one({"forum_chan_id": cid}) + # role = guild.get_role(team["role_id"]) + + # threadMembers = [member for member in thread.members] + # teamMembers = [user for user in role.members] + + # for user in teamMembers: + # if user not in threadMembers: + # await thread.add_user(user) + # except: + # pass + + # print("Returning from add_chal") + + # return [ + # ( + # None, + # f"<#{thread.id}> (`{name}`) has been added!", + # ) + # ] + + @chk_archive async def archive(self): - cid = self.__chan_id + cmd_cid = self.__cmd_chan_id + forum_cid = self.__forum_chan_id guild = self.__guild teams = self.__teams + + self.refresh() + - total_channels = len(self.challenges) + 1 - category_archives = [] - previous_picked_archive = -1 - while total_channels != 0: - channels_in_category = min(CATEGORY_CHANNEL_LIMIT, total_channels) - category, previous_picked_archive = await _find_available_archive_category( - guild, channels_in_category, previous_picked_archive + 1 - ) - category_archives.append( - { - "channels": channels_in_category, - "category": category, - } - ) - total_channels -= channels_in_category + category = await _find_available_archive_category(guild) + + # Archive all challenge threads + + cmd_chan = guild.get_channel(cmd_cid) + forum_chan = guild.get_channel(forum_cid) + + try: + for thread in self.challenges: + await thread._archive() + self.refresh() + except: + #Failproof if challenge doesn't exist for some reason + pass + await cmd_chan.edit(category=category, position=0) + await forum_chan.edit(category=category, position=0) + # Update database chk_upd( - self.name, teams.update_one({"chan_id": cid}, {"$set": {"archived": True}}) + self.name, teams.update_one({"cmd_chan_id": cmd_cid}, {"$set": {"archived": True}}) ) self.refresh() - - # Archive all challenge channels - main_chan = guild.get_channel(cid) + + #Archive all threads + channel = guild.get_channel(forum_cid) + for thread in channel.threads: + await thread.edit(archived=True) + if config["archive_access_to_all_users"]: - await main_chan.set_permissions( + # await asyncio.sleep(1) #Because the sync permissions was unstable + test1 = await cmd_chan.set_permissions( + guild.default_role, overwrite=basic_read_send + ) + test2 = await forum_chan.set_permissions( guild.default_role, overwrite=basic_read_send ) - category_archives[-1]["channels"] -= 1 - - for i, d in enumerate(category_archives): - for ix in range( - i * CATEGORY_CHANNEL_LIMIT, i * CATEGORY_CHANNEL_LIMIT + d["channels"] - ): - await self.challenges[ix]._archive(d["category"]) - - await main_chan.edit(category=category_archives[-1]["category"]) return [(None, f"{self.name} CTF has been archived.")] + async def unarchive(self): - cid = self.__chan_id + forum_cid = self.__forum_chan_id + cmd_cid = self.__cmd_chan_id guild = self.__guild teams = self.__teams catg_working = load_category(guild, config["categories"]["working"]) - catg_done = load_category(guild, config["categories"]["done"]) - # if not self.is_archived: - # raise TaskFailed('This is already not archived!') + if not self.is_archived: + raise TaskFailed('This is already not archived!') + + + #chal = db.challdb[str(guild.id)].find_one({"thread_id": thread_id}) + team = db.teamdb[str(guild.id)].find_one({"forum_chan_id": forum_cid}) # May be able to use teams here + role = guild.get_role(team["role_id"]) + + + # Update database + chk_upd( + self.name, teams.update_one({"forum_chan_id": forum_cid}, {"$set": {"role_id": role.id}}) + ) + + # Unarchive challenge channel + forum_chan = guild.get_channel(forum_cid) + cmd_chan = guild.get_channel(cmd_cid) + + await forum_chan.edit(category=catg_working, position=0) + await cmd_chan.edit(category=catg_working, position=0) + if config["archive_access_to_all_users"]: + # await asyncio.sleep(1) #Because the sync permissions was unstable + test1 = forum_chan.set_permissions( + guild.default_role, overwrite=basic_disallow + ) + test2 = cmd_chan.set_permissions( + guild.default_role, overwrite=basic_disallow + ) + # Update database chk_upd( - self.name, teams.update_one({"chan_id": cid}, {"$set": {"archived": False}}) + self.name, teams.update_one({"cmd_chan_id": cmd_cid}, {"$set": {"archived": False}}) ) self.refresh() + + + # Unarchive all challenge threads + # Same noise as in add challenge, fix this also if another method is preferred + # if not config['react_for_challenge']: + for thread in forum_chan.threads: + await thread.join() + # TODO: Check if needed + # Add all users with role to thread + # usersWithRole = [user for user in role.members] + # for user in usersWithRole: + # await thread.add_user(user) + + await thread.edit(archived=False) + + try: + for thread in self.challenges: + await thread._unarchive() + except: + #Failproof: If no challenges exists + pass + self.refresh() - # Unarchive all challenge channels - main_chan = guild.get_channel(cid) - await main_chan.edit(category=None, position=0) - await main_chan.set_permissions(guild.default_role, overwrite=basic_disallow) - for chal in self.challenges: - await chal._unarchive(catg_working, catg_done) - - return [(cid, f"{self.name} CTF has been unarchived.")] - + return [(cmd_cid, f"{self.name} CTF has been unarchived.")] + @chk_archive async def del_chal(self, name): - cid = self.__chan_id + forum_cid = self.__forum_chan_id guild = self.__guild teams = self.__teams - category_archive = await _find_available_archive_category( - guild, len(self.challenges) - ) - # Update database - fullname = f"{self.name}-{name}" chal = self.find_chal(name) chk_upd( - fullname, - teams.update_one({"chan_id": cid}, {"$pull": {"chals": chal.chan_id}}), + name, + teams.update_one({"forum_chan_id": forum_cid}, {"$pull": {"chals": chal.thread_id}}), ) - await chal._delete(category_archive) + await chal._delete(forum_cid) self.refresh() - return [(None, f'Challenge "{name}" is deleted, challenge channel archived.')] + return [(None, f'Challenge "{name}" is deleted, challenge thread deleted.')] def find_chal(self, name, err_on_fail=True): - return Challenge.find(self.__guild, self.__chan_id, name, err_on_fail) + return Challenge.find(self.__guild, self.__forum_chan_id, name, err_on_fail) @chk_archive async def invite(self, author, user): guild = self.__guild team = self.__teamdata + cid = self.__forum_chan_id # Add role for user role = chk_get_role(guild, team["role_id"]) @@ -416,13 +608,18 @@ async def invite(self, author, user): raise TaskFailed(f"{user.name} has already joined {self.name}") await user.add_roles(role) + # TODO: Check if needed + # channel = guild.get_channel(cid) + # for thread in channel.threads: + # await thread.add_user(user) + return [ (None, f'{author.mention} invited {user.mention} to the "{self.name}" team') ] @chk_archive async def join(self, user): - cid = self.__chan_id + cid = self.__forum_chan_id guild = self.__guild team = self.__teamdata @@ -432,6 +629,11 @@ async def join(self, user): raise TaskFailed(f"{user.mention} has already joined {self.name}") await user.add_roles(role) + # TODO: Check if needed + # channel = guild.get_channel(cid) + # for thread in channel.threads: + # await thread.add_user(user) + return [(None, f"{user.mention} has joined the <#{cid}> team! :sparkles:")] @chk_archive @@ -449,11 +651,12 @@ async def leave(self, user): return [(None, f'{user.mention} has left the {team["name"]} team...')] def refresh(self): - team = self.__teams.find_one({"chan_id": self.__chan_id}) + team = self.__teams.find_one({"cmd_chan_id": self.__cmd_chan_id}) if not team: raise ChannelNotFoundException(f"{self.__chan_id}: Invalid CTF channel ID") self.__teamdata = team + async def deletectf(self, author, confirmation): if author.id not in config["maintainers"]: raise TaskFailed("Only maintainers can delete CTFs.") @@ -466,7 +669,17 @@ async def deletectf(self, author, confirmation): f"Confirmation does not equal the CTF name. Execute `!deletectf {self.name}`" ) - for c in [self.__chan_id] + [ch.chan_id for ch in self.challenges]: + # There should be just one channel id? + for c in [self.__cmd_chan_id]: + try: + await self.__guild.get_channel(c).delete(reason="Deleting CTF") + except Exception as e: + raise ChannelDeleteFailedException( + f"Deletion of channel {str(c)} failed: {str(e)}" + ) + + # There should be just one channel id? + for c in [self.__forum_chan_id]: try: await self.__guild.get_channel(c).delete(reason="Deleting CTF") except Exception as e: @@ -482,57 +695,66 @@ class Challenge: __chals__ = {} @staticmethod - def create(guild, ctf_id, chan_id, name): + def create(guild, ctf_id, thread_id, name, forum_id): + print("Challenge.create") chals = db.challdb[str(guild.id)] chals.insert_one( { - "name": name, + "name": name+"-"+str(ctf_id), "ctf_id": ctf_id, + "forum_id":forum_id, "finished": False, "solvers": [], - "chan_id": chan_id, + "thread_id": thread_id, "owner": 0, + "msg_id": 0, + "working": [], } ) + - chal = Challenge(guild, chan_id) - Challenge.__chals__[chan_id] = chal + chal = Challenge(guild, thread_id) + Challenge.__chals__[thread_id] = chal return chal @staticmethod - def fetch(guild, chan_id): - if chan_id not in Challenge.__chals__: - chal = db.challdb[str(guild.id)].find_one({"chan_id": chan_id}) + def fetch(guild, thread_id): + if thread_id not in Challenge.__chals__: + chal = db.challdb[str(guild.id)].find_one({"thread_id": thread_id}) if not chal: return None - Challenge.__chals__[chan_id] = Challenge(guild, chan_id) - chal = Challenge.__chals__[chan_id] + Challenge.__chals__[thread_id] = Challenge(guild, thread_id) + chal = Challenge.__chals__[thread_id] chal.refresh() return chal @staticmethod def find(guild, ctfid, name, err_on_fail=True): - chal = db.challdb[str(guild.id)].find_one({"name": name, "ctf_id": ctfid}) + chal = db.challdb[str(guild.id)].find_one({"name": name+"-"+str(ctfid), "forum_id": ctfid}) if chal: - return Challenge.fetch(guild, chal["chan_id"]) + return Challenge.fetch(guild, chal["thread_id"]) elif err_on_fail: raise TaskFailed(f'Challenge "{name}" does not exist!') else: return None - def __init__(self, guild, chan_id): + def __init__(self, guild, thread_id): self.__guild = guild - self.__id = chan_id + self.__id = thread_id self.__chals = db.challdb[str(guild.id)] self.refresh() @property - def chan_id(self): + def thread_id(self): return self.__id @property def ctf_id(self): return self.__chalinfo["ctf_id"] + + @property + def forum_id(self): + return self.__chalinfo["forum_id"] @property def is_archived(self): @@ -544,7 +766,7 @@ def is_finished(self): @property def name(self): - return self.__chalinfo["name"] + return "-".join(self.__chalinfo["name"].split("-")[:-1]) @property def owner(self): @@ -555,6 +777,12 @@ def solver_ids(self): if not self.is_finished: return return self.__chalinfo["solvers"] + + @property + def worker_ids(self): + if not self.is_finished: + return + return self.__chalinfo["working"] async def solver_users(self): if not self.is_finished: @@ -567,70 +795,75 @@ async def status(self): return f"Solved by {solvers}" else: return "Unsolved" + + async def working_users(self): + if self.is_finished and self.__chalinfo['working']: + return + return [await self.__guild.fetch_member(id) for id in self.__chalinfo['working']] + + # TODO: Consider removing + async def working(self): + if not self.is_finished and self.__chalinfo['working']: + workers = ", ".join(user.name for user in await self.working_users()) + return f"{workers}" + else: + return @property def team(self): return CtfTeam.fetch(self.__guild, self.ctf_id) - async def _archive(self, catg_archive): - cid = self.__id + async def _archive(self): + thread_id = self.__id guild = self.__guild + #Update db chk_upd( self.name, - self.__chals.update_one({"chan_id": cid}, {"$set": {"archived": True}}), + self.__chals.update_one({"thread_id": thread_id}, {"$set": {"archived": True}}), ) - channel = guild.get_channel(cid) - if channel is not None: - await channel.edit(category=catg_archive) - if config["archive_access_to_all_users"]: - await channel.set_permissions( - guild.default_role, overwrite=basic_read_send - ) - self.refresh() - else: - raise ChannelNotFoundException(f"Couldn't find channel {cid}") + self.refresh() + + # raise ThreadNotFoundException(f"Couldn't find thread {thread_id}") - async def _unarchive(self, catg_working, catg_done): - cid = self.__id + + # async def _unarchive(self, catg_working, catg_done): + async def _unarchive(self): + tid = self.__id guild = self.__guild + + #Update db chk_upd( self.name, - self.__chals.update_one({"chan_id": cid}, {"$set": {"archived": False}}), + self.__chals.update_one({"thread_id": tid}, {"$set": {"archived": False}}), ) - channel = guild.get_channel(cid) - - if channel is not None: - await channel.edit(category=(catg_working, catg_done)[self.is_finished]) - await channel.set_permissions(guild.default_role, overwrite=basic_disallow) - self.refresh() - else: - raise ChannelNotFoundException(f"Couldn't find channel {cid}") - + self.refresh() + def check_done(self, user): - if not self.is_finished or Challenge._uid(user) == self.owner: + if not self.is_finished:# or Challenge._uid(user) == self.owner: return guild = self.__guild if not guild.get_channel(self.__id).permissions_for(user).manage_channels: raise commands.MissingPermissions("manage_channels") - async def _delete(self, catg_archive): + + async def _delete(self, channelid): cid = self.__id # Delete entry - chk_del(self.name, self.__chals.delete_one({"chan_id": self.__id})) + chk_del(self.name, self.__chals.delete_one({"thread_id": self.__id})) del Challenge.__chals__[self.__id] - # Archive channel - await self.__guild.get_channel(cid).edit(category=catg_archive) + # Delete thread + channel = self.__guild.get_channel(channelid) + + await channel.get_thread(cid).delete() @chk_archive async def done(self, owner, users): - cid = self.__id + thread_id = self.__id guild = self.__guild - catg_done = load_category(guild, config["categories"]["done"]) - # Create list of solvers owner = Challenge._uid(owner) users = [Challenge._uid(u) for u in users] @@ -646,105 +879,138 @@ async def done(self, owner, users): if old_solvers == users: raise TaskFailed("This task is already solved with same users") + forum_channel = discord.utils.get(guild.forums,id=self.forum_id) + + tag = [tag for tag in forum_channel.available_tags if tag.name == "Solved"][0] + + # Mark thread as done + thread = guild.get_thread(thread_id) + if thread is not None: + newName = thread.name.replace("❌","✅") + await thread.edit( + name=newName, + archived=True, + applied_tags=[tag] + ) + self.refresh() + else: + raise ThreadNotFoundException("The thread cannot be found!") + + mentions = " ".join(mentions) + self.refresh() + # Update database chk_upd( self.name, self.__chals.update_one( - {"chan_id": cid}, + {"thread_id": thread_id}, {"$set": {"finished": True, "owner": owner, "solvers": users}}, ), ) - # Move channel to done - await guild.get_channel(cid).edit(category=catg_done) - - mentions = " ".join(mentions) self.refresh() + return [ ( self.ctf_id, - f"{self.team.mention} :tada: <#{cid}> has been completed by {mentions}!", + f"{self.team.mention} :tada: <#{thread_id}> has been completed by {mentions}!", ), - (None, "Challenge moved to done!"), - ] - - @chk_archive - async def invite(self, author, user): - ccid = self.team.chan_id - guild = self.__guild - chan = guild.get_channel(self.__id) - - if user in chan.overwrites: - raise TaskFailed(f'{user.name} is already in the "{self.name}" challenge') - - await chan.set_permissions( - user, - overwrite=basic_allow, - reason=f'{author.name} invited user to work on "{self.name}" challenge', - ) - return [ - ( - ccid, - f'{author.mention} invited {user.mention} to work on "{self.name}" challenge', - ) + (None, "Challenge marked as complete!"), ] - @chk_archive - async def leave(self, user): - ccid = self.team.chan_id - guild = self.__guild - chan = guild.get_channel(self.__id) - await chan.set_permissions( - user, overwrite=None, reason=f'Left "{self.name}" challenge' - ) - return [(ccid, f'{user.mention} has left "{self.name}" challenge')] + # #TODO Check if this is needed + # @chk_archive + # async def invite(self, author, user): + # ccid = self.team.chan_id + # guild = self.__guild + # chan = guild.get_channel(self.__id) + + # if user in chan.overwrites: + # raise TaskFailed(f'{user.name} is already in the "{self.name}" challenge') + + # await chan.set_permissions( + # user, + # overwrite=basic_allow, + # reason=f'{author.name} invited user to work on "{self.name}" challenge', + # ) + # return [ + # ( + # ccid, + # f'{author.mention} invited {user.mention} to work on "{self.name}" challenge', + # ) + # ] + + # #TODO Check if this is needed + # @chk_archive + # async def leave(self, user): + # ccid = self.team.chan_id + # guild = self.__guild + # chan = guild.get_channel(self.__id) + # await chan.set_permissions( + # user, overwrite=None, reason=f'Left "{self.name}" challenge' + # ) + # return [(ccid, f'{user.mention} has left "{self.name}" challenge')] def refresh(self): cid = self.__id - chal = self.__chals.find_one({"chan_id": cid}) + chal = self.__chals.find_one({"thread_id": cid}) if not chal: - raise ChannelNotFoundException(f"{cid}: Invalid challenge channel ID") + raise ThreadNotFoundException(f"{cid}: Invalid challenge thread ID") self.__chalinfo = chal @chk_archive async def undone(self): - cid = self.__id + thread_id = self.__id guild = self.__guild - catg_working = load_category(guild, config["categories"]["working"]) + # catg_working = load_category(guild, config["categories"]["working"]) if not self.is_finished: raise TaskFailed("This ctf challenge has not been completed yet") + forum_channel = discord.utils.get(guild.forums,id=self.forum_id) + + tag = [tag for tag in forum_channel.available_tags if tag.name == "Unsolved"][0] + + # Mark thread as undone + thread = guild.get_thread(thread_id) + if thread is not None: + await thread.edit(name=thread.name.replace("✅","❌"), + applied_tags=[tag] + ) + + self.refresh() + else: + raise ThreadNotFoundException("The thread cannot be found!") + # Update database chk_upd( self.name, - self.__chals.update_one({"chan_id": cid}, {"$set": {"finished": False}}), + self.__chals.update_one({"thread_id": thread_id}, {"$set": {"finished": False}}), ) - # Move channel to working - await guild.get_channel(cid).edit(category=catg_working) - self.refresh() + return [ (None, f'Reopened "{self.name}" as not done'), ( self.ctf_id, - f"""{self.team.mention} <#{cid}> is now undone. :weary:""", + f"""{self.team.mention} <#{thread_id}> is now undone. :weary:""", ), ] - @chk_archive - async def working(self, user): - ccid = self.team.chan_id - guild = self.__guild - chan = guild.get_channel(self.__id) - if user in chan.overwrites: - raise TaskFailed(f'{user.name} is already in the "{self.name}" challenge') - await chan.set_permissions( - user, overwrite=basic_allow, reason=f'Working on "{self.name}" challenge' - ) - return [(ccid, f'{user.mention} is working on "{self.name}" challenge')] + # #TODO Check if this is needed + # @chk_archive + # async def working(self, user): + # ccid = self.team.chan_id + # guild = self.__guild + # chan = guild.get_channel(self.__id) + # if user in chan.overwrites: + # raise TaskFailed(f'{user.name} is already in the "{self.name}" challenge') + # await chan.set_permissions( + # user, overwrite=basic_allow, reason=f'Working on "{self.name}" challenge' + # ) + # return [(ccid, f'{user.mention} is working on "{self.name}" challenge')] @staticmethod def _uid(user): @@ -756,7 +1022,7 @@ def _uid(user): return user.id raise ValueError(f"Cannot convert to user: {user}") - +#TODO fix export async def export(ctx, author): guild = ctx.guild @@ -766,15 +1032,17 @@ async def export(ctx, author): main_chan = ctx.channel channels = [main_chan] - for chal in guild.text_channels: - if f"{main_chan.name}-" in chal.name: - channels.append(chal) + + # Makes no sense when we only have the mainchan with threads + # for chal in guild.text_channels: + # if f"{main_chan.name}-" in chal.name: + # channels.append(chal) CTF = await exportChannels(channels) return await save(guild, guild.name, main_chan.name, CTF) - +#TODO fix export async def exportChannels(channels): CTF = {"channels": []} for channel in channels: diff --git a/bot/ctfs.py b/bot/ctfs.py index 63374b5..d3a4a9d 100644 --- a/bot/ctfs.py +++ b/bot/ctfs.py @@ -1,4 +1,5 @@ import re +import asyncio import ctf_model import db @@ -22,13 +23,13 @@ def predicate(ctx): return commands.check(predicate) + class Ctfs(commands.Cog): def __init__(self, bot): self.bot = bot - self.challenges = {} self.ctfname = "" - self.limit = 20 + self.limit = 400 self.guilds = {} self.cleanup.start() @@ -41,23 +42,30 @@ def __init__(self, bot): async def create(self, ctx, *name): name = config["challenge_name_delimiter"].join(name) emoji = "🏃" - messages = await respond_with_reaction( + chan_id, messages = await respond_with_reaction( ctx, emoji, ctf_model.CtfTeam.create, ctx.channel.guild, name ) + db.teamdb[str(ctx.channel.guild.id)].update_one( - {"name": name}, {"$set": {"msg_id": messages[0].id}} + {"name": name+"-"+str(chan_id)}, {"$set": {"msg_id": messages[0].id}} ) ################################################################################## # CTF main channel commands ################################################################################## + @commands.bot_has_permissions(manage_channels=True) @commands.command() async def add(self, ctx, *words): - name = config["challenge_name_delimiter"].join(words) + name = " ".join(words) name = check_name(name) emoji = "🔨" - await respond_with_reaction(ctx, emoji, chk_fetch_team(ctx).add_chal, name) + print("add") + # _, messages = await respond_with_reaction(ctx, emoji, chk_fetch_team(ctx).add_chal, name) + _, messages = await respond_with_reaction(ctx, "", chk_fetch_team(ctx).add_chal, name) + db.challdb[str(ctx.channel.guild.id)].update_one( + {"name": name+"-"+str(messages[0].channel.id)}, {"$set": {"msg_id": messages[0].id}} + ) @commands.bot_has_permissions(manage_channels=True) @commands.has_permissions(manage_channels=True) @@ -65,7 +73,73 @@ async def add(self, ctx, *words): async def delete(self, ctx, name): name = check_name(name) await respond(ctx, chk_fetch_team(ctx).del_chal, name) - + + @commands.command() + async def status(self, ctx, state="lol"): #Legge til statuscheck + chals = chk_fetch_team(ctx).challenges + if len(chals) == 0: + await ctx.send("No challenges added...") + return + uns = True + if str(state).lower()=="unsolved": + uns = False + + msg_len = 50 + lines = [] + for chal in chals: + status = await chal.status() + if status.lower()[:4] != "unso" and not uns: + continue + chall_line = f"[{chal.team.name}] [{chal.name}] - {status}" + msg_len += len(chall_line) + 1 + if msg_len > 1000: # Over limit + lines = "\n".join(lines) + await ctx.send(f"```ini\n{lines}```") + lines = [] + msg_len = len(chall_line) + 51 + lines.append(chall_line) + if len(lines)==0: + await ctx.send(f"Every started challenge is completed! :tada:") + else: + lines = "\n".join(lines) + await ctx.send(f"```ini\n{lines}```") + + # Not needed in this context + # @commands.command() + # async def working(self, ctx): + # # chk_fetch_team(ctx).refresh + # chals = chk_fetch_team(ctx).challenges + # if len(chals) == 0: + # await ctx.send("No challenges added...") + # return + # if not config['react_for_challenge']: + # await ctx.send("Makes no sense to check who is working when everyone has joined every thread...") + # return + + # msg_len = 50 + # lines = [] + # for chal in chals: + # status = await chal.status() + # if status.lower()[:4] != "unso": + # continue + # workers = await chal.working() + # if not workers: + # chall_line = f"[{chal.team.name}] [{chal.name}] - No one works this challenge..." + # else: + # chall_line = f"[{chal.team.name}] [{chal.name}] - {workers} works on this challenge" + # msg_len += len(chall_line) + 1 + # if msg_len > 1000: # Over limit + # lines = "\n".join(lines) + # await ctx.send(f"```ini\n{lines}```") + # lines = [] + # msg_len = len(chall_line) + 51 + # lines.append(chall_line) + # if len(lines)==0: + # await ctx.send(f"No one is working on any unsolved challenge...") + # else: + # lines = "\n".join(lines) + # await ctx.send(f"```ini\n{lines}```") + @commands.bot_has_permissions(manage_roles=True) @commands.command() async def join(self, ctx, name=None): @@ -111,55 +185,30 @@ async def deletectf(self, ctx, ctf_name): except ChannelDeleteFailedException as e: await ctx.send(str(e)) - @commands.command() - async def status(self, ctx): - chals = chk_fetch_team(ctx).challenges - if len(chals) == 0: - await ctx.send("No challenges added...") - return - - msg_len = 50 - lines = [] - for chal in chals: - chall_line = f"[{chal.team.name}] [{chal.name}] - {await chal.status()}" - msg_len += len(chall_line) + 1 - if msg_len > 1000: # Over limit - lines = "\n".join(lines) - await ctx.send(f"```ini\n{lines}```") - lines = [] - msg_len = len(chall_line) + 51 - lines.append(chall_line) - - lines = "\n".join(lines) - await ctx.send(f"```ini\n{lines}```") @commands.bot_has_permissions(manage_roles=True, manage_channels=True) # @commands.has_permissions(manage_channels=True) @commands.command() async def export(self, ctx): await respond(ctx, ctf_model.export, ctx, ctx.author) - + + ################################################################################## # CTF challenge specific commands ################################################################################## @commands.bot_has_permissions(manage_channels=True) - @verify_owner() + #@verify_owner() @commands.command() async def done(self, ctx, *withlist: Member): users = list(set(withlist)) await respond(ctx, chk_fetch_chal(ctx).done, ctx.author, users) @commands.bot_has_permissions(manage_channels=True) - @verify_owner() + #@verify_owner() @commands.command() async def undone(self, ctx): await respond(ctx, chk_fetch_chal(ctx).undone) - # TODO: delete? not really used as everyone has the same role - @commands.bot_has_permissions(manage_channels=True) - @commands.command() - async def leave_challenge(self, ctx): - await respond(ctx, chk_fetch_chal(ctx).leave, ctx.author) ################################################################################## # Automatic cleanup @@ -167,6 +216,18 @@ async def leave_challenge(self, ctx): @loop(minutes=30) async def cleanup(self): for guild_id, guild in self.guilds.items(): + + #Archive threads in archived ctf channels and make sure threads in active CTFs is not archived + for channel in guild.channels: + if config['categories']["archive-prefix"].lower() in str(channel.category).lower(): + for thread in channel.threads: + if not thread.archived: + await thread.edit(archived=True) + elif config['categories']["working"].lower() in str(channel.category).lower(): + for thread in channel.threads: + if "❌" in thread.name: + await thread.edit(archived=False) + archived = sorted( [ (ObjectId(ctf["_id"]).generation_time, ctf) @@ -190,6 +251,8 @@ async def cleanup(self): await delete(guild, channels) break # safety measure to take only one + + @cleanup.before_loop async def cleanup_before(self): await self.bot.wait_until_ready() @@ -212,17 +275,21 @@ async def respond(ctx, callback, *args): async def respond_with_reaction(ctx, emoji, callback, *args): + print("Respond with reaction") messages = [] guild = ctx.channel.guild async with ctx.channel.typing(): for chan_id, msg in await callback(*args): - chan = guild.get_channel(chan_id) if chan_id else ctx.channel + chan = ctx.channel + print(msg) msg = await chan.send(msg) - await msg.add_reaction(emoji) + #Just in case we don't want to add reaction, which was really not needed for add challenge + if emoji: + await msg.add_reaction(emoji) messages.append(msg) - return messages - + return chan_id, messages +#TODO find out which filters is needed for threads def check_name(name): if len(name) > 32: raise ctf_model.TaskFailed("Challenge name is too long!") @@ -231,7 +298,7 @@ def check_name(name): raise ctf_model.TaskFailed("Challenge contains invalid characters!") # Replace spaces with a dash with a configured delimiter - return re.sub(r" +", config["challenge_name_delimiter"], name).lower() + return re.sub(r" +", " ", name).lower() def chk_fetch_team_by_name(ctx, name): @@ -250,14 +317,14 @@ def chk_fetch_team_by_name(ctx, name): def chk_fetch_team(ctx): + print("chk_fetch_team") team = ctf_model.CtfTeam.fetch(ctx.channel.guild, ctx.channel.id) if not team: raise ctf_model.TaskFailed( - "Please type this command in the main channel of a CTF." + "Please type this command in the channel of a CTF." ) return team - def chk_fetch_chal(ctx): chal = ctf_model.Challenge.fetch(ctx.channel.guild, ctx.channel.id) if not chal: @@ -265,5 +332,5 @@ def chk_fetch_chal(ctx): return chal -def setup(bot): - bot.add_cog(Ctfs(bot)) +async def setup(bot): + await bot.add_cog(Ctfs(bot)) diff --git a/bot/ctftime.py b/bot/ctftime.py index 4e1176f..69e5f1d 100644 --- a/bot/ctftime.py +++ b/bot/ctftime.py @@ -463,5 +463,5 @@ def format_table(table, seperator=" "): ) -def setup(bot): - bot.add_cog(Ctftime(bot)) +async def setup(bot): + await bot.add_cog(Ctftime(bot)) diff --git a/bot/exceptions.py b/bot/exceptions.py index c5c68a6..77a81d1 100644 --- a/bot/exceptions.py +++ b/bot/exceptions.py @@ -4,3 +4,6 @@ class ChannelNotFoundException(Exception): class ChannelDeleteFailedException(Exception): pass + +class ThreadNotFoundException(Exception): + pass \ No newline at end of file diff --git a/bot/helpers.py b/bot/helpers.py index 2777717..1406c6a 100644 --- a/bot/helpers.py +++ b/bot/helpers.py @@ -1,8 +1,9 @@ from config import config -from constants import SOURCE_FORK1, SOURCE_FORK2, SOURCE_FORK3 +from constants import SOURCE_FORK1, SOURCE_FORK2, SOURCE_FORK3, SOURCE_FORK4 core = f""" -Fork from: {SOURCE_FORK3} +Fork from: {SOURCE_FORK4} +Who again forked from {SOURCE_FORK3} Who again forked from {SOURCE_FORK2} Who again forked from {SOURCE_FORK1} @@ -25,16 +26,50 @@ "!", config["prefix"] ) +# ctf = """ +# These commands are callable from a main CTF channel. + +# `!add ` +# Add a `challenge` and a respective channel. Challenge names may be altered to meet Discord restrictions. +# (i.e. no special characters, less than 32 characters long, etc...) + +# `!delete ` +# Remove a challenge (this requires the bot has manage channels permissions). +# This will **not** automatically delete the respective private channel. Server staff can remove manually if required. + +# `!join ` +# Join a CTF by its name, can be used instead of reactions. + +# `!leave` +# Leave the CTF team and all of the respective challenge channels. + +# `!invite ` +# Invites a user to CTF team - `user` is granted the CTF role. + +# `!archive` +# Archives this CTF and all the respective challenges (this requires the bot has manage channels permissions). + +# `!unarchive` +# Unarchives this CTF and all the respective challenges (this requires the bot has manage channels permissions). + +# `!status` +# Lists the status (unsolved, or solved and by whom) of each challenge in the CTF. + +# `!deletectf ` +# Deletes the CTF and it's challenge channels, provide the CTF name as an argument to this command +# """.replace( +# "!", config["prefix"] +# ) + ctf = """ -These commands are callable from a main CTF channel. +These commands are callable from a CTF channel. `!add ` -Add a `challenge` and a respective channel. Challenge names may be altered to meet Discord restrictions. -(i.e. no special characters, less than 32 characters long, etc...) +Add a `challenge` and a respective thread. `!delete ` Remove a challenge (this requires the bot has manage channels permissions). -This will **not** automatically delete the respective private channel. Server staff can remove manually if required. +This will **not** automatically delete the respective private thread. Server staff can remove manually if required. `!join ` Join a CTF by its name, can be used instead of reactions. @@ -53,28 +88,30 @@ `!status` Lists the status (unsolved, or solved and by whom) of each challenge in the CTF. +Add unsolved as an argument to list only unsolved challenges `!deletectf ` -Deletes the CTF and it's challenge channels, provide the CTF name as an argument to this command +Deletes the CTF, provide the CTF name as an argument to this command """.replace( "!", config["prefix"] ) -# TODO: add export helper - challenge = """ These commands are callable from a CTF **challenge** environment. `!done @user1 @user2 ...` -Marks this challenge as completed, and moves channel to "done" category. You may optionally include @'s of `users` that worked with you. -Once a challenge is completed, **no one** except you (and admins) can alter the done list or change reset the status to "undone". +Marks this challenge as completed, and changes the thread emoji. You may optionally include @'s of `users` that worked with you. +Once a challenge is completed, **no one** except you (and admins) can alter the done list or change the status to "undone". `!undone` -Marks this challenge as **not** completed. This will move the channel back to the "working" category. +Marks this challenge as **not** completed. This will change the thread emoji. """.replace( "!", config["prefix"] ) +# TODO: add export helper + + helpers = { "core": { "title": "Help for core commands", diff --git a/docker-compose.yml b/docker-compose.yml index c6435f0..e94498f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: - "CHANNEL_NAME_DELIMITER=${CHANNEL_NAME_DELIMITER}" - "CTFTIME_TEAM_ID=${CTFTIME_TEAM_ID}" - "CTFTIME_TEAM_NAME=${CTFTIME_TEAM_NAME}" + - "REACT_FOR_CHALLENGE=${REACT_FOR_CHALLENGE}" volumes: - ./bot:/home/bot - ./backups:/home/bot/backups diff --git a/images/rumble-add1.PNG b/images/rumble-add1.PNG new file mode 100644 index 0000000..687da45 Binary files /dev/null and b/images/rumble-add1.PNG differ diff --git a/images/rumble-add2.PNG b/images/rumble-add2.PNG new file mode 100644 index 0000000..dbf5590 Binary files /dev/null and b/images/rumble-add2.PNG differ diff --git a/images/rumble-create.PNG b/images/rumble-create.PNG new file mode 100644 index 0000000..84e5c7d Binary files /dev/null and b/images/rumble-create.PNG differ diff --git a/images/rumble-done.PNG b/images/rumble-done.PNG new file mode 100644 index 0000000..fdbc043 Binary files /dev/null and b/images/rumble-done.PNG differ diff --git a/requirements/base.txt b/requirements/base.txt index 897f927..da6ff98 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,6 +1,6 @@ colorthief==0.2.1 python-dateutil==2.8.2 -discord.py==1.7.3 +discord.py==2.1.0 pymongo==3.12.1 requests==2.28.1 -lxml==4.9.2 +lxml==4.9.2 \ No newline at end of file