From fb9cbe434fc4531d117e6b8bdbd778dc4e9803a5 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Wed, 7 Jul 2021 22:24:21 +0300 Subject: Create events ext, prepare jams cog for file split --- bot/exts/events/__init__.py | 0 bot/exts/events/code_jams/__init__.py | 8 ++ bot/exts/events/code_jams/_cog.py | 176 ++++++++++++++++++++++++++++++++ bot/exts/filters/antimalware.py | 2 +- bot/exts/filters/antispam.py | 2 +- bot/exts/filters/filtering.py | 2 +- bot/exts/utils/jams.py | 176 -------------------------------- tests/bot/exts/events/__init__.py | 0 tests/bot/exts/events/test_code_jams.py | 174 +++++++++++++++++++++++++++++++ tests/bot/exts/utils/test_jams.py | 174 ------------------------------- 10 files changed, 361 insertions(+), 353 deletions(-) create mode 100644 bot/exts/events/__init__.py create mode 100644 bot/exts/events/code_jams/__init__.py create mode 100644 bot/exts/events/code_jams/_cog.py delete mode 100644 bot/exts/utils/jams.py create mode 100644 tests/bot/exts/events/__init__.py create mode 100644 tests/bot/exts/events/test_code_jams.py delete mode 100644 tests/bot/exts/utils/test_jams.py diff --git a/bot/exts/events/__init__.py b/bot/exts/events/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/exts/events/code_jams/__init__.py b/bot/exts/events/code_jams/__init__.py new file mode 100644 index 000000000..16e81e365 --- /dev/null +++ b/bot/exts/events/code_jams/__init__.py @@ -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)) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py new file mode 100644 index 000000000..87ae847f6 --- /dev/null +++ b/bot/exts/events/code_jams/_cog.py @@ -0,0 +1,176 @@ +import csv +import logging +import typing as t +from collections import defaultdict + +import discord +from discord.ext import commands + +from bot.bot import Bot +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): + """Manages the code-jam related parts of our server.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.group() + @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: + await ctx.send_help(ctx.command) + + @codejam.command() + async def create(self, ctx: commands.Context, csv_file: t.Optional[str]) -> 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']}") + 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 self.create_team_channel(ctx.guild, team_name, members, team_leaders) + + 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. + + 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: + return category + + return await self.create_category(guild) + + 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: 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 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: 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( + 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 + await code_jam_category.create_text_channel( + team_name, + overwrites=team_channel_overwrites, + ) + + 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) + + 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 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_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: + """Load the CodeJams cog.""" + bot.add_cog(CodeJams(bot)) diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index 4c4836c88..3f6213db3 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/antimalware.py @@ -7,7 +7,7 @@ from discord.ext.commands import Cog from bot.bot import Bot from bot.constants import Channels, Filter, URLs -from bot.exts.utils.jams import CATEGORY_NAME as JAM_CATEGORY_NAME +from bot.exts.events.code_jams._cog import CATEGORY_NAME as JAM_CATEGORY_NAME log = logging.getLogger(__name__) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 48c3aa5a6..124905cb4 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -17,8 +17,8 @@ from bot.constants import ( Guild as GuildConfig, Icons, ) from bot.converters import Duration +from bot.exts.events.code_jams._cog import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.exts.moderation.modlog import ModLog -from bot.exts.utils.jams import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.utils import lock, scheduling from bot.utils.messages import format_user, send_attachments diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 16aaf11cf..0810425e2 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -19,8 +19,8 @@ from bot.constants import ( Channels, Colours, Filter, Guild, Icons, URLs ) +from bot.exts.events.code_jams._cog import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.exts.moderation.modlog import ModLog -from bot.exts.utils.jams import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.utils.messages import format_user from bot.utils.regex import INVITE_RE from bot.utils.scheduling import Scheduler diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py deleted file mode 100644 index 87ae847f6..000000000 --- a/bot/exts/utils/jams.py +++ /dev/null @@ -1,176 +0,0 @@ -import csv -import logging -import typing as t -from collections import defaultdict - -import discord -from discord.ext import commands - -from bot.bot import Bot -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): - """Manages the code-jam related parts of our server.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @commands.group() - @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: - await ctx.send_help(ctx.command) - - @codejam.command() - async def create(self, ctx: commands.Context, csv_file: t.Optional[str]) -> 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']}") - 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 self.create_team_channel(ctx.guild, team_name, members, team_leaders) - - 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. - - 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: - return category - - return await self.create_category(guild) - - 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: 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 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: 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( - 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 - await code_jam_category.create_text_channel( - team_name, - overwrites=team_channel_overwrites, - ) - - 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) - - 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 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_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: - """Load the CodeJams cog.""" - bot.add_cog(CodeJams(bot)) diff --git a/tests/bot/exts/events/__init__.py b/tests/bot/exts/events/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/exts/events/test_code_jams.py b/tests/bot/exts/events/test_code_jams.py new file mode 100644 index 000000000..d7b8aa4d2 --- /dev/null +++ b/tests/bot/exts/events/test_code_jams.py @@ -0,0 +1,174 @@ +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.events.code_jams import _cog +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: + """Return a mocked code jam category.""" + category = create_autospec(CategoryChannel, spec_set=True, instance=True) + category.name = name + category.channels = [MockTextChannel() for _ in range(channel_count)] + + return category + + +class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): + """Tests for `codejam create` command.""" + + def setUp(self): + self.bot = MockBot() + self.admin_role = MockRole(name="Admins", id=Roles.admins) + self.command_user = MockMember([self.admin_role]) + self.guild = MockGuild([self.admin_role]) + self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) + self.cog = _cog.CodeJams(self.bot) + + async def test_message_without_attachments(self): + """If no link or attachments are provided, commands.BadArgument should be raised.""" + self.ctx.message.attachments = [] + + with self.assertRaises(BadArgument): + await self.cog.create(self.cog, self.ctx, None) + + 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() + + 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() + + 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() + + 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.ctx.send.assert_awaited_once() + + async def test_category_doesnt_exist(self): + """Should create a new code jam category.""" + subtests = ( + [], + [get_mock_category(_cog.MAX_CHANNELS, _cog.CATEGORY_NAME)], + [get_mock_category(_cog.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"] + + self.assertFalse(category_overwrites[self.guild.default_role].read_messages) + self.assertTrue(category_overwrites[self.guild.me].read_messages) + self.assertEqual(self.guild.create_category_channel.return_value, actual_category) + + async def test_category_channel_exist(self): + """Should not try to create category channel.""" + expected_category = get_mock_category(_cog.MAX_CHANNELS - 2, _cog.CATEGORY_NAME) + self.guild.categories = [ + get_mock_category(_cog.MAX_CHANNELS - 2, "other"), + expected_category, + get_mock_category(0, _cog.CATEGORY_NAME), + ] + + actual_category = await self.cog.get_category(self.guild) + self.assertEqual(expected_category, actual_category) + + async def test_channel_overwrites(self): + """Should have correct permission overwrites for users and roles.""" + leader = (MockMember(), True) + members = [leader] + [(MockMember(), False) for _ in range(4)] + overwrites = self.cog.get_overwrites(members, self.guild) + + for member, _ in members: + self.assertTrue(overwrites[member].read_messages) + + async def test_team_channels_creation(self): + """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.cog.get_category.return_value = category + self.cog.add_team_leader_roles = AsyncMock() + + 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) + + category.create_text_channel.assert_awaited_once_with( + "my-team", + 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") + + leader = MockMember() + members = [(leader, True)] + [(MockMember(), False) for _ in range(4)] + await self.cog.add_team_leader_roles(members, leader_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): + """Test for `setup` function of `CodeJam` cog.""" + + def test_setup(self): + """Should call `bot.add_cog`.""" + bot = MockBot() + _cog.setup(bot) + bot.add_cog.assert_called_once() diff --git a/tests/bot/exts/utils/test_jams.py b/tests/bot/exts/utils/test_jams.py deleted file mode 100644 index 368a15476..000000000 --- a/tests/bot/exts/utils/test_jams.py +++ /dev/null @@ -1,174 +0,0 @@ -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 ( - 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: - """Return a mocked code jam category.""" - category = create_autospec(CategoryChannel, spec_set=True, instance=True) - category.name = name - category.channels = [MockTextChannel() for _ in range(channel_count)] - - return category - - -class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): - """Tests for `codejam create` command.""" - - def setUp(self): - self.bot = MockBot() - self.admin_role = MockRole(name="Admins", id=Roles.admins) - self.command_user = MockMember([self.admin_role]) - self.guild = MockGuild([self.admin_role]) - self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) - self.cog = jams.CodeJams(self.bot) - - async def test_message_without_attachments(self): - """If no link or attachments are provided, commands.BadArgument should be raised.""" - self.ctx.message.attachments = [] - - with self.assertRaises(BadArgument): - await self.cog.create(self.cog, self.ctx, None) - - 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() - - 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() - - 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() - - 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.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, 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"] - - self.assertFalse(category_overwrites[self.guild.default_role].read_messages) - self.assertTrue(category_overwrites[self.guild.me].read_messages) - self.assertEqual(self.guild.create_category_channel.return_value, actual_category) - - async def test_category_channel_exist(self): - """Should not try to create category channel.""" - expected_category = get_mock_category(jams.MAX_CHANNELS - 2, jams.CATEGORY_NAME) - self.guild.categories = [ - get_mock_category(jams.MAX_CHANNELS - 2, "other"), - expected_category, - get_mock_category(0, jams.CATEGORY_NAME), - ] - - actual_category = await self.cog.get_category(self.guild) - self.assertEqual(expected_category, actual_category) - - async def test_channel_overwrites(self): - """Should have correct permission overwrites for users and roles.""" - leader = (MockMember(), True) - members = [leader] + [(MockMember(), False) for _ in range(4)] - overwrites = self.cog.get_overwrites(members, self.guild) - - for member, _ in members: - self.assertTrue(overwrites[member].read_messages) - - async def test_team_channels_creation(self): - """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.cog.get_category.return_value = category - self.cog.add_team_leader_roles = AsyncMock() - - 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) - - category.create_text_channel.assert_awaited_once_with( - "my-team", - 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") - - leader = MockMember() - members = [(leader, True)] + [(MockMember(), False) for _ in range(4)] - await self.cog.add_team_leader_roles(members, leader_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): - """Test for `setup` function of `CodeJam` cog.""" - - def test_setup(self): - """Should call `bot.add_cog`.""" - bot = MockBot() - jams.setup(bot) - bot.add_cog.assert_called_once() -- cgit v1.2.3 From 698660004b13273371baefa1f41ce2f908a3431f Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 8 Jul 2021 02:16:33 +0300 Subject: Move jam channels creation to separate file The channel creations are static and clutter the cog class. We want to add more commands to the cog, so we move the static functions away to a separate file first. --- bot/exts/events/code_jams/_channels.py | 113 ++++++++++++++++++++++++++++++++ bot/exts/events/code_jams/_cog.py | 113 ++------------------------------ bot/exts/filters/antimalware.py | 2 +- bot/exts/filters/antispam.py | 2 +- bot/exts/filters/filtering.py | 2 +- tests/bot/exts/events/test_code_jams.py | 64 +++++++++--------- 6 files changed, 150 insertions(+), 146 deletions(-) create mode 100644 bot/exts/events/code_jams/_channels.py diff --git a/bot/exts/events/code_jams/_channels.py b/bot/exts/events/code_jams/_channels.py new file mode 100644 index 000000000..8b199a3c2 --- /dev/null +++ b/bot/exts/events/code_jams/_channels.py @@ -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: + 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 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) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index 87ae847f6..2d0873de7 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -3,16 +3,14 @@ import logging import typing as t from collections import defaultdict -import discord from discord.ext import commands from bot.bot import Bot -from bot.constants import Categories, Channels, Emojis, Roles +from bot.constants import Emojis, Roles +from bot.exts.events.code_jams import _channels log = logging.getLogger(__name__) -MAX_CHANNELS = 50 -CATEGORY_NAME = "Code Jam" TEAM_LEADERS_COLOUR = 0x11806a @@ -67,110 +65,7 @@ class CodeJams(commands.Cog): 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) + await _channels.create_team_channel(ctx.guild, team_name, members, team_leaders) - await self.create_team_leader_channel(ctx.guild, 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.") - - async def get_category(self, 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: - return category - - return await self.create_category(guild) - - 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: 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 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: 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( - 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 - await code_jam_category.create_text_channel( - team_name, - overwrites=team_channel_overwrites, - ) - - 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) - - 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 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_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: - """Load the CodeJams cog.""" - bot.add_cog(CodeJams(bot)) diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index 3f6213db3..0eedeb0fb 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/antimalware.py @@ -7,7 +7,7 @@ from discord.ext.commands import Cog from bot.bot import Bot from bot.constants import Channels, Filter, URLs -from bot.exts.events.code_jams._cog import CATEGORY_NAME as JAM_CATEGORY_NAME +from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME log = logging.getLogger(__name__) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 124905cb4..1830e23b8 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -17,7 +17,7 @@ from bot.constants import ( Guild as GuildConfig, Icons, ) from bot.converters import Duration -from bot.exts.events.code_jams._cog import CATEGORY_NAME as JAM_CATEGORY_NAME +from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.exts.moderation.modlog import ModLog from bot.utils import lock, scheduling from bot.utils.messages import format_user, send_attachments diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 0810425e2..10cc7885d 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -19,7 +19,7 @@ from bot.constants import ( Channels, Colours, Filter, Guild, Icons, URLs ) -from bot.exts.events.code_jams._cog import CATEGORY_NAME as JAM_CATEGORY_NAME +from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.exts.moderation.modlog import ModLog from bot.utils.messages import format_user from bot.utils.regex import INVITE_RE diff --git a/tests/bot/exts/events/test_code_jams.py b/tests/bot/exts/events/test_code_jams.py index d7b8aa4d2..b9ee1e363 100644 --- a/tests/bot/exts/events/test_code_jams.py +++ b/tests/bot/exts/events/test_code_jams.py @@ -1,14 +1,15 @@ import unittest -from unittest.mock import AsyncMock, MagicMock, create_autospec +from unittest.mock import AsyncMock, MagicMock, create_autospec, patch from discord import CategoryChannel from discord.ext.commands import BadArgument from bot.constants import Roles -from bot.exts.events.code_jams import _cog +from bot.exts.events import code_jams +from bot.exts.events.code_jams import _channels, _cog from tests.helpers import ( MockAttachment, MockBot, MockCategoryChannel, MockContext, - MockGuild, MockMember, MockRole, MockTextChannel + MockGuild, MockMember, MockRole, MockTextChannel, autospec ) TEST_CSV = b"""\ @@ -49,7 +50,9 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): with self.assertRaises(BadArgument): await self.cog.create(self.cog, self.ctx, None) - async def test_result_sending(self): + @patch.object(_channels, "create_team_channel") + @patch.object(_channels, "create_team_leader_channel") + async def test_result_sending(self, create_leader_channel, create_team_channel): """Should call `ctx.send` when everything goes right.""" self.ctx.message.attachments = [MockAttachment()] self.ctx.message.attachments[0].read = AsyncMock() @@ -61,14 +64,12 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): 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() 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( + create_team_channel.assert_awaited() + create_leader_channel.assert_awaited_once_with( self.ctx.guild, team_leaders ) self.ctx.send.assert_awaited_once() @@ -81,25 +82,24 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): self.ctx.send.assert_awaited_once() - async def test_category_doesnt_exist(self): + @patch.object(_channels, "_send_status_update") + async def test_category_doesnt_exist(self, update): """Should create a new code jam category.""" subtests = ( [], - [get_mock_category(_cog.MAX_CHANNELS, _cog.CATEGORY_NAME)], - [get_mock_category(_cog.MAX_CHANNELS - 2, "other")], + [get_mock_category(_channels.MAX_CHANNELS, _channels.CATEGORY_NAME)], + [get_mock_category(_channels.MAX_CHANNELS - 2, "other")], ) - self.cog.send_status_update = AsyncMock() - for categories in subtests: - self.cog.send_status_update.reset_mock() + 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) + actual_category = await _channels._get_category(self.guild) - self.cog.send_status_update.assert_called_once() + update.assert_called_once() self.guild.create_category_channel.assert_awaited_once() category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] @@ -109,45 +109,41 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): async def test_category_channel_exist(self): """Should not try to create category channel.""" - expected_category = get_mock_category(_cog.MAX_CHANNELS - 2, _cog.CATEGORY_NAME) + expected_category = get_mock_category(_channels.MAX_CHANNELS - 2, _channels.CATEGORY_NAME) self.guild.categories = [ - get_mock_category(_cog.MAX_CHANNELS - 2, "other"), + get_mock_category(_channels.MAX_CHANNELS - 2, "other"), expected_category, - get_mock_category(0, _cog.CATEGORY_NAME), + get_mock_category(0, _channels.CATEGORY_NAME), ] - actual_category = await self.cog.get_category(self.guild) + actual_category = await _channels._get_category(self.guild) self.assertEqual(expected_category, actual_category) async def test_channel_overwrites(self): """Should have correct permission overwrites for users and roles.""" leader = (MockMember(), True) members = [leader] + [(MockMember(), False) for _ in range(4)] - overwrites = self.cog.get_overwrites(members, self.guild) + overwrites = _channels._get_overwrites(members, self.guild) for member, _ in members: self.assertTrue(overwrites[member].read_messages) - async def test_team_channels_creation(self): + @patch.object(_channels, "_get_overwrites") + @patch.object(_channels, "_get_category") + @autospec(_channels, "_add_team_leader_roles", pass_mocks=False) + async def test_team_channels_creation(self, get_category, get_overwrites): """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.cog.get_category.return_value = category - self.cog.add_team_leader_roles = AsyncMock() - - 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) + get_category.return_value = category + await _channels.create_team_channel(self.guild, "my-team", members, team_leaders) category.create_text_channel.assert_awaited_once_with( "my-team", - overwrites=self.cog.get_overwrites.return_value + overwrites=get_overwrites.return_value ) async def test_jam_roles_adding(self): @@ -156,7 +152,7 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): leader = MockMember() members = [(leader, True)] + [(MockMember(), False) for _ in range(4)] - await self.cog.add_team_leader_roles(members, leader_role) + await _channels._add_team_leader_roles(members, leader_role) leader.add_roles.assert_awaited_once_with(leader_role) for member, is_leader in members: @@ -170,5 +166,5 @@ class CodeJamSetup(unittest.TestCase): def test_setup(self): """Should call `bot.add_cog`.""" bot = MockBot() - _cog.setup(bot) + code_jams.setup(bot) bot.add_cog.assert_called_once() -- cgit v1.2.3 From d4271b0c1df1e7acbbf694c9f585bacc25edaecd Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Thu, 8 Jul 2021 22:09:12 +0300 Subject: More code jam functionality - An info embed with team the member is in. The team is decided by finding in which channel the member has overwrites. - Command to move a member from one team to another by changing the permissions of the appropriate team channels. - A command to end the code jam and delete all the team channels and categories. --- bot/exts/events/code_jams/_cog.py | 105 +++++++++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index 2d0873de7..39577a5c3 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -3,6 +3,8 @@ 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 @@ -20,7 +22,9 @@ class CodeJams(commands.Cog): def __init__(self, bot: Bot): self.bot = bot - @commands.group() + self.end_counter = 0 + + @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.""" @@ -69,3 +73,102 @@ class CodeJams(commands.Cog): 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: + """ + Call it three times while spinning around for it all to end. + + Deletes all code jam channels and wipes the cache. + """ + self.end_counter += 1 + if self.end_counter == 1: + await ctx.send("Are you sure about that?") + return + if self.end_counter == 2: + await ctx.send("Are you *really really* sure about that?") + return + + self.end_counter = 0 + + for category in self.jam_categories(ctx.guild): + for channel in category.channels: + await channel.delete(reason="Code jam ended.") + await category.delete(reason="Code jam ended.") + + await ctx.message.add_reaction(Emojis.check_mark) + + @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)}`." + ) + + @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() -- cgit v1.2.3 From bc390bcb66d060aeba29c835d6c5cceb3d366626 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 8 Jul 2021 23:14:47 +0300 Subject: Added command to remove from team --- bot/exts/events/code_jams/_cog.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index 39577a5c3..862e53a13 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -149,6 +149,18 @@ class CodeJams(commands.Cog): 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: + """Removes 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="Participant removed from the team.") + 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.""" -- cgit v1.2.3 From fdc6f2387ee9894c21f84fb41d9a909a52e42971 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 8 Jul 2021 23:23:34 +0300 Subject: Fix end command docstring --- bot/exts/events/code_jams/_cog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index 862e53a13..83e2e18ce 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -78,9 +78,9 @@ class CodeJams(commands.Cog): @commands.has_any_role(Roles.admins) async def end(self, ctx: commands.Context) -> None: """ - Call it three times while spinning around for it all to end. + Deletes all code jam channels. - Deletes all code jam channels and wipes the cache. + Call it three times while spinning around for it all to end. """ self.end_counter += 1 if self.end_counter == 1: -- cgit v1.2.3 From 19cd66fa1615ed8f220cef8dbff5f826c7d4670c Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 25 Jul 2021 00:39:22 +0300 Subject: Improved codejam end confirmation The command now sends the details of all channels about to be deleted to the pasting service, and confirmation is done through a reaction by the invoker of the command within a limited time (10 seconds). --- bot/exts/events/code_jams/_cog.py | 74 +++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 15 deletions(-) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index 83e2e18ce..d0c206b5e 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -1,3 +1,4 @@ +import asyncio import csv import logging import typing as t @@ -10,10 +11,12 @@ 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" class CodeJams(commands.Cog): @@ -22,8 +25,6 @@ class CodeJams(commands.Cog): def __init__(self, bot: Bot): self.bot = bot - self.end_counter = 0 - @commands.group(aliases=("cj", "jam")) @commands.has_any_role(Roles.admins) async def codejam(self, ctx: commands.Context) -> None: @@ -80,24 +81,67 @@ class CodeJams(commands.Cog): """ Deletes all code jam channels. - Call it three times while spinning around for it all to end. + Displays a confirmation message with the categories and channels to be deleted. Pressing the added reaction + deletes those channels. """ - self.end_counter += 1 - if self.end_counter == 1: - await ctx.send("Are you sure about that?") - return - if self.end_counter == 2: - await ctx.send("Are you *really really* sure about that?") + 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) return - self.end_counter = 0 + else: + 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.") - for category in self.jam_categories(ctx.guild): - for channel in category.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)}:" + "".join(f"\n - {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.**" - await ctx.message.add_reaction(Emojis.check_mark) + 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) -- cgit v1.2.3 From 35047316636fa1da6405954c3a199b4b3d4e0e44 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Aug 2021 12:54:13 -0700 Subject: CodeSnippets: refactor on_message Reduce nesting and code duplication. --- bot/exts/info/code_snippets.py | 73 ++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 35 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 24a9ae28a..9cef16b83 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -222,43 +222,46 @@ class CodeSnippets(Cog): @Cog.listener() async def on_message(self, message: Message) -> None: """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" - if not message.author.bot: - all_snippets = [] - - for pattern, handler in self.pattern_handlers: - for match in pattern.finditer(message.content): - try: - snippet = await handler(**match.groupdict()) - all_snippets.append((match.start(), snippet)) - except ClientResponseError as error: - error_message = error.message # noqa: B306 - log.log( - logging.DEBUG if error.status == 404 else logging.ERROR, - f'Failed to fetch code snippet from {match[0]!r}: {error.status} ' - f'{error_message} for GET {error.request_info.real_url.human_repr()}' - ) - - # Sorts the list of snippets by their match index and joins them into a single message - message_to_send = '\n'.join(map(lambda x: x[1], sorted(all_snippets))) - - if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 15: - await message.edit(suppress=True) - if len(message_to_send) > 1000 and message.channel.id != Channels.bot_commands: - # Redirects to #bot-commands if the snippet contents are too long - await self.bot.wait_until_guild_available() - await message.channel.send(('The snippet you tried to send was too long. Please ' - f'see <#{Channels.bot_commands}> for the full snippet.')) - bot_commands_channel = self.bot.get_channel(Channels.bot_commands) - await wait_for_deletion( - await bot_commands_channel.send(message_to_send), - (message.author.id,) - ) - else: - await wait_for_deletion( - await message.channel.send(message_to_send), - (message.author.id,) + if message.author.bot: + return + + all_snippets = [] + + for pattern, handler in self.pattern_handlers: + for match in pattern.finditer(message.content): + try: + snippet = await handler(**match.groupdict()) + all_snippets.append((match.start(), snippet)) + except ClientResponseError as error: + error_message = error.message # noqa: B306 + log.log( + logging.DEBUG if error.status == 404 else logging.ERROR, + f'Failed to fetch code snippet from {match[0]!r}: {error.status} ' + f'{error_message} for GET {error.request_info.real_url.human_repr()}' ) + # Sorts the list of snippets by their match index and joins them into a single message + message_to_send = '\n'.join(map(lambda x: x[1], sorted(all_snippets))) + destination = message.channel + + if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 15: + await message.edit(suppress=True) + + if len(message_to_send) > 1000 and message.channel.id != Channels.bot_commands: + # Redirects to #bot-commands if the snippet contents are too long + await self.bot.wait_until_guild_available() + destination = self.bot.get_channel(Channels.bot_commands) + + await message.channel.send( + 'The snippet you tried to send was too long. ' + f'Please see {destination.mention} for the full snippet.' + ) + + await wait_for_deletion( + await destination.send(message_to_send), + (message.author.id,) + ) + def setup(bot: Bot) -> None: """Load the CodeSnippets cog.""" -- cgit v1.2.3 From cfbeabb6a18b3ea6c19a81881139b3b389dd50ed Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Aug 2021 12:56:25 -0700 Subject: CodeSnippets: move __init__ to the top of the class definition --- bot/exts/info/code_snippets.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 9cef16b83..cb231350d 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -45,6 +45,17 @@ class CodeSnippets(Cog): Matches each message against a regex and prints the contents of all matched snippets. """ + def __init__(self, bot: Bot): + """Initializes the cog's bot.""" + self.bot = bot + + self.pattern_handlers = [ + (GITHUB_RE, self._fetch_github_snippet), + (GITHUB_GIST_RE, self._fetch_github_gist_snippet), + (GITLAB_RE, self._fetch_gitlab_snippet), + (BITBUCKET_RE, self._fetch_bitbucket_snippet) + ] + async def _fetch_response(self, url: str, response_format: str, **kwargs) -> Any: """Makes http requests using aiohttp.""" async with self.bot.http_session.get(url, raise_for_status=True, **kwargs) as response: @@ -208,17 +219,6 @@ class CodeSnippets(Cog): # Returns an empty codeblock if the snippet is empty return f'{ret}``` ```' - def __init__(self, bot: Bot): - """Initializes the cog's bot.""" - self.bot = bot - - self.pattern_handlers = [ - (GITHUB_RE, self._fetch_github_snippet), - (GITHUB_GIST_RE, self._fetch_github_gist_snippet), - (GITLAB_RE, self._fetch_gitlab_snippet), - (BITBUCKET_RE, self._fetch_bitbucket_snippet) - ] - @Cog.listener() async def on_message(self, message: Message) -> None: """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" -- cgit v1.2.3 From 47532644f3ebeb07fe9a47b7a51e6d4413ecd58c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Aug 2021 13:02:42 -0700 Subject: CodeSnippets: refactor snippet parsing into a separate function --- bot/exts/info/code_snippets.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index cb231350d..8c86b9a93 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -219,16 +219,12 @@ class CodeSnippets(Cog): # Returns an empty codeblock if the snippet is empty return f'{ret}``` ```' - @Cog.listener() - async def on_message(self, message: Message) -> None: - """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" - if message.author.bot: - return - + async def _parse_snippets(self, content: str) -> str: + """Parse message content and return a string with a code block for each URL found.""" all_snippets = [] for pattern, handler in self.pattern_handlers: - for match in pattern.finditer(message.content): + for match in pattern.finditer(content): try: snippet = await handler(**match.groupdict()) all_snippets.append((match.start(), snippet)) @@ -241,7 +237,15 @@ class CodeSnippets(Cog): ) # Sorts the list of snippets by their match index and joins them into a single message - message_to_send = '\n'.join(map(lambda x: x[1], sorted(all_snippets))) + return '\n'.join(map(lambda x: x[1], sorted(all_snippets))) + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" + if message.author.bot: + return + + message_to_send = await self._parse_snippets(message.content) destination = message.channel if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 15: -- cgit v1.2.3 From 900bf69a3178e1cdacfd7492a359d53f3b7db72a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Aug 2021 13:10:05 -0700 Subject: CodeSnippets: don't send snippets if the original message was deleted Fixes BOT-13B --- bot/exts/info/code_snippets.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 8c86b9a93..4a90a0668 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -4,8 +4,8 @@ import textwrap from typing import Any from urllib.parse import quote_plus +import discord from aiohttp import ClientResponseError -from discord import Message from discord.ext.commands import Cog from bot.bot import Bot @@ -240,7 +240,7 @@ class CodeSnippets(Cog): return '\n'.join(map(lambda x: x[1], sorted(all_snippets))) @Cog.listener() - async def on_message(self, message: Message) -> None: + async def on_message(self, message: discord.Message) -> None: """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" if message.author.bot: return @@ -249,7 +249,11 @@ class CodeSnippets(Cog): destination = message.channel if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 15: - await message.edit(suppress=True) + try: + await message.edit(suppress=True) + except discord.NotFound: + # Don't send snippets if the original message was deleted. + return if len(message_to_send) > 1000 and message.channel.id != Channels.bot_commands: # Redirects to #bot-commands if the snippet contents are too long -- cgit v1.2.3 From 1a952752de4b2d9d472f385d3598eb357be0abcd Mon Sep 17 00:00:00 2001 From: Ryu18 Date: Sat, 7 Aug 2021 00:52:52 +0200 Subject: added escape markdown in PythonNews --- bot/exts/info/python_news.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index a7837c93a..651a33d02 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -1,6 +1,7 @@ import logging import typing as t from datetime import date, datetime +import re import discord import feedparser @@ -72,6 +73,12 @@ class PythonNews(Cog): if mail["name"].split("@")[0] in constants.PythonNews.mail_lists: self.webhook_names[mail["name"].split("@")[0]] = mail["display_name"] + @staticmethod + def escape_markdown(content: str) -> str: + """Escape the markdown underlines""" + # taken from discord.utils.escape_markdown + return re.sub(r'[_\\~|]', lambda match: '\\' + match[0], content, 0, re.MULTILINE) + async def post_pep_news(self) -> None: """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" # Wait until everything is ready and http_session available @@ -103,7 +110,7 @@ class PythonNews(Cog): # Build an embed and send a webhook embed = discord.Embed( title=new["title"], - description=new["summary"], + description=self.escape_markdown(new["summary"]), timestamp=new_datetime, url=new["link"], colour=constants.Colours.soft_green @@ -167,7 +174,7 @@ class PythonNews(Cog): ): continue - content = email_information["content"] + content = self.escape_markdown(email_information["content"]) link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist) # Build an embed and send a message to the webhook -- cgit v1.2.3 From 93456475548ff883bd534ab5e0de62a29d9dc936 Mon Sep 17 00:00:00 2001 From: Ryu18 Date: Sat, 7 Aug 2021 01:00:29 +0200 Subject: fix linting issues --- bot/exts/info/python_news.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index 651a33d02..0b6e230b4 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -1,7 +1,7 @@ import logging +import re import typing as t from datetime import date, datetime -import re import discord import feedparser @@ -75,7 +75,7 @@ class PythonNews(Cog): @staticmethod def escape_markdown(content: str) -> str: - """Escape the markdown underlines""" + """Escape the markdown underlines.""" # taken from discord.utils.escape_markdown return re.sub(r'[_\\~|]', lambda match: '\\' + match[0], content, 0, re.MULTILINE) -- cgit v1.2.3 From 357c7181d8b0b11ab38ac63c96bf6667b55b0524 Mon Sep 17 00:00:00 2001 From: Ryu1845 <77058942+Ryu1845@users.noreply.github.com> Date: Sun, 8 Aug 2021 00:47:49 +0200 Subject: Removed comment The code is now almost completely different from discord.py. --- bot/exts/info/python_news.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index 0b6e230b4..8d7ffec88 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -76,7 +76,6 @@ class PythonNews(Cog): @staticmethod def escape_markdown(content: str) -> str: """Escape the markdown underlines.""" - # taken from discord.utils.escape_markdown return re.sub(r'[_\\~|]', lambda match: '\\' + match[0], content, 0, re.MULTILINE) async def post_pep_news(self) -> None: -- cgit v1.2.3 From 4ecbb2e821f9f0452fddb49161989f68f98dbd38 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 12 Aug 2021 12:38:56 +0100 Subject: fix: Nomination message now checks historic and new style nominations Previously nomination messages had role pings in them, now they don't as we moved them into a thread. Due to this, we need to detect both in the interim of historic nominations existing. A 'proper' fix for this is to store the nomination message IDs when we post them against the nomination object in the site api. We are planing to work on this soon, this commit is a short term fix. --- bot/exts/recruitment/talentpool/_review.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 74056dbf5..4d496a1f7 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -33,10 +33,12 @@ MAX_MESSAGE_SIZE = 2000 # Maximum amount of characters allowed in an embed MAX_EMBED_SIZE = 4000 -# Regex finding the user ID of a user mention -MENTION_RE = re.compile(r"<@!?(\d+?)>") -# Regex matching role pings -ROLE_MENTION_RE = re.compile(r"<@&\d+>") +# Regex for finding the first message of a nomination, and extracting the nominee. +# Historic nominations will have 2 role mentions at the start, new ones won't, optionally match for this. +NOMINATION_MESSAGE_REGEX = re.compile( + r"(?:<@&\d+> <@&\d+>\n)*?<@!?(\d+?)> \(.+#\d{4}\) for Helper!\n\n\*\*Nominated by:\*\*", + re.MULTILINE +) class Reviewer: @@ -142,14 +144,14 @@ class Reviewer: """Archive this vote to #nomination-archive.""" message = await message.fetch() - # We consider the first message in the nomination to contain the two role pings + # We consider the first message in the nomination to contain the user ping, username#discrim, and fixed text messages = [message] - if not len(ROLE_MENTION_RE.findall(message.content)) >= 2: + if not NOMINATION_MESSAGE_REGEX.search(message.content): with contextlib.suppress(NoMoreItems): async for new_message in message.channel.history(before=message.created_at): messages.append(new_message) - if len(ROLE_MENTION_RE.findall(new_message.content)) >= 2: + if NOMINATION_MESSAGE_REGEX.search(new_message.content): break log.debug(f"Found {len(messages)} messages: {', '.join(str(m.id) for m in messages)}") @@ -161,7 +163,7 @@ class Reviewer: content = "".join(parts) # We assume that the first user mentioned is the user that we are voting on - user_id = int(MENTION_RE.search(content).group(1)) + user_id = int(NOMINATION_MESSAGE_REGEX.search(content).group(1)) # Get reaction counts reviewed = await count_unique_users_reaction( -- cgit v1.2.3 From 94f03bcc5bc6b7264d0fc32c403b60bf7ec9ac20 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Sun, 15 Aug 2021 23:10:59 +0300 Subject: Add default value to csv_file Co-authored-by: Bluenix --- bot/exts/events/code_jams/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index d0c206b5e..78f375416 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -33,7 +33,7 @@ class CodeJams(commands.Cog): await ctx.send_help(ctx.command) @codejam.command() - async def create(self, ctx: commands.Context, csv_file: t.Optional[str]) -> None: + 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. -- cgit v1.2.3 From be2c547586e80924bf416785bc354bbc7c392cfd Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 15 Aug 2021 23:45:41 +0300 Subject: Docstring corrections Co-authored-by: Bluenix --- bot/exts/events/code_jams/_channels.py | 2 +- bot/exts/events/code_jams/_cog.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/events/code_jams/_channels.py b/bot/exts/events/code_jams/_channels.py index 8b199a3c2..34ff0ad41 100644 --- a/bot/exts/events/code_jams/_channels.py +++ b/bot/exts/events/code_jams/_channels.py @@ -107,7 +107,7 @@ async def _send_status_update(guild: discord.Guild, message: str) -> None: 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.""" + """Assign the team leader role to the team leaders.""" for member, is_leader in members: if is_leader: await member.add_roles(team_leaders) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index d0c206b5e..b2ea97c38 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -79,9 +79,9 @@ class CodeJams(commands.Cog): @commands.has_any_role(Roles.admins) async def end(self, ctx: commands.Context) -> None: """ - Deletes all code jam channels. + Delete all code jam channels. - Displays a confirmation message with the categories and channels to be deleted. Pressing the added reaction + 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: @@ -196,7 +196,7 @@ class CodeJams(commands.Cog): @codejam.command() @commands.has_any_role(Roles.admins) async def remove(self, ctx: commands.Context, member: Member) -> None: - """Removes the participant from their team. Does not remove the participants or leader roles.""" + """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.") -- cgit v1.2.3 From a5b761791f1162f2854775d87b00c687bf397a0f Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 15 Aug 2021 23:51:02 +0300 Subject: Add team name to audit log reason Interestingly enough, the reason doesn't seem to be displayed for channel permission overrides. --- bot/exts/events/code_jams/_cog.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index b2ea97c38..64f886f0a 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -202,7 +202,11 @@ class CodeJams(commands.Cog): await ctx.send(":x: I can't find the team channel for this member.") return - await channel.set_permissions(member, overwrite=None, reason="Participant removed from the team.") + 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 -- cgit v1.2.3 From 73187a35c7349c661e50f4a429ae30560ee92ddd Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 15 Aug 2021 23:57:23 +0300 Subject: Add `cj end` timeout message, improve style Co-authored-by: Bluenix --- bot/exts/events/code_jams/_cog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index 64f886f0a..e385f6441 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -109,6 +109,7 @@ class CodeJams(commands.Cog): except asyncio.TimeoutError: await message.clear_reaction(DELETION_REACTION) + await ctx.send("Command timed out.", reference=message) return else: @@ -131,7 +132,7 @@ class CodeJams(commands.Cog): 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)}:" + "".join(f"\n - {channel_repr(channel)}" for channel in channels) + 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() -- cgit v1.2.3 From 054d5da2af3e75a8bb29f7088272f8222f2d0b33 Mon Sep 17 00:00:00 2001 From: Ryu18 Date: Mon, 16 Aug 2021 00:53:58 +0200 Subject: apply changes for review https://github.com/python-discord/bot/pull/1725\#pullrequestreview-730223166 --- bot/exts/info/python_news.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index 8d7ffec88..045f9e6f9 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -76,7 +76,7 @@ class PythonNews(Cog): @staticmethod def escape_markdown(content: str) -> str: """Escape the markdown underlines.""" - return re.sub(r'[_\\~|]', lambda match: '\\' + match[0], content, 0, re.MULTILINE) + return re.sub(r"[_|]", lambda match: "\\" + match[0], content) async def post_pep_news(self) -> None: """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" -- cgit v1.2.3 From c5d26a9358099660d18f9409e8d5a9c3fe9fd344 Mon Sep 17 00:00:00 2001 From: Ryu18 Date: Mon, 16 Aug 2021 00:56:31 +0200 Subject: change docstring in escape markdown to reflect actual behavior --- bot/exts/info/python_news.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index 045f9e6f9..63eb4ac17 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -75,7 +75,7 @@ class PythonNews(Cog): @staticmethod def escape_markdown(content: str) -> str: - """Escape the markdown underlines.""" + """Escape the markdown underlines and spoilers.""" return re.sub(r"[_|]", lambda match: "\\" + match[0], content) async def post_pep_news(self) -> None: -- cgit v1.2.3 From 5f0d7f739109e0b4e6ae7146a81de7a3ce173492 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 16 Aug 2021 01:40:19 +0200 Subject: Enhance security in the Webhook remover. From now on, webhooks that were posted in the chat will be also deleted from Discord in order to eliminate the risk. --- bot/exts/filters/webhook_remover.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index f11fc8912..dc1799f88 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -13,8 +13,8 @@ WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\ ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted a Discord webhook URL. Therefore, your " - "message has been removed. Your webhook may have been **compromised** so " - "please re-create the webhook **immediately**. If you believe this was a " + "message has been removed, alongside with your webhook" + "you can re-create it if you wish to. If you believe this was a " "mistake, please let us know." ) @@ -32,7 +32,7 @@ class WebhookRemover(Cog): """Get current instance of `ModLog`.""" return self.bot.get_cog("ModLog") - async def delete_and_respond(self, msg: Message, redacted_url: str) -> None: + async def delete_and_respond(self, msg: Message, redacted_url: str, webhook_deleted: bool) -> None: """Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`.""" # Don't log this, due internal delete, not by user. Will make different entry. self.mod_log.ignore(Event.message_delete, msg.id) @@ -44,9 +44,12 @@ class WebhookRemover(Cog): return await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention)) - + if webhook_deleted: + delete_state = "The webhook was successfully deleted." + else: + delete_state = "There was an error when deleting the webhook, it might have already been removed." message = ( - f"{format_user(msg.author)} posted a Discord webhook URL to {msg.channel.mention}. " + f"{format_user(msg.author)} posted a Discord webhook URL to {msg.channel.mention}.{delete_state} " f"Webhook URL was `{redacted_url}`" ) log.debug(message) @@ -72,7 +75,12 @@ class WebhookRemover(Cog): matches = WEBHOOK_URL_RE.search(msg.content) if matches: - await self.delete_and_respond(msg, matches[1] + "xxx") + async with self.bot.http_session.delete(msg.content) as resp: + # The Discord API Returns a 204 NO CONTENT repsonse on success. + if resp.status == 204: + await self.delete_and_respond(msg, matches[1] + "xxx", True) + else: + await self.delete_and_respond(msg, matches[1] + "xxx", False) @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: -- cgit v1.2.3 From 8731b19ef771b18bad6418ebb3699b6a3550c60b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 15 Aug 2021 18:41:44 -0700 Subject: HelpChannels: fix incomplete init of available channel set If the cog is reloaded while there are less than the maximum amount of available channels, it makes some channels available until the limit is reached. When a channel is made available, it updates the `available_help_channels` set. The `update_available_help_channels()` function would not update this set if it saw that the set already contains elements. This resulted in only the channels that were just made available being in the set; the set would not contain the channels that were already available when the bot started. Fix this by unconditionally populating the set, but moving it to `init_available()` so it only happens once. Fix BOT-Z1 Fix #1715 --- bot/exts/help_channels/_cog.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index afaf9b0bd..34fae7248 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -267,6 +267,10 @@ class HelpChannels(commands.Cog): for channel in channels[:abs(missing)]: await self.unclaim_channel(channel, closed_on=_channel.ClosingReason.CLEANUP) + self.available_help_channels = { + c for c in self.available_category.channels if not _channel.is_excluded_channel(c) + } + # Getting channels that need to be included in the dynamic message. await self.update_available_help_channels() log.trace("Dynamic available help message updated.") @@ -519,11 +523,6 @@ class HelpChannels(commands.Cog): async def update_available_help_channels(self) -> None: """Updates the dynamic message within #how-to-get-help for available help channels.""" - if not self.available_help_channels: - self.available_help_channels = set( - c for c in self.available_category.channels if not _channel.is_excluded_channel(c) - ) - available_channels = AVAILABLE_HELP_CHANNELS.format( available=", ".join( c.mention for c in sorted(self.available_help_channels, key=attrgetter("position")) -- cgit v1.2.3 From f6a1f00b637fd8e7922913d530cf0d7cc54ec225 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 15 Aug 2021 18:45:25 -0700 Subject: HelpChannels: simplify formatting of category names in dormant msg Remove redundant retrieval of category objects. Include the asking guide URL in the message via an f-string. --- bot/exts/help_channels/_cog.py | 7 ++----- bot/exts/help_channels/_message.py | 8 ++++---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 34fae7248..e71e58e7b 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -391,13 +391,10 @@ class HelpChannels(commands.Cog): ) log.trace(f"Sending dormant message for #{channel} ({channel.id}).") - dormant_category = await channel_utils.try_get_channel(constants.Categories.help_dormant) - available_category = await channel_utils.try_get_channel(constants.Categories.help_available) embed = discord.Embed( description=_message.DORMANT_MSG.format( - dormant=dormant_category.name, - available=available_category.name, - asking_guide=_message.ASKING_GUIDE_URL + dormant=self.dormant_category.name, + available=self.available_category.name, ) ) await channel.send(embed=embed) diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index cf070be83..077b20b47 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -29,15 +29,15 @@ AVAILABLE_TITLE = "Available help channel" AVAILABLE_FOOTER = "Closes after a period of inactivity, or when you send !close." -DORMANT_MSG = """ -This help channel has been marked as **dormant**, and has been moved into the **{dormant}** \ +DORMANT_MSG = f""" +This help channel has been marked as **dormant**, and has been moved into the **{{dormant}}** \ category at the bottom of the channel list. It is no longer possible to send messages in this \ channel until it becomes available again. If your question wasn't answered yet, you can claim a new help channel from the \ -**{available}** category by simply asking your question again. Consider rephrasing the \ +**{{available}}** category by simply asking your question again. Consider rephrasing the \ question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for **[asking a good question]({asking_guide})**. +through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. """ -- cgit v1.2.3 From d388d39487acbbf8071b9b5072955f54caba3bbf Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 16 Aug 2021 18:18:05 +0200 Subject: Improve code consistency in webhook_remover --- bot/exts/filters/webhook_remover.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index dc1799f88..040ae93b6 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -13,8 +13,8 @@ WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\ ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted a Discord webhook URL. Therefore, your " - "message has been removed, alongside with your webhook" - "you can re-create it if you wish to. If you believe this was a " + "message has been removed, alongside with your webhook." + "You can re-create it if you wish to. If you believe this was a " "mistake, please let us know." ) @@ -32,7 +32,7 @@ class WebhookRemover(Cog): """Get current instance of `ModLog`.""" return self.bot.get_cog("ModLog") - async def delete_and_respond(self, msg: Message, redacted_url: str, webhook_deleted: bool) -> None: + async def delete_and_respond(self, msg: Message, redacted_url: str, *, webhook_deleted: bool) -> None: """Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`.""" # Don't log this, due internal delete, not by user. Will make different entry. self.mod_log.ignore(Event.message_delete, msg.id) @@ -49,7 +49,7 @@ class WebhookRemover(Cog): else: delete_state = "There was an error when deleting the webhook, it might have already been removed." message = ( - f"{format_user(msg.author)} posted a Discord webhook URL to {msg.channel.mention}.{delete_state} " + f"{format_user(msg.author)} posted a Discord webhook URL to {msg.channel.mention}. {delete_state} " f"Webhook URL was `{redacted_url}`" ) log.debug(message) @@ -76,11 +76,9 @@ class WebhookRemover(Cog): matches = WEBHOOK_URL_RE.search(msg.content) if matches: async with self.bot.http_session.delete(msg.content) as resp: - # The Discord API Returns a 204 NO CONTENT repsonse on success. - if resp.status == 204: - await self.delete_and_respond(msg, matches[1] + "xxx", True) - else: - await self.delete_and_respond(msg, matches[1] + "xxx", False) + # The Discord API Returns a 204 NO CONTENT response on success. + deleted_successfully = resp.status == 204 + await self.delete_and_respond(msg, matches[1] + "xxx", webhook_deleted=deleted_successfully) @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: -- cgit v1.2.3 From 44adebc02aaedaf96634988019205d6b853d22db Mon Sep 17 00:00:00 2001 From: wookie184 Date: Mon, 16 Aug 2021 17:57:16 +0100 Subject: Remove the admins mention from the modmail tag --- bot/resources/tags/modmail.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/modmail.md b/bot/resources/tags/modmail.md index 412468174..8ac19c8a7 100644 --- a/bot/resources/tags/modmail.md +++ b/bot/resources/tags/modmail.md @@ -6,4 +6,4 @@ It supports attachments, codeblocks, and reactions. As communication happens ove **To use it, simply send a direct message to the bot.** -Should there be an urgent and immediate need for a moderator or admin to look at a channel, feel free to ping the <@&831776746206265384> or <@&267628507062992896> role instead. +Should there be an urgent and immediate need for a moderator to look at a channel, feel free to ping the <@&831776746206265384> role instead. -- cgit v1.2.3 From e8b22358c579cca3447a1db29c93c85f65bb6f56 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 16 Aug 2021 20:19:09 +0200 Subject: Fix up, and improve code consistency in webhook_remover --- bot/exts/filters/webhook_remover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index 040ae93b6..50bb4bef7 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -13,7 +13,7 @@ WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\ ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted a Discord webhook URL. Therefore, your " - "message has been removed, alongside with your webhook." + "message has been removed, and your webhook has been deleted." "You can re-create it if you wish to. If you believe this was a " "mistake, please let us know." ) @@ -75,7 +75,7 @@ class WebhookRemover(Cog): matches = WEBHOOK_URL_RE.search(msg.content) if matches: - async with self.bot.http_session.delete(msg.content) as resp: + async with self.bot.http_session.delete(matches[0]) as resp: # The Discord API Returns a 204 NO CONTENT response on success. deleted_successfully = resp.status == 204 await self.delete_and_respond(msg, matches[1] + "xxx", webhook_deleted=deleted_successfully) -- cgit v1.2.3 From 7d732153a218df95d2e7ade8f44d32a13ebe9be6 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 16 Aug 2021 22:08:05 +0200 Subject: Patch the regEx pattern in webhook_remover to match edge cases The regEx pattern did not match the 'https://' part in case of canary urls Thus, from now on the regEx pattern matches the full canary urls as well. --- bot/exts/filters/webhook_remover.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index 50bb4bef7..b1592ae07 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -9,7 +9,10 @@ from bot.constants import Channels, Colours, Event, Icons from bot.exts.moderation.modlog import ModLog from bot.utils.messages import format_user -WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.IGNORECASE) +WEBHOOK_URL_RE = re.compile( + r"((?:https?:\/\/)?(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/)\S+\/?", + re.IGNORECASE +) ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted a Discord webhook URL. Therefore, your " -- cgit v1.2.3 From 60b1d16785214be1d8c670d5b5822e9dd0f63719 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 16 Aug 2021 23:07:23 +0200 Subject: Improve output message consistency in webhook_remover Co-authored-by: Bluenix --- bot/exts/filters/webhook_remover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index b1592ae07..25e267426 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -16,7 +16,7 @@ WEBHOOK_URL_RE = re.compile( ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted a Discord webhook URL. Therefore, your " - "message has been removed, and your webhook has been deleted." + "message has been removed, and your webhook has been deleted. " "You can re-create it if you wish to. If you believe this was a " "mistake, please let us know." ) -- cgit v1.2.3 From 052ceadc25a3e45b00bf8203e65d03c6798d082c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 16 Aug 2021 14:28:40 -0700 Subject: HelpChannels: use utility method to get available channels --- bot/exts/help_channels/_cog.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index e71e58e7b..cfc9cf477 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -267,9 +267,7 @@ class HelpChannels(commands.Cog): for channel in channels[:abs(missing)]: await self.unclaim_channel(channel, closed_on=_channel.ClosingReason.CLEANUP) - self.available_help_channels = { - c for c in self.available_category.channels if not _channel.is_excluded_channel(c) - } + self.available_help_channels = set(_channel.get_category_channels(self.available_category)) # Getting channels that need to be included in the dynamic message. await self.update_available_help_channels() -- cgit v1.2.3 From fe5c6a57222b5d5a6fab974dde41fc42c66f2173 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 19 Aug 2021 01:25:42 +0100 Subject: Fix linebreak formatting on server command The features part of the embed isn't included in some channels, this leads to there not being a linebreak between voice regions and roles in the embed. By changing it to this, rather than dedent, we specify exact where we want the linebreaks to be. --- bot/exts/info/information.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 167731e64..54c03e139 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -190,13 +190,13 @@ class Information(Cog): f"{constants.Emojis.status_offline} {offline_presences}" ) - embed.description = textwrap.dedent(f""" - Created: {created} - Voice region: {region}\ - {features} - Roles: {num_roles} - Member status: {member_status} - """) + embed.description = ( + f"Created: {created}" + f"\nVoice region: {region}" + f"{features}" + f"\nRoles: {num_roles}" + f"\nMember status: {member_status}" + ) embed.set_thumbnail(url=ctx.guild.icon_url) # Members -- cgit v1.2.3 From 480d26d0959f78d4990a83e46ee0c481e74ea62b Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 19 Aug 2021 01:26:10 +0100 Subject: Add comma separators to member counts in !server --- bot/exts/info/information.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 54c03e139..83ca59bea 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -186,8 +186,8 @@ class Information(Cog): online_presences = py_invite.approximate_presence_count offline_presences = py_invite.approximate_member_count - online_presences member_status = ( - f"{constants.Emojis.status_online} {online_presences} " - f"{constants.Emojis.status_offline} {offline_presences}" + f"{constants.Emojis.status_online} {online_presences:,} " + f"{constants.Emojis.status_offline} {offline_presences:,}" ) embed.description = ( @@ -200,7 +200,7 @@ class Information(Cog): embed.set_thumbnail(url=ctx.guild.icon_url) # Members - total_members = ctx.guild.member_count + total_members = f"{ctx.guild.member_count:,}" member_counts = self.get_member_counts(ctx.guild) member_info = "\n".join(f"{role}: {count}" for role, count in member_counts.items()) embed.add_field(name=f"Members: {total_members}", value=member_info) -- cgit v1.2.3