-
-
Notifications
You must be signed in to change notification settings - Fork 751
Additional code jam management utilities #1677
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fb9cbe4
6986600
d4271b0
bc390bc
fdc6f23
19cd66f
94f03bc
be2c547
a5b7617
73187a3
d849d03
a2cbf0a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| from bot.bot import Bot | ||
|
|
||
|
|
||
| def setup(bot: Bot) -> None: | ||
| """Load the CodeJams cog.""" | ||
| from bot.exts.events.code_jams._cog import CodeJams | ||
|
|
||
| bot.add_cog(CodeJams(bot)) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| import logging | ||
| import typing as t | ||
|
|
||
| import discord | ||
|
|
||
| from bot.constants import Categories, Channels, Roles | ||
|
|
||
| log = logging.getLogger(__name__) | ||
|
|
||
| MAX_CHANNELS = 50 | ||
| CATEGORY_NAME = "Code Jam" | ||
|
|
||
|
|
||
| async def _get_category(guild: discord.Guild) -> discord.CategoryChannel: | ||
| """ | ||
| Return a code jam category. | ||
|
|
||
| If all categories are full or none exist, create a new category. | ||
| """ | ||
| for category in guild.categories: | ||
| if category.name == CATEGORY_NAME and len(category.channels) < MAX_CHANNELS: | ||
|
Bluenix2 marked this conversation as resolved.
|
||
| return category | ||
|
|
||
| return await _create_category(guild) | ||
|
|
||
|
|
||
| async def _create_category(guild: discord.Guild) -> discord.CategoryChannel: | ||
| """Create a new code jam category and return it.""" | ||
| log.info("Creating a new code jam category.") | ||
|
|
||
| category_overwrites = { | ||
| guild.default_role: discord.PermissionOverwrite(read_messages=False), | ||
| guild.me: discord.PermissionOverwrite(read_messages=True) | ||
| } | ||
|
|
||
| category = await guild.create_category_channel( | ||
| CATEGORY_NAME, | ||
| overwrites=category_overwrites, | ||
| reason="It's code jam time!" | ||
| ) | ||
|
|
||
| await _send_status_update( | ||
| guild, f"Created a new category with the ID {category.id} for this Code Jam's team channels." | ||
| ) | ||
|
|
||
| return category | ||
|
|
||
|
|
||
| def _get_overwrites( | ||
| members: list[tuple[discord.Member, bool]], | ||
| guild: discord.Guild, | ||
| ) -> dict[t.Union[discord.Member, discord.Role], discord.PermissionOverwrite]: | ||
| """Get code jam team channels permission overwrites.""" | ||
| team_channel_overwrites = { | ||
| guild.default_role: discord.PermissionOverwrite(read_messages=False), | ||
| guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite(read_messages=True) | ||
| } | ||
|
|
||
| for member, _ in members: | ||
| team_channel_overwrites[member] = discord.PermissionOverwrite( | ||
| read_messages=True | ||
| ) | ||
|
|
||
| return team_channel_overwrites | ||
|
|
||
|
|
||
| async def create_team_channel( | ||
| guild: discord.Guild, | ||
| team_name: str, | ||
| members: list[tuple[discord.Member, bool]], | ||
| team_leaders: discord.Role | ||
| ) -> None: | ||
| """Create the team's text channel.""" | ||
| await _add_team_leader_roles(members, team_leaders) | ||
|
|
||
| # Get permission overwrites and category | ||
| team_channel_overwrites = _get_overwrites(members, guild) | ||
| code_jam_category = await _get_category(guild) | ||
|
|
||
| # Create a text channel for the team | ||
| await code_jam_category.create_text_channel( | ||
| team_name, | ||
| overwrites=team_channel_overwrites, | ||
| ) | ||
|
|
||
|
|
||
| async def create_team_leader_channel(guild: discord.Guild, team_leaders: discord.Role) -> None: | ||
| """Create the Team Leader Chat channel for the Code Jam team leaders.""" | ||
| category: discord.CategoryChannel = guild.get_channel(Categories.summer_code_jam) | ||
|
|
||
| team_leaders_chat = await category.create_text_channel( | ||
| name="team-leaders-chat", | ||
| overwrites={ | ||
| guild.default_role: discord.PermissionOverwrite(read_messages=False), | ||
| team_leaders: discord.PermissionOverwrite(read_messages=True) | ||
| } | ||
| ) | ||
|
|
||
| await _send_status_update(guild, f"Created {team_leaders_chat.mention} in the {category} category.") | ||
|
|
||
|
|
||
| async def _send_status_update(guild: discord.Guild, message: str) -> None: | ||
| """Inform the events lead with a status update when the command is ran.""" | ||
| channel: discord.TextChannel = guild.get_channel(Channels.code_jam_planning) | ||
|
|
||
| await channel.send(f"<@&{Roles.events_lead}>\n\n{message}") | ||
|
|
||
|
|
||
| async def _add_team_leader_roles(members: list[tuple[discord.Member, bool]], team_leaders: discord.Role) -> None: | ||
| """Assign the team leader role to the team leaders.""" | ||
| for member, is_leader in members: | ||
| if is_leader: | ||
| await member.add_roles(team_leaders) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,235 @@ | ||
| import asyncio | ||
| import csv | ||
| import logging | ||
| import typing as t | ||
| from collections import defaultdict | ||
|
|
||
| import discord | ||
| from discord import Colour, Embed, Guild, Member | ||
| from discord.ext import commands | ||
|
|
||
| from bot.bot import Bot | ||
| from bot.constants import Emojis, Roles | ||
| from bot.exts.events.code_jams import _channels | ||
| from bot.utils.services import send_to_paste_service | ||
|
|
||
| log = logging.getLogger(__name__) | ||
|
|
||
| TEAM_LEADERS_COLOUR = 0x11806a | ||
| DELETION_REACTION = "\U0001f4a5" | ||
|
Bluenix2 marked this conversation as resolved.
|
||
|
|
||
|
|
||
| class CodeJams(commands.Cog): | ||
| """Manages the code-jam related parts of our server.""" | ||
|
|
||
| def __init__(self, bot: Bot): | ||
| self.bot = bot | ||
|
|
||
| @commands.group(aliases=("cj", "jam")) | ||
| @commands.has_any_role(Roles.admins) | ||
| async def codejam(self, ctx: commands.Context) -> None: | ||
| """A Group of commands for managing Code Jams.""" | ||
| if ctx.invoked_subcommand is None: | ||
|
Bluenix2 marked this conversation as resolved.
|
||
| await ctx.send_help(ctx.command) | ||
|
|
||
| @codejam.command() | ||
| async def create(self, ctx: commands.Context, csv_file: t.Optional[str] = None) -> None: | ||
| """ | ||
| Create code-jam teams from a CSV file or a link to one, specifying the team names, leaders and members. | ||
|
|
||
| The CSV file must have 3 columns: 'Team Name', 'Team Member Discord ID', and 'Team Leader'. | ||
|
|
||
| This will create the text channels for the teams, and give the team leaders their roles. | ||
| """ | ||
| async with ctx.typing(): | ||
| if csv_file: | ||
| async with self.bot.http_session.get(csv_file) as response: | ||
| if response.status != 200: | ||
| await ctx.send(f"Got a bad response from the URL: {response.status}") | ||
| return | ||
|
|
||
| csv_file = await response.text() | ||
|
|
||
| elif ctx.message.attachments: | ||
| csv_file = (await ctx.message.attachments[0].read()).decode("utf8") | ||
| else: | ||
| raise commands.BadArgument("You must include either a CSV file or a link to one.") | ||
|
|
||
| teams = defaultdict(list) | ||
| reader = csv.DictReader(csv_file.splitlines()) | ||
|
|
||
| for row in reader: | ||
| member = ctx.guild.get_member(int(row["Team Member Discord ID"])) | ||
|
|
||
| if member is None: | ||
| log.trace(f"Got an invalid member ID: {row['Team Member Discord ID']}") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Perhaps we want to keep track of this, or maybe just a bool. So that you know when running the command that some members were omitted?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was raised in the PR which added this command, but out of scope for this one. |
||
| continue | ||
|
|
||
| teams[row["Team Name"]].append((member, row["Team Leader"].upper() == "Y")) | ||
|
|
||
| team_leaders = await ctx.guild.create_role(name="Code Jam Team Leaders", colour=TEAM_LEADERS_COLOUR) | ||
|
|
||
| for team_name, members in teams.items(): | ||
| await _channels.create_team_channel(ctx.guild, team_name, members, team_leaders) | ||
|
|
||
| await _channels.create_team_leader_channel(ctx.guild, team_leaders) | ||
| await ctx.send(f"{Emojis.check_mark} Created Code Jam with {len(teams)} teams.") | ||
|
|
||
| @codejam.command() | ||
| @commands.has_any_role(Roles.admins) | ||
| async def end(self, ctx: commands.Context) -> None: | ||
| """ | ||
| Delete all code jam channels. | ||
|
|
||
| A confirmation message is displayed with the categories and channels to be deleted.. Pressing the added reaction | ||
| deletes those channels. | ||
| """ | ||
| def predicate_deletion_emoji_reaction(reaction: discord.Reaction, user: discord.User) -> bool: | ||
| """Return True if the reaction :boom: was added by the context message author on this message.""" | ||
| return ( | ||
| reaction.message.id == message.id | ||
| and user.id == ctx.author.id | ||
| and str(reaction) == DELETION_REACTION | ||
| ) | ||
|
|
||
| # A copy of the list of channels is stored. This is to make sure that we delete precisely the channels displayed | ||
| # in the confirmation message. | ||
| categories = self.jam_categories(ctx.guild) | ||
| category_channels = {category: category.channels.copy() for category in categories} | ||
|
|
||
| confirmation_message = await self._build_confirmation_message(category_channels) | ||
| message = await ctx.send(confirmation_message) | ||
| await message.add_reaction(DELETION_REACTION) | ||
| try: | ||
| await self.bot.wait_for( | ||
| 'reaction_add', | ||
| check=predicate_deletion_emoji_reaction, | ||
| timeout=10 | ||
| ) | ||
|
|
||
| except asyncio.TimeoutError: | ||
| await message.clear_reaction(DELETION_REACTION) | ||
|
mbaruh marked this conversation as resolved.
|
||
| await ctx.send("Command timed out.", reference=message) | ||
| return | ||
|
|
||
| else: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You return in the except, no need to have this in an
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I know, but I personally like the explicitness of the flow. |
||
| await message.clear_reaction(DELETION_REACTION) | ||
| for category, channels in category_channels.items(): | ||
| for channel in channels: | ||
| await channel.delete(reason="Code jam ended.") | ||
| await category.delete(reason="Code jam ended.") | ||
|
|
||
| await message.add_reaction(Emojis.check_mark) | ||
|
|
||
| @staticmethod | ||
| async def _build_confirmation_message( | ||
| categories: dict[discord.CategoryChannel, list[discord.abc.GuildChannel]] | ||
| ) -> str: | ||
| """Sends details of the channels to be deleted to the pasting service, and formats the confirmation message.""" | ||
| def channel_repr(channel: discord.abc.GuildChannel) -> str: | ||
| """Formats the channel name and ID and a readable format.""" | ||
| return f"{channel.name} ({channel.id})" | ||
|
|
||
| def format_category_info(category: discord.CategoryChannel, channels: list[discord.abc.GuildChannel]) -> str: | ||
| """Displays the category and the channels within it in a readable format.""" | ||
| return f"{channel_repr(category)}:\n" + "\n".join(" - " + channel_repr(channel) for channel in channels) | ||
|
|
||
| deletion_details = "\n\n".join( | ||
| format_category_info(category, channels) for category, channels in categories.items() | ||
| ) | ||
|
|
||
| url = await send_to_paste_service(deletion_details) | ||
| if url is None: | ||
| url = "**Unable to send deletion details to the pasting service.**" | ||
|
|
||
| return f"Are you sure you want to delete all code jam channels?\n\nThe channels to be deleted: {url}" | ||
|
|
||
| @codejam.command() | ||
| @commands.has_any_role(Roles.admins, Roles.code_jam_event_team) | ||
| async def info(self, ctx: commands.Context, member: Member) -> None: | ||
| """ | ||
| Send an info embed about the member with the team they're in. | ||
|
|
||
| The team is found by searching the permissions of the team channels. | ||
| """ | ||
| channel = self.team_channel(ctx.guild, member) | ||
| if not channel: | ||
| await ctx.send(":x: I can't find the team channel for this member.") | ||
| return | ||
|
|
||
| embed = Embed( | ||
| title=str(member), | ||
| colour=Colour.blurple() | ||
| ) | ||
| embed.add_field(name="Team", value=self.team_name(channel), inline=True) | ||
|
|
||
| await ctx.send(embed=embed) | ||
|
|
||
| @codejam.command() | ||
| @commands.has_any_role(Roles.admins) | ||
| async def move(self, ctx: commands.Context, member: Member, new_team_name: str) -> None: | ||
| """Move participant from one team to another by changing the user's permissions for the relevant channels.""" | ||
| old_team_channel = self.team_channel(ctx.guild, member) | ||
| if not old_team_channel: | ||
| await ctx.send(":x: I can't find the team channel for this member.") | ||
| return | ||
|
|
||
| if old_team_channel.name == new_team_name or self.team_name(old_team_channel) == new_team_name: | ||
| await ctx.send(f"`{member}` is already in `{new_team_name}`.") | ||
| return | ||
|
|
||
| new_team_channel = self.team_channel(ctx.guild, new_team_name) | ||
| if not new_team_channel: | ||
| await ctx.send(f":x: I can't find a team channel named `{new_team_name}`.") | ||
| return | ||
|
|
||
| await old_team_channel.set_permissions(member, overwrite=None, reason=f"Participant moved to {new_team_name}") | ||
| await new_team_channel.set_permissions( | ||
| member, | ||
| overwrite=discord.PermissionOverwrite(read_messages=True), | ||
| reason=f"Participant moved from {old_team_channel.name}" | ||
| ) | ||
|
|
||
| await ctx.send( | ||
| f"Participant moved from `{self.team_name(old_team_channel)}` to `{self.team_name(new_team_channel)}`." | ||
| ) | ||
|
|
||
| @codejam.command() | ||
| @commands.has_any_role(Roles.admins) | ||
| async def remove(self, ctx: commands.Context, member: Member) -> None: | ||
| """Remove the participant from their team. Does not remove the participants or leader roles.""" | ||
| channel = self.team_channel(ctx.guild, member) | ||
| if not channel: | ||
| await ctx.send(":x: I can't find the team channel for this member.") | ||
| return | ||
|
|
||
| await channel.set_permissions( | ||
| member, | ||
| overwrite=None, | ||
| reason=f"Participant removed from the team {self.team_name(channel)}." | ||
| ) | ||
| await ctx.send(f"Removed the participant from `{self.team_name(channel)}`.") | ||
|
|
||
| @staticmethod | ||
| def jam_categories(guild: Guild) -> list[discord.CategoryChannel]: | ||
| """Get all the code jam team categories.""" | ||
| return [category for category in guild.categories if category.name == _channels.CATEGORY_NAME] | ||
|
|
||
| @staticmethod | ||
| def team_channel(guild: Guild, criterion: t.Union[str, Member]) -> t.Optional[discord.TextChannel]: | ||
| """Get a team channel through either a participant or the team name.""" | ||
| for category in CodeJams.jam_categories(guild): | ||
| for channel in category.channels: | ||
| if isinstance(channel, discord.TextChannel): | ||
| if ( | ||
| # If it's a string. | ||
| criterion == channel.name or criterion == CodeJams.team_name(channel) | ||
| # If it's a member. | ||
| or criterion in channel.overwrites | ||
| ): | ||
| return channel | ||
|
|
||
| @staticmethod | ||
| def team_name(channel: discord.TextChannel) -> str: | ||
| """Retrieves the team name from the given channel.""" | ||
| return channel.name.replace("-", " ").title() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we know what kind of side effects we're trying to prevent here? I noticed a similar tactic in the help_channels package, but I'm not sure what we're specifically trying to prevent.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Circular imports presumably