From 7d1ee897b565daef1a8cc073d4dbaf0602185528 Mon Sep 17 00:00:00 2001 From: ToxicKidz Date: Mon, 5 Jul 2021 17:26:11 -0400 Subject: chore: Add the codejam create command This command takes a CSV file or a link to one. This CSV file has three rows: 'Team Name', 'Team Member Discord ID', and 'Team Leader'. The Team Name will be the name of the team's channel, the Member ID tells which user belongs to this team, and leam leader, which is either Y/N, tells if this user is the team leader. It will create text channels for each team and make a team leaders chat channel as well. It will ping the Events Lead role with updates for this command. --- bot/constants.py | 6 +- bot/exts/utils/jams.py | 171 +++++++++++++++++++++++++++++-------------------- config-default.yml | 5 +- 3 files changed, 111 insertions(+), 71 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 885b5c822..f33c14798 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -400,6 +400,8 @@ class Categories(metaclass=YAMLGetter): modmail: int voice: int + # 2021 Summer Code Jam + summer_code_jam: int class Channels(metaclass=YAMLGetter): section = "guild" @@ -437,6 +439,7 @@ class Channels(metaclass=YAMLGetter): discord_py: int esoteric: int voice_gate: int + code_jam_planning: int admins: int admin_spam: int @@ -495,8 +498,10 @@ class Roles(metaclass=YAMLGetter): admins: int core_developers: int + code_jam_event_team: int devops: int domain_leads: int + events_lead: int helpers: int moderators: int mod_team: int @@ -504,7 +509,6 @@ class Roles(metaclass=YAMLGetter): project_leads: int jammers: int - team_leaders: int class Guild(metaclass=YAMLGetter): diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py index 98fbcb303..d45f9b57f 100644 --- a/bot/exts/utils/jams.py +++ b/bot/exts/utils/jams.py @@ -1,17 +1,19 @@ +import csv import logging import typing as t +from collections import defaultdict -from discord import CategoryChannel, Guild, Member, PermissionOverwrite, Role +import discord from discord.ext import commands -from more_itertools import unique_everseen from bot.bot import Bot -from bot.constants import Roles +from bot.constants import Categories, Channels, Emojis, Roles log = logging.getLogger(__name__) MAX_CHANNELS = 50 CATEGORY_NAME = "Code Jam" +TEAM_LEADERS_COLOUR = 0x11806a class CodeJams(commands.Cog): @@ -20,39 +22,57 @@ class CodeJams(commands.Cog): def __init__(self, bot: Bot): self.bot = bot - @commands.command() + @commands.group() @commands.has_any_role(Roles.admins) - async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None: + async def codejam(self, ctx: commands.Context) -> None: + """A Group of commands for managing Code Jams.""" + if ctx.invoked_subcommand is None: + await ctx.send_help(ctx.command) + + @codejam.command() + async def create(self, ctx: commands.Context, csv_file: t.Optional[str]) -> None: """ - Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team. + 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'. - The first user passed will always be the team leader. + This will create the text channels for the teams, and give the team leaders their roles. """ - # Ignore duplicate members - members = list(unique_everseen(members)) - - # We had a little issue during Code Jam 4 here, the greedy converter did it's job - # and ignored anything which wasn't a valid argument which left us with teams of - # two members or at some times even 1 member. This fixes that by checking that there - # are always 3 members in the members list. - if len(members) < 3: - await ctx.send( - ":no_entry_sign: One of your arguments was invalid\n" - f"There must be a minimum of 3 valid members in your team. Found: {len(members)}" - " members" - ) - return + 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 - team_channel = await self.create_channels(ctx.guild, team_name, members) - await self.add_roles(ctx.guild, members) + csv_file = await response.text() - await ctx.send( - f":ok_hand: Team created: {team_channel}\n" - f"**Team Leader:** {members[0].mention}\n" - f"**Team Members:** {' '.join(member.mention for member in members[1:])}" - ) + 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']}") + continue + + teams[row["Team Name"]].append((member, row["Team Leader"].upper() == "Y")) + + team_leaders = await ctx.guild.create_role(name="Team Leaders", colour=TEAM_LEADERS_COLOUR) + + for team_name, members in teams.items(): + await self.create_team_channel(ctx.guild, team_name, members, team_leaders) - async def get_category(self, guild: Guild) -> CategoryChannel: + await self.create_team_leader_channel(ctx.guild, team_leaders) + await ctx.send(f"{Emojis.check_mark} Created Code Jam with {len(teams)} teams.") + + async def get_category(self, guild: discord.Guild) -> discord.CategoryChannel: """ Return a code jam category. @@ -60,84 +80,97 @@ class CodeJams(commands.Cog): """ for category in guild.categories: # Need 2 available spaces: one for the text channel and one for voice. - if category.name == CATEGORY_NAME and MAX_CHANNELS - len(category.channels) >= 2: + if category.name == CATEGORY_NAME and len(category.channels) < MAX_CHANNELS: return category return await self.create_category(guild) - @staticmethod - async def create_category(guild: Guild) -> CategoryChannel: + async def create_category(self, 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: PermissionOverwrite(read_messages=False), - guild.me: PermissionOverwrite(read_messages=True) + guild.default_role: discord.PermissionOverwrite(read_messages=False), + guild.me: discord.PermissionOverwrite(read_messages=True) } - return await guild.create_category_channel( + category = await guild.create_category_channel( CATEGORY_NAME, overwrites=category_overwrites, reason="It's code jam time!" ) + await self.send_status_update( + guild, f"Created a new category with the ID {category.id} for this Code Jam's team channels." + ) + + return category + @staticmethod - def get_overwrites(members: t.List[Member], guild: Guild) -> t.Dict[t.Union[Member, Role], PermissionOverwrite]: + 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.""" - # First member is always the team leader team_channel_overwrites = { - members[0]: PermissionOverwrite( - manage_messages=True, - read_messages=True, - manage_webhooks=True, - connect=True - ), - guild.default_role: PermissionOverwrite(read_messages=False, connect=False), + guild.default_role: discord.PermissionOverwrite(read_messages=False), + guild.get_role(Roles.moderators): discord.PermissionOverwrite(read_messages=True), + guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite(read_messages=True) } - # Rest of members should just have read_messages - for member in members[1:]: - team_channel_overwrites[member] = PermissionOverwrite( - read_messages=True, - connect=True + for member, _ in members: + team_channel_overwrites[member] = discord.PermissionOverwrite( + read_messages=True ) return team_channel_overwrites - async def create_channels(self, guild: Guild, team_name: str, members: t.List[Member]) -> str: - """Create team text and voice channels. Return the mention for the text channel.""" + async def create_team_channel( + self, + guild: discord.Guild, + team_name: str, + members: list[tuple[discord.Member, bool]], + team_leaders: discord.Role + ) -> None: + """Create the team's text channel.""" + await self.add_team_leader_roles(members, team_leaders) + # Get permission overwrites and category team_channel_overwrites = self.get_overwrites(members, guild) code_jam_category = await self.get_category(guild) # Create a text channel for the team - team_channel = await guild.create_text_channel( + await code_jam_category.create_text_channel( team_name, overwrites=team_channel_overwrites, - category=code_jam_category ) - # Create a voice channel for the team - team_voice_name = " ".join(team_name.split("-")).title() + async def create_team_leader_channel(self, 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) - await guild.create_voice_channel( - team_voice_name, - overwrites=team_channel_overwrites, - category=code_jam_category + 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) + } ) - return team_channel.mention + await self.send_status_update(guild, f"Created {team_leaders_chat.mention} in the {category} category.") + + async def send_status_update(self, 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}") @staticmethod - async def add_roles(guild: Guild, members: t.List[Member]) -> None: - """Assign team leader and jammer roles.""" - # Assign team leader role - await members[0].add_roles(guild.get_role(Roles.team_leaders)) - - # Assign rest of roles - jammer_role = guild.get_role(Roles.jammers) - for member in members: - await member.add_roles(jammer_role) + async def add_team_leader_roles(members: list[tuple[discord.Member, bool]], team_leaders: discord.Role) -> None: + """Assign team leader role, the jammer role and their team role.""" + for member, is_leader in members: + if is_leader: + await member.add_roles(team_leaders) def setup(bot: Bot) -> None: diff --git a/config-default.yml b/config-default.yml index 394c51c26..8c30ecf69 100644 --- a/config-default.yml +++ b/config-default.yml @@ -142,6 +142,7 @@ guild: moderators: &MODS_CATEGORY 749736277464842262 modmail: &MODMAIL 714494672835444826 voice: 356013253765234688 + summer_code_jam: 861692638540857384 channels: # Public announcement and news channels @@ -185,6 +186,7 @@ guild: bot_commands: &BOT_CMD 267659945086812160 esoteric: 470884583684964352 voice_gate: 764802555427029012 + code_jam_planning: 490217981872177157 # Staff admins: &ADMINS 365960823622991872 @@ -258,8 +260,10 @@ guild: # Staff admins: &ADMINS_ROLE 267628507062992896 core_developers: 587606783669829632 + code_jam_event_team: 787816728474288181 devops: 409416496733880320 domain_leads: 807415650778742785 + events_lead: 778361735739998228 helpers: &HELPERS_ROLE 267630620367257601 moderators: &MODS_ROLE 831776746206265384 mod_team: &MOD_TEAM_ROLE 267629731250176001 @@ -268,7 +272,6 @@ guild: # Code Jam jammers: 737249140966162473 - team_leaders: 737250302834638889 # Streaming video: 764245844798079016 -- cgit v1.2.3 From 2a5a15f69d8ea3079f60e0e5d44387bc59061de5 Mon Sep 17 00:00:00 2001 From: ToxicKidz Date: Mon, 5 Jul 2021 19:48:29 -0400 Subject: chore: Update tests for the new codejam create command --- bot/exts/utils/jams.py | 1 - tests/bot/exts/utils/test_jams.py | 137 +++++++++++++++++++------------------- tests/helpers.py | 22 ++++++ 3 files changed, 92 insertions(+), 68 deletions(-) diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py index d45f9b57f..0fc84c2eb 100644 --- a/bot/exts/utils/jams.py +++ b/bot/exts/utils/jams.py @@ -79,7 +79,6 @@ class CodeJams(commands.Cog): If all categories are full or none exist, create a new category. """ for category in guild.categories: - # Need 2 available spaces: one for the text channel and one for voice. if category.name == CATEGORY_NAME and len(category.channels) < MAX_CHANNELS: return category diff --git a/tests/bot/exts/utils/test_jams.py b/tests/bot/exts/utils/test_jams.py index 85d6a1173..368a15476 100644 --- a/tests/bot/exts/utils/test_jams.py +++ b/tests/bot/exts/utils/test_jams.py @@ -2,10 +2,24 @@ import unittest from unittest.mock import AsyncMock, MagicMock, create_autospec from discord import CategoryChannel +from discord.ext.commands import BadArgument from bot.constants import Roles from bot.exts.utils import jams -from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel +from tests.helpers import ( + MockAttachment, MockBot, MockCategoryChannel, MockContext, + MockGuild, MockMember, MockRole, MockTextChannel +) + +TEST_CSV = b"""\ +Team Name,Team Member Discord ID,Team Leader +Annoyed Alligators,12345,Y +Annoyed Alligators,54321,N +Oscillating Otters,12358,Y +Oscillating Otters,74832,N +Oscillating Otters,19903,N +Annoyed Alligators,11111,N +""" def get_mock_category(channel_count: int, name: str) -> CategoryChannel: @@ -17,8 +31,8 @@ def get_mock_category(channel_count: int, name: str) -> CategoryChannel: return category -class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): - """Tests for `createteam` command.""" +class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): + """Tests for `codejam create` command.""" def setUp(self): self.bot = MockBot() @@ -28,60 +42,64 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) self.cog = jams.CodeJams(self.bot) - async def test_too_small_amount_of_team_members_passed(self): - """Should `ctx.send` and exit early when too small amount of members.""" - for case in (1, 2): - with self.subTest(amount_of_members=case): - self.cog.create_channels = AsyncMock() - self.cog.add_roles = AsyncMock() + async def test_message_without_attachments(self): + """If no link or attachments are provided, commands.BadArgument should be raised.""" + self.ctx.message.attachments = [] - self.ctx.reset_mock() - members = (MockMember() for _ in range(case)) - await self.cog.createteam(self.cog, self.ctx, "foo", members) + with self.assertRaises(BadArgument): + await self.cog.create(self.cog, self.ctx, None) - self.ctx.send.assert_awaited_once() - self.cog.create_channels.assert_not_awaited() - self.cog.add_roles.assert_not_awaited() + async def test_result_sending(self): + """Should call `ctx.send` when everything goes right.""" + self.ctx.message.attachments = [MockAttachment()] + self.ctx.message.attachments[0].read = AsyncMock() + self.ctx.message.attachments[0].read.return_value = TEST_CSV + + team_leaders = MockRole() + + self.guild.get_member.return_value = MockMember() - async def test_duplicate_members_provided(self): - """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member.""" - self.cog.create_channels = AsyncMock() + self.ctx.guild.create_role = AsyncMock() + self.ctx.guild.create_role.return_value = team_leaders + self.cog.create_team_channel = AsyncMock() + self.cog.create_team_leader_channel = AsyncMock() self.cog.add_roles = AsyncMock() - member = MockMember() - await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5))) + await self.cog.create(self.cog, self.ctx, None) + self.cog.create_team_channel.assert_awaited() + self.cog.create_team_leader_channel.assert_awaited_once_with( + self.ctx.guild, team_leaders + ) self.ctx.send.assert_awaited_once() - self.cog.create_channels.assert_not_awaited() - self.cog.add_roles.assert_not_awaited() - - async def test_result_sending(self): - """Should call `ctx.send` when everything goes right.""" - self.cog.create_channels = AsyncMock() - self.cog.add_roles = AsyncMock() - members = [MockMember() for _ in range(5)] - await self.cog.createteam(self.cog, self.ctx, "foo", members) + async def test_link_returning_non_200_status(self): + """When the URL passed returns a non 200 status, it should send a message informing them.""" + self.bot.http_session.get.return_value = mock = MagicMock() + mock.status = 404 + await self.cog.create(self.cog, self.ctx, "https://not-a-real-link.com") - self.cog.create_channels.assert_awaited_once() - self.cog.add_roles.assert_awaited_once() self.ctx.send.assert_awaited_once() async def test_category_doesnt_exist(self): """Should create a new code jam category.""" subtests = ( [], - [get_mock_category(jams.MAX_CHANNELS - 1, jams.CATEGORY_NAME)], + [get_mock_category(jams.MAX_CHANNELS, jams.CATEGORY_NAME)], [get_mock_category(jams.MAX_CHANNELS - 2, "other")], ) + self.cog.send_status_update = AsyncMock() + for categories in subtests: + self.cog.send_status_update.reset_mock() self.guild.reset_mock() self.guild.categories = categories with self.subTest(categories=categories): actual_category = await self.cog.get_category(self.guild) + self.cog.send_status_update.assert_called_once() self.guild.create_category_channel.assert_awaited_once() category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] @@ -103,62 +121,47 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): async def test_channel_overwrites(self): """Should have correct permission overwrites for users and roles.""" - leader = MockMember() - members = [leader] + [MockMember() for _ in range(4)] + leader = (MockMember(), True) + members = [leader] + [(MockMember(), False) for _ in range(4)] overwrites = self.cog.get_overwrites(members, self.guild) - # Leader permission overwrites - self.assertTrue(overwrites[leader].manage_messages) - self.assertTrue(overwrites[leader].read_messages) - self.assertTrue(overwrites[leader].manage_webhooks) - self.assertTrue(overwrites[leader].connect) - - # Other members permission overwrites - for member in members[1:]: + for member, _ in members: self.assertTrue(overwrites[member].read_messages) - self.assertTrue(overwrites[member].connect) - - # Everyone role overwrite - self.assertFalse(overwrites[self.guild.default_role].read_messages) - self.assertFalse(overwrites[self.guild.default_role].connect) async def test_team_channels_creation(self): - """Should create new voice and text channel for team.""" - members = [MockMember() for _ in range(5)] + """Should create a text channel for a team.""" + team_leaders = MockRole() + members = [(MockMember(), True)] + [(MockMember(), False) for _ in range(5)] + category = MockCategoryChannel() + category.create_text_channel = AsyncMock() self.cog.get_overwrites = MagicMock() self.cog.get_category = AsyncMock() - self.ctx.guild.create_text_channel.return_value = MockTextChannel(mention="foobar-channel") - actual = await self.cog.create_channels(self.guild, "my-team", members) + self.cog.get_category.return_value = category + self.cog.add_team_leader_roles = AsyncMock() - self.assertEqual("foobar-channel", actual) + await self.cog.create_team_channel(self.guild, "my-team", members, team_leaders) + self.cog.add_team_leader_roles.assert_awaited_once_with(members, team_leaders) self.cog.get_overwrites.assert_called_once_with(members, self.guild) self.cog.get_category.assert_awaited_once_with(self.guild) - self.guild.create_text_channel.assert_awaited_once_with( + category.create_text_channel.assert_awaited_once_with( "my-team", - overwrites=self.cog.get_overwrites.return_value, - category=self.cog.get_category.return_value - ) - self.guild.create_voice_channel.assert_awaited_once_with( - "My Team", - overwrites=self.cog.get_overwrites.return_value, - category=self.cog.get_category.return_value + overwrites=self.cog.get_overwrites.return_value ) async def test_jam_roles_adding(self): """Should add team leader role to leader and jam role to every team member.""" leader_role = MockRole(name="Team Leader") - jam_role = MockRole(name="Jammer") - self.guild.get_role.side_effect = [leader_role, jam_role] leader = MockMember() - members = [leader] + [MockMember() for _ in range(4)] - await self.cog.add_roles(self.guild, members) + members = [(leader, True)] + [(MockMember(), False) for _ in range(4)] + await self.cog.add_team_leader_roles(members, leader_role) - leader.add_roles.assert_any_await(leader_role) - for member in members: - member.add_roles.assert_any_await(jam_role) + leader.add_roles.assert_awaited_once_with(leader_role) + for member, is_leader in members: + if not is_leader: + member.add_roles.assert_not_awaited() class CodeJamSetup(unittest.TestCase): diff --git a/tests/helpers.py b/tests/helpers.py index e3dc5fe5b..eedd7a601 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -361,6 +361,27 @@ class MockDMChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): super().__init__(**collections.ChainMap(kwargs, default_kwargs)) +# Create CategoryChannel instance to get a realistic MagicMock of `discord.CategoryChannel` +category_channel_data = { + 'id': 1, + 'type': discord.ChannelType.category, + 'name': 'category', + 'position': 1, +} + +state = unittest.mock.MagicMock() +guild = unittest.mock.MagicMock() +category_channel_instance = discord.CategoryChannel( + state=state, guild=guild, data=category_channel_data +) + + +class MockCategoryChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): + def __init__(self, **kwargs) -> None: + default_kwargs = {'id': next(self.discord_id)} + super().__init__(**collections.ChainMap(default_kwargs, kwargs)) + + # Create a Message instance to get a realistic MagicMock of `discord.Message` message_data = { 'id': 1, @@ -403,6 +424,7 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock): self.guild = kwargs.get('guild', MockGuild()) self.author = kwargs.get('author', MockMember()) self.channel = kwargs.get('channel', MockTextChannel()) + self.message = kwargs.get('message', MockMessage()) self.invoked_from_error_handler = kwargs.get('invoked_from_error_handler', False) -- cgit v1.2.3 From 37f8637955350c6c5b437d66807f42d8f26adfcd Mon Sep 17 00:00:00 2001 From: ToxicKidz Date: Mon, 5 Jul 2021 19:58:04 -0400 Subject: chore: Remove the moderators role from the team channels' overwrites --- bot/exts/utils/jams.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py index 0fc84c2eb..b2f7dab04 100644 --- a/bot/exts/utils/jams.py +++ b/bot/exts/utils/jams.py @@ -113,7 +113,6 @@ class CodeJams(commands.Cog): """Get code jam team channels permission overwrites.""" team_channel_overwrites = { guild.default_role: discord.PermissionOverwrite(read_messages=False), - guild.get_role(Roles.moderators): discord.PermissionOverwrite(read_messages=True), guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite(read_messages=True) } -- cgit v1.2.3 From 1905f7cf62370f98ca4e484b54cf912353856a35 Mon Sep 17 00:00:00 2001 From: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> Date: Mon, 5 Jul 2021 20:00:59 -0400 Subject: chore: Change the `Code Jam Team Leader` role's name Co-authored-by: Boris Muratov <8bee278@gmail.com> --- bot/exts/utils/jams.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py index b2f7dab04..87ae847f6 100644 --- a/bot/exts/utils/jams.py +++ b/bot/exts/utils/jams.py @@ -64,7 +64,7 @@ class CodeJams(commands.Cog): teams[row["Team Name"]].append((member, row["Team Leader"].upper() == "Y")) - team_leaders = await ctx.guild.create_role(name="Team Leaders", colour=TEAM_LEADERS_COLOUR) + team_leaders = await ctx.guild.create_role(name="Code Jam Team Leaders", colour=TEAM_LEADERS_COLOUR) for team_name, members in teams.items(): await self.create_team_channel(ctx.guild, team_name, members, team_leaders) -- cgit v1.2.3