From 12cbfe77c9529631ae9038649845e93e41d8d021 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 2 Apr 2020 10:27:29 +0300 Subject: (Aliases, discord.py 1.3.x Migration): Replaced `ctx.invoke` with direct awaiting command. --- bot/cogs/alias.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 55c7efe65..d7e49b390 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -32,7 +32,7 @@ class Alias (Cog): f'{str(ctx.author)} tried to run the command "{cmd_name}" but lacks permission.' ) - await ctx.invoke(cmd, *args, **kwargs) + await cmd(ctx, *args, **kwargs) @command(name='aliases') async def aliases_command(self, ctx: Context) -> None: -- cgit v1.2.3 From 61a93c18a4b0ec0f40efb9b36fb2f423a9e2193a Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 2 Apr 2020 12:13:37 +0300 Subject: (Snekbox, discord.py 1.3.x Migration): Replaced message full reaction clear with only reeval emoji clear. --- bot/cogs/snekbox.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 315383b12..4ec08886c 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -233,12 +233,12 @@ class Snekbox(Cog): ) code = await self.get_code(new_message) - await ctx.message.clear_reactions() + await ctx.message.clear_reaction(REEVAL_EMOJI) with contextlib.suppress(HTTPException): await response.delete() except asyncio.TimeoutError: - await ctx.message.clear_reactions() + await ctx.message.clear_reaction(REEVAL_EMOJI) return None return code -- cgit v1.2.3 From 88b4c72d8f20d6eb9c9f620ea9ac041a5fa5b9e1 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 2 Apr 2020 12:57:53 +0300 Subject: (Patches, discord.py 1.3.x Migration): Removed patches due not longer necessary. --- bot/__main__.py | 5 ----- bot/patches/__init__.py | 6 ------ bot/patches/message_edited_at.py | 32 -------------------------------- tests/bot/patches/__init__.py | 0 4 files changed, 43 deletions(-) delete mode 100644 bot/patches/__init__.py delete mode 100644 bot/patches/message_edited_at.py delete mode 100644 tests/bot/patches/__init__.py diff --git a/bot/__main__.py b/bot/__main__.py index 8c3ae02e3..0ae869d0d 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -5,7 +5,6 @@ import sentry_sdk from discord.ext.commands import when_mentioned_or from sentry_sdk.integrations.logging import LoggingIntegration -from bot import patches from bot.bot import Bot from bot.constants import Bot as BotConfig @@ -66,8 +65,4 @@ bot.load_extension("bot.cogs.watchchannels") bot.load_extension("bot.cogs.webhook_remover") bot.load_extension("bot.cogs.wolfram") -# Apply `message_edited_at` patch if discord.py did not yet release a bug fix. -if not hasattr(discord.message.Message, '_handle_edited_timestamp'): - patches.message_edited_at.apply_patch() - bot.run(BotConfig.token) diff --git a/bot/patches/__init__.py b/bot/patches/__init__.py deleted file mode 100644 index 60f6becaa..000000000 --- a/bot/patches/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Subpackage that contains patches for discord.py.""" -from . import message_edited_at - -__all__ = [ - message_edited_at, -] diff --git a/bot/patches/message_edited_at.py b/bot/patches/message_edited_at.py deleted file mode 100644 index a0154f12d..000000000 --- a/bot/patches/message_edited_at.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -# message_edited_at patch. - -Date: 2019-09-16 -Author: Scragly -Added by: Ves Zappa - -Due to a bug in our current version of discord.py (1.2.3), the edited_at timestamp of -`discord.Messages` are not being handled correctly. This patch fixes that until a new -release of discord.py is released (and we've updated to it). -""" -import logging - -from discord import message, utils - -log = logging.getLogger(__name__) - - -def _handle_edited_timestamp(self: message.Message, value: str) -> None: - """Helper function that takes care of parsing the edited timestamp.""" - self._edited_timestamp = utils.parse_time(value) - - -def apply_patch() -> None: - """Applies the `edited_at` patch to the `discord.message.Message` class.""" - message.Message._handle_edited_timestamp = _handle_edited_timestamp - message.Message._HANDLERS['edited_timestamp'] = message.Message._handle_edited_timestamp - log.info("Patch applied: message_edited_at") - - -if __name__ == "__main__": - apply_patch() diff --git a/tests/bot/patches/__init__.py b/tests/bot/patches/__init__.py deleted file mode 100644 index e69de29bb..000000000 -- cgit v1.2.3 From 1a14f4f8deee13055393bc49477b97aec30cb6c9 Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 2 Apr 2020 13:05:22 +0300 Subject: (Off-Topic Names, discord.py 1.3.x Migration): Replaced `asyncio.sleep` with `discord.utils.sleep_until`. --- bot/cogs/off_topic_names.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 81511f99d..29aadedc4 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -1,10 +1,10 @@ -import asyncio import difflib import logging from datetime import datetime, timedelta from discord import Colour, Embed from discord.ext.commands import BadArgument, Cog, Context, Converter, group +from discord.utils import sleep_until from bot.api import ResponseCodeError from bot.bot import Bot @@ -51,8 +51,7 @@ async def update_names(bot: Bot) -> None: # we go past midnight in the `seconds_to_sleep` set below. today_at_midnight = datetime.utcnow().replace(microsecond=0, second=0, minute=0, hour=0) next_midnight = today_at_midnight + timedelta(days=1) - seconds_to_sleep = (next_midnight - datetime.utcnow()).seconds + 1 - await asyncio.sleep(seconds_to_sleep) + await sleep_until(next_midnight) try: channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get( -- cgit v1.2.3 From a4a4b987dd0d042e5d4272782c520c53d804470c Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 2 Apr 2020 13:23:18 +0300 Subject: (Reddit, discord.py 1.3.x Migration): Replaced `asyncio.sleep` with `discord.utils.sleep_until` --- bot/cogs/reddit.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 5a7fa100f..7f0ba98d2 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -10,6 +10,7 @@ from aiohttp import BasicAuth, ClientError from discord import Colour, Embed, TextChannel from discord.ext.commands import Cog, Context, group from discord.ext.tasks import loop +from discord.utils import sleep_until from bot.bot import Bot from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks @@ -200,13 +201,13 @@ class Reddit(Cog): @loop() async def auto_poster_loop(self) -> None: """Post the top 5 posts daily, and the top 5 posts weekly.""" - # once we upgrade to d.py 1.3 this can be removed and the loop can use the `time=datetime.time.min` parameter + # once d.py get support for `time` parameter in loop decorator, + # this can be removed and the loop can use the `time=datetime.time.min` parameter now = datetime.utcnow() tomorrow = now + timedelta(days=1) midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0) - seconds_until = (midnight_tomorrow - now).total_seconds() - await asyncio.sleep(seconds_until) + await sleep_until(midnight_tomorrow) await self.bot.wait_until_guild_available() if not self.webhook: -- cgit v1.2.3 From 5064fc717cd119f78af4ea146408c4a02a23f42b Mon Sep 17 00:00:00 2001 From: ks123 Date: Thu, 2 Apr 2020 13:37:58 +0300 Subject: (Snekbox Fix, discord.py 1.3.x Migration): Applied one reaction clear to tests. --- tests/bot/cogs/test_snekbox.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 1dec0ccaf..1443f7cdc 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -296,7 +296,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): ) ) ctx.message.add_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI) - ctx.message.clear_reactions.assert_called_once() + ctx.message.clear_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI) response.delete.assert_called_once() async def test_continue_eval_does_not_continue(self): @@ -305,7 +305,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): actual = await self.cog.continue_eval(ctx, MockMessage()) self.assertEqual(actual, None) - ctx.message.clear_reactions.assert_called_once() + ctx.message.clear_reaction.assert_called_once_with(snekbox.REEVAL_EMOJI) async def test_get_code(self): """Should return 1st arg (or None) if eval cmd in message, otherwise return full content.""" -- cgit v1.2.3 From 9114c4177f5a6bcb71531c75908e6aba14e4c4ed Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 09:03:28 +0300 Subject: (Tags, discord.py 1.3.x Migration): Replaced with direct function call. --- bot/cogs/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index a6e5952ff..5aa060f5e 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -143,7 +143,7 @@ class Tags(Cog): @group(name='tags', aliases=('tag', 't'), invoke_without_command=True) async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None: """Show all known tags, a single tag, or run a subcommand.""" - await ctx.invoke(self.get_command, tag_name=tag_name) + await self.get_command(ctx, tag_name=tag_name) @tags_group.group(name='search', invoke_without_command=True) async def search_tag_content(self, ctx: Context, *, keywords: str) -> None: -- cgit v1.2.3 From e6455deb5c81efd247cad765fc4edda1ead1fb65 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 09:07:49 +0300 Subject: (Bot Cog, discord.py 1.3.x Migration): Replaced `ctx.invoke` with `ctx.send_help`. --- bot/cogs/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index e897b30ff..963dc4926 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -49,7 +49,7 @@ class BotCog(Cog, name="Bot"): @with_role(Roles.verified) async def botinfo_group(self, ctx: Context) -> None: """Bot informational commands.""" - await ctx.invoke(self.bot.get_command("help"), "bot") + await ctx.send_help("bot") @botinfo_group.command(name='about', aliases=('info',), hidden=True) @with_role(Roles.verified) -- cgit v1.2.3 From 0ff6ffdf1ae1759cd931c7a675f831f770178018 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 15:47:48 +0300 Subject: (Information Tests, discord.py 1.3.x Migration): Moved from `unittest.TestCase` to `unittest.IsolatedAsyncTestCase` in `InformationCogTests`. --- tests/bot/cogs/test_information.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 3c26374f5..d93a1adef 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -14,7 +14,7 @@ from tests import helpers COG_PATH = "bot.cogs.information.Information" -class InformationCogTests(unittest.TestCase): +class InformationCogTests(unittest.IsolatedAsyncioTestCase): """Tests the Information cog.""" @classmethod @@ -30,7 +30,7 @@ class InformationCogTests(unittest.TestCase): self.ctx = helpers.MockContext() self.ctx.author.roles.append(self.moderator_role) - def test_roles_command_command(self): + async def test_roles_command_command(self): """Test if the `role_info` command correctly returns the `moderator_role`.""" self.ctx.guild.roles.append(self.moderator_role) @@ -49,7 +49,7 @@ class InformationCogTests(unittest.TestCase): self.assertEqual(embed.colour, discord.Colour.blurple()) self.assertEqual(embed.description, f"\n`{self.moderator_role.id}` - {self.moderator_role.mention}\n") - def test_role_info_command(self): + async def test_role_info_command(self): """Tests the `role info` command.""" dummy_role = helpers.MockRole( name="Dummy", @@ -99,7 +99,7 @@ class InformationCogTests(unittest.TestCase): self.assertEqual(admin_embed.colour, discord.Colour.red()) @unittest.mock.patch('bot.cogs.information.time_since') - def test_server_info_command(self, time_since_patch): + async def test_server_info_command(self, time_since_patch): time_since_patch.return_value = '2 days ago' self.ctx.guild = helpers.MockGuild( -- cgit v1.2.3 From ae470541d6dede7b1aabe0e90d6125d313f6bd46 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 17:00:55 +0300 Subject: (Information Tests, discord.py 1.3.x Migration): Moved from `unittest.TestCase` to `unittest.IsolatedAsyncTestCase` rest of test case classes. --- tests/bot/cogs/test_information.py | 54 ++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index d93a1adef..f3cc2ccbd 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -167,7 +167,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') -class UserInfractionHelperMethodTests(unittest.TestCase): +class UserInfractionHelperMethodTests(unittest.IsolatedAsyncioTestCase): """Tests for the helper methods of the `!user` command.""" def setUp(self): @@ -177,7 +177,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): self.cog = information.Information(self.bot) self.member = helpers.MockMember(id=1234) - def test_user_command_helper_method_get_requests(self): + async def test_user_command_helper_method_get_requests(self): """The helper methods should form the correct get requests.""" test_values = ( { @@ -203,7 +203,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): self.bot.api_client.get.assert_called_once_with(endpoint, params=params) self.bot.api_client.get.reset_mock() - def _method_subtests(self, method, test_values, default_header): + async def _method_subtests(self, method, test_values, default_header): """Helper method that runs the subtests for the different helper methods.""" for test_value in test_values: api_response = test_value["api response"] @@ -217,7 +217,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): self.assertEqual(expected_output, actual_output) - def test_basic_user_infraction_counts_returns_correct_strings(self): + async def test_basic_user_infraction_counts_returns_correct_strings(self): """The method should correctly list both the total and active number of non-hidden infractions.""" test_values = ( # No infractions means zero counts @@ -248,9 +248,9 @@ class UserInfractionHelperMethodTests(unittest.TestCase): header = ["**Infractions**"] - self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) + await self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) - def test_expanded_user_infraction_counts_returns_correct_strings(self): + async def test_expanded_user_infraction_counts_returns_correct_strings(self): """The method should correctly list the total and active number of all infractions split by infraction type.""" test_values = ( { @@ -303,9 +303,9 @@ class UserInfractionHelperMethodTests(unittest.TestCase): header = ["**Infractions**"] - self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) + await self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) - def test_user_nomination_counts_returns_correct_strings(self): + async def test_user_nomination_counts_returns_correct_strings(self): """The method should list the number of active and historical nominations for the user.""" test_values = ( { @@ -333,12 +333,12 @@ class UserInfractionHelperMethodTests(unittest.TestCase): header = ["**Nominations**"] - self._method_subtests(self.cog.user_nomination_counts, test_values, header) + await self._method_subtests(self.cog.user_nomination_counts, test_values, header) @unittest.mock.patch("bot.cogs.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago")) @unittest.mock.patch("bot.cogs.information.constants.MODERATION_CHANNELS", new=[50]) -class UserEmbedTests(unittest.TestCase): +class UserEmbedTests(unittest.IsolatedAsyncioTestCase): """Tests for the creation of the `!user` embed.""" def setUp(self): @@ -348,7 +348,7 @@ class UserEmbedTests(unittest.TestCase): self.cog = information.Information(self.bot) @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): + async def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): """The embed should use the string representation of the user if they don't have a nick.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) user = helpers.MockMember() @@ -360,7 +360,7 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.title, "Mr. Hemlock") @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_nick_in_title_if_available(self): + async def test_create_user_embed_uses_nick_in_title_if_available(self): """The embed should use the nick if it's available.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) user = helpers.MockMember() @@ -372,7 +372,7 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_ignores_everyone_role(self): + async def test_create_user_embed_ignores_everyone_role(self): """Created `!user` embeds should not contain mention of the @everyone-role.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) admins_role = helpers.MockRole(name='Admins') @@ -388,7 +388,11 @@ class UserEmbedTests(unittest.TestCase): @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock) @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) - def test_create_user_embed_expanded_information_in_moderation_channels(self, nomination_counts, infraction_counts): + async def test_create_user_embed_expanded_information_in_moderation_channels( + self, + nomination_counts, + infraction_counts + ): """The embed should contain expanded infractions and nomination info in mod channels.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=50)) @@ -423,7 +427,7 @@ class UserEmbedTests(unittest.TestCase): ) @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) - def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): + async def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): """The embed should contain only basic infraction data outside of mod channels.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=100)) @@ -454,7 +458,7 @@ class UserEmbedTests(unittest.TestCase): ) @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): + async def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): """The embed should be created with the colour of the top role, if a top role is available.""" ctx = helpers.MockContext() @@ -467,7 +471,7 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): + async def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): """The embed should be created with a blurple colour if the user has no assigned roles.""" ctx = helpers.MockContext() @@ -477,7 +481,7 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.colour, discord.Colour.blurple()) @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) - def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): + async def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): """The embed thumbnail should be set to the user's avatar in `png` format.""" ctx = helpers.MockContext() @@ -490,7 +494,7 @@ class UserEmbedTests(unittest.TestCase): @unittest.mock.patch("bot.cogs.information.constants") -class UserCommandTests(unittest.TestCase): +class UserCommandTests(unittest.IsolatedAsyncioTestCase): """Tests for the `!user` command.""" def setUp(self): @@ -506,7 +510,7 @@ class UserCommandTests(unittest.TestCase): self.moderator = helpers.MockMember(id=2, name="riffautae", roles=[self.moderator_role]) self.target = helpers.MockMember(id=3, name="__fluzz__") - def test_regular_member_cannot_target_another_member(self, constants): + async def test_regular_member_cannot_target_another_member(self, constants): """A regular user should not be able to use `!user` targeting another user.""" constants.MODERATION_ROLES = [self.moderator_role.id] @@ -516,7 +520,7 @@ class UserCommandTests(unittest.TestCase): ctx.send.assert_called_once_with("You may not use this command on users other than yourself.") - def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants): + async def test_regular_member_cannot_use_command_outside_of_bot_commands(self, constants): """A regular user should not be able to use this command outside of bot-commands.""" constants.MODERATION_ROLES = [self.moderator_role.id] constants.STAFF_ROLES = [self.moderator_role.id] @@ -529,7 +533,7 @@ class UserCommandTests(unittest.TestCase): asyncio.run(self.cog.user_info.callback(self.cog, ctx)) @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) - def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): + async def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): """A regular user should be allowed to use `!user` targeting themselves in bot-commands.""" constants.STAFF_ROLES = [self.moderator_role.id] constants.Channels.bot_commands = 50 @@ -542,7 +546,7 @@ class UserCommandTests(unittest.TestCase): ctx.send.assert_called_once() @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) - def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants): + async def test_regular_user_can_explicitly_target_themselves(self, create_embed, constants): """A user should target itself with `!user` when a `user` argument was not provided.""" constants.STAFF_ROLES = [self.moderator_role.id] constants.Channels.bot_commands = 50 @@ -555,7 +559,7 @@ class UserCommandTests(unittest.TestCase): ctx.send.assert_called_once() @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) - def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): + async def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): """Staff members should be able to bypass the bot-commands channel restriction.""" constants.STAFF_ROLES = [self.moderator_role.id] constants.Channels.bot_commands = 50 @@ -568,7 +572,7 @@ class UserCommandTests(unittest.TestCase): ctx.send.assert_called_once() @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) - def test_moderators_can_target_another_member(self, create_embed, constants): + async def test_moderators_can_target_another_member(self, create_embed, constants): """A moderator should be able to use `!user` targeting another user.""" constants.MODERATION_ROLES = [self.moderator_role.id] constants.STAFF_ROLES = [self.moderator_role.id] -- cgit v1.2.3 From 0917d9d1c15febeb79064065983bf19b0b02b55d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 17:03:44 +0300 Subject: (Information Tests, discord.py 1.3.x Migration): In `InformationCogTests`, replaced `.callback` calls with direct command awaits. --- tests/bot/cogs/test_information.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index f3cc2ccbd..7137949a0 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -37,9 +37,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase): self.cog.roles_info.can_run = unittest.mock.AsyncMock() self.cog.roles_info.can_run.return_value = True - coroutine = self.cog.roles_info.callback(self.cog, self.ctx) - - self.assertIsNone(asyncio.run(coroutine)) + self.assertIsNone(await self.cog.roles_info(self.ctx)) self.ctx.send.assert_called_once() _, kwargs = self.ctx.send.call_args @@ -74,9 +72,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase): self.cog.role_info.can_run = unittest.mock.AsyncMock() self.cog.role_info.can_run.return_value = True - coroutine = self.cog.role_info.callback(self.cog, self.ctx, dummy_role, admin_role) - - self.assertIsNone(asyncio.run(coroutine)) + self.assertIsNone(await self.cog.role_info(self.ctx, dummy_role, admin_role)) self.assertEqual(self.ctx.send.call_count, 2) @@ -133,8 +129,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase): icon_url='a-lemon.jpg', ) - coroutine = self.cog.server_info.callback(self.cog, self.ctx) - self.assertIsNone(asyncio.run(coroutine)) + self.assertIsNone(await self.cog.server_info(self.ctx)) time_since_patch.assert_called_once_with(self.ctx.guild.created_at, precision='days') _, kwargs = self.ctx.send.call_args -- cgit v1.2.3 From 1eed7d64e953e55cf6a7ed24b247212a2f550fa1 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 17:15:17 +0300 Subject: (Information Tests, discord.py 1.3.x Migration): Fixed `InformationCogTests` command calls. --- tests/bot/cogs/test_information.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 7137949a0..941a049d9 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -37,7 +37,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase): self.cog.roles_info.can_run = unittest.mock.AsyncMock() self.cog.roles_info.can_run.return_value = True - self.assertIsNone(await self.cog.roles_info(self.ctx)) + self.assertIsNone(await self.cog.roles_info(self.cog, self.ctx)) self.ctx.send.assert_called_once() _, kwargs = self.ctx.send.call_args @@ -72,7 +72,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase): self.cog.role_info.can_run = unittest.mock.AsyncMock() self.cog.role_info.can_run.return_value = True - self.assertIsNone(await self.cog.role_info(self.ctx, dummy_role, admin_role)) + self.assertIsNone(await self.cog.role_info(self.cog, self.ctx, dummy_role, admin_role)) self.assertEqual(self.ctx.send.call_count, 2) @@ -129,7 +129,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase): icon_url='a-lemon.jpg', ) - self.assertIsNone(await self.cog.server_info(self.ctx)) + self.assertIsNone(await self.cog.server_info(self.cog, self.ctx)) time_since_patch.assert_called_once_with(self.ctx.guild.created_at, precision='days') _, kwargs = self.ctx.send.call_args -- cgit v1.2.3 From 892777c19d0f5169b53a785deda9be3436b59663 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 17:18:30 +0300 Subject: (Information Tests): Replaced `asyncio.run` with `await` in `UserInfractionHelperMethodTests.` --- tests/bot/cogs/test_information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 941a049d9..60d49ff5c 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -194,7 +194,7 @@ class UserInfractionHelperMethodTests(unittest.IsolatedAsyncioTestCase): endpoint, params = test_value["expected_args"] with self.subTest(method=helper_method, endpoint=endpoint, params=params): - asyncio.run(helper_method(self.member)) + await helper_method(self.member) self.bot.api_client.get.assert_called_once_with(endpoint, params=params) self.bot.api_client.get.reset_mock() @@ -208,7 +208,7 @@ class UserInfractionHelperMethodTests(unittest.IsolatedAsyncioTestCase): self.bot.api_client.get.return_value = api_response expected_output = "\n".join(default_header + expected_lines) - actual_output = asyncio.run(method(self.member)) + actual_output = await method(self.member) self.assertEqual(expected_output, actual_output) -- cgit v1.2.3 From 7020300ea5a7ae65a78ef56d1a898967f3f5ddba Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 18:11:17 +0300 Subject: (Information Tests): Replaced `asyncio.run` with `await` in `UserEmbedTests`. --- tests/bot/cogs/test_information.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 60d49ff5c..1ea2acd30 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -350,7 +350,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): user.nick = None user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + embed = await self.cog.create_user_embed(ctx, user) self.assertEqual(embed.title, "Mr. Hemlock") @@ -362,7 +362,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): user.nick = "Cat lover" user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + embed = await self.cog.create_user_embed(ctx, user) self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") @@ -376,7 +376,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): # A `MockMember` has the @Everyone role by default; we add the Admins to that. user = helpers.MockMember(roles=[admins_role], top_role=admins_role) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + embed = await self.cog.create_user_embed(ctx, user) self.assertIn("&Admins", embed.description) self.assertNotIn("&Everyone", embed.description) @@ -398,7 +398,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): nomination_counts.return_value = "nomination info" user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + embed = await self.cog.create_user_embed(ctx, user) infraction_counts.assert_called_once_with(user) nomination_counts.assert_called_once_with(user) @@ -432,7 +432,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): infraction_counts.return_value = "basic infractions info" user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + embed = await self.cog.create_user_embed(ctx, user) infraction_counts.assert_called_once_with(user) @@ -461,7 +461,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): moderators_role.colour = 100 user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + embed = await self.cog.create_user_embed(ctx, user) self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) @@ -471,7 +471,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): ctx = helpers.MockContext() user = helpers.MockMember(id=217) - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + embed = await self.cog.create_user_embed(ctx, user) self.assertEqual(embed.colour, discord.Colour.blurple()) @@ -482,7 +482,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): user = helpers.MockMember(id=217) user.avatar_url_as.return_value = "avatar url" - embed = asyncio.run(self.cog.create_user_embed(ctx, user)) + embed = await self.cog.create_user_embed(ctx, user) user.avatar_url_as.assert_called_once_with(format="png") self.assertEqual(embed.thumbnail.url, "avatar url") -- cgit v1.2.3 From 5e8093dc65d97e427ca9ac4858dc25103075e10b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 18:14:15 +0300 Subject: (Information Tests): Replaced `asyncio.run` with `await` in `UserCommandTests`. --- tests/bot/cogs/test_information.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 1ea2acd30..a3f80b1e5 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -1,4 +1,3 @@ -import asyncio import textwrap import unittest import unittest.mock @@ -511,7 +510,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): ctx = helpers.MockContext(author=self.author) - asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) + await self.cog.user_info(self.cog, ctx, self.target) ctx.send.assert_called_once_with("You may not use this command on users other than yourself.") @@ -525,7 +524,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): msg = "Sorry, but you may only use this command within <#50>." with self.assertRaises(InChannelCheckFailure, msg=msg): - asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + await self.cog.user_info(self.cog, ctx) @unittest.mock.patch("bot.cogs.information.Information.create_user_embed", new_callable=unittest.mock.AsyncMock) async def test_regular_user_may_use_command_in_bot_commands_channel(self, create_embed, constants): @@ -535,7 +534,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) - asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + await self.cog.user_info(self.cog, ctx) create_embed.assert_called_once_with(ctx, self.author) ctx.send.assert_called_once() @@ -548,7 +547,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): ctx = helpers.MockContext(author=self.author, channel=helpers.MockTextChannel(id=50)) - asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.author)) + await self.cog.user_info(self.cog, ctx, self.author) create_embed.assert_called_once_with(ctx, self.author) ctx.send.assert_called_once() @@ -561,7 +560,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200)) - asyncio.run(self.cog.user_info.callback(self.cog, ctx)) + await self.cog.user_info(self.cog, ctx) create_embed.assert_called_once_with(ctx, self.moderator) ctx.send.assert_called_once() @@ -574,7 +573,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=50)) - asyncio.run(self.cog.user_info.callback(self.cog, ctx, self.target)) + await self.cog.user_info(self.cog, ctx, self.target) create_embed.assert_called_once_with(ctx, self.target) ctx.send.assert_called_once() -- cgit v1.2.3 From 92f901d498a18148c0b59bb2489f0d9b7e902b6d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 18:16:52 +0300 Subject: (Snekbox Tests, discord.py 1.3.x Migrations): Removed `.callback` from commands calling. --- tests/bot/cogs/test_snekbox.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 1443f7cdc..d84e5accf 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -175,7 +175,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.cog.send_eval = AsyncMock(return_value=response) self.cog.continue_eval = AsyncMock(return_value=None) - await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') + await self.cog.eval_command(self.cog, ctx=ctx, code='MyAwesomeCode') self.cog.prepare_input.assert_called_once_with('MyAwesomeCode') self.cog.send_eval.assert_called_once_with(ctx, 'MyAwesomeFormattedCode') self.cog.continue_eval.assert_called_once_with(ctx, response) @@ -189,7 +189,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.cog.continue_eval = AsyncMock() self.cog.continue_eval.side_effect = ('MyAwesomeCode-2', None) - await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') + await self.cog.eval_command(self.cog, ctx=ctx, code='MyAwesomeCode') self.cog.prepare_input.has_calls(call('MyAwesomeCode'), call('MyAwesomeCode-2')) self.cog.send_eval.assert_called_with(ctx, 'MyAwesomeFormattedCode') self.cog.continue_eval.assert_called_with(ctx, response) @@ -201,7 +201,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): ctx.author.mention = '@LemonLemonishBeard#0042' ctx.send = AsyncMock() self.cog.jobs = (42,) - await self.cog.eval_command.callback(self.cog, ctx=ctx, code='MyAwesomeCode') + await self.cog.eval_command(self.cog, ctx=ctx, code='MyAwesomeCode') ctx.send.assert_called_once_with( "@LemonLemonishBeard#0042 You've already got a job running - please wait for it to finish!" ) @@ -210,7 +210,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): """Test if the eval command call the help command if no code is provided.""" ctx = MockContext() ctx.invoke = AsyncMock() - await self.cog.eval_command.callback(self.cog, ctx=ctx, code='') + await self.cog.eval_command(self.cog, ctx=ctx, code='') ctx.invoke.assert_called_once_with(self.bot.get_command("help"), "eval") async def test_send_eval(self): -- cgit v1.2.3 From c5949686fc03ecb74787cfd23412c8600638139f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 18:19:17 +0300 Subject: (Silence Tests, discord.py 1.3.x Migrations): Removed `.callback` from commands calling. --- tests/bot/cogs/moderation/test_silence.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 3fd149f04..52b7d47f1 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -122,14 +122,14 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): starting_unsilenced_state=_silence_patch_return ): with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): - await self.cog.silence.callback(self.cog, self.ctx, duration) + await self.cog.silence(self.cog, self.ctx, duration) self.ctx.send.assert_called_once_with(result_message) self.ctx.reset_mock() async def test_unsilence_sent_correct_discord_message(self): """Proper reply after a successful unsilence.""" with mock.patch.object(self.cog, "_unsilence", return_value=True): - await self.cog.unsilence.callback(self.cog, self.ctx) + await self.cog.unsilence(self.cog, self.ctx) self.ctx.send.assert_called_once_with(f"{Emojis.check_mark} unsilenced current channel.") async def test_silence_private_for_false(self): -- cgit v1.2.3 From ca4e21b1c7e8b537ac37f55b71eb29e09f16bf74 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 18:20:12 +0300 Subject: (Sync Cog Tests, discord.py 1.3.x Migrations): Removed `.callback` from commands calling. --- tests/bot/cogs/sync/test_cog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 81398c61f..a4745f7b4 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -344,14 +344,14 @@ class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): async def test_sync_roles_command(self): """sync() should be called on the RoleSyncer.""" ctx = helpers.MockContext() - await self.cog.sync_roles_command.callback(self.cog, ctx) + await self.cog.sync_roles_command(self.cog, ctx) self.cog.role_syncer.sync.assert_called_once_with(ctx.guild, ctx) async def test_sync_users_command(self): """sync() should be called on the UserSyncer.""" ctx = helpers.MockContext() - await self.cog.sync_users_command.callback(self.cog, ctx) + await self.cog.sync_users_command(self.cog, ctx) self.cog.user_syncer.sync.assert_called_once_with(ctx.guild, ctx) -- cgit v1.2.3 From 56edfa39c0f03ae11647454fcb06415dc8cfcb20 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 18:57:55 +0300 Subject: (Docs, discord.py 1.3.x Migrations): Replaced `ctx.invoke` with direct calling command. --- bot/cogs/doc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 204cffb37..ddff9d14c 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -345,7 +345,7 @@ class Doc(commands.Cog): @commands.group(name='docs', aliases=('doc', 'd'), invoke_without_command=True) async def docs_group(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: """Lookup documentation for Python symbols.""" - await ctx.invoke(self.get_command, symbol) + await self.get_command(ctx, symbol) @docs_group.command(name='get', aliases=('g',)) async def get_command(self, ctx: commands.Context, symbol: commands.clean_content = None) -> None: -- cgit v1.2.3 From c30e5b16d48aea5bef792de9186e73d5df4a94e4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:02:36 +0300 Subject: (Eval, discord.py 1.3.x Migrations): Replaced `help` command getting with `ctx.send_help`. --- bot/cogs/eval.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index 52136fc8d..2d52197e8 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/eval.py @@ -178,7 +178,7 @@ async def func(): # (None,) -> Any async def internal_group(self, ctx: Context) -> None: """Internal commands. Top secret!""" if not ctx.invoked_subcommand: - await ctx.invoke(self.bot.get_command("help"), "internal") + await ctx.send_help("internal") @internal_group.command(name='eval', aliases=('e',)) @with_role(Roles.admins, Roles.owners) -- cgit v1.2.3 From f4a95d904a5476639dfdcbbb5a08fed0b9b19813 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:06:59 +0300 Subject: (Extensions, discord.py 1.3.x Migrations): Replaced `help` command getting with `ctx.send_help`. --- bot/cogs/extensions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index fb6cd9aa3..4493046e1 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -65,7 +65,7 @@ class Extensions(commands.Cog): @group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True) async def extensions_group(self, ctx: Context) -> None: """Load, unload, reload, and list loaded extensions.""" - await ctx.invoke(self.bot.get_command("help"), "extensions") + await ctx.send_help("extensions") @extensions_group.command(name="load", aliases=("l",)) async def load_command(self, ctx: Context, *extensions: Extension) -> None: @@ -75,7 +75,7 @@ class Extensions(commands.Cog): If '\*' or '\*\*' is given as the name, all unloaded extensions will be loaded. """ # noqa: W605 if not extensions: - await ctx.invoke(self.bot.get_command("help"), "extensions load") + await ctx.send_help("extensions load") return if "*" in extensions or "**" in extensions: @@ -92,7 +92,7 @@ class Extensions(commands.Cog): If '\*' or '\*\*' is given as the name, all loaded extensions will be unloaded. """ # noqa: W605 if not extensions: - await ctx.invoke(self.bot.get_command("help"), "extensions unload") + await ctx.send_help("extensions unload") return blacklisted = "\n".join(UNLOAD_BLACKLIST & set(extensions)) @@ -118,7 +118,7 @@ class Extensions(commands.Cog): If '\*\*' is given as the name, all extensions, including unloaded ones, will be reloaded. """ # noqa: W605 if not extensions: - await ctx.invoke(self.bot.get_command("help"), "extensions reload") + await ctx.send_help("extensions reload") return if "**" in extensions: -- cgit v1.2.3 From 7b9e6b0b90ab3445b9fa7cde30f5a923486c4094 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:14:21 +0300 Subject: (Off-Topic Names, discord.py 1.3.x Migrations): Replaced `help` command getting with `ctx.send_help`. --- bot/cogs/off_topic_names.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 29aadedc4..fd386858e 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -96,7 +96,7 @@ class OffTopicNames(Cog): @with_role(*MODERATION_ROLES) async def otname_group(self, ctx: Context) -> None: """Add or list items from the off-topic channel name rotation.""" - await ctx.invoke(self.bot.get_command("help"), "otname") + await ctx.send_help("otname") @otname_group.command(name='add', aliases=('a',)) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From 1a2edf7b4cda8a39c86337e5a0effc5e8874b73c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:17:13 +0300 Subject: (Reddit, discord.py 1.3.x Migrations): Replaced `help` command getting with `ctx.send_help`. --- bot/cogs/reddit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 7f0ba98d2..426c34bfa 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -246,7 +246,7 @@ class Reddit(Cog): @group(name="reddit", invoke_without_command=True) async def reddit_group(self, ctx: Context) -> None: """View the top posts from various subreddits.""" - await ctx.invoke(self.bot.get_command("help"), "reddit") + await ctx.send_help("reddit") @reddit_group.command(name="top") async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: -- cgit v1.2.3 From 8c33b8adaae3b40cb49c3da6fab72e1dadb3a6bc Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:21:00 +0300 Subject: (Reminders, discord.py 1.3.x Migrations): Replaced `ctx.invoke` with direct command calling, replaced `help` command getting with `ctx.send_help`. --- bot/cogs/reminders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 24c279357..d5f59dd62 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -161,7 +161,7 @@ class Reminders(Scheduler, Cog): @group(name="remind", aliases=("reminder", "reminders"), invoke_without_command=True) async def remind_group(self, ctx: Context, expiration: Duration, *, content: str) -> None: """Commands for managing your reminders.""" - await ctx.invoke(self.new_reminder, expiration=expiration, content=content) + await self.new_reminder(ctx, expiration=expiration, content=content) @remind_group.command(name="new", aliases=("add", "create")) async def new_reminder(self, ctx: Context, expiration: Duration, *, content: str) -> t.Optional[discord.Message]: @@ -281,7 +281,7 @@ class Reminders(Scheduler, Cog): @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True) async def edit_reminder_group(self, ctx: Context) -> None: """Commands for modifying your current reminders.""" - await ctx.invoke(self.bot.get_command("help"), "reminders", "edit") + await ctx.send_help("reminders edit") @edit_reminder_group.command(name="duration", aliases=("time",)) async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None: -- cgit v1.2.3 From a11c34d533e9483b96564f4db9488ca6c8bc8db7 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:22:05 +0300 Subject: (Site, discord.py 1.3.x Migrations): Replaced `help` command getting with `ctx.send_help`. --- bot/cogs/site.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/site.py b/bot/cogs/site.py index 853e29568..c17761a2b 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -21,7 +21,7 @@ class Site(Cog): @group(name="site", aliases=("s",), invoke_without_command=True) async def site_group(self, ctx: Context) -> None: """Commands for getting info about our website.""" - await ctx.invoke(self.bot.get_command("help"), "site") + await ctx.send_help("site") @site_group.command(name="home", aliases=("about",)) async def site_main(self, ctx: Context) -> None: -- cgit v1.2.3 From 2fe4149e7728db3c4fc7989caa098e0f9d76e093 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:24:36 +0300 Subject: (Snekbox, discord.py 1.3.x Migrations): Replaced `help` command getting with `ctx.send_help`. --- bot/cogs/snekbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 4ec08886c..99c1a7278 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -285,7 +285,7 @@ class Snekbox(Cog): return if not code: # None or empty string - await ctx.invoke(self.bot.get_command("help"), "eval") + await ctx.send_help("eval") return log.info(f"Received code from {ctx.author} for evaluation:\n{code}") -- cgit v1.2.3 From 538ef551be279ec1bed3465fcee711e3154fe234 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:27:54 +0300 Subject: (Utils, discord.py 1.3.x Migrations): Replaced `help` command getting with `ctx.send_help`. --- bot/cogs/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 3ed471bbf..0d34d4c71 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -58,7 +58,7 @@ class Utils(Cog): if pep_number.isdigit(): pep_number = int(pep_number) else: - await ctx.invoke(self.bot.get_command("help"), "pep") + await ctx.send_help("pep") return # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. -- cgit v1.2.3 From 54606cd8f20197c39cff264972aaa4a34e47ca53 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:34:20 +0300 Subject: (Mod Management, discord.py 1.3.x Migrations): Replaced `help` command getting with `ctx.send_help`, replaced `ctx.invoke` with direct command call. --- bot/cogs/moderation/management.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 250a24247..075d45e2d 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -43,7 +43,7 @@ class ModManagement(commands.Cog): @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf'), invoke_without_command=True) async def infraction_group(self, ctx: Context) -> None: """Infraction manipulation commands.""" - await ctx.invoke(self.bot.get_command("help"), "infraction") + await ctx.send_help("infraction") @infraction_group.command(name='edit') async def infraction_edit( @@ -183,9 +183,9 @@ class ModManagement(commands.Cog): async def infraction_search_group(self, ctx: Context, query: InfractionSearchQuery) -> None: """Searches for infractions in the database.""" if isinstance(query, discord.User): - await ctx.invoke(self.search_user, query) + await self.search_user(ctx, query) else: - await ctx.invoke(self.search_reason, query) + await self.search_reason(ctx, query) @infraction_search_group.command(name="user", aliases=("member", "id")) async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None: -- cgit v1.2.3 From 361fabc024880c071b1db7137186a42989dab2ad Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:40:41 +0300 Subject: (Mod Management, discord.py 1.3.x Migrations): Replaced `help` command getting with `ctx.send_help`. --- bot/cogs/watchchannels/bigbrother.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 903c87f85..37f2d2b9d 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -30,7 +30,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): @with_role(*MODERATION_ROLES) async def bigbrother_group(self, ctx: Context) -> None: """Monitors users by relaying their messages to the Big Brother watch channel.""" - await ctx.invoke(self.bot.get_command("help"), "bigbrother") + await ctx.send_help("bigbrother") @bigbrother_group.command(name='watched', aliases=('all', 'list')) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From b00f023466c044b5b459701a479bdfcb01d9bfa6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:43:16 +0300 Subject: (Talent Pool, discord.py 1.3.x Migrations): Replaced `help` command getting with `ctx.send_help`. --- bot/cogs/watchchannels/talentpool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index ad0c51fa6..b8473963d 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -34,7 +34,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @with_role(*MODERATION_ROLES) async def nomination_group(self, ctx: Context) -> None: """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" - await ctx.invoke(self.bot.get_command("help"), "talentpool") + await ctx.send_help("talentpool") @nomination_group.command(name='watched', aliases=('all', 'list')) @with_role(*MODERATION_ROLES) @@ -173,7 +173,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @with_role(*MODERATION_ROLES) async def nomination_edit_group(self, ctx: Context) -> None: """Commands to edit nominations.""" - await ctx.invoke(self.bot.get_command("help"), "talentpool", "edit") + await ctx.send_help("talentpool edit") @nomination_edit_group.command(name='reason') @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From 41f3dfa1a93e0850c6120e5979f9a8a52386c516 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 3 Apr 2020 19:49:36 +0300 Subject: (Snekbox Tests, discord.py 1.3.x Migrations): Fixed wrong assertion of help command call. --- tests/bot/cogs/test_snekbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index d84e5accf..bcb3550f8 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -211,7 +211,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): ctx = MockContext() ctx.invoke = AsyncMock() await self.cog.eval_command(self.cog, ctx=ctx, code='') - ctx.invoke.assert_called_once_with(self.bot.get_command("help"), "eval") + ctx.send_help.assert_called_once_with("eval") async def test_send_eval(self): """Test the send_eval function.""" -- cgit v1.2.3 From 1447327e337e0565a25ff83476d285c8fe4b1e72 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 21 Apr 2020 19:29:11 +0300 Subject: Improve `!pep` command - Made `pep_number` type hint to `int` to avoid unnecessary manual converting. - Added `ctx.trigger_typing` calling to show user that bot is responding. --- bot/cogs/utils.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 3ed471bbf..bf8887538 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -53,13 +53,10 @@ class Utils(Cog): self.base_github_pep_url = "https://raw.githubusercontent.com/python/peps/master/pep-" @command(name='pep', aliases=('get_pep', 'p')) - async def pep_command(self, ctx: Context, pep_number: str) -> None: + async def pep_command(self, ctx: Context, pep_number: int) -> None: """Fetches information about a PEP and sends it to the channel.""" - if pep_number.isdigit(): - pep_number = int(pep_number) - else: - await ctx.invoke(self.bot.get_command("help"), "pep") - return + # Trigger typing in chat to show users that bot is responding + await ctx.trigger_typing() # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. if pep_number == 0: -- cgit v1.2.3 From 6b6d2a75f3cb6d31c1ed287362c28ca47298b019 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 25 Apr 2020 08:26:14 +0300 Subject: Moved `async_cache` decorator from `Doc` cog file to `utils/cache.py` --- bot/cogs/doc.py | 32 ++------------------------------ bot/utils/cache.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 30 deletions(-) create mode 100644 bot/utils/cache.py diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 204cffb37..ff60fc80a 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -6,7 +6,7 @@ import textwrap from collections import OrderedDict from contextlib import suppress from types import SimpleNamespace -from typing import Any, Callable, Optional, Tuple +from typing import Optional, Tuple import discord from bs4 import BeautifulSoup @@ -23,6 +23,7 @@ from bot.constants import MODERATION_ROLES, RedirectOutput from bot.converters import ValidPythonIdentifier, ValidURL from bot.decorators import with_role from bot.pagination import LinePaginator +from bot.utils.cache import async_cache log = logging.getLogger(__name__) @@ -66,35 +67,6 @@ FAILED_REQUEST_RETRY_AMOUNT = 3 NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay -def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: - """ - LRU cache implementation for coroutines. - - Once the cache exceeds the maximum size, keys are deleted in FIFO order. - - An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key. - """ - # Assign the cache to the function itself so we can clear it from outside. - async_cache.cache = OrderedDict() - - def decorator(function: Callable) -> Callable: - """Define the async_cache decorator.""" - @functools.wraps(function) - async def wrapper(*args) -> Any: - """Decorator wrapper for the caching logic.""" - key = ':'.join(args[arg_offset:]) - - value = async_cache.cache.get(key) - if value is None: - if len(async_cache.cache) > max_size: - async_cache.cache.popitem(last=False) - - async_cache.cache[key] = await function(*args) - return async_cache.cache[key] - return wrapper - return decorator - - class DocMarkdownConverter(MarkdownConverter): """Subclass markdownify's MarkdownCoverter to provide custom conversion methods.""" diff --git a/bot/utils/cache.py b/bot/utils/cache.py new file mode 100644 index 000000000..338924df8 --- /dev/null +++ b/bot/utils/cache.py @@ -0,0 +1,32 @@ +import functools +from collections import OrderedDict +from typing import Any, Callable + + +def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: + """ + LRU cache implementation for coroutines. + + Once the cache exceeds the maximum size, keys are deleted in FIFO order. + + An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key. + """ + # Assign the cache to the function itself so we can clear it from outside. + async_cache.cache = OrderedDict() + + def decorator(function: Callable) -> Callable: + """Define the async_cache decorator.""" + @functools.wraps(function) + async def wrapper(*args) -> Any: + """Decorator wrapper for the caching logic.""" + key = ':'.join(str(args[arg_offset:])) + + value = async_cache.cache.get(key) + if value is None: + if len(async_cache.cache) > max_size: + async_cache.cache.popitem(last=False) + + async_cache.cache[key] = await function(*args) + return async_cache.cache[key] + return wrapper + return decorator -- cgit v1.2.3 From bf26ad7f7648384182d95d76618faf1c9392b403 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 25 Apr 2020 08:45:35 +0300 Subject: Created new task in `Utils` cog: `refresh_peps_urls` Task refresh listing of PEPs + URLs in every 24 hours --- bot/cogs/utils.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index bf8887538..8e7f41088 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -5,11 +5,12 @@ import unicodedata from asyncio import TimeoutError, sleep from email.parser import HeaderParser from io import StringIO -from typing import Tuple, Union +from typing import Dict, Tuple, Union from dateutil import relativedelta from discord import Colour, Embed, Message, Role from discord.ext.commands import BadArgument, Cog, Context, command +from discord.ext.tasks import loop from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES @@ -51,6 +52,24 @@ class Utils(Cog): self.base_pep_url = "http://www.python.org/dev/peps/pep-" self.base_github_pep_url = "https://raw.githubusercontent.com/python/peps/master/pep-" + self.peps_listing_api_url = "https://api.github.com/repos/python/peps/contents?ref=master" + + self.peps: Dict[int, str] = {} + self.refresh_peps_urls.start() + + @loop(hours=24) + async def refresh_peps_urls(self) -> None: + """Refresh PEP URLs listing every day at once.""" + # Wait until HTTP client is available + await self.bot.wait_until_guild_available() + + async with self.bot.http_session.get(self.peps_listing_api_url) as resp: + listing = await resp.json() + + for file in listing: + name = file["name"] + if name.startswith("pep-") and (name.endswith(".txt") or name.endswith(".rst")): + self.peps[int(name.split(".")[0].split("-")[1])] = file["download_url"] @command(name='pep', aliases=('get_pep', 'p')) async def pep_command(self, ctx: Context, pep_number: int) -> None: -- cgit v1.2.3 From a2f0de1c34dc320f4ee61d64a33b0d866bf41af2 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 25 Apr 2020 09:22:57 +0300 Subject: Refactor `pep` command, implement caching Moved PEP embed getting to function, that use caching. --- bot/cogs/utils.py | 101 +++++++++++++++++++++++++----------------------------- 1 file changed, 46 insertions(+), 55 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 8e7f41088..995221b80 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -15,6 +15,7 @@ from discord.ext.tasks import loop from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES, Mention, STAFF_ROLES from bot.decorators import in_channel, with_role +from bot.utils.cache import async_cache from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -79,59 +80,10 @@ class Utils(Cog): # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. if pep_number == 0: - return await self.send_pep_zero(ctx) - - possible_extensions = ['.txt', '.rst'] - found_pep = False - for extension in possible_extensions: - # Attempt to fetch the PEP - pep_url = f"{self.base_github_pep_url}{pep_number:04}{extension}" - log.trace(f"Requesting PEP {pep_number} with {pep_url}") - response = await self.bot.http_session.get(pep_url) - - if response.status == 200: - log.trace("PEP found") - found_pep = True - - pep_content = await response.text() - - # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 - pep_header = HeaderParser().parse(StringIO(pep_content)) - - # Assemble the embed - pep_embed = Embed( - title=f"**PEP {pep_number} - {pep_header['Title']}**", - description=f"[Link]({self.base_pep_url}{pep_number:04})", - ) - - pep_embed.set_thumbnail(url=ICON_URL) - - # Add the interesting information - fields_to_check = ("Status", "Python-Version", "Created", "Type") - for field in fields_to_check: - # Check for a PEP metadata field that is present but has an empty value - # embed field values can't contain an empty string - if pep_header.get(field, ""): - pep_embed.add_field(name=field, value=pep_header[field]) - - elif response.status != 404: - # any response except 200 and 404 is expected - found_pep = True # actually not, but it's easier to display this way - log.trace(f"The user requested PEP {pep_number}, but the response had an unexpected status code: " - f"{response.status}.\n{response.text}") - - error_message = "Unexpected HTTP error during PEP search. Please let us know." - pep_embed = Embed(title="Unexpected error", description=error_message) - pep_embed.colour = Colour.red() - break - - if not found_pep: - log.trace("PEP was not found") - not_found = f"PEP {pep_number} does not exist." - pep_embed = Embed(title="PEP not found", description=not_found) - pep_embed.colour = Colour.red() - - await ctx.message.channel.send(embed=pep_embed) + pep_embed = await self.get_pep_zero_embed() + else: + pep_embed = await self.get_pep_embed(pep_number) + await ctx.send(embed=pep_embed) @command() @in_channel(Channels.bot_commands, bypass_roles=STAFF_ROLES) @@ -310,7 +262,7 @@ class Utils(Cog): for reaction in options: await message.add_reaction(reaction) - async def send_pep_zero(self, ctx: Context) -> None: + async def get_pep_zero_embed(self) -> Embed: """Send information about PEP 0.""" pep_embed = Embed( title=f"**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", @@ -321,7 +273,46 @@ class Utils(Cog): pep_embed.add_field(name="Created", value="13-Jul-2000") pep_embed.add_field(name="Type", value="Informational") - await ctx.send(embed=pep_embed) + return pep_embed + + @async_cache(arg_offset=1) + async def get_pep_embed(self, pep_nr: int) -> Embed: + """Fetch, generate and return PEP embed. Implement `async_cache`.""" + if pep_nr not in self.peps: + log.trace(f"PEP {pep_nr} was not found") + not_found = f"PEP {pep_nr} does not exist." + return Embed(title="PEP not found", description=not_found, colour=Colour.red()) + response = await self.bot.http_session.get(self.peps[pep_nr]) + + if response.status == 200: + log.trace(f"PEP {pep_nr} found") + pep_content = await response.text() + + # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 + pep_header = HeaderParser().parse(StringIO(pep_content)) + + # Assemble the embed + pep_embed = Embed( + title=f"**PEP {pep_nr} - {pep_header['Title']}**", + description=f"[Link]({self.base_pep_url}{pep_nr:04})", + ) + + pep_embed.set_thumbnail(url=ICON_URL) + + # Add the interesting information + fields_to_check = ("Status", "Python-Version", "Created", "Type") + for field in fields_to_check: + # Check for a PEP metadata field that is present but has an empty value + # embed field values can't contain an empty string + if pep_header.get(field, ""): + pep_embed.add_field(name=field, value=pep_header[field]) + return pep_embed + else: + log.trace(f"The user requested PEP {pep_nr}, but the response had an unexpected status code: " + f"{response.status}.\n{response.text}") + + error_message = "Unexpected HTTP error during PEP search. Please let us know." + return Embed(title="Unexpected error", description=error_message, colour=Colour.red()) def setup(bot: Bot) -> None: -- cgit v1.2.3 From fa3d369b68644dfa30d1db22ca7dd1c76b9d608e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 25 Apr 2020 09:24:27 +0300 Subject: Replaced 24 hours with 3 hours in `refresh_peps_urls` Made modification to include new PEPs faster. --- bot/cogs/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 995221b80..626169b42 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -58,9 +58,9 @@ class Utils(Cog): self.peps: Dict[int, str] = {} self.refresh_peps_urls.start() - @loop(hours=24) + @loop(hours=3) async def refresh_peps_urls(self) -> None: - """Refresh PEP URLs listing every day at once.""" + """Refresh PEP URLs listing in every 3 hours.""" # Wait until HTTP client is available await self.bot.wait_until_guild_available() -- cgit v1.2.3 From 2b8efb61c766cc1982e022608d3098c8cca6783b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 13 May 2020 19:54:11 +0300 Subject: PEP Improvisations: Moved PEP functions to one region --- bot/cogs/utils.py | 56 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 626169b42..7c6541ccb 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -58,33 +58,6 @@ class Utils(Cog): self.peps: Dict[int, str] = {} self.refresh_peps_urls.start() - @loop(hours=3) - async def refresh_peps_urls(self) -> None: - """Refresh PEP URLs listing in every 3 hours.""" - # Wait until HTTP client is available - await self.bot.wait_until_guild_available() - - async with self.bot.http_session.get(self.peps_listing_api_url) as resp: - listing = await resp.json() - - for file in listing: - name = file["name"] - if name.startswith("pep-") and (name.endswith(".txt") or name.endswith(".rst")): - self.peps[int(name.split(".")[0].split("-")[1])] = file["download_url"] - - @command(name='pep', aliases=('get_pep', 'p')) - async def pep_command(self, ctx: Context, pep_number: int) -> None: - """Fetches information about a PEP and sends it to the channel.""" - # Trigger typing in chat to show users that bot is responding - await ctx.trigger_typing() - - # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. - if pep_number == 0: - pep_embed = await self.get_pep_zero_embed() - else: - pep_embed = await self.get_pep_embed(pep_number) - await ctx.send(embed=pep_embed) - @command() @in_channel(Channels.bot_commands, bypass_roles=STAFF_ROLES) async def charinfo(self, ctx: Context, *, characters: str) -> None: @@ -262,6 +235,35 @@ class Utils(Cog): for reaction in options: await message.add_reaction(reaction) + # PEPs area + + @loop(hours=3) + async def refresh_peps_urls(self) -> None: + """Refresh PEP URLs listing in every 3 hours.""" + # Wait until HTTP client is available + await self.bot.wait_until_guild_available() + + async with self.bot.http_session.get(self.peps_listing_api_url) as resp: + listing = await resp.json() + + for file in listing: + name = file["name"] + if name.startswith("pep-") and (name.endswith(".txt") or name.endswith(".rst")): + self.peps[int(name.split(".")[0].split("-")[1])] = file["download_url"] + + @command(name='pep', aliases=('get_pep', 'p')) + async def pep_command(self, ctx: Context, pep_number: int) -> None: + """Fetches information about a PEP and sends it to the channel.""" + # Trigger typing in chat to show users that bot is responding + await ctx.trigger_typing() + + # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. + if pep_number == 0: + pep_embed = await self.get_pep_zero_embed() + else: + pep_embed = await self.get_pep_embed(pep_number) + await ctx.send(embed=pep_embed) + async def get_pep_zero_embed(self) -> Embed: """Send information about PEP 0.""" pep_embed = Embed( -- cgit v1.2.3 From 34509a59664fc7d00e1eff85800d1e35e33ccb85 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 13 May 2020 19:56:27 +0300 Subject: PEP Improvisations: Added `staticmethod` decorator to `get_pep_zero_embed` --- bot/cogs/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 7c6541ccb..e56ffb4dd 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -264,7 +264,8 @@ class Utils(Cog): pep_embed = await self.get_pep_embed(pep_number) await ctx.send(embed=pep_embed) - async def get_pep_zero_embed(self) -> Embed: + @staticmethod + async def get_pep_zero_embed() -> Embed: """Send information about PEP 0.""" pep_embed = Embed( title=f"**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", -- cgit v1.2.3 From 34b8ae45e644226c75ce070db4b8b375129d278a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 13 May 2020 19:59:03 +0300 Subject: PEP Improvisations: Replaced `wait_until_guild_available` with `wait_until_ready` --- bot/cogs/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index e56ffb4dd..91f462c42 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -241,7 +241,7 @@ class Utils(Cog): async def refresh_peps_urls(self) -> None: """Refresh PEP URLs listing in every 3 hours.""" # Wait until HTTP client is available - await self.bot.wait_until_guild_available() + await self.bot.wait_until_ready() async with self.bot.http_session.get(self.peps_listing_api_url) as resp: listing = await resp.json() -- cgit v1.2.3 From 04cdb55fabfc21fc548754ed10aafec845ffe8db Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 13 May 2020 20:02:25 +0300 Subject: PEP Improvisations: Added logging to PEP URLs fetching task --- bot/cogs/utils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 91f462c42..b72ba8d5a 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -242,14 +242,17 @@ class Utils(Cog): """Refresh PEP URLs listing in every 3 hours.""" # Wait until HTTP client is available await self.bot.wait_until_ready() + log.trace("Started refreshing PEP URLs.") async with self.bot.http_session.get(self.peps_listing_api_url) as resp: listing = await resp.json() + log.trace("Got PEP URLs listing from GitHub API") for file in listing: name = file["name"] if name.startswith("pep-") and (name.endswith(".txt") or name.endswith(".rst")): self.peps[int(name.split(".")[0].split("-")[1])] = file["download_url"] + log.info("Successfully refreshed PEP URLs listing.") @command(name='pep', aliases=('get_pep', 'p')) async def pep_command(self, ctx: Context, pep_number: int) -> None: -- cgit v1.2.3 From b6968695a9da0f3c3597b9fb187753b98b778718 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 13 May 2020 20:08:35 +0300 Subject: PEP Improvisations: Made PEP URLs refreshing task PEP number resolving easier --- bot/cogs/utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index b72ba8d5a..f6b56db73 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -250,8 +250,10 @@ class Utils(Cog): for file in listing: name = file["name"] - if name.startswith("pep-") and (name.endswith(".txt") or name.endswith(".rst")): - self.peps[int(name.split(".")[0].split("-")[1])] = file["download_url"] + name: str + if name.startswith("pep-") and name.endswith((".rst", ".txt")): + pep_number = name.replace("pep-", "").split(".")[0] + self.peps[int(pep_number)] = file["download_url"] log.info("Successfully refreshed PEP URLs listing.") @command(name='pep', aliases=('get_pep', 'p')) -- cgit v1.2.3 From be71ac7847723f8f90dc095ebaa7257e189fa1c6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 14 May 2020 08:42:55 +0300 Subject: PEP Improvisations: Implemented stats to PEP command --- bot/cogs/utils.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index f6b56db73..bb655085d 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -265,12 +265,16 @@ class Utils(Cog): # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. if pep_number == 0: pep_embed = await self.get_pep_zero_embed() + success = True else: - pep_embed = await self.get_pep_embed(pep_number) + pep_embed, success = await self.get_pep_embed(pep_number) await ctx.send(embed=pep_embed) - @staticmethod - async def get_pep_zero_embed() -> Embed: + if success: + log.trace(f"PEP {pep_number} getting and sending finished successfully. Increasing stat.") + self.bot.stats.incr(f"pep_fetches.{pep_number}") + + async def get_pep_zero_embed(self) -> Embed: """Send information about PEP 0.""" pep_embed = Embed( title=f"**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", @@ -284,12 +288,12 @@ class Utils(Cog): return pep_embed @async_cache(arg_offset=1) - async def get_pep_embed(self, pep_nr: int) -> Embed: + async def get_pep_embed(self, pep_nr: int) -> Tuple[Embed, bool]: """Fetch, generate and return PEP embed. Implement `async_cache`.""" if pep_nr not in self.peps: log.trace(f"PEP {pep_nr} was not found") not_found = f"PEP {pep_nr} does not exist." - return Embed(title="PEP not found", description=not_found, colour=Colour.red()) + return Embed(title="PEP not found", description=not_found, colour=Colour.red()), False response = await self.bot.http_session.get(self.peps[pep_nr]) if response.status == 200: @@ -314,13 +318,13 @@ class Utils(Cog): # embed field values can't contain an empty string if pep_header.get(field, ""): pep_embed.add_field(name=field, value=pep_header[field]) - return pep_embed + return pep_embed, True else: log.trace(f"The user requested PEP {pep_nr}, but the response had an unexpected status code: " f"{response.status}.\n{response.text}") error_message = "Unexpected HTTP error during PEP search. Please let us know." - return Embed(title="Unexpected error", description=error_message, colour=Colour.red()) + return Embed(title="Unexpected error", description=error_message, colour=Colour.red()), False def setup(bot: Bot) -> None: -- cgit v1.2.3 From d560b8315f46b7598c0ef7b7b5c75b3c035796da Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 14 May 2020 08:45:16 +0300 Subject: PEP Improvisations: Moved `get_pep_zero_embed` to outside of Cog --- bot/cogs/utils.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index bb655085d..15a3e9e8c 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -45,6 +45,20 @@ Namespaces are one honking great idea -- let's do more of those! ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" +def get_pep_zero_embed() -> Embed: + """Send information about PEP 0.""" + pep_embed = Embed( + title=f"**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", + description=f"[Link](https://www.python.org/dev/peps/)" + ) + pep_embed.set_thumbnail(url=ICON_URL) + pep_embed.add_field(name="Status", value="Active") + pep_embed.add_field(name="Created", value="13-Jul-2000") + pep_embed.add_field(name="Type", value="Informational") + + return pep_embed + + class Utils(Cog): """A selection of utilities which don't have a clear category.""" @@ -264,7 +278,7 @@ class Utils(Cog): # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. if pep_number == 0: - pep_embed = await self.get_pep_zero_embed() + pep_embed = get_pep_zero_embed() success = True else: pep_embed, success = await self.get_pep_embed(pep_number) @@ -274,19 +288,6 @@ class Utils(Cog): log.trace(f"PEP {pep_number} getting and sending finished successfully. Increasing stat.") self.bot.stats.incr(f"pep_fetches.{pep_number}") - async def get_pep_zero_embed(self) -> Embed: - """Send information about PEP 0.""" - pep_embed = Embed( - title=f"**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", - description=f"[Link](https://www.python.org/dev/peps/)" - ) - pep_embed.set_thumbnail(url=ICON_URL) - pep_embed.add_field(name="Status", value="Active") - pep_embed.add_field(name="Created", value="13-Jul-2000") - pep_embed.add_field(name="Type", value="Informational") - - return pep_embed - @async_cache(arg_offset=1) async def get_pep_embed(self, pep_nr: int) -> Tuple[Embed, bool]: """Fetch, generate and return PEP embed. Implement `async_cache`.""" -- cgit v1.2.3 From e2c30322fc32b601faa0b2a66367cd98f91fe627 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 14 May 2020 09:07:03 +0300 Subject: PEP Improvisations: Fix imports Replace `in_channel` with `in_whitelist`. This mistake was made to merge conflicts. --- bot/cogs/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 0b1436f3a..fe7e5b3e9 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -12,7 +12,7 @@ from discord.ext.tasks import loop from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES -from bot.decorators import in_channel, with_role +from bot.decorators import in_whitelist, with_role from bot.utils.cache import async_cache log = logging.getLogger(__name__) -- cgit v1.2.3 From 36dac33d81bd174d8a005e6fc02055d8d096cfd8 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 15 May 2020 09:15:51 +0300 Subject: PEP Improvisations: Remove unnecessary typehint Removed unnecessary type hint that I used for IDE and what I forget to remove. --- bot/cogs/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index fe7e5b3e9..c24252aa6 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -220,7 +220,6 @@ class Utils(Cog): for file in listing: name = file["name"] - name: str if name.startswith("pep-") and name.endswith((".rst", ".txt")): pep_number = name.replace("pep-", "").split(".")[0] self.peps[int(pep_number)] = file["download_url"] -- cgit v1.2.3 From 248ce24936cd09e560c651c6c5953d1ea90d8229 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 15 May 2020 09:17:20 +0300 Subject: PEP Improvisations: Fix log text formatting Use repo own alignment of multiline text. --- bot/cogs/utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index c24252aa6..6562ea0b4 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -276,8 +276,10 @@ class Utils(Cog): pep_embed.add_field(name=field, value=pep_header[field]) return pep_embed, True else: - log.trace(f"The user requested PEP {pep_nr}, but the response had an unexpected status code: " - f"{response.status}.\n{response.text}") + log.trace( + f"The user requested PEP {pep_nr}, but the response had an unexpected status code: " + f"{response.status}.\n{response.text}" + ) error_message = "Unexpected HTTP error during PEP search. Please let us know." return Embed(title="Unexpected error", description=error_message, colour=Colour.red()), False -- cgit v1.2.3 From 378929eff99fa6330c2d1a5b1c1108ff80e11d92 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 15 May 2020 09:19:22 +0300 Subject: PEP Improvisations: Move `get_pep_zero_embed` back to Cog Moved `get_pep_zero_embed` back to the cog, but made this `staticmethod`. --- bot/cogs/utils.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 6562ea0b4..80cdd9210 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -42,20 +42,6 @@ Namespaces are one honking great idea -- let's do more of those! ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" -def get_pep_zero_embed() -> Embed: - """Send information about PEP 0.""" - pep_embed = Embed( - title=f"**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", - description=f"[Link](https://www.python.org/dev/peps/)" - ) - pep_embed.set_thumbnail(url=ICON_URL) - pep_embed.add_field(name="Status", value="Active") - pep_embed.add_field(name="Created", value="13-Jul-2000") - pep_embed.add_field(name="Type", value="Informational") - - return pep_embed - - class Utils(Cog): """A selection of utilities which don't have a clear category.""" @@ -233,7 +219,7 @@ class Utils(Cog): # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. if pep_number == 0: - pep_embed = get_pep_zero_embed() + pep_embed = self.get_pep_zero_embed() success = True else: pep_embed, success = await self.get_pep_embed(pep_number) @@ -243,6 +229,20 @@ class Utils(Cog): log.trace(f"PEP {pep_number} getting and sending finished successfully. Increasing stat.") self.bot.stats.incr(f"pep_fetches.{pep_number}") + @staticmethod + def get_pep_zero_embed() -> Embed: + """Send information about PEP 0.""" + pep_embed = Embed( + title=f"**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", + description=f"[Link](https://www.python.org/dev/peps/)" + ) + pep_embed.set_thumbnail(url=ICON_URL) + pep_embed.add_field(name="Status", value="Active") + pep_embed.add_field(name="Created", value="13-Jul-2000") + pep_embed.add_field(name="Type", value="Informational") + + return pep_embed + @async_cache(arg_offset=1) async def get_pep_embed(self, pep_nr: int) -> Tuple[Embed, bool]: """Fetch, generate and return PEP embed. Implement `async_cache`.""" -- cgit v1.2.3 From c412ceb33b1309a728d1e607e0e97b5ea6f1be3d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 15 May 2020 09:20:05 +0300 Subject: PEP Improvisations: Fix `get_pep_zero_embed` docstring --- bot/cogs/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 80cdd9210..09c17dbff 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -231,7 +231,7 @@ class Utils(Cog): @staticmethod def get_pep_zero_embed() -> Embed: - """Send information about PEP 0.""" + """Get information embed about PEP 0.""" pep_embed = Embed( title=f"**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", description=f"[Link](https://www.python.org/dev/peps/)" -- cgit v1.2.3 From 811ee70da17654a00d6ae3fbf32261b3e4f4c784 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 15 May 2020 09:20:47 +0300 Subject: PEP Improvisations: Fix `get_pep_embed` docstring --- bot/cogs/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 09c17dbff..6871ba44c 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -245,7 +245,7 @@ class Utils(Cog): @async_cache(arg_offset=1) async def get_pep_embed(self, pep_nr: int) -> Tuple[Embed, bool]: - """Fetch, generate and return PEP embed. Implement `async_cache`.""" + """Fetch, generate and return PEP embed.""" if pep_nr not in self.peps: log.trace(f"PEP {pep_nr} was not found") not_found = f"PEP {pep_nr} does not exist." -- cgit v1.2.3 From 3bc2c1b116e4b696b8b2409d0621bde3197d2763 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 15 May 2020 09:28:46 +0300 Subject: PEP Improvisations: Move errors sending from PEP command to `get_pep_embed` Before this, all error embeds was returned on `get_pep_embed` but now this send this itself and return only correct embed to make checking easier in command. --- bot/cogs/utils.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 6871ba44c..a2f9d362e 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -4,7 +4,7 @@ import re import unicodedata from email.parser import HeaderParser from io import StringIO -from typing import Dict, Tuple, Union +from typing import Dict, Optional, Tuple, Union from discord import Colour, Embed from discord.ext.commands import BadArgument, Cog, Context, command @@ -220,12 +220,11 @@ class Utils(Cog): # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. if pep_number == 0: pep_embed = self.get_pep_zero_embed() - success = True else: - pep_embed, success = await self.get_pep_embed(pep_number) - await ctx.send(embed=pep_embed) + pep_embed = await self.get_pep_embed(pep_number, ctx) - if success: + if pep_embed: + await ctx.send(embed=pep_embed) log.trace(f"PEP {pep_number} getting and sending finished successfully. Increasing stat.") self.bot.stats.incr(f"pep_fetches.{pep_number}") @@ -244,12 +243,15 @@ class Utils(Cog): return pep_embed @async_cache(arg_offset=1) - async def get_pep_embed(self, pep_nr: int) -> Tuple[Embed, bool]: + async def get_pep_embed(self, pep_nr: int, ctx: Context) -> Optional[Embed]: """Fetch, generate and return PEP embed.""" if pep_nr not in self.peps: log.trace(f"PEP {pep_nr} was not found") not_found = f"PEP {pep_nr} does not exist." - return Embed(title="PEP not found", description=not_found, colour=Colour.red()), False + await ctx.send( + embed=Embed(title="PEP not found", description=not_found, colour=Colour.red()) + ) + return response = await self.bot.http_session.get(self.peps[pep_nr]) if response.status == 200: @@ -274,7 +276,7 @@ class Utils(Cog): # embed field values can't contain an empty string if pep_header.get(field, ""): pep_embed.add_field(name=field, value=pep_header[field]) - return pep_embed, True + return pep_embed else: log.trace( f"The user requested PEP {pep_nr}, but the response had an unexpected status code: " @@ -282,7 +284,10 @@ class Utils(Cog): ) error_message = "Unexpected HTTP error during PEP search. Please let us know." - return Embed(title="Unexpected error", description=error_message, colour=Colour.red()), False + await ctx.send( + embed=Embed(title="Unexpected error", description=error_message, colour=Colour.red()) + ) + return def setup(bot: Bot) -> None: -- cgit v1.2.3 From e3be25f8d64db4adec36798700423191916353d8 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 17 May 2020 09:25:37 +0300 Subject: PEP Improvisations: Simplify cache item check on `async_cache` decorator Co-authored-by: Mark --- bot/utils/cache.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/utils/cache.py b/bot/utils/cache.py index 338924df8..1c0935faa 100644 --- a/bot/utils/cache.py +++ b/bot/utils/cache.py @@ -21,8 +21,7 @@ def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: """Decorator wrapper for the caching logic.""" key = ':'.join(str(args[arg_offset:])) - value = async_cache.cache.get(key) - if value is None: + if key in async_cache.cache: if len(async_cache.cache) > max_size: async_cache.cache.popitem(last=False) -- cgit v1.2.3 From fb27b234a92e4572697a973b3f151863bb13bea1 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 17 May 2020 10:32:14 +0300 Subject: PEP Improvisations: Fix formatting of blocks Added newline before logging after indention block. Co-authored-by: Mark --- bot/cogs/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index a2f9d362e..12f7204fc 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -202,6 +202,7 @@ class Utils(Cog): async with self.bot.http_session.get(self.peps_listing_api_url) as resp: listing = await resp.json() + log.trace("Got PEP URLs listing from GitHub API") for file in listing: @@ -209,6 +210,7 @@ class Utils(Cog): if name.startswith("pep-") and name.endswith((".rst", ".txt")): pep_number = name.replace("pep-", "").split(".")[0] self.peps[int(pep_number)] = file["download_url"] + log.info("Successfully refreshed PEP URLs listing.") @command(name='pep', aliases=('get_pep', 'p')) -- cgit v1.2.3 From 7d0f56917c10709a951f579a030177701ea66339 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 17 May 2020 10:34:38 +0300 Subject: PEP Improvisations: Remove response from logging to avoid newline --- bot/cogs/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 12f7204fc..fac2af721 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -281,8 +281,7 @@ class Utils(Cog): return pep_embed else: log.trace( - f"The user requested PEP {pep_nr}, but the response had an unexpected status code: " - f"{response.status}.\n{response.text}" + f"The user requested PEP {pep_nr}, but the response had an unexpected status code: {response.status}." ) error_message = "Unexpected HTTP error during PEP search. Please let us know." -- cgit v1.2.3 From 50ee35da9cf759094bd73d9c17a77283c1dd7547 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 17 May 2020 10:37:55 +0300 Subject: PEP Improvisations: Move error embed to variables instead creating on `ctx.send` --- bot/cogs/utils.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index fac2af721..303a8c1fb 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -250,10 +250,10 @@ class Utils(Cog): if pep_nr not in self.peps: log.trace(f"PEP {pep_nr} was not found") not_found = f"PEP {pep_nr} does not exist." - await ctx.send( - embed=Embed(title="PEP not found", description=not_found, colour=Colour.red()) - ) + embed = Embed(title="PEP not found", description=not_found, colour=Colour.red()) + await ctx.send(embed=embed) return + response = await self.bot.http_session.get(self.peps[pep_nr]) if response.status == 200: @@ -285,9 +285,8 @@ class Utils(Cog): ) error_message = "Unexpected HTTP error during PEP search. Please let us know." - await ctx.send( - embed=Embed(title="Unexpected error", description=error_message, colour=Colour.red()) - ) + embed = Embed(title="Unexpected error", description=error_message, colour=Colour.red()) + await ctx.send(embed=embed) return -- cgit v1.2.3 From d3072a23d460524e9bb64b8724afbd0b2c44e305 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 17 May 2020 10:58:54 +0300 Subject: PEP Improvisations: Fix cache if statement Add `not` in check is key exist in cache. --- bot/utils/cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/cache.py b/bot/utils/cache.py index 1c0935faa..96e1aef95 100644 --- a/bot/utils/cache.py +++ b/bot/utils/cache.py @@ -21,7 +21,7 @@ def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: """Decorator wrapper for the caching logic.""" key = ':'.join(str(args[arg_offset:])) - if key in async_cache.cache: + if key not in async_cache.cache: if len(async_cache.cache) > max_size: async_cache.cache.popitem(last=False) -- cgit v1.2.3 From 0f0faa06b60a12a965065f82e6400bab31ab1284 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 17 May 2020 11:01:16 +0300 Subject: PEP Improvisations: Remove PEP URLs refreshing task + replace it with new system Now PEP command request PEP listing when PEP is not found and last refresh was more time ago than 30 minutes instead task. --- bot/cogs/utils.py | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 303a8c1fb..55164faf1 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -2,13 +2,13 @@ import difflib import logging import re import unicodedata +from datetime import datetime, timedelta from email.parser import HeaderParser from io import StringIO from typing import Dict, Optional, Tuple, Union from discord import Colour, Embed from discord.ext.commands import BadArgument, Cog, Context, command -from discord.ext.tasks import loop from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES @@ -53,7 +53,8 @@ class Utils(Cog): self.peps_listing_api_url = "https://api.github.com/repos/python/peps/contents?ref=master" self.peps: Dict[int, str] = {} - self.refresh_peps_urls.start() + self.last_refreshed_peps: Optional[datetime] = None + self.bot.loop.create_task(self.refresh_peps_urls()) @command() @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) @@ -193,7 +194,6 @@ class Utils(Cog): # PEPs area - @loop(hours=3) async def refresh_peps_urls(self) -> None: """Refresh PEP URLs listing in every 3 hours.""" # Wait until HTTP client is available @@ -211,6 +211,7 @@ class Utils(Cog): pep_number = name.replace("pep-", "").split(".")[0] self.peps[int(pep_number)] = file["download_url"] + self.last_refreshed_peps = datetime.now() log.info("Successfully refreshed PEP URLs listing.") @command(name='pep', aliases=('get_pep', 'p')) @@ -223,7 +224,7 @@ class Utils(Cog): if pep_number == 0: pep_embed = self.get_pep_zero_embed() else: - pep_embed = await self.get_pep_embed(pep_number, ctx) + pep_embed = await self.get_pep_embed(ctx, pep_number) if pep_embed: await ctx.send(embed=pep_embed) @@ -244,15 +245,20 @@ class Utils(Cog): return pep_embed - @async_cache(arg_offset=1) - async def get_pep_embed(self, pep_nr: int, ctx: Context) -> Optional[Embed]: + @async_cache(arg_offset=2) + async def get_pep_embed(self, ctx: Context, pep_nr: int) -> Optional[Embed]: """Fetch, generate and return PEP embed.""" - if pep_nr not in self.peps: - log.trace(f"PEP {pep_nr} was not found") - not_found = f"PEP {pep_nr} does not exist." - embed = Embed(title="PEP not found", description=not_found, colour=Colour.red()) - await ctx.send(embed=embed) - return + while True: + if pep_nr not in self.peps and (self.last_refreshed_peps + timedelta(minutes=30)) > datetime.now(): + log.trace(f"PEP {pep_nr} was not found") + not_found = f"PEP {pep_nr} does not exist." + embed = Embed(title="PEP not found", description=not_found, colour=Colour.red()) + await ctx.send(embed=embed) + return + elif pep_nr not in self.peps: + await self.refresh_peps_urls() + else: + break response = await self.bot.http_session.get(self.peps[pep_nr]) -- cgit v1.2.3 From 65c07cc96b8309c9002b87a07a7ebdbb9538342a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 18 May 2020 08:13:21 +0300 Subject: PEP: Removed `while` loop from refresh checking on `get_pep_embed` --- bot/cogs/utils.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 55164faf1..73337f012 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -248,17 +248,15 @@ class Utils(Cog): @async_cache(arg_offset=2) async def get_pep_embed(self, ctx: Context, pep_nr: int) -> Optional[Embed]: """Fetch, generate and return PEP embed.""" - while True: - if pep_nr not in self.peps and (self.last_refreshed_peps + timedelta(minutes=30)) > datetime.now(): - log.trace(f"PEP {pep_nr} was not found") - not_found = f"PEP {pep_nr} does not exist." - embed = Embed(title="PEP not found", description=not_found, colour=Colour.red()) - await ctx.send(embed=embed) - return - elif pep_nr not in self.peps: - await self.refresh_peps_urls() - else: - break + if pep_nr not in self.peps and (self.last_refreshed_peps + timedelta(minutes=30)) <= datetime.now(): + await self.refresh_peps_urls() + + if pep_nr not in self.peps: + log.trace(f"PEP {pep_nr} was not found") + not_found = f"PEP {pep_nr} does not exist." + embed = Embed(title="PEP not found", description=not_found, colour=Colour.red()) + await ctx.send(embed=embed) + return response = await self.bot.http_session.get(self.peps[pep_nr]) -- cgit v1.2.3 From b1f2b40623f45daf880186fa825fd69a7fc12092 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:23:27 -0700 Subject: Move code block formatting detection to a separate extension/cog It was really out of place in the BotCog, which is meant more for general, simple utility commands. --- bot/cogs/bot.py | 324 +--------------------------------------- bot/cogs/codeblock/__init__.py | 7 + bot/cogs/codeblock/cog.py | 332 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 342 insertions(+), 321 deletions(-) create mode 100644 bot/cogs/codeblock/__init__.py create mode 100644 bot/cogs/codeblock/cog.py diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index a79b37d25..89c691ccd 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -1,22 +1,15 @@ -import ast import logging -import re -import time -from typing import Optional, Tuple +from typing import Optional -from discord import Embed, Message, RawMessageUpdateEvent, TextChannel +from discord import Embed, TextChannel from discord.ext.commands import Cog, Context, command, group from bot.bot import Bot -from bot.cogs.token_remover import TokenRemover -from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs +from bot.constants import Guild, MODERATION_ROLES, Roles, URLs from bot.decorators import with_role -from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) -RE_MARKDOWN = re.compile(r'([*_~`|>])') - class BotCog(Cog, name="Bot"): """Bot information commands.""" @@ -24,19 +17,6 @@ class BotCog(Cog, name="Bot"): def __init__(self, bot: Bot): self.bot = bot - # Stores allowed channels plus epoch time since last call. - self.channel_cooldowns = { - Channels.python_discussion: 0, - } - - # These channels will also work, but will not be subject to cooldown - self.channel_whitelist = ( - Channels.bot_commands, - ) - - # Stores improperly formatted Python codeblock message ids and the corresponding bot message - self.codeblock_message_ids = {} - @group(invoke_without_command=True, name="bot", hidden=True) @with_role(Roles.verified) async def botinfo_group(self, ctx: Context) -> None: @@ -77,304 +57,6 @@ class BotCog(Cog, name="Bot"): embed = Embed(description=text) await ctx.send(embed=embed) - def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]: - """ - Strip msg in order to find Python code. - - Tries to strip out Python code out of msg and returns the stripped block or - None if the block is a valid Python codeblock. - """ - if msg.count("\n") >= 3: - # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. - if re.search("```(?:py|python)\n(.*?)```", msg, re.IGNORECASE | re.DOTALL) and not bad_ticks: - log.trace( - "Someone wrote a message that was already a " - "valid Python syntax highlighted code block. No action taken." - ) - return None - - else: - # Stripping backticks from every line of the message. - log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") - content = "" - for line in msg.splitlines(keepends=True): - content += line.strip("`") - - content = content.strip() - - # Remove "Python" or "Py" from start of the message if it exists. - log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") - pycode = False - if content.lower().startswith("python"): - content = content[6:] - pycode = True - elif content.lower().startswith("py"): - content = content[2:] - pycode = True - - if pycode: - content = content.splitlines(keepends=True) - - # Check if there might be code in the first line, and preserve it. - first_line = content[0] - if " " in content[0]: - first_space = first_line.index(" ") - content[0] = first_line[first_space:] - content = "".join(content) - - # If there's no code we can just get rid of the first line. - else: - content = "".join(content[1:]) - - # Strip it again to remove any leading whitespace. This is neccessary - # if the first line of the message looked like ```python - old = content.strip() - - # Strips REPL code out of the message if there is any. - content, repl_code = self.repl_stripping(old) - if old != content: - return (content, old), repl_code - - # Try to apply indentation fixes to the code. - content = self.fix_indentation(content) - - # Check if the code contains backticks, if it does ignore the message. - if "`" in content: - log.trace("Detected ` inside the code, won't reply") - return None - else: - log.trace(f"Returning message.\n\n{content}\n\n") - return (content,), repl_code - - def fix_indentation(self, msg: str) -> str: - """Attempts to fix badly indented code.""" - def unindent(code: str, skip_spaces: int = 0) -> str: - """Unindents all code down to the number of spaces given in skip_spaces.""" - final = "" - current = code[0] - leading_spaces = 0 - - # Get numbers of spaces before code in the first line. - while current == " ": - current = code[leading_spaces + 1] - leading_spaces += 1 - leading_spaces -= skip_spaces - - # If there are any, remove that number of spaces from every line. - if leading_spaces > 0: - for line in code.splitlines(keepends=True): - line = line[leading_spaces:] - final += line - return final - else: - return code - - # Apply fix for "all lines are overindented" case. - msg = unindent(msg) - - # If the first line does not end with a colon, we can be - # certain the next line will be on the same indentation level. - # - # If it does end with a colon, we will need to indent all successive - # lines one additional level. - first_line = msg.splitlines()[0] - code = "".join(msg.splitlines(keepends=True)[1:]) - if not first_line.endswith(":"): - msg = f"{first_line}\n{unindent(code)}" - else: - msg = f"{first_line}\n{unindent(code, 4)}" - return msg - - def repl_stripping(self, msg: str) -> Tuple[str, bool]: - """ - Strip msg in order to extract Python code out of REPL output. - - Tries to strip out REPL Python code out of msg and returns the stripped msg. - - Returns True for the boolean if REPL code was found in the input msg. - """ - final = "" - for line in msg.splitlines(keepends=True): - if line.startswith(">>>") or line.startswith("..."): - final += line[4:] - log.trace(f"Formatted: \n\n{msg}\n\n to \n\n{final}\n\n") - if not final: - log.trace(f"Found no REPL code in \n\n{msg}\n\n") - return msg, False - else: - log.trace(f"Found REPL code in \n\n{msg}\n\n") - return final.rstrip(), True - - def has_bad_ticks(self, msg: Message) -> bool: - """Check to see if msg contains ticks that aren't '`'.""" - not_backticks = [ - "'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019", - "\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033", - "\u3003\u3003\u3003" - ] - - return msg.content[:3] in not_backticks - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """ - Detect poorly formatted Python code in new messages. - - If poorly formatted code is detected, send the user a helpful message explaining how to do - properly formatted Python syntax highlighting codeblocks. - """ - is_help_channel = ( - getattr(msg.channel, "category", None) - and msg.channel.category.id in (Categories.help_available, Categories.help_in_use) - ) - parse_codeblock = ( - ( - is_help_channel - or msg.channel.id in self.channel_cooldowns - or msg.channel.id in self.channel_whitelist - ) - and not msg.author.bot - and len(msg.content.splitlines()) > 3 - and not TokenRemover.find_token_in_message(msg) - ) - - if parse_codeblock: # no token in the msg - on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 - if not on_cooldown or DEBUG_MODE: - try: - if self.has_bad_ticks(msg): - ticks = msg.content[:3] - content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) - if content is None: - return - - content, repl_code = content - - if len(content) == 2: - content = content[1] - else: - content = content[0] - - space_left = 204 - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto = ( - "It looks like you are trying to paste code into this channel.\n\n" - "You seem to be using the wrong symbols to indicate where the codeblock should start. " - f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" - "**Here is an example of how it should look:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - else: - howto = "" - content = self.codeblock_stripping(msg.content, False) - if content is None: - return - - content, repl_code = content - # Attempts to parse the message into an AST node. - # Invalid Python code will raise a SyntaxError. - tree = ast.parse(content[0]) - - # Multiple lines of single words could be interpreted as expressions. - # This check is to avoid all nodes being parsed as expressions. - # (e.g. words over multiple lines) - if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: - # Shorten the code to 10 lines and/or 204 characters. - space_left = 204 - if content and repl_code: - content = content[1] - else: - content = content[0] - - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto += ( - "It looks like you're trying to paste code into this channel.\n\n" - "Discord has support for Markdown, which allows you to post code with full " - "syntax highlighting. Please use these whenever you paste code, as this " - "helps improve the legibility and makes it easier for us to help you.\n\n" - f"**To do this, use the following method:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - log.debug(f"{msg.author} posted something that needed to be put inside python code " - "blocks. Sending the user some instructions.") - else: - log.trace("The code consists only of expressions, not sending instructions") - - if howto != "": - # Increase amount of codeblock correction in stats - self.bot.stats.incr("codeblock_corrections") - howto_embed = Embed(description=howto) - bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) - self.codeblock_message_ids[msg.id] = bot_message.id - - self.bot.loop.create_task( - wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) - ) - else: - return - - if msg.channel.id not in self.channel_whitelist: - self.channel_cooldowns[msg.channel.id] = time.time() - - except SyntaxError: - log.trace( - f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " - "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " - f"The message that was posted was:\n\n{msg.content}\n\n" - ) - - @Cog.listener() - async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: - """Check to see if an edited message (previously called out) still contains poorly formatted code.""" - if ( - # Checks to see if the message was called out by the bot - payload.message_id not in self.codeblock_message_ids - # Makes sure that there is content in the message - or payload.data.get("content") is None - # Makes sure there's a channel id in the message payload - or payload.data.get("channel_id") is None - ): - return - - # Retrieve channel and message objects for use later - channel = self.bot.get_channel(int(payload.data.get("channel_id"))) - user_message = await channel.fetch_message(payload.message_id) - - # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None - has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message)) - - # If the message is fixed, delete the bot message and the entry from the id dictionary - if has_fixed_codeblock is None: - bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) - await bot_message.delete() - del self.codeblock_message_ids[payload.message_id] - log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") - def setup(bot: Bot) -> None: """Load the Bot cog.""" diff --git a/bot/cogs/codeblock/__init__.py b/bot/cogs/codeblock/__init__.py new file mode 100644 index 000000000..466933191 --- /dev/null +++ b/bot/cogs/codeblock/__init__.py @@ -0,0 +1,7 @@ +from bot.bot import Bot +from .cog import CodeBlockCog + + +def setup(bot: Bot) -> None: + """Load the CodeBlockCog cog.""" + bot.add_cog(CodeBlockCog(bot)) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py new file mode 100644 index 000000000..7e35e24a9 --- /dev/null +++ b/bot/cogs/codeblock/cog.py @@ -0,0 +1,332 @@ +import ast +import logging +import re +import time +from typing import Optional, Tuple + +from discord import Embed, Message, RawMessageUpdateEvent +from discord.ext.commands import Bot, Cog + +from bot.cogs.token_remover import TokenRemover +from bot.constants import Categories, Channels, DEBUG_MODE +from bot.utils.messages import wait_for_deletion + +log = logging.getLogger(__name__) + +RE_MARKDOWN = re.compile(r'([*_~`|>])') + + +class CodeBlockCog(Cog, name="Code Block"): + """Detect improperly formatted code blocks and suggest proper formatting.""" + + def __init__(self, bot: Bot): + self.bot = bot + + # Stores allowed channels plus epoch time since last call. + self.channel_cooldowns = { + Channels.python_discussion: 0, + } + + # These channels will also work, but will not be subject to cooldown + self.channel_whitelist = ( + Channels.bot_commands, + ) + + # Stores improperly formatted Python codeblock message ids and the corresponding bot message + self.codeblock_message_ids = {} + + def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]: + """ + Strip msg in order to find Python code. + + Tries to strip out Python code out of msg and returns the stripped block or + None if the block is a valid Python codeblock. + """ + if msg.count("\n") >= 3: + # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. + if re.search("```(?:py|python)\n(.*?)```", msg, re.IGNORECASE | re.DOTALL) and not bad_ticks: + log.trace( + "Someone wrote a message that was already a " + "valid Python syntax highlighted code block. No action taken." + ) + return None + + else: + # Stripping backticks from every line of the message. + log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") + content = "" + for line in msg.splitlines(keepends=True): + content += line.strip("`") + + content = content.strip() + + # Remove "Python" or "Py" from start of the message if it exists. + log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") + pycode = False + if content.lower().startswith("python"): + content = content[6:] + pycode = True + elif content.lower().startswith("py"): + content = content[2:] + pycode = True + + if pycode: + content = content.splitlines(keepends=True) + + # Check if there might be code in the first line, and preserve it. + first_line = content[0] + if " " in content[0]: + first_space = first_line.index(" ") + content[0] = first_line[first_space:] + content = "".join(content) + + # If there's no code we can just get rid of the first line. + else: + content = "".join(content[1:]) + + # Strip it again to remove any leading whitespace. This is neccessary + # if the first line of the message looked like ```python + old = content.strip() + + # Strips REPL code out of the message if there is any. + content, repl_code = self.repl_stripping(old) + if old != content: + return (content, old), repl_code + + # Try to apply indentation fixes to the code. + content = self.fix_indentation(content) + + # Check if the code contains backticks, if it does ignore the message. + if "`" in content: + log.trace("Detected ` inside the code, won't reply") + return None + else: + log.trace(f"Returning message.\n\n{content}\n\n") + return (content,), repl_code + + def fix_indentation(self, msg: str) -> str: + """Attempts to fix badly indented code.""" + def unindent(code: str, skip_spaces: int = 0) -> str: + """Unindents all code down to the number of spaces given in skip_spaces.""" + final = "" + current = code[0] + leading_spaces = 0 + + # Get numbers of spaces before code in the first line. + while current == " ": + current = code[leading_spaces + 1] + leading_spaces += 1 + leading_spaces -= skip_spaces + + # If there are any, remove that number of spaces from every line. + if leading_spaces > 0: + for line in code.splitlines(keepends=True): + line = line[leading_spaces:] + final += line + return final + else: + return code + + # Apply fix for "all lines are overindented" case. + msg = unindent(msg) + + # If the first line does not end with a colon, we can be + # certain the next line will be on the same indentation level. + # + # If it does end with a colon, we will need to indent all successive + # lines one additional level. + first_line = msg.splitlines()[0] + code = "".join(msg.splitlines(keepends=True)[1:]) + if not first_line.endswith(":"): + msg = f"{first_line}\n{unindent(code)}" + else: + msg = f"{first_line}\n{unindent(code, 4)}" + return msg + + def repl_stripping(self, msg: str) -> Tuple[str, bool]: + """ + Strip msg in order to extract Python code out of REPL output. + + Tries to strip out REPL Python code out of msg and returns the stripped msg. + + Returns True for the boolean if REPL code was found in the input msg. + """ + final = "" + for line in msg.splitlines(keepends=True): + if line.startswith(">>>") or line.startswith("..."): + final += line[4:] + log.trace(f"Formatted: \n\n{msg}\n\n to \n\n{final}\n\n") + if not final: + log.trace(f"Found no REPL code in \n\n{msg}\n\n") + return msg, False + else: + log.trace(f"Found REPL code in \n\n{msg}\n\n") + return final.rstrip(), True + + def has_bad_ticks(self, msg: Message) -> bool: + """Check to see if msg contains ticks that aren't '`'.""" + not_backticks = [ + "'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019", + "\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033", + "\u3003\u3003\u3003" + ] + + return msg.content[:3] in not_backticks + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """ + Detect poorly formatted Python code in new messages. + + If poorly formatted code is detected, send the user a helpful message explaining how to do + properly formatted Python syntax highlighting codeblocks. + """ + is_help_channel = ( + getattr(msg.channel, "category", None) + and msg.channel.category.id in (Categories.help_available, Categories.help_in_use) + ) + parse_codeblock = ( + ( + is_help_channel + or msg.channel.id in self.channel_cooldowns + or msg.channel.id in self.channel_whitelist + ) + and not msg.author.bot + and len(msg.content.splitlines()) > 3 + and not TokenRemover.find_token_in_message(msg) + ) + + if parse_codeblock: # no token in the msg + on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 + if not on_cooldown or DEBUG_MODE: + try: + if self.has_bad_ticks(msg): + ticks = msg.content[:3] + content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) + if content is None: + return + + content, repl_code = content + + if len(content) == 2: + content = content[1] + else: + content = content[0] + + space_left = 204 + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + howto = ( + "It looks like you are trying to paste code into this channel.\n\n" + "You seem to be using the wrong symbols to indicate where the codeblock should start. " + f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" + "**Here is an example of how it should look:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + + else: + howto = "" + content = self.codeblock_stripping(msg.content, False) + if content is None: + return + + content, repl_code = content + # Attempts to parse the message into an AST node. + # Invalid Python code will raise a SyntaxError. + tree = ast.parse(content[0]) + + # Multiple lines of single words could be interpreted as expressions. + # This check is to avoid all nodes being parsed as expressions. + # (e.g. words over multiple lines) + if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: + # Shorten the code to 10 lines and/or 204 characters. + space_left = 204 + if content and repl_code: + content = content[1] + else: + content = content[0] + + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + howto += ( + "It looks like you're trying to paste code into this channel.\n\n" + "Discord has support for Markdown, which allows you to post code with full " + "syntax highlighting. Please use these whenever you paste code, as this " + "helps improve the legibility and makes it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + + log.debug(f"{msg.author} posted something that needed to be put inside python code " + "blocks. Sending the user some instructions.") + else: + log.trace("The code consists only of expressions, not sending instructions") + + if howto != "": + howto_embed = Embed(description=howto) + bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) + self.codeblock_message_ids[msg.id] = bot_message.id + + self.bot.loop.create_task( + wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) + ) + else: + return + + if msg.channel.id not in self.channel_whitelist: + self.channel_cooldowns[msg.channel.id] = time.time() + + except SyntaxError: + log.trace( + f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " + "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " + f"The message that was posted was:\n\n{msg.content}\n\n" + ) + + @Cog.listener() + async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: + """Check to see if an edited message (previously called out) still contains poorly formatted code.""" + if ( + # Checks to see if the message was called out by the bot + payload.message_id not in self.codeblock_message_ids + # Makes sure that there is content in the message + or payload.data.get("content") is None + # Makes sure there's a channel id in the message payload + or payload.data.get("channel_id") is None + ): + return + + # Retrieve channel and message objects for use later + channel = self.bot.get_channel(int(payload.data.get("channel_id"))) + user_message = await channel.fetch_message(payload.message_id) + + # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None + has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message)) + + # If the message is fixed, delete the bot message and the entry from the id dictionary + if has_fixed_codeblock is None: + bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + await bot_message.delete() + del self.codeblock_message_ids[payload.message_id] + log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") -- cgit v1.2.3 From 652bc5a1be4c181221ee40087a9c79d01fad10b8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:28:46 -0700 Subject: Code block: add helper function to check for help channels --- bot/cogs/codeblock/cog.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 7e35e24a9..af283120d 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -4,6 +4,7 @@ import re import time from typing import Optional, Tuple +import discord from discord import Embed, Message, RawMessageUpdateEvent from discord.ext.commands import Bot, Cog @@ -173,6 +174,14 @@ class CodeBlockCog(Cog, name="Code Block"): return msg.content[:3] in not_backticks + @staticmethod + def is_help_channel(channel: discord.TextChannel) -> bool: + """Return True if `channel` is in one of the help categories.""" + return ( + getattr(channel, "category", None) + and channel.category.id in (Categories.help_available, Categories.help_in_use) + ) + @Cog.listener() async def on_message(self, msg: Message) -> None: """ @@ -181,13 +190,9 @@ class CodeBlockCog(Cog, name="Code Block"): If poorly formatted code is detected, send the user a helpful message explaining how to do properly formatted Python syntax highlighting codeblocks. """ - is_help_channel = ( - getattr(msg.channel, "category", None) - and msg.channel.category.id in (Categories.help_available, Categories.help_in_use) - ) parse_codeblock = ( ( - is_help_channel + self.is_help_channel(msg.channel) or msg.channel.id in self.channel_cooldowns or msg.channel.id in self.channel_whitelist ) -- cgit v1.2.3 From 8af716254eb88bbf401665441a8d0ac1ca054671 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:34:11 -0700 Subject: Code block: add helper function to check channel is valid --- bot/cogs/codeblock/cog.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index af283120d..a1733ea99 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -182,6 +182,14 @@ class CodeBlockCog(Cog, name="Code Block"): and channel.category.id in (Categories.help_available, Categories.help_in_use) ) + def is_valid_channel(self, channel: discord.TextChannel) -> bool: + """Return True if `channel` is a help channel, may be on cooldown, or is whitelisted.""" + return ( + self.is_help_channel(channel) + or channel.id in self.channel_cooldowns + or channel.id in self.channel_whitelist + ) + @Cog.listener() async def on_message(self, msg: Message) -> None: """ @@ -191,11 +199,7 @@ class CodeBlockCog(Cog, name="Code Block"): properly formatted Python syntax highlighting codeblocks. """ parse_codeblock = ( - ( - self.is_help_channel(msg.channel) - or msg.channel.id in self.channel_cooldowns - or msg.channel.id in self.channel_whitelist - ) + self.is_valid_channel(msg.channel) and not msg.author.bot and len(msg.content.splitlines()) > 3 and not TokenRemover.find_token_in_message(msg) -- cgit v1.2.3 From 3b967c5228e439e127d096510d3097896536add3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:38:57 -0700 Subject: Code block: add helper function to check if msg should be parsed * Check for bot author first because it's a simpler/faster check --- bot/cogs/codeblock/cog.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index a1733ea99..9dd42fa81 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -190,6 +190,24 @@ class CodeBlockCog(Cog, name="Code Block"): or channel.id in self.channel_whitelist ) + def should_parse(self, message: discord.Message) -> bool: + """ + Return True if `message` should be parsed. + + A qualifying message: + + 1. Is not authored by a bot + 2. Is in a valid channel + 3. Has more than 3 lines + 4. Has no bot token + """ + return ( + not message.author.bot + and self.is_valid_channel(message.channel) + and len(message.content.splitlines()) > 3 + and not TokenRemover.find_token_in_message(message) + ) + @Cog.listener() async def on_message(self, msg: Message) -> None: """ @@ -198,14 +216,7 @@ class CodeBlockCog(Cog, name="Code Block"): If poorly formatted code is detected, send the user a helpful message explaining how to do properly formatted Python syntax highlighting codeblocks. """ - parse_codeblock = ( - self.is_valid_channel(msg.channel) - and not msg.author.bot - and len(msg.content.splitlines()) > 3 - and not TokenRemover.find_token_in_message(msg) - ) - - if parse_codeblock: # no token in the msg + if self.should_parse(msg): # no token in the msg on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 if not on_cooldown or DEBUG_MODE: try: -- cgit v1.2.3 From 76eff088a6e2aa832165087d441effee26d8fead Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:42:16 -0700 Subject: Code block: add helper function to check for channel cooldown --- bot/cogs/codeblock/cog.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 9dd42fa81..be7c3df84 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -182,6 +182,14 @@ class CodeBlockCog(Cog, name="Code Block"): and channel.category.id in (Categories.help_available, Categories.help_in_use) ) + def is_on_cooldown(self, channel: discord.TextChannel) -> bool: + """ + Return True if an embed was sent for `channel` in the last 300 seconds. + + Note: only channels in the `channel_cooldowns` have cooldowns enabled. + """ + return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < 300 + def is_valid_channel(self, channel: discord.TextChannel) -> bool: """Return True if `channel` is a help channel, may be on cooldown, or is whitelisted.""" return ( @@ -217,8 +225,7 @@ class CodeBlockCog(Cog, name="Code Block"): properly formatted Python syntax highlighting codeblocks. """ if self.should_parse(msg): # no token in the msg - on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 - if not on_cooldown or DEBUG_MODE: + if not self.is_on_cooldown(msg.channel) or DEBUG_MODE: try: if self.has_bad_ticks(msg): ticks = msg.content[:3] -- cgit v1.2.3 From 8f79a8bf5f1a7372c6de7d768f1593d5da599789 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:43:25 -0700 Subject: Code block: invert conditions to reduce nesting --- bot/cogs/codeblock/cog.py | 209 ++++++++++++++++++++++++---------------------- 1 file changed, 107 insertions(+), 102 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index be7c3df84..36c761764 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -224,113 +224,118 @@ class CodeBlockCog(Cog, name="Code Block"): If poorly formatted code is detected, send the user a helpful message explaining how to do properly formatted Python syntax highlighting codeblocks. """ - if self.should_parse(msg): # no token in the msg - if not self.is_on_cooldown(msg.channel) or DEBUG_MODE: - try: - if self.has_bad_ticks(msg): - ticks = msg.content[:3] - content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) - if content is None: - return - - content, repl_code = content - - if len(content) == 2: - content = content[1] - else: - content = content[0] - - space_left = 204 - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto = ( - "It looks like you are trying to paste code into this channel.\n\n" - "You seem to be using the wrong symbols to indicate where the codeblock should start. " - f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" - "**Here is an example of how it should look:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) + if not self.should_parse(msg): + return - else: - howto = "" - content = self.codeblock_stripping(msg.content, False) - if content is None: - return - - content, repl_code = content - # Attempts to parse the message into an AST node. - # Invalid Python code will raise a SyntaxError. - tree = ast.parse(content[0]) - - # Multiple lines of single words could be interpreted as expressions. - # This check is to avoid all nodes being parsed as expressions. - # (e.g. words over multiple lines) - if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: - # Shorten the code to 10 lines and/or 204 characters. - space_left = 204 - if content and repl_code: - content = content[1] - else: - content = content[0] - - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto += ( - "It looks like you're trying to paste code into this channel.\n\n" - "Discord has support for Markdown, which allows you to post code with full " - "syntax highlighting. Please use these whenever you paste code, as this " - "helps improve the legibility and makes it easier for us to help you.\n\n" - f"**To do this, use the following method:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - log.debug(f"{msg.author} posted something that needed to be put inside python code " - "blocks. Sending the user some instructions.") - else: - log.trace("The code consists only of expressions, not sending instructions") - - if howto != "": - howto_embed = Embed(description=howto) - bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) - self.codeblock_message_ids[msg.id] = bot_message.id - - self.bot.loop.create_task( - wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) - ) - else: - return + # When debugging, ignore cooldowns. + if self.is_on_cooldown(msg.channel) and not DEBUG_MODE: + return + + try: + if self.has_bad_ticks(msg): + ticks = msg.content[:3] + content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) + if content is None: + return - if msg.channel.id not in self.channel_whitelist: - self.channel_cooldowns[msg.channel.id] = time.time() + content, repl_code = content - except SyntaxError: - log.trace( - f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " - "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " - f"The message that was posted was:\n\n{msg.content}\n\n" + if len(content) == 2: + content = content[1] + else: + content = content[0] + + space_left = 204 + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + howto = ( + "It looks like you are trying to paste code into this channel.\n\n" + "You seem to be using the wrong symbols to indicate where the codeblock should start. " + f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" + "**Here is an example of how it should look:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + + else: + howto = "" + content = self.codeblock_stripping(msg.content, False) + if content is None: + return + + content, repl_code = content + # Attempts to parse the message into an AST node. + # Invalid Python code will raise a SyntaxError. + tree = ast.parse(content[0]) + + # Multiple lines of single words could be interpreted as expressions. + # This check is to avoid all nodes being parsed as expressions. + # (e.g. words over multiple lines) + if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: + # Shorten the code to 10 lines and/or 204 characters. + space_left = 204 + if content and repl_code: + content = content[1] + else: + content = content[0] + + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + howto += ( + "It looks like you're trying to paste code into this channel.\n\n" + "Discord has support for Markdown, which allows you to post code with full " + "syntax highlighting. Please use these whenever you paste code, as this " + "helps improve the legibility and makes it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" ) + log.debug(f"{msg.author} posted something that needed to be put inside python code " + "blocks. Sending the user some instructions.") + else: + log.trace("The code consists only of expressions, not sending instructions") + + if howto != "": + howto_embed = Embed(description=howto) + bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) + self.codeblock_message_ids[msg.id] = bot_message.id + + self.bot.loop.create_task( + wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) + ) + else: + return + + if msg.channel.id not in self.channel_whitelist: + self.channel_cooldowns[msg.channel.id] = time.time() + + except SyntaxError: + log.trace( + f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " + "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " + f"The message that was posted was:\n\n{msg.content}\n\n" + ) + @Cog.listener() async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: """Check to see if an edited message (previously called out) still contains poorly formatted code.""" -- cgit v1.2.3 From 644918f7a4952a8c5eb96c2c1181a3784e73cfb5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:53:05 -0700 Subject: Code block: add helper function to send the embed --- bot/cogs/codeblock/cog.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 36c761764..a4cd743e4 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -198,6 +198,20 @@ class CodeBlockCog(Cog, name="Code Block"): or channel.id in self.channel_whitelist ) + async def send_guide_embed(self, message: discord.Message, description: str) -> None: + """ + Send an embed with `description` as a guide for an improperly formatted `message`. + + The embed will be deleted automatically after 5 minutes. + """ + embed = Embed(description=description) + bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed) + self.codeblock_message_ids[message.id] = bot_message.id + + self.bot.loop.create_task( + wait_for_deletion(bot_message, user_ids=(message.author.id,), client=self.bot) + ) + def should_parse(self, message: discord.Message) -> bool: """ Return True if `message` should be parsed. @@ -316,13 +330,7 @@ class CodeBlockCog(Cog, name="Code Block"): log.trace("The code consists only of expressions, not sending instructions") if howto != "": - howto_embed = Embed(description=howto) - bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) - self.codeblock_message_ids[msg.id] = bot_message.id - - self.bot.loop.create_task( - wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) - ) + await self.send_guide_embed(msg, howto) else: return -- cgit v1.2.3 From fc5d7407dc0e52461c8940cf2eabb832e9c7a4a7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:56:35 -0700 Subject: Code block: move final send/cooldown code outside the try-except Reduces nesting for improved readability. The code would have never thrown a syntax error in the manner expected anyway. --- bot/cogs/codeblock/cog.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index a4cd743e4..312a7034e 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -328,21 +328,18 @@ class CodeBlockCog(Cog, name="Code Block"): "blocks. Sending the user some instructions.") else: log.trace("The code consists only of expressions, not sending instructions") - - if howto != "": - await self.send_guide_embed(msg, howto) - else: - return - - if msg.channel.id not in self.channel_whitelist: - self.channel_cooldowns[msg.channel.id] = time.time() - except SyntaxError: log.trace( f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " f"The message that was posted was:\n\n{msg.content}\n\n" ) + return + + if howto: + await self.send_guide_embed(msg, howto) + if msg.channel.id not in self.channel_whitelist: + self.channel_cooldowns[msg.channel.id] = time.time() @Cog.listener() async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: -- cgit v1.2.3 From aa37ffc42abf70135d17c3810bb2d35f810f965f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 14 Apr 2020 09:38:24 -0700 Subject: Code block: move bad ticks message creation to a new function --- bot/cogs/codeblock/cog.py | 70 +++++++++++++++++++++++++---------------------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 312a7034e..ddbe081dd 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -105,6 +105,42 @@ class CodeBlockCog(Cog, name="Code Block"): log.trace(f"Returning message.\n\n{content}\n\n") return (content,), repl_code + def format_bad_ticks_message(self, message: discord.Message) -> Optional[str]: + """Return the guide message to output for bad code block ticks in `message`.""" + ticks = message.content[:3] + content = self.codeblock_stripping(f"```{message.content[3:-3]}```", True) + if content is None: + return + + content, repl_code = content + + if len(content) == 2: + content = content[1] + else: + content = content[0] + + space_left = 204 + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + + return ( + "It looks like you are trying to paste code into this channel.\n\n" + "You seem to be using the wrong symbols to indicate where the codeblock should start. " + f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" + "**Here is an example of how it should look:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + def fix_indentation(self, msg: str) -> str: """Attempts to fix badly indented code.""" def unindent(code: str, skip_spaces: int = 0) -> str: @@ -247,39 +283,7 @@ class CodeBlockCog(Cog, name="Code Block"): try: if self.has_bad_ticks(msg): - ticks = msg.content[:3] - content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) - if content is None: - return - - content, repl_code = content - - if len(content) == 2: - content = content[1] - else: - content = content[0] - - space_left = 204 - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto = ( - "It looks like you are trying to paste code into this channel.\n\n" - "You seem to be using the wrong symbols to indicate where the codeblock should start. " - f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" - "**Here is an example of how it should look:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - + howto = self.format_bad_ticks_message(msg) else: howto = "" content = self.codeblock_stripping(msg.content, False) -- cgit v1.2.3 From 254fa81c691d387fa5fae661b56d642da7375863 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 14 Apr 2020 09:45:25 -0700 Subject: Code block: move standard guide message creation to a new function * Rename `howto` variable to `description` --- bot/cogs/codeblock/cog.py | 105 ++++++++++++++++++++++++---------------------- 1 file changed, 55 insertions(+), 50 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index ddbe081dd..7a9ca8e04 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -141,6 +141,57 @@ class CodeBlockCog(Cog, name="Code Block"): f"```python\n{content}\n```" ) + def format_guide_message(self, message: discord.Message) -> Optional[str]: + """Return the guide message to output for a poorly formatted code block in `message`.""" + content = self.codeblock_stripping(message.content, False) + if content is None: + return + + content, repl_code = content + # Attempts to parse the message into an AST node. + # Invalid Python code will raise a SyntaxError. + tree = ast.parse(content[0]) + + # Multiple lines of single words could be interpreted as expressions. + # This check is to avoid all nodes being parsed as expressions. + # (e.g. words over multiple lines) + if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: + # Shorten the code to 10 lines and/or 204 characters. + space_left = 204 + if content and repl_code: + content = content[1] + else: + content = content[0] + + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + + log.debug( + f"{message.author} posted something that needed to be put inside python code " + f"blocks. Sending the user some instructions." + ) + + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + return ( + "It looks like you're trying to paste code into this channel.\n\n" + "Discord has support for Markdown, which allows you to post code with full " + "syntax highlighting. Please use these whenever you paste code, as this " + "helps improve the legibility and makes it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + else: + log.trace("The code consists only of expressions, not sending instructions") + def fix_indentation(self, msg: str) -> str: """Attempts to fix badly indented code.""" def unindent(code: str, skip_spaces: int = 0) -> str: @@ -283,55 +334,9 @@ class CodeBlockCog(Cog, name="Code Block"): try: if self.has_bad_ticks(msg): - howto = self.format_bad_ticks_message(msg) + description = self.format_bad_ticks_message(msg) else: - howto = "" - content = self.codeblock_stripping(msg.content, False) - if content is None: - return - - content, repl_code = content - # Attempts to parse the message into an AST node. - # Invalid Python code will raise a SyntaxError. - tree = ast.parse(content[0]) - - # Multiple lines of single words could be interpreted as expressions. - # This check is to avoid all nodes being parsed as expressions. - # (e.g. words over multiple lines) - if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: - # Shorten the code to 10 lines and/or 204 characters. - space_left = 204 - if content and repl_code: - content = content[1] - else: - content = content[0] - - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto += ( - "It looks like you're trying to paste code into this channel.\n\n" - "Discord has support for Markdown, which allows you to post code with full " - "syntax highlighting. Please use these whenever you paste code, as this " - "helps improve the legibility and makes it easier for us to help you.\n\n" - f"**To do this, use the following method:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - log.debug(f"{msg.author} posted something that needed to be put inside python code " - "blocks. Sending the user some instructions.") - else: - log.trace("The code consists only of expressions, not sending instructions") + description = self.format_guide_message(msg) except SyntaxError: log.trace( f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " @@ -340,8 +345,8 @@ class CodeBlockCog(Cog, name="Code Block"): ) return - if howto: - await self.send_guide_embed(msg, howto) + if description: + await self.send_guide_embed(msg, description) if msg.channel.id not in self.channel_whitelist: self.channel_cooldowns[msg.channel.id] = time.time() -- cgit v1.2.3 From d0232f76cdf09ecf61ca1329f09f6f78f3e3cf23 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 14 Apr 2020 09:58:46 -0700 Subject: Code block: make invalid backticks a constant set A set should be faster since it's being used to test for membership. A constant just means it won't need to be redefined every time the function is called. * Make `has_bad_ticks` a static method * Add comments describing characters represented by the Unicode escapes --- bot/cogs/codeblock/cog.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 7a9ca8e04..e435d036c 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -15,6 +15,18 @@ from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) RE_MARKDOWN = re.compile(r'([*_~`|>])') +INVALID_BACKTICKS = { + "'''", + '"""', + "\u00b4\u00b4\u00b4", # ACUTE ACCENT + "\u2018\u2018\u2018", # LEFT SINGLE QUOTATION MARK + "\u2019\u2019\u2019", # RIGHT SINGLE QUOTATION MARK + "\u2032\u2032\u2032", # PRIME + "\u201c\u201c\u201c", # LEFT DOUBLE QUOTATION MARK + "\u201d\u201d\u201d", # RIGHT DOUBLE QUOTATION MARK + "\u2033\u2033\u2033", # DOUBLE PRIME + "\u3003\u3003\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF +} class CodeBlockCog(Cog, name="Code Block"): @@ -251,15 +263,10 @@ class CodeBlockCog(Cog, name="Code Block"): log.trace(f"Found REPL code in \n\n{msg}\n\n") return final.rstrip(), True - def has_bad_ticks(self, msg: Message) -> bool: - """Check to see if msg contains ticks that aren't '`'.""" - not_backticks = [ - "'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019", - "\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033", - "\u3003\u3003\u3003" - ] - - return msg.content[:3] in not_backticks + @staticmethod + def has_bad_ticks(message: discord.Message) -> bool: + """Return True if `message` starts with 3 characters which look like but aren't '`'.""" + return message.content[:3] in INVALID_BACKTICKS @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: -- cgit v1.2.3 From 66a3af006a7e9928afd55d0f4ccf48d886b79487 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 14 Apr 2020 10:02:37 -0700 Subject: Code block: simplify log message --- bot/cogs/codeblock/cog.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index e435d036c..c49d7574c 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -346,9 +346,8 @@ class CodeBlockCog(Cog, name="Code Block"): description = self.format_guide_message(msg) except SyntaxError: log.trace( - f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " - "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " - f"The message that was posted was:\n\n{msg.content}\n\n" + f"SyntaxError while parsing code block sent by {msg.author}; " + f"code posted probably just wasn't Python:\n\n{msg.content}\n\n" ) return -- cgit v1.2.3 From 381872deedd39c171f3fff3312c6049c19c4371f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 15 Apr 2020 21:07:38 -0700 Subject: Code block: ignore if code block has *any* language If the code was valid Python syntax, the guide embed would be sent despite a non-Python language being explicitly specified for the code block by the message author. * Make the code block language regex a compiled pattern constant Fixes #829 --- bot/cogs/codeblock/cog.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index c49d7574c..fc515c8df 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -15,6 +15,7 @@ from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) RE_MARKDOWN = re.compile(r'([*_~`|>])') +RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_])\n(.*?)```", re.DOTALL) INVALID_BACKTICKS = { "'''", '"""', @@ -57,11 +58,8 @@ class CodeBlockCog(Cog, name="Code Block"): """ if msg.count("\n") >= 3: # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. - if re.search("```(?:py|python)\n(.*?)```", msg, re.IGNORECASE | re.DOTALL) and not bad_ticks: - log.trace( - "Someone wrote a message that was already a " - "valid Python syntax highlighted code block. No action taken." - ) + if RE_CODE_BLOCK_LANGUAGE.search(msg) and not bad_ticks: + log.trace("Code block already has valid syntax highlighting; no action taken") return None else: -- cgit v1.2.3 From e3c0f7c00b78484f8d802e3e70e0b711122580ba Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Apr 2020 08:50:04 -0700 Subject: Code block: use a more efficient line count check --- bot/cogs/codeblock/cog.py | 116 +++++++++++++++++++++++----------------------- 1 file changed, 59 insertions(+), 57 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index fc515c8df..6699abd2f 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -56,64 +56,66 @@ class CodeBlockCog(Cog, name="Code Block"): Tries to strip out Python code out of msg and returns the stripped block or None if the block is a valid Python codeblock. """ - if msg.count("\n") >= 3: - # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. - if RE_CODE_BLOCK_LANGUAGE.search(msg) and not bad_ticks: - log.trace("Code block already has valid syntax highlighting; no action taken") - return None + if len(msg.split("\n", 3)) <= 3: + return None - else: - # Stripping backticks from every line of the message. - log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") - content = "" - for line in msg.splitlines(keepends=True): - content += line.strip("`") - - content = content.strip() - - # Remove "Python" or "Py" from start of the message if it exists. - log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") - pycode = False - if content.lower().startswith("python"): - content = content[6:] - pycode = True - elif content.lower().startswith("py"): - content = content[2:] - pycode = True - - if pycode: - content = content.splitlines(keepends=True) - - # Check if there might be code in the first line, and preserve it. - first_line = content[0] - if " " in content[0]: - first_space = first_line.index(" ") - content[0] = first_line[first_space:] - content = "".join(content) - - # If there's no code we can just get rid of the first line. - else: - content = "".join(content[1:]) - - # Strip it again to remove any leading whitespace. This is neccessary - # if the first line of the message looked like ```python - old = content.strip() - - # Strips REPL code out of the message if there is any. - content, repl_code = self.repl_stripping(old) - if old != content: - return (content, old), repl_code - - # Try to apply indentation fixes to the code. - content = self.fix_indentation(content) - - # Check if the code contains backticks, if it does ignore the message. - if "`" in content: - log.trace("Detected ` inside the code, won't reply") - return None + # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. + if RE_CODE_BLOCK_LANGUAGE.search(msg) and not bad_ticks: + log.trace("Code block already has valid syntax highlighting; no action taken") + return None + + else: + # Stripping backticks from every line of the message. + log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") + content = "" + for line in msg.splitlines(keepends=True): + content += line.strip("`") + + content = content.strip() + + # Remove "Python" or "Py" from start of the message if it exists. + log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") + pycode = False + if content.lower().startswith("python"): + content = content[6:] + pycode = True + elif content.lower().startswith("py"): + content = content[2:] + pycode = True + + if pycode: + content = content.splitlines(keepends=True) + + # Check if there might be code in the first line, and preserve it. + first_line = content[0] + if " " in content[0]: + first_space = first_line.index(" ") + content[0] = first_line[first_space:] + content = "".join(content) + + # If there's no code we can just get rid of the first line. else: - log.trace(f"Returning message.\n\n{content}\n\n") - return (content,), repl_code + content = "".join(content[1:]) + + # Strip it again to remove any leading whitespace. This is neccessary + # if the first line of the message looked like ```python + old = content.strip() + + # Strips REPL code out of the message if there is any. + content, repl_code = self.repl_stripping(old) + if old != content: + return (content, old), repl_code + + # Try to apply indentation fixes to the code. + content = self.fix_indentation(content) + + # Check if the code contains backticks, if it does ignore the message. + if "`" in content: + log.trace("Detected ` inside the code, won't reply") + return None + else: + log.trace(f"Returning message.\n\n{content}\n\n") + return (content,), repl_code def format_bad_ticks_message(self, message: discord.Message) -> Optional[str]: """Return the guide message to output for bad code block ticks in `message`.""" @@ -318,7 +320,7 @@ class CodeBlockCog(Cog, name="Code Block"): return ( not message.author.bot and self.is_valid_channel(message.channel) - and len(message.content.splitlines()) > 3 + and len(message.content.split("\n", 3)) > 3 and not TokenRemover.find_token_in_message(message) ) -- cgit v1.2.3 From b914d236b8129ae2616424629922db81a79eeead Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 May 2020 20:44:15 -0700 Subject: Code block: fix code block language regex It was missing a quantifier to match more than 1 character. --- bot/cogs/codeblock/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 6699abd2f..cde16bd9f 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -15,7 +15,7 @@ from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) RE_MARKDOWN = re.compile(r'([*_~`|>])') -RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_])\n(.*?)```", re.DOTALL) +RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_]+)\n(.*?)```", re.DOTALL) INVALID_BACKTICKS = { "'''", '"""', -- cgit v1.2.3 From 964d14a150edf583c7211ddaad74ce67ee98cd80 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 May 2020 21:22:40 -0700 Subject: Code block: add regex to search for any code blocks This regex supports both valid and invalid ticks. The ticks are in a group so it's later possible to detect if valid ones were used. --- bot/cogs/codeblock/cog.py | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index cde16bd9f..292735f3f 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -16,18 +16,31 @@ log = logging.getLogger(__name__) RE_MARKDOWN = re.compile(r'([*_~`|>])') RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_]+)\n(.*?)```", re.DOTALL) -INVALID_BACKTICKS = { - "'''", - '"""', - "\u00b4\u00b4\u00b4", # ACUTE ACCENT - "\u2018\u2018\u2018", # LEFT SINGLE QUOTATION MARK - "\u2019\u2019\u2019", # RIGHT SINGLE QUOTATION MARK - "\u2032\u2032\u2032", # PRIME - "\u201c\u201c\u201c", # LEFT DOUBLE QUOTATION MARK - "\u201d\u201d\u201d", # RIGHT DOUBLE QUOTATION MARK - "\u2033\u2033\u2033", # DOUBLE PRIME - "\u3003\u3003\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF +TICKS = { + "`", + "'", + '"', + "\u00b4", # ACUTE ACCENT + "\u2018", # LEFT SINGLE QUOTATION MARK + "\u2019", # RIGHT SINGLE QUOTATION MARK + "\u2032", # PRIME + "\u201c", # LEFT DOUBLE QUOTATION MARK + "\u201d", # RIGHT DOUBLE QUOTATION MARK + "\u2033", # DOUBLE PRIME + "\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF } +RE_CODE_BLOCK = re.compile( + fr""" + ( + ([{''.join(TICKS)}]) # Put all ticks into a character class within a group. + \2{{2}} # Match the previous group 2 more times to ensure it's the same char. + ) + ([^\W_]+\n)? # Optionally match a language specifier followed by a newline. + (.+?) # Match the actual code within the block. + \1 # Match the same 3 ticks used at the start of the block. + """, + re.DOTALL | re.VERBOSE +) class CodeBlockCog(Cog, name="Code Block"): @@ -266,7 +279,7 @@ class CodeBlockCog(Cog, name="Code Block"): @staticmethod def has_bad_ticks(message: discord.Message) -> bool: """Return True if `message` starts with 3 characters which look like but aren't '`'.""" - return message.content[:3] in INVALID_BACKTICKS + return message.content[:3] in TICKS @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: -- cgit v1.2.3 From f51b2cacdb8824b51517d10a479be9ec0629d066 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 May 2020 21:40:06 -0700 Subject: Code block: add function to find invalid code blocks * Create a `NamedTuple` representing a code block --- bot/cogs/codeblock/cog.py | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 292735f3f..6e87f9f15 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -2,7 +2,7 @@ import ast import logging import re import time -from typing import Optional, Tuple +from typing import NamedTuple, Optional, Sequence, Tuple import discord from discord import Embed, Message, RawMessageUpdateEvent @@ -16,8 +16,9 @@ log = logging.getLogger(__name__) RE_MARKDOWN = re.compile(r'([*_~`|>])') RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_]+)\n(.*?)```", re.DOTALL) +BACKTICK = "`" TICKS = { - "`", + BACKTICK, "'", '"', "\u00b4", # ACUTE ACCENT @@ -43,6 +44,14 @@ RE_CODE_BLOCK = re.compile( ) +class CodeBlock(NamedTuple): + """Represents a Markdown code block.""" + + content: str + language: str + tick: str + + class CodeBlockCog(Cog, name="Code Block"): """Detect improperly formatted code blocks and suggest proper formatting.""" @@ -217,6 +226,27 @@ class CodeBlockCog(Cog, name="Code Block"): else: log.trace("The code consists only of expressions, not sending instructions") + @staticmethod + def find_invalid_code_blocks(message: str) -> Sequence[CodeBlock]: + """ + Find and return all invalid Markdown code blocks in the `message`. + + An invalid code block is considered to be one which uses invalid back ticks. + + If the `message` contains at least one valid code block, return an empty sequence. This is + based on the assumption that if the user managed to get one code block right, they already + know how to fix the rest themselves. + """ + code_blocks = [] + for _, tick, language, content in RE_CODE_BLOCK.finditer(message): + if tick == BACKTICK: + return () + else: + code_block = CodeBlock(content, language.strip(), tick) + code_blocks.append(code_block) + + return code_blocks + def fix_indentation(self, msg: str) -> str: """Attempts to fix badly indented code.""" def unindent(code: str, skip_spaces: int = 0) -> str: -- cgit v1.2.3 From 1db3327239c65def7e3ddfcc54453cdadf240a90 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 May 2020 21:45:55 -0700 Subject: Code block: return code blocks with valid ticks but no lang Such code block will be useful down the road for sending information on including a language specified if the content successfully parses as valid Python. --- bot/cogs/codeblock/cog.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 6e87f9f15..970cbd63d 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -227,26 +227,23 @@ class CodeBlockCog(Cog, name="Code Block"): log.trace("The code consists only of expressions, not sending instructions") @staticmethod - def find_invalid_code_blocks(message: str) -> Sequence[CodeBlock]: + def find_code_blocks(message: str) -> Sequence[CodeBlock]: """ - Find and return all invalid Markdown code blocks in the `message`. + Find and return all Markdown code blocks in the `message`. - An invalid code block is considered to be one which uses invalid back ticks. - - If the `message` contains at least one valid code block, return an empty sequence. This is - based on the assumption that if the user managed to get one code block right, they already - know how to fix the rest themselves. + If the `message` contains at least one code block with valid ticks and a specified language, + return an empty sequence. This is based on the assumption that if the user managed to get + one code block right, they already know how to fix the rest themselves. """ code_blocks = [] for _, tick, language, content in RE_CODE_BLOCK.finditer(message): - if tick == BACKTICK: + language = language.strip() + if tick == BACKTICK and language: return () else: - code_block = CodeBlock(content, language.strip(), tick) + code_block = CodeBlock(content, language, tick) code_blocks.append(code_block) - return code_blocks - def fix_indentation(self, msg: str) -> str: """Attempts to fix badly indented code.""" def unindent(code: str, skip_spaces: int = 0) -> str: -- cgit v1.2.3 From 7169d2a6828babc3f670b9936a1e9111e1fe3948 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 4 May 2020 10:43:29 -0700 Subject: Code block: add function to truncate content The code was duplicated in each of the format message functions. The function also ensures content is truncated to 10 lines. Previously, code could have skipped truncating by being 100 lines long but under 204 characters in length. --- bot/cogs/codeblock/cog.py | 37 ++++++++++++++++--------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 970cbd63d..c5704b730 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -153,16 +153,7 @@ class CodeBlockCog(Cog, name="Code Block"): else: content = content[0] - space_left = 204 - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." + content = self.truncate(content) content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) return ( @@ -190,22 +181,12 @@ class CodeBlockCog(Cog, name="Code Block"): # This check is to avoid all nodes being parsed as expressions. # (e.g. words over multiple lines) if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: - # Shorten the code to 10 lines and/or 204 characters. - space_left = 204 if content and repl_code: content = content[1] else: content = content[0] - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." + content = self.truncate(content) log.debug( f"{message.author} posted something that needed to be put inside python code " @@ -364,6 +345,20 @@ class CodeBlockCog(Cog, name="Code Block"): and not TokenRemover.find_token_in_message(message) ) + @staticmethod + def truncate(content: str, max_chars: int = 204, max_lines: int = 10) -> str: + """Return `content` truncated to be at most `max_chars` or `max_lines` in length.""" + current_length = 0 + lines_walked = 0 + + for line in content.splitlines(keepends=True): + if current_length + len(line) > max_chars or lines_walked == max_lines: + break + current_length += len(line) + lines_walked += 1 + + return content[:current_length] + "#..." + @Cog.listener() async def on_message(self, msg: Message) -> None: """ -- cgit v1.2.3 From 4c0c58252034a28debcee57aa0bb6b3a72e653d5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 5 May 2020 18:51:27 -0700 Subject: Code block: add function to check for valid Python code --- bot/cogs/codeblock/cog.py | 72 ++++++++++++++++++++++++++++------------------- 1 file changed, 43 insertions(+), 29 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index c5704b730..92bf43feb 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -173,39 +173,33 @@ class CodeBlockCog(Cog, name="Code Block"): return content, repl_code = content - # Attempts to parse the message into an AST node. - # Invalid Python code will raise a SyntaxError. - tree = ast.parse(content[0]) - # Multiple lines of single words could be interpreted as expressions. - # This check is to avoid all nodes being parsed as expressions. - # (e.g. words over multiple lines) - if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: - if content and repl_code: - content = content[1] - else: - content = content[0] + if not repl_code and not self.is_python_code(content[0]): + return - content = self.truncate(content) + if content and repl_code: + content = content[1] + else: + content = content[0] - log.debug( - f"{message.author} posted something that needed to be put inside python code " - f"blocks. Sending the user some instructions." - ) + content = self.truncate(content) - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - return ( - "It looks like you're trying to paste code into this channel.\n\n" - "Discord has support for Markdown, which allows you to post code with full " - "syntax highlighting. Please use these whenever you paste code, as this " - "helps improve the legibility and makes it easier for us to help you.\n\n" - f"**To do this, use the following method:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - else: - log.trace("The code consists only of expressions, not sending instructions") + log.debug( + f"{message.author} posted something that needed to be put inside python code " + f"blocks. Sending the user some instructions." + ) + + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + return ( + "It looks like you're trying to paste code into this channel.\n\n" + "Discord has support for Markdown, which allows you to post code with full " + "syntax highlighting. Please use these whenever you paste code, as this " + "helps improve the legibility and makes it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) @staticmethod def find_code_blocks(message: str) -> Sequence[CodeBlock]: @@ -305,6 +299,26 @@ class CodeBlockCog(Cog, name="Code Block"): """ return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < 300 + @staticmethod + def is_python_code(content: str) -> bool: + """Return True if `content` is valid Python consisting of more than just expressions.""" + try: + # Attempt to parse the message into an AST node. + # Invalid Python code will raise a SyntaxError. + tree = ast.parse(content) + except SyntaxError: + log.trace("Code is not valid Python.") + return False + + # Multiple lines of single words could be interpreted as expressions. + # This check is to avoid all nodes being parsed as expressions. + # (e.g. words over multiple lines) + if not all(isinstance(node, ast.Expr) for node in tree.body): + return True + else: + log.trace("Code consists only of expressions.") + return False + def is_valid_channel(self, channel: discord.TextChannel) -> bool: """Return True if `channel` is a help channel, may be on cooldown, or is whitelisted.""" return ( -- cgit v1.2.3 From fb6017a8a00f5c54ea4532ff035abe8f34500f6f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 5 May 2020 19:43:41 -0700 Subject: Code block: exclude code blocks 3 lines or shorter --- bot/cogs/codeblock/cog.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 92bf43feb..64f9a4cbc 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -206,6 +206,8 @@ class CodeBlockCog(Cog, name="Code Block"): """ Find and return all Markdown code blocks in the `message`. + Code blocks with 3 or less lines are excluded. + If the `message` contains at least one code block with valid ticks and a specified language, return an empty sequence. This is based on the assumption that if the user managed to get one code block right, they already know how to fix the rest themselves. @@ -215,7 +217,7 @@ class CodeBlockCog(Cog, name="Code Block"): language = language.strip() if tick == BACKTICK and language: return () - else: + elif len(content.split("\n", 3)) > 3: code_block = CodeBlock(content, language, tick) code_blocks.append(code_block) -- cgit v1.2.3 From edc6c9a39c7681a72fca7ba053f5161f46eadfb9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 11:22:28 -0700 Subject: Code block: add function to check if REPL code exists The `repl_stripping` function was re-purposed. The plan going forward is to not show the user's code in the output so actual stripping is no longer necessary. --- bot/cogs/codeblock/cog.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 64f9a4cbc..25791801e 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -260,25 +260,18 @@ class CodeBlockCog(Cog, name="Code Block"): msg = f"{first_line}\n{unindent(code, 4)}" return msg - def repl_stripping(self, msg: str) -> Tuple[str, bool]: - """ - Strip msg in order to extract Python code out of REPL output. + @staticmethod + def is_repl_code(content: str, threshold: int = 3) -> bool: + """Return True if `content` has at least `threshold` number of Python REPL-like lines.""" + repl_lines = 0 + for line in content.splitlines(): + if line.startswith(">>> ") or line.startswith("... "): + repl_lines += 1 - Tries to strip out REPL Python code out of msg and returns the stripped msg. + if repl_lines == threshold: + return True - Returns True for the boolean if REPL code was found in the input msg. - """ - final = "" - for line in msg.splitlines(keepends=True): - if line.startswith(">>>") or line.startswith("..."): - final += line[4:] - log.trace(f"Formatted: \n\n{msg}\n\n to \n\n{final}\n\n") - if not final: - log.trace(f"Found no REPL code in \n\n{msg}\n\n") - return msg, False - else: - log.trace(f"Found REPL code in \n\n{msg}\n\n") - return final.rstrip(), True + return False @staticmethod def has_bad_ticks(message: discord.Message) -> bool: -- cgit v1.2.3 From 4d05e1de961d13389936896bba7704b8618be9c0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 11:43:09 -0700 Subject: Code block: remove obsolete functions The user's original code will not be displayed in the output so there is no longer a need for the functions which format their code. --- bot/cogs/codeblock/cog.py | 109 +--------------------------------------------- 1 file changed, 1 insertion(+), 108 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 25791801e..d0ffcab3f 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -2,7 +2,7 @@ import ast import logging import re import time -from typing import NamedTuple, Optional, Sequence, Tuple +from typing import NamedTuple, Optional, Sequence import discord from discord import Embed, Message, RawMessageUpdateEvent @@ -71,74 +71,6 @@ class CodeBlockCog(Cog, name="Code Block"): # Stores improperly formatted Python codeblock message ids and the corresponding bot message self.codeblock_message_ids = {} - def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]: - """ - Strip msg in order to find Python code. - - Tries to strip out Python code out of msg and returns the stripped block or - None if the block is a valid Python codeblock. - """ - if len(msg.split("\n", 3)) <= 3: - return None - - # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. - if RE_CODE_BLOCK_LANGUAGE.search(msg) and not bad_ticks: - log.trace("Code block already has valid syntax highlighting; no action taken") - return None - - else: - # Stripping backticks from every line of the message. - log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") - content = "" - for line in msg.splitlines(keepends=True): - content += line.strip("`") - - content = content.strip() - - # Remove "Python" or "Py" from start of the message if it exists. - log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") - pycode = False - if content.lower().startswith("python"): - content = content[6:] - pycode = True - elif content.lower().startswith("py"): - content = content[2:] - pycode = True - - if pycode: - content = content.splitlines(keepends=True) - - # Check if there might be code in the first line, and preserve it. - first_line = content[0] - if " " in content[0]: - first_space = first_line.index(" ") - content[0] = first_line[first_space:] - content = "".join(content) - - # If there's no code we can just get rid of the first line. - else: - content = "".join(content[1:]) - - # Strip it again to remove any leading whitespace. This is neccessary - # if the first line of the message looked like ```python - old = content.strip() - - # Strips REPL code out of the message if there is any. - content, repl_code = self.repl_stripping(old) - if old != content: - return (content, old), repl_code - - # Try to apply indentation fixes to the code. - content = self.fix_indentation(content) - - # Check if the code contains backticks, if it does ignore the message. - if "`" in content: - log.trace("Detected ` inside the code, won't reply") - return None - else: - log.trace(f"Returning message.\n\n{content}\n\n") - return (content,), repl_code - def format_bad_ticks_message(self, message: discord.Message) -> Optional[str]: """Return the guide message to output for bad code block ticks in `message`.""" ticks = message.content[:3] @@ -221,45 +153,6 @@ class CodeBlockCog(Cog, name="Code Block"): code_block = CodeBlock(content, language, tick) code_blocks.append(code_block) - def fix_indentation(self, msg: str) -> str: - """Attempts to fix badly indented code.""" - def unindent(code: str, skip_spaces: int = 0) -> str: - """Unindents all code down to the number of spaces given in skip_spaces.""" - final = "" - current = code[0] - leading_spaces = 0 - - # Get numbers of spaces before code in the first line. - while current == " ": - current = code[leading_spaces + 1] - leading_spaces += 1 - leading_spaces -= skip_spaces - - # If there are any, remove that number of spaces from every line. - if leading_spaces > 0: - for line in code.splitlines(keepends=True): - line = line[leading_spaces:] - final += line - return final - else: - return code - - # Apply fix for "all lines are overindented" case. - msg = unindent(msg) - - # If the first line does not end with a colon, we can be - # certain the next line will be on the same indentation level. - # - # If it does end with a colon, we will need to indent all successive - # lines one additional level. - first_line = msg.splitlines()[0] - code = "".join(msg.splitlines(keepends=True)[1:]) - if not first_line.endswith(":"): - msg = f"{first_line}\n{unindent(code)}" - else: - msg = f"{first_line}\n{unindent(code, 4)}" - return msg - @staticmethod def is_repl_code(content: str, threshold: int = 3) -> bool: """Return True if `content` has at least `threshold` number of Python REPL-like lines.""" -- cgit v1.2.3 From 89c54fbda81d790d09213fa3093772261d0c4947 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 14:59:04 -0700 Subject: Code block: move parsing functions to a separate module This reduces clutter in the cog. The cog should only have Discord- related functionality. --- bot/cogs/codeblock/cog.py | 128 +++--------------------------------------- bot/cogs/codeblock/parsing.py | 117 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 119 deletions(-) create mode 100644 bot/cogs/codeblock/parsing.py diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index d0ffcab3f..dad0cc9cc 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -1,8 +1,6 @@ -import ast import logging -import re import time -from typing import NamedTuple, Optional, Sequence +from typing import Optional import discord from discord import Embed, Message, RawMessageUpdateEvent @@ -11,46 +9,10 @@ from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover from bot.constants import Categories, Channels, DEBUG_MODE from bot.utils.messages import wait_for_deletion +from . import parsing log = logging.getLogger(__name__) -RE_MARKDOWN = re.compile(r'([*_~`|>])') -RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_]+)\n(.*?)```", re.DOTALL) -BACKTICK = "`" -TICKS = { - BACKTICK, - "'", - '"', - "\u00b4", # ACUTE ACCENT - "\u2018", # LEFT SINGLE QUOTATION MARK - "\u2019", # RIGHT SINGLE QUOTATION MARK - "\u2032", # PRIME - "\u201c", # LEFT DOUBLE QUOTATION MARK - "\u201d", # RIGHT DOUBLE QUOTATION MARK - "\u2033", # DOUBLE PRIME - "\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF -} -RE_CODE_BLOCK = re.compile( - fr""" - ( - ([{''.join(TICKS)}]) # Put all ticks into a character class within a group. - \2{{2}} # Match the previous group 2 more times to ensure it's the same char. - ) - ([^\W_]+\n)? # Optionally match a language specifier followed by a newline. - (.+?) # Match the actual code within the block. - \1 # Match the same 3 ticks used at the start of the block. - """, - re.DOTALL | re.VERBOSE -) - - -class CodeBlock(NamedTuple): - """Represents a Markdown code block.""" - - content: str - language: str - tick: str - class CodeBlockCog(Cog, name="Code Block"): """Detect improperly formatted code blocks and suggest proper formatting.""" @@ -85,8 +47,8 @@ class CodeBlockCog(Cog, name="Code Block"): else: content = content[0] - content = self.truncate(content) - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + content = parsing.truncate(content) + content_escaped_markdown = parsing.RE_MARKDOWN.sub(r'\\\1', content) return ( "It looks like you are trying to paste code into this channel.\n\n" @@ -106,7 +68,7 @@ class CodeBlockCog(Cog, name="Code Block"): content, repl_code = content - if not repl_code and not self.is_python_code(content[0]): + if not repl_code and not parsing.is_python_code(content[0]): return if content and repl_code: @@ -114,14 +76,14 @@ class CodeBlockCog(Cog, name="Code Block"): else: content = content[0] - content = self.truncate(content) + content = parsing.truncate(content) log.debug( f"{message.author} posted something that needed to be put inside python code " f"blocks. Sending the user some instructions." ) - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + content_escaped_markdown = parsing.RE_MARKDOWN.sub(r'\\\1', content) return ( "It looks like you're trying to paste code into this channel.\n\n" "Discord has support for Markdown, which allows you to post code with full " @@ -133,44 +95,6 @@ class CodeBlockCog(Cog, name="Code Block"): f"```python\n{content}\n```" ) - @staticmethod - def find_code_blocks(message: str) -> Sequence[CodeBlock]: - """ - Find and return all Markdown code blocks in the `message`. - - Code blocks with 3 or less lines are excluded. - - If the `message` contains at least one code block with valid ticks and a specified language, - return an empty sequence. This is based on the assumption that if the user managed to get - one code block right, they already know how to fix the rest themselves. - """ - code_blocks = [] - for _, tick, language, content in RE_CODE_BLOCK.finditer(message): - language = language.strip() - if tick == BACKTICK and language: - return () - elif len(content.split("\n", 3)) > 3: - code_block = CodeBlock(content, language, tick) - code_blocks.append(code_block) - - @staticmethod - def is_repl_code(content: str, threshold: int = 3) -> bool: - """Return True if `content` has at least `threshold` number of Python REPL-like lines.""" - repl_lines = 0 - for line in content.splitlines(): - if line.startswith(">>> ") or line.startswith("... "): - repl_lines += 1 - - if repl_lines == threshold: - return True - - return False - - @staticmethod - def has_bad_ticks(message: discord.Message) -> bool: - """Return True if `message` starts with 3 characters which look like but aren't '`'.""" - return message.content[:3] in TICKS - @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: """Return True if `channel` is in one of the help categories.""" @@ -187,26 +111,6 @@ class CodeBlockCog(Cog, name="Code Block"): """ return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < 300 - @staticmethod - def is_python_code(content: str) -> bool: - """Return True if `content` is valid Python consisting of more than just expressions.""" - try: - # Attempt to parse the message into an AST node. - # Invalid Python code will raise a SyntaxError. - tree = ast.parse(content) - except SyntaxError: - log.trace("Code is not valid Python.") - return False - - # Multiple lines of single words could be interpreted as expressions. - # This check is to avoid all nodes being parsed as expressions. - # (e.g. words over multiple lines) - if not all(isinstance(node, ast.Expr) for node in tree.body): - return True - else: - log.trace("Code consists only of expressions.") - return False - def is_valid_channel(self, channel: discord.TextChannel) -> bool: """Return True if `channel` is a help channel, may be on cooldown, or is whitelisted.""" return ( @@ -247,20 +151,6 @@ class CodeBlockCog(Cog, name="Code Block"): and not TokenRemover.find_token_in_message(message) ) - @staticmethod - def truncate(content: str, max_chars: int = 204, max_lines: int = 10) -> str: - """Return `content` truncated to be at most `max_chars` or `max_lines` in length.""" - current_length = 0 - lines_walked = 0 - - for line in content.splitlines(keepends=True): - if current_length + len(line) > max_chars or lines_walked == max_lines: - break - current_length += len(line) - lines_walked += 1 - - return content[:current_length] + "#..." - @Cog.listener() async def on_message(self, msg: Message) -> None: """ @@ -277,7 +167,7 @@ class CodeBlockCog(Cog, name="Code Block"): return try: - if self.has_bad_ticks(msg): + if parsing.has_bad_ticks(msg): description = self.format_bad_ticks_message(msg) else: description = self.format_guide_message(msg) @@ -311,7 +201,7 @@ class CodeBlockCog(Cog, name="Code Block"): user_message = await channel.fetch_message(payload.message_id) # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None - has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message)) + has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), parsing.has_bad_ticks(user_message)) # If the message is fixed, delete the bot message and the entry from the id dictionary if has_fixed_codeblock is None: diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py new file mode 100644 index 000000000..7a096758b --- /dev/null +++ b/bot/cogs/codeblock/parsing.py @@ -0,0 +1,117 @@ +import ast +import logging +import re +from typing import NamedTuple, Sequence + +import discord + +log = logging.getLogger(__name__) + +RE_MARKDOWN = re.compile(r'([*_~`|>])') +RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_]+)\n(.*?)```", re.DOTALL) +BACKTICK = "`" +TICKS = { + BACKTICK, + "'", + '"', + "\u00b4", # ACUTE ACCENT + "\u2018", # LEFT SINGLE QUOTATION MARK + "\u2019", # RIGHT SINGLE QUOTATION MARK + "\u2032", # PRIME + "\u201c", # LEFT DOUBLE QUOTATION MARK + "\u201d", # RIGHT DOUBLE QUOTATION MARK + "\u2033", # DOUBLE PRIME + "\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF +} +RE_CODE_BLOCK = re.compile( + fr""" + ( + ([{''.join(TICKS)}]) # Put all ticks into a character class within a group. + \2{{2}} # Match the previous group 2 more times to ensure it's the same char. + ) + ([^\W_]+\n)? # Optionally match a language specifier followed by a newline. + (.+?) # Match the actual code within the block. + \1 # Match the same 3 ticks used at the start of the block. + """, + re.DOTALL | re.VERBOSE +) + + +class CodeBlock(NamedTuple): + """Represents a Markdown code block.""" + + content: str + language: str + tick: str + + +def find_code_blocks(message: str) -> Sequence[CodeBlock]: + """ + Find and return all Markdown code blocks in the `message`. + + Code blocks with 3 or less lines are excluded. + + If the `message` contains at least one code block with valid ticks and a specified language, + return an empty sequence. This is based on the assumption that if the user managed to get + one code block right, they already know how to fix the rest themselves. + """ + code_blocks = [] + for _, tick, language, content in RE_CODE_BLOCK.finditer(message): + language = language.strip() + if tick == BACKTICK and language: + return () + elif len(content.split("\n", 3)) > 3: + code_block = CodeBlock(content, language, tick) + code_blocks.append(code_block) + + +def has_bad_ticks(message: discord.Message) -> bool: + """Return True if `message` starts with 3 characters which look like but aren't '`'.""" + return message.content[:3] in TICKS + + +def is_python_code(content: str) -> bool: + """Return True if `content` is valid Python consisting of more than just expressions.""" + try: + # Attempt to parse the message into an AST node. + # Invalid Python code will raise a SyntaxError. + tree = ast.parse(content) + except SyntaxError: + log.trace("Code is not valid Python.") + return False + + # Multiple lines of single words could be interpreted as expressions. + # This check is to avoid all nodes being parsed as expressions. + # (e.g. words over multiple lines) + if not all(isinstance(node, ast.Expr) for node in tree.body): + return True + else: + log.trace("Code consists only of expressions.") + return False + + +def is_repl_code(content: str, threshold: int = 3) -> bool: + """Return True if `content` has at least `threshold` number of Python REPL-like lines.""" + repl_lines = 0 + for line in content.splitlines(): + if line.startswith(">>> ") or line.startswith("... "): + repl_lines += 1 + + if repl_lines == threshold: + return True + + return False + + +def truncate(content: str, max_chars: int = 204, max_lines: int = 10) -> str: + """Return `content` truncated to be at most `max_chars` or `max_lines` in length.""" + current_length = 0 + lines_walked = 0 + + for line in content.splitlines(keepends=True): + if current_length + len(line) > max_chars or lines_walked == max_lines: + break + current_length += len(line) + lines_walked += 1 + + return content[:current_length] + "#..." -- cgit v1.2.3 From 2a7dcccf7a6b352e3f43b4248d00d9ec15af243e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 13:30:51 -0700 Subject: Code block: rework the instruction formatting functions A new module, `instructions`, was created to house the functions. 4 ways in which code blocks can be incorrect are considered: 1. The code is not within a code block at all 2. Incorrect characters are used for back ticks 3. A language is not specified 4. A language is specified incorrectly Splitting it up into these 4 cases allows for more specific and relevant instructions to be shown to users. If a message has both incorrect back ticks and an issue with the language specifier, the instructions for fixing both issues are combined. The instructions show a generic code example rather than using the original code from the message. This circumvents any ambiguities when parsing their message and trying to fix it. The escaped code block also failed to preserve indentation. This was a problem because some users would copy it anyway and end up with poorly formatted code. By using a simple example that doesn't rely on indentation, it makes it clear the example is not meant to be copied. Finally, the new examples are shorter and thus make the embed not as giant. --- bot/cogs/codeblock/cog.py | 63 --------------------- bot/cogs/codeblock/instructions.py | 113 +++++++++++++++++++++++++++++++++++++ bot/cogs/codeblock/parsing.py | 2 - 3 files changed, 113 insertions(+), 65 deletions(-) create mode 100644 bot/cogs/codeblock/instructions.py diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index dad0cc9cc..efc22c8a5 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -1,6 +1,5 @@ import logging import time -from typing import Optional import discord from discord import Embed, Message, RawMessageUpdateEvent @@ -33,68 +32,6 @@ class CodeBlockCog(Cog, name="Code Block"): # Stores improperly formatted Python codeblock message ids and the corresponding bot message self.codeblock_message_ids = {} - def format_bad_ticks_message(self, message: discord.Message) -> Optional[str]: - """Return the guide message to output for bad code block ticks in `message`.""" - ticks = message.content[:3] - content = self.codeblock_stripping(f"```{message.content[3:-3]}```", True) - if content is None: - return - - content, repl_code = content - - if len(content) == 2: - content = content[1] - else: - content = content[0] - - content = parsing.truncate(content) - content_escaped_markdown = parsing.RE_MARKDOWN.sub(r'\\\1', content) - - return ( - "It looks like you are trying to paste code into this channel.\n\n" - "You seem to be using the wrong symbols to indicate where the codeblock should start. " - f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" - "**Here is an example of how it should look:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - def format_guide_message(self, message: discord.Message) -> Optional[str]: - """Return the guide message to output for a poorly formatted code block in `message`.""" - content = self.codeblock_stripping(message.content, False) - if content is None: - return - - content, repl_code = content - - if not repl_code and not parsing.is_python_code(content[0]): - return - - if content and repl_code: - content = content[1] - else: - content = content[0] - - content = parsing.truncate(content) - - log.debug( - f"{message.author} posted something that needed to be put inside python code " - f"blocks. Sending the user some instructions." - ) - - content_escaped_markdown = parsing.RE_MARKDOWN.sub(r'\\\1', content) - return ( - "It looks like you're trying to paste code into this channel.\n\n" - "Discord has support for Markdown, which allows you to post code with full " - "syntax highlighting. Please use these whenever you paste code, as this " - "helps improve the legibility and makes it easier for us to help you.\n\n" - f"**To do this, use the following method:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: """Return True if `channel` is in one of the help categories.""" diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py new file mode 100644 index 000000000..0bcd2eda8 --- /dev/null +++ b/bot/cogs/codeblock/instructions.py @@ -0,0 +1,113 @@ +import logging +from typing import Optional + +from . import parsing + +log = logging.getLogger(__name__) + +PY_LANG_CODES = ("python", "py") +EXAMPLE_PY = f"python\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. +EXAMPLE_CODE_BLOCKS = ( + "\\`\\`\\`{content}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + "```{content}```" +) + + +def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: + """Return instructions on using the correct ticks for `code_block`.""" + valid_ticks = f"\\{parsing.BACKTICK}" * 3 + + # The space at the end is important here because something may be appended! + instructions = ( + "It looks like you are trying to paste code into this channel.\n\n" + "You seem to be using the wrong symbols to indicate where the code block should start. " + f"The correct symbols would be {valid_ticks}, not `{code_block.tick * 3}`. " + ) + + # Check if the code has an issue with the language specifier. + addition_msg = get_bad_lang_message(code_block.content) + if not addition_msg: + addition_msg = get_no_lang_message(code_block.content) + + # Combine the back ticks message with the language specifier message. The latter will + # already have an example code block. + if addition_msg: + # The first line has a double line break which is not desirable when appending the msg. + addition_msg = addition_msg.replace("\n\n", "\n", 1) + + # Make the first character of the addition lower case. + instructions += "Furthermore, " + addition_msg[0].lower() + addition_msg[1:] + else: + # Determine the example code to put in the code block based on the language specifier. + if code_block.language.lower() in PY_LANG_CODES: + content = EXAMPLE_PY + elif code_block.language: + # It's not feasible to determine what would be a valid example for other languages. + content = f"{code_block.language}\n..." + else: + content = "Hello, world!" + + example_blocks = EXAMPLE_CODE_BLOCKS.format(content) + instructions += f"\n\n**Here is an example of how it should look:**\n{example_blocks}" + + return instructions + + +def get_no_ticks_message(content: str) -> Optional[str]: + """If `content` is Python/REPL code, return instructions on using code blocks.""" + if parsing.is_repl_code(content) or parsing.is_python_code(content): + example_blocks = EXAMPLE_CODE_BLOCKS.format(EXAMPLE_PY) + return ( + "It looks like you're trying to paste code into this channel.\n\n" + "Discord has support for Markdown, which allows you to post code with full " + "syntax highlighting. Please use these whenever you paste code, as this " + "helps improve the legibility and makes it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n{example_blocks}" + ) + + +def get_bad_lang_message(content: str) -> Optional[str]: + """ + Return instructions on fixing the Python language specifier for a code block. + + If `content` doesn't start with "python" or "py" as the language specifier, return None. + """ + stripped = content.lstrip().lower() + lang = next((lang for lang in PY_LANG_CODES if stripped.startswith(lang)), None) + + if lang: + # Note that get_bad_ticks_message expects the first line to have an extra newline. + lines = ["It looks like you incorrectly specified a language for your code block.\n"] + + if content.startswith(" "): + lines.append(f"Make sure there are no spaces between the back ticks and `{lang}`.") + + if stripped[len(lang)] != "\n": + lines.append( + f"Make sure you put your code on a new line following `{lang}`. " + f"There must not be any spaces after `{lang}`." + ) + + example_blocks = EXAMPLE_CODE_BLOCKS.format(EXAMPLE_PY) + lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") + + return "\n".join(lines) + + +def get_no_lang_message(content: str) -> Optional[str]: + """ + Return instructions on specifying a language for a code block. + + If `content` is not valid Python or Python REPL code, return None. + """ + if parsing.is_repl_code(content) or parsing.is_python_code(content): + example_blocks = EXAMPLE_CODE_BLOCKS.format(EXAMPLE_PY) + + # Note that get_bad_ticks_message expects the first line to have an extra newline. + return ( + "It looks like you pasted Python code without syntax highlighting.\n\n" + "Please use syntax highlighting to improve the legibility of your code and make" + "it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n{example_blocks}" + ) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 7a096758b..d541441e0 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -7,8 +7,6 @@ import discord log = logging.getLogger(__name__) -RE_MARKDOWN = re.compile(r'([*_~`|>])') -RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_]+)\n(.*?)```", re.DOTALL) BACKTICK = "`" TICKS = { BACKTICK, -- cgit v1.2.3 From 59dfd276adabeb8ba643a0b22128af7d765d3210 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 13:33:09 -0700 Subject: Code block: remove truncate function No longer used anywhere. --- bot/cogs/codeblock/parsing.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index d541441e0..bb71aaaaf 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -99,17 +99,3 @@ def is_repl_code(content: str, threshold: int = 3) -> bool: return True return False - - -def truncate(content: str, max_chars: int = 204, max_lines: int = 10) -> str: - """Return `content` truncated to be at most `max_chars` or `max_lines` in length.""" - current_length = 0 - lines_walked = 0 - - for line in content.splitlines(keepends=True): - if current_length + len(line) > max_chars or lines_walked == max_lines: - break - current_length += len(line) - lines_walked += 1 - - return content[:current_length] + "#..." -- cgit v1.2.3 From a61d0564b46ee4f2cb295317cdad6a47bfd88e13 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 13:41:37 -0700 Subject: Code block: use new formatting functions in on_message --- bot/cogs/codeblock/cog.py | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index efc22c8a5..959fc138e 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -8,7 +8,7 @@ from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover from bot.constants import Categories, Channels, DEBUG_MODE from bot.utils.messages import wait_for_deletion -from . import parsing +from . import instructions, parsing log = logging.getLogger(__name__) @@ -90,12 +90,7 @@ class CodeBlockCog(Cog, name="Code Block"): @Cog.listener() async def on_message(self, msg: Message) -> None: - """ - Detect poorly formatted Python code in new messages. - - If poorly formatted code is detected, send the user a helpful message explaining how to do - properly formatted Python syntax highlighting codeblocks. - """ + """Detect incorrect Markdown code blocks in `msg` and send instructions to fix them.""" if not self.should_parse(msg): return @@ -103,17 +98,25 @@ class CodeBlockCog(Cog, name="Code Block"): if self.is_on_cooldown(msg.channel) and not DEBUG_MODE: return - try: - if parsing.has_bad_ticks(msg): - description = self.format_bad_ticks_message(msg) + blocks = parsing.find_code_blocks(msg.content) + if not blocks: + # No code blocks found in the message. + description = instructions.get_no_ticks_message(msg.content) + else: + # Get the first code block with invalid ticks. + block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) + + if block: + # A code block exists but has invalid ticks. + description = instructions.get_bad_ticks_message(block) else: - description = self.format_guide_message(msg) - except SyntaxError: - log.trace( - f"SyntaxError while parsing code block sent by {msg.author}; " - f"code posted probably just wasn't Python:\n\n{msg.content}\n\n" - ) - return + # Only other possibility is a block with valid ticks but a missing language. + block = blocks[0] + + # Check for a bad language first to avoid parsing content into an AST. + description = instructions.get_bad_lang_message(block.content) + if not description: + description = instructions.get_no_lang_message(block.content) if description: await self.send_guide_embed(msg, description) -- cgit v1.2.3 From 3fe6c4aac91b691de9b60c9fd89d23539a18b9a4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 13:53:11 -0700 Subject: Code block: use find_code_blocks to check if an edited msg was fixed * Remove has_bad_ticks - it's obsolete --- bot/cogs/codeblock/cog.py | 17 ++++++++--------- bot/cogs/codeblock/parsing.py | 7 ------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 959fc138e..19ddb8c73 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -125,7 +125,7 @@ class CodeBlockCog(Cog, name="Code Block"): @Cog.listener() async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: - """Check to see if an edited message (previously called out) still contains poorly formatted code.""" + """Delete the instructions message if an edited message had its code blocks fixed.""" if ( # Checks to see if the message was called out by the bot payload.message_id not in self.codeblock_message_ids @@ -136,16 +136,15 @@ class CodeBlockCog(Cog, name="Code Block"): ): return - # Retrieve channel and message objects for use later - channel = self.bot.get_channel(int(payload.data.get("channel_id"))) - user_message = await channel.fetch_message(payload.message_id) + # Parse the message to see if the code blocks have been fixed. + code_blocks = parsing.find_code_blocks(payload.data.get("content")) - # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None - has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), parsing.has_bad_ticks(user_message)) + # If the message is fixed, delete the bot message and the entry from the id dictionary. + if not code_blocks: + log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") - # If the message is fixed, delete the bot message and the entry from the id dictionary - if has_fixed_codeblock is None: + channel = self.bot.get_channel(int(payload.data.get("channel_id"))) bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + await bot_message.delete() del self.codeblock_message_ids[payload.message_id] - log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index bb71aaaaf..88a5c7b7a 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -3,8 +3,6 @@ import logging import re from typing import NamedTuple, Sequence -import discord - log = logging.getLogger(__name__) BACKTICK = "`" @@ -63,11 +61,6 @@ def find_code_blocks(message: str) -> Sequence[CodeBlock]: code_blocks.append(code_block) -def has_bad_ticks(message: discord.Message) -> bool: - """Return True if `message` starts with 3 characters which look like but aren't '`'.""" - return message.content[:3] in TICKS - - def is_python_code(content: str) -> bool: """Return True if `content` is valid Python consisting of more than just expressions.""" try: -- cgit v1.2.3 From 8c34a279175ee1193cb3a4df625f81758c258da5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 14:08:37 -0700 Subject: Code block: load the extension --- bot/__main__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/__main__.py b/bot/__main__.py index 4e0d4a111..8bbb7fbb3 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -51,6 +51,7 @@ bot.load_extension("bot.cogs.verification") # Feature cogs bot.load_extension("bot.cogs.alias") +bot.load_extension("bot.cogs.codeblock") bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.duck_pond") bot.load_extension("bot.cogs.eval") -- cgit v1.2.3 From 8782d3018e5cbc4ef04e4b8e74b90025de3004b3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 14:18:40 -0700 Subject: Code block: fix find_code_blocks iteration and missing return * Add named capture groups to the regex --- bot/cogs/codeblock/parsing.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 88a5c7b7a..9adb4e0ab 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -21,13 +21,13 @@ TICKS = { } RE_CODE_BLOCK = re.compile( fr""" - ( - ([{''.join(TICKS)}]) # Put all ticks into a character class within a group. - \2{{2}} # Match the previous group 2 more times to ensure it's the same char. + (?P + (?P[{''.join(TICKS)}]) # Put all ticks into a character class within a group. + \2{{2}} # Match previous group 2 more times to ensure the same char. ) - ([^\W_]+\n)? # Optionally match a language specifier followed by a newline. - (.+?) # Match the actual code within the block. - \1 # Match the same 3 ticks used at the start of the block. + (?P[^\W_]+\n)? # Optionally match a language specifier followed by a newline. + (?P.+?) # Match the actual code within the block. + \1 # Match the same 3 ticks used at the start of the block. """, re.DOTALL | re.VERBOSE ) @@ -52,14 +52,19 @@ def find_code_blocks(message: str) -> Sequence[CodeBlock]: one code block right, they already know how to fix the rest themselves. """ code_blocks = [] - for _, tick, language, content in RE_CODE_BLOCK.finditer(message): - language = language.strip() - if tick == BACKTICK and language: + for match in RE_CODE_BLOCK.finditer(message): + # Used to ensure non-matched groups have an empty string as the default value. + groups = match.groupdict("") + language = groups["lang"].strip() # Strip the newline cause it's included in the group. + + if groups["tick"] == BACKTICK and language: return () - elif len(content.split("\n", 3)) > 3: - code_block = CodeBlock(content, language, tick) + elif len(groups["code"].split("\n", 3)) > 3: + code_block = CodeBlock(groups["code"], language, groups["tick"]) code_blocks.append(code_block) + return code_blocks + def is_python_code(content: str) -> bool: """Return True if `content` is valid Python consisting of more than just expressions.""" -- cgit v1.2.3 From 38d07cacadfb34fb4caf536eb792d36a066e3629 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 14:21:23 -0700 Subject: Code block: fix formatting of example code blocks --- bot/cogs/codeblock/instructions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 0bcd2eda8..6d267239d 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -48,7 +48,7 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: else: content = "Hello, world!" - example_blocks = EXAMPLE_CODE_BLOCKS.format(content) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=content) instructions += f"\n\n**Here is an example of how it should look:**\n{example_blocks}" return instructions @@ -57,7 +57,7 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: def get_no_ticks_message(content: str) -> Optional[str]: """If `content` is Python/REPL code, return instructions on using code blocks.""" if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = EXAMPLE_CODE_BLOCKS.format(EXAMPLE_PY) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY) return ( "It looks like you're trying to paste code into this channel.\n\n" "Discord has support for Markdown, which allows you to post code with full " @@ -89,7 +89,7 @@ def get_bad_lang_message(content: str) -> Optional[str]: f"There must not be any spaces after `{lang}`." ) - example_blocks = EXAMPLE_CODE_BLOCKS.format(EXAMPLE_PY) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY) lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") return "\n".join(lines) @@ -102,7 +102,7 @@ def get_no_lang_message(content: str) -> Optional[str]: If `content` is not valid Python or Python REPL code, return None. """ if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = EXAMPLE_CODE_BLOCKS.format(EXAMPLE_PY) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY) # Note that get_bad_ticks_message expects the first line to have an extra newline. return ( -- cgit v1.2.3 From 29d4962518e1b0aa1664b676c33b631e634ad9ea Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 14:21:44 -0700 Subject: Code block: fix missing space between words in message --- bot/cogs/codeblock/instructions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 6d267239d..0f05e68b1 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -107,7 +107,7 @@ def get_no_lang_message(content: str) -> Optional[str]: # Note that get_bad_ticks_message expects the first line to have an extra newline. return ( "It looks like you pasted Python code without syntax highlighting.\n\n" - "Please use syntax highlighting to improve the legibility of your code and make" + "Please use syntax highlighting to improve the legibility of your code and make " "it easier for us to help you.\n\n" f"**To do this, use the following method:**\n{example_blocks}" ) -- cgit v1.2.3 From 30967602e2faabb6654d30c1fc7e1c4f4e3d2919 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 14:24:49 -0700 Subject: Code block: fix formatting of the additional message The newlines should be replaced with a space rather than with 1 newline. To separate the two issues, a double newline is prepended to the entire additional message. --- bot/cogs/codeblock/instructions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 0f05e68b1..dec5af874 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -34,10 +34,10 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: # already have an example code block. if addition_msg: # The first line has a double line break which is not desirable when appending the msg. - addition_msg = addition_msg.replace("\n\n", "\n", 1) + addition_msg = addition_msg.replace("\n\n", " ", 1) # Make the first character of the addition lower case. - instructions += "Furthermore, " + addition_msg[0].lower() + addition_msg[1:] + instructions += "\n\nFurthermore, " + addition_msg[0].lower() + addition_msg[1:] else: # Determine the example code to put in the code block based on the language specifier. if code_block.language.lower() in PY_LANG_CODES: -- cgit v1.2.3 From 0eca42cee34672fd59b82d0b36a70627a13d6354 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 14:30:37 -0700 Subject: Code block: use same lang specifier as the user for the py example Keeping examples consistent will hopefully make things clearer to the user. --- bot/cogs/codeblock/instructions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index dec5af874..9de418765 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -6,7 +6,7 @@ from . import parsing log = logging.getLogger(__name__) PY_LANG_CODES = ("python", "py") -EXAMPLE_PY = f"python\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. +EXAMPLE_PY = "{lang}\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. EXAMPLE_CODE_BLOCKS = ( "\\`\\`\\`{content}\n\\`\\`\\`\n\n" "**This will result in the following:**\n" @@ -41,7 +41,7 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: else: # Determine the example code to put in the code block based on the language specifier. if code_block.language.lower() in PY_LANG_CODES: - content = EXAMPLE_PY + content = EXAMPLE_PY.format(lang=code_block.language) elif code_block.language: # It's not feasible to determine what would be a valid example for other languages. content = f"{code_block.language}\n..." @@ -57,7 +57,7 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: def get_no_ticks_message(content: str) -> Optional[str]: """If `content` is Python/REPL code, return instructions on using code blocks.""" if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang="python")) return ( "It looks like you're trying to paste code into this channel.\n\n" "Discord has support for Markdown, which allows you to post code with full " @@ -89,7 +89,7 @@ def get_bad_lang_message(content: str) -> Optional[str]: f"There must not be any spaces after `{lang}`." ) - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang=lang)) lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") return "\n".join(lines) @@ -102,7 +102,7 @@ def get_no_lang_message(content: str) -> Optional[str]: If `content` is not valid Python or Python REPL code, return None. """ if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang="python")) # Note that get_bad_ticks_message expects the first line to have an extra newline. return ( -- cgit v1.2.3 From 6ec3c712113d350cc027a503ebb0951cfa2fd65a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 22:39:31 -0700 Subject: Code block: add trace logging --- bot/cogs/codeblock/cog.py | 17 +++++++++++++---- bot/cogs/codeblock/instructions.py | 26 ++++++++++++++++++++++++-- bot/cogs/codeblock/parsing.py | 11 +++++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 19ddb8c73..e4b87938d 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -35,6 +35,7 @@ class CodeBlockCog(Cog, name="Code Block"): @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: """Return True if `channel` is in one of the help categories.""" + log.trace(f"Checking if #{channel} is a help channel.") return ( getattr(channel, "category", None) and channel.category.id in (Categories.help_available, Categories.help_in_use) @@ -46,10 +47,12 @@ class CodeBlockCog(Cog, name="Code Block"): Note: only channels in the `channel_cooldowns` have cooldowns enabled. """ + log.trace(f"Checking if #{channel} is on cooldown.") return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < 300 def is_valid_channel(self, channel: discord.TextChannel) -> bool: """Return True if `channel` is a help channel, may be on cooldown, or is whitelisted.""" + log.trace(f"Checking if #{channel} qualifies for code block detection.") return ( self.is_help_channel(channel) or channel.id in self.channel_cooldowns @@ -62,6 +65,8 @@ class CodeBlockCog(Cog, name="Code Block"): The embed will be deleted automatically after 5 minutes. """ + log.trace("Sending an embed with code block formatting instructions.") + embed = Embed(description=description) bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed) self.codeblock_message_ids[message.id] = bot_message.id @@ -92,25 +97,27 @@ class CodeBlockCog(Cog, name="Code Block"): async def on_message(self, msg: Message) -> None: """Detect incorrect Markdown code blocks in `msg` and send instructions to fix them.""" if not self.should_parse(msg): + log.trace(f"Skipping code block detection of {msg.id}: message doesn't qualify.") return # When debugging, ignore cooldowns. if self.is_on_cooldown(msg.channel) and not DEBUG_MODE: + log.trace(f"Skipping code block detection of {msg.id}: #{msg.channel} is on cooldown.") return blocks = parsing.find_code_blocks(msg.content) if not blocks: - # No code blocks found in the message. + log.trace(f"No code blocks were found in message {msg.id}.") description = instructions.get_no_ticks_message(msg.content) else: - # Get the first code block with invalid ticks. + log.trace("Searching results for a code block with invalid ticks.") block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) if block: - # A code block exists but has invalid ticks. + log.trace(f"A code block exists in {msg.id} but has invalid ticks.") description = instructions.get_bad_ticks_message(block) else: - # Only other possibility is a block with valid ticks but a missing language. + log.trace(f"A code block exists in {msg.id} but is missing a language.") block = blocks[0] # Check for a bad language first to avoid parsing content into an AST. @@ -121,6 +128,7 @@ class CodeBlockCog(Cog, name="Code Block"): if description: await self.send_guide_embed(msg, description) if msg.channel.id not in self.channel_whitelist: + log.trace(f"Adding #{msg.channel} to the channel cooldowns.") self.channel_cooldowns[msg.channel.id] = time.time() @Cog.listener() @@ -134,6 +142,7 @@ class CodeBlockCog(Cog, name="Code Block"): # Makes sure there's a channel id in the message payload or payload.data.get("channel_id") is None ): + log.trace("Message edit does not qualify for code block detection.") return # Parse the message to see if the code blocks have been fixed. diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 9de418765..28242ce75 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -5,7 +5,7 @@ from . import parsing log = logging.getLogger(__name__) -PY_LANG_CODES = ("python", "py") +PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. EXAMPLE_PY = "{lang}\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. EXAMPLE_CODE_BLOCKS = ( "\\`\\`\\`{content}\n\\`\\`\\`\n\n" @@ -16,6 +16,7 @@ EXAMPLE_CODE_BLOCKS = ( def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: """Return instructions on using the correct ticks for `code_block`.""" + log.trace("Creating instructions for incorrect code block ticks.") valid_ticks = f"\\{parsing.BACKTICK}" * 3 # The space at the end is important here because something may be appended! @@ -25,7 +26,7 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: f"The correct symbols would be {valid_ticks}, not `{code_block.tick * 3}`. " ) - # Check if the code has an issue with the language specifier. + log.trace("Check if the bad ticks code block also has issues with the language specifier.") addition_msg = get_bad_lang_message(code_block.content) if not addition_msg: addition_msg = get_no_lang_message(code_block.content) @@ -33,19 +34,26 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: # Combine the back ticks message with the language specifier message. The latter will # already have an example code block. if addition_msg: + log.trace("Language specifier issue found; appending additional instructions.") + # The first line has a double line break which is not desirable when appending the msg. addition_msg = addition_msg.replace("\n\n", " ", 1) # Make the first character of the addition lower case. instructions += "\n\nFurthermore, " + addition_msg[0].lower() + addition_msg[1:] else: + log.trace("No issues with the language specifier found.") + # Determine the example code to put in the code block based on the language specifier. if code_block.language.lower() in PY_LANG_CODES: + log.trace(f"Code block has a Python language specifier `{code_block.language}`.") content = EXAMPLE_PY.format(lang=code_block.language) elif code_block.language: + log.trace(f"Code block has a foreign language specifier `{code_block.language}`.") # It's not feasible to determine what would be a valid example for other languages. content = f"{code_block.language}\n..." else: + log.trace("Code block has no language specifier (and the code isn't valid Python).") content = "Hello, world!" example_blocks = EXAMPLE_CODE_BLOCKS.format(content=content) @@ -56,6 +64,8 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: def get_no_ticks_message(content: str) -> Optional[str]: """If `content` is Python/REPL code, return instructions on using code blocks.""" + log.trace("Creating instructions for a missing code block.") + if parsing.is_repl_code(content) or parsing.is_python_code(content): example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang="python")) return ( @@ -65,6 +75,8 @@ def get_no_ticks_message(content: str) -> Optional[str]: "helps improve the legibility and makes it easier for us to help you.\n\n" f"**To do this, use the following method:**\n{example_blocks}" ) + else: + log.trace("Aborting missing code block instructions: content is not Python code.") def get_bad_lang_message(content: str) -> Optional[str]: @@ -73,6 +85,8 @@ def get_bad_lang_message(content: str) -> Optional[str]: If `content` doesn't start with "python" or "py" as the language specifier, return None. """ + log.trace("Creating instructions for a poorly specified language.") + stripped = content.lstrip().lower() lang = next((lang for lang in PY_LANG_CODES if stripped.startswith(lang)), None) @@ -81,9 +95,11 @@ def get_bad_lang_message(content: str) -> Optional[str]: lines = ["It looks like you incorrectly specified a language for your code block.\n"] if content.startswith(" "): + log.trace("Language specifier was preceded by a space.") lines.append(f"Make sure there are no spaces between the back ticks and `{lang}`.") if stripped[len(lang)] != "\n": + log.trace("Language specifier was not followed by a newline.") lines.append( f"Make sure you put your code on a new line following `{lang}`. " f"There must not be any spaces after `{lang}`." @@ -93,6 +109,8 @@ def get_bad_lang_message(content: str) -> Optional[str]: lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") return "\n".join(lines) + else: + log.trace("Aborting bad language instructions: language specified isn't Python.") def get_no_lang_message(content: str) -> Optional[str]: @@ -101,6 +119,8 @@ def get_no_lang_message(content: str) -> Optional[str]: If `content` is not valid Python or Python REPL code, return None. """ + log.trace("Creating instructions for a missing language.") + if parsing.is_repl_code(content) or parsing.is_python_code(content): example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang="python")) @@ -111,3 +131,5 @@ def get_no_lang_message(content: str) -> Optional[str]: "it easier for us to help you.\n\n" f"**To do this, use the following method:**\n{example_blocks}" ) + else: + log.trace("Aborting missing language instructions: content is not Python code.") diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 9adb4e0ab..7409653d7 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -51,6 +51,8 @@ def find_code_blocks(message: str) -> Sequence[CodeBlock]: return an empty sequence. This is based on the assumption that if the user managed to get one code block right, they already know how to fix the rest themselves. """ + log.trace("Finding all code blocks in a message.") + code_blocks = [] for match in RE_CODE_BLOCK.finditer(message): # Used to ensure non-matched groups have an empty string as the default value. @@ -58,16 +60,20 @@ def find_code_blocks(message: str) -> Sequence[CodeBlock]: language = groups["lang"].strip() # Strip the newline cause it's included in the group. if groups["tick"] == BACKTICK and language: + log.trace("Message has a valid code block with a language; returning empty tuple.") return () elif len(groups["code"].split("\n", 3)) > 3: code_block = CodeBlock(groups["code"], language, groups["tick"]) code_blocks.append(code_block) + else: + log.trace("Skipped a code block shorter than 4 lines.") return code_blocks def is_python_code(content: str) -> bool: """Return True if `content` is valid Python consisting of more than just expressions.""" + log.trace("Checking if content is Python code.") try: # Attempt to parse the message into an AST node. # Invalid Python code will raise a SyntaxError. @@ -80,6 +86,7 @@ def is_python_code(content: str) -> bool: # This check is to avoid all nodes being parsed as expressions. # (e.g. words over multiple lines) if not all(isinstance(node, ast.Expr) for node in tree.body): + log.trace("Code is valid python.") return True else: log.trace("Code consists only of expressions.") @@ -88,12 +95,16 @@ def is_python_code(content: str) -> bool: def is_repl_code(content: str, threshold: int = 3) -> bool: """Return True if `content` has at least `threshold` number of Python REPL-like lines.""" + log.trace(f"Checking if content is Python REPL code using a threshold of {threshold}.") + repl_lines = 0 for line in content.splitlines(): if line.startswith(">>> ") or line.startswith("... "): repl_lines += 1 if repl_lines == threshold: + log.trace("Content is Python REPL code.") return True + log.trace("Content is not Python REPL code.") return False -- cgit v1.2.3 From 808fe261cb0163fe5759da36e36418fc392cb846 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 22:46:07 -0700 Subject: Code block: fix valid code block being parsed as a missing block `find_code_blocks` was returning an empty tuple if there was at least one valid code block. However, the caller could not distinguish between that case and simply no code blocks being found. Therefore, None is explicitly returned to distinguish it from a lack of results. --- bot/cogs/codeblock/cog.py | 3 +++ bot/cogs/codeblock/parsing.py | 12 ++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index e4b87938d..15dffce7a 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -106,6 +106,9 @@ class CodeBlockCog(Cog, name="Code Block"): return blocks = parsing.find_code_blocks(msg.content) + if blocks is None: + # None is returned when there's at least one valid block with a language. + return if not blocks: log.trace(f"No code blocks were found in message {msg.id}.") description = instructions.get_no_ticks_message(msg.content) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 7409653d7..055c21118 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -1,7 +1,7 @@ import ast import logging import re -from typing import NamedTuple, Sequence +from typing import NamedTuple, Optional, Sequence log = logging.getLogger(__name__) @@ -41,15 +41,15 @@ class CodeBlock(NamedTuple): tick: str -def find_code_blocks(message: str) -> Sequence[CodeBlock]: +def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: """ Find and return all Markdown code blocks in the `message`. Code blocks with 3 or less lines are excluded. If the `message` contains at least one code block with valid ticks and a specified language, - return an empty sequence. This is based on the assumption that if the user managed to get - one code block right, they already know how to fix the rest themselves. + return None. This is based on the assumption that if the user managed to get one code block + right, they already know how to fix the rest themselves. """ log.trace("Finding all code blocks in a message.") @@ -60,8 +60,8 @@ def find_code_blocks(message: str) -> Sequence[CodeBlock]: language = groups["lang"].strip() # Strip the newline cause it's included in the group. if groups["tick"] == BACKTICK and language: - log.trace("Message has a valid code block with a language; returning empty tuple.") - return () + log.trace("Message has a valid code block with a language; returning None.") + return None elif len(groups["code"].split("\n", 3)) > 3: code_block = CodeBlock(groups["code"], language, groups["tick"]) code_blocks.append(code_block) -- cgit v1.2.3 From 45a13341f0eba0b04d57a5e240748e4939ab97a3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 22:58:43 -0700 Subject: Code block: move instructions deletion to a separate function --- bot/cogs/codeblock/cog.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 15dffce7a..396353d40 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -59,6 +59,21 @@ class CodeBlockCog(Cog, name="Code Block"): or channel.id in self.channel_whitelist ) + async def remove_instructions(self, payload: RawMessageUpdateEvent) -> None: + """ + Remove the code block instructions message. + + `payload` is the data for the message edit event performed by a user which resulted in their + code blocks being corrected. + """ + log.trace("User's incorrect code block has been fixed. Removing instructions message.") + + channel = self.bot.get_channel(int(payload.data.get("channel_id"))) + bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + + await bot_message.delete() + del self.codeblock_message_ids[payload.message_id] + async def send_guide_embed(self, message: discord.Message, description: str) -> None: """ Send an embed with `description` as a guide for an improperly formatted `message`. @@ -153,10 +168,4 @@ class CodeBlockCog(Cog, name="Code Block"): # If the message is fixed, delete the bot message and the entry from the id dictionary. if not code_blocks: - log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") - - channel = self.bot.get_channel(int(payload.data.get("channel_id"))) - bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) - - await bot_message.delete() - del self.codeblock_message_ids[payload.message_id] + await self.remove_instructions(payload) -- cgit v1.2.3 From e03c194242b16d5f5ef9d937a13daef424800bec Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 23:12:20 -0700 Subject: Code block: move instructions retrieval to a separate function Not only is it cleaner and more testable, but it allows for other functions to also retrieve instructions. --- bot/cogs/codeblock/cog.py | 32 ++++++-------------------------- bot/cogs/codeblock/instructions.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 396353d40..23d5267a9 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -8,7 +8,8 @@ from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover from bot.constants import Categories, Channels, DEBUG_MODE from bot.utils.messages import wait_for_deletion -from . import instructions, parsing +from . import parsing +from .instructions import get_instructions log = logging.getLogger(__name__) @@ -120,31 +121,10 @@ class CodeBlockCog(Cog, name="Code Block"): log.trace(f"Skipping code block detection of {msg.id}: #{msg.channel} is on cooldown.") return - blocks = parsing.find_code_blocks(msg.content) - if blocks is None: - # None is returned when there's at least one valid block with a language. - return - if not blocks: - log.trace(f"No code blocks were found in message {msg.id}.") - description = instructions.get_no_ticks_message(msg.content) - else: - log.trace("Searching results for a code block with invalid ticks.") - block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) - - if block: - log.trace(f"A code block exists in {msg.id} but has invalid ticks.") - description = instructions.get_bad_ticks_message(block) - else: - log.trace(f"A code block exists in {msg.id} but is missing a language.") - block = blocks[0] - - # Check for a bad language first to avoid parsing content into an AST. - description = instructions.get_bad_lang_message(block.content) - if not description: - description = instructions.get_no_lang_message(block.content) - - if description: - await self.send_guide_embed(msg, description) + instructions = get_instructions(msg.content) + if instructions: + await self.send_guide_embed(msg, instructions) + if msg.channel.id not in self.channel_whitelist: log.trace(f"Adding #{msg.channel} to the channel cooldowns.") self.channel_cooldowns[msg.channel.id] = time.time() diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 28242ce75..d331dd2ee 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -133,3 +133,34 @@ def get_no_lang_message(content: str) -> Optional[str]: ) else: log.trace("Aborting missing language instructions: content is not Python code.") + + +def get_instructions(content: str) -> Optional[str]: + """Return code block formatting instructions for `content` or None if nothing's wrong.""" + log.trace("Getting formatting instructions.") + + blocks = parsing.find_code_blocks(content) + if blocks is None: + log.trace("At least one valid code block found; no instructions to return.") + return + + if not blocks: + log.trace(f"No code blocks were found in message.") + return get_no_ticks_message(content) + else: + log.trace("Searching results for a code block with invalid ticks.") + block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) + + if block: + log.trace(f"A code block exists but has invalid ticks.") + return get_bad_ticks_message(block) + else: + log.trace(f"A code block exists but is missing a language.") + block = blocks[0] + + # Check for a bad language first to avoid parsing content into an AST. + description = get_bad_lang_message(block.content) + if not description: + description = get_no_lang_message(block.content) + + return description -- cgit v1.2.3 From ee8dae3ff890369ba7cd9badaa0e45ddcb926c8c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 23:29:13 -0700 Subject: Code block: move bot message retrieval to a separate function This bot message retrieval is the actual part of `remove_instructions` that will soon get re-used elsewhere. * Remove `remove_instructions` since it became a bit too simple given the separation of bot message retrieval. --- bot/cogs/codeblock/cog.py | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 23d5267a9..276bf8f9b 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -33,6 +33,13 @@ class CodeBlockCog(Cog, name="Code Block"): # Stores improperly formatted Python codeblock message ids and the corresponding bot message self.codeblock_message_ids = {} + async def get_sent_instructions(self, payload: RawMessageUpdateEvent) -> discord.Message: + """Return the bot's sent instructions message using the user message ID from a `payload`.""" + log.trace(f"Retrieving instructions message for ID {payload.message_id}") + + channel = self.bot.get_channel(int(payload.data.get("channel_id"))) + return await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: """Return True if `channel` is in one of the help categories.""" @@ -60,21 +67,6 @@ class CodeBlockCog(Cog, name="Code Block"): or channel.id in self.channel_whitelist ) - async def remove_instructions(self, payload: RawMessageUpdateEvent) -> None: - """ - Remove the code block instructions message. - - `payload` is the data for the message edit event performed by a user which resulted in their - code blocks being corrected. - """ - log.trace("User's incorrect code block has been fixed. Removing instructions message.") - - channel = self.bot.get_channel(int(payload.data.get("channel_id"))) - bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) - - await bot_message.delete() - del self.codeblock_message_ids[payload.message_id] - async def send_guide_embed(self, message: discord.Message, description: str) -> None: """ Send an embed with `description` as a guide for an improperly formatted `message`. @@ -148,4 +140,7 @@ class CodeBlockCog(Cog, name="Code Block"): # If the message is fixed, delete the bot message and the entry from the id dictionary. if not code_blocks: - await self.remove_instructions(payload) + log.trace("User's incorrect code block has been fixed. Removing instructions message.") + bot_message = await self.get_sent_instructions(payload) + await bot_message.delete() + del self.codeblock_message_ids[payload.message_id] -- cgit v1.2.3 From fd4bed07a08a5fdbd482345c99838131dba45e98 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 23:35:20 -0700 Subject: Code block: edit instructions if edited message is still invalid Editing instructions means the user will always see what is currently relevant to them. Sometimes an incorrect edit could result in a different problem that was not mentioned in the original instructions. This change also fixes detection of fixed messages by using the same detection logic as the original `on_message`. Previously, it considered an edited message without code blocks to be fixed. --- bot/cogs/codeblock/cog.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 276bf8f9b..5844f4d16 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -8,7 +8,6 @@ from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover from bot.constants import Categories, Channels, DEBUG_MODE from bot.utils.messages import wait_for_deletion -from . import parsing from .instructions import get_instructions log = logging.getLogger(__name__) @@ -136,11 +135,14 @@ class CodeBlockCog(Cog, name="Code Block"): return # Parse the message to see if the code blocks have been fixed. - code_blocks = parsing.find_code_blocks(payload.data.get("content")) + content = payload.data.get("content") + instructions = get_instructions(content) + bot_message = await self.get_sent_instructions(payload) - # If the message is fixed, delete the bot message and the entry from the id dictionary. - if not code_blocks: + if not instructions: log.trace("User's incorrect code block has been fixed. Removing instructions message.") - bot_message = await self.get_sent_instructions(payload) await bot_message.delete() del self.codeblock_message_ids[payload.message_id] + else: + log.trace("Message edited but still has invalid code blocks; editing the instructions.") + await bot_message.edit(content=instructions) -- cgit v1.2.3 From b86d9a66519b2c8b8c50c255c8b23d924be35f5a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 11:09:44 -0700 Subject: Code block: clarify log messages in message edit event If statement was separated so there could be separate messages that are more specific. The message ID was also included to distinguish events. --- bot/cogs/codeblock/cog.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 5844f4d16..0f0a8cd51 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -123,15 +123,12 @@ class CodeBlockCog(Cog, name="Code Block"): @Cog.listener() async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: """Delete the instructions message if an edited message had its code blocks fixed.""" - if ( - # Checks to see if the message was called out by the bot - payload.message_id not in self.codeblock_message_ids - # Makes sure that there is content in the message - or payload.data.get("content") is None - # Makes sure there's a channel id in the message payload - or payload.data.get("channel_id") is None - ): - log.trace("Message edit does not qualify for code block detection.") + if payload.message_id not in self.codeblock_message_ids: + log.trace(f"Ignoring message edit {payload.message_id}: message isn't being tracked.") + return + + if payload.data.get("content") is None or payload.data.get("channel_id") is None: + log.trace(f"Ignoring message edit {payload.message_id}: missing content or channel ID.") return # Parse the message to see if the code blocks have been fixed. -- cgit v1.2.3 From 3728d8a1e8bbf9cfb0dce7a9a548c6527b554290 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 11:16:41 -0700 Subject: Code block: fix error retrieving a deleted instructions message --- bot/cogs/codeblock/cog.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 0f0a8cd51..f64ac8c45 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -1,5 +1,6 @@ import logging import time +from typing import Optional import discord from discord import Embed, Message, RawMessageUpdateEvent @@ -32,12 +33,21 @@ class CodeBlockCog(Cog, name="Code Block"): # Stores improperly formatted Python codeblock message ids and the corresponding bot message self.codeblock_message_ids = {} - async def get_sent_instructions(self, payload: RawMessageUpdateEvent) -> discord.Message: - """Return the bot's sent instructions message using the user message ID from a `payload`.""" - log.trace(f"Retrieving instructions message for ID {payload.message_id}") + async def get_sent_instructions(self, payload: RawMessageUpdateEvent) -> Optional[Message]: + """ + Return the bot's sent instructions message associated with a user's message `payload`. + Return None if the message cannot be found. In this case, it's likely the message was + deleted either manually via a reaction or automatically by a timer. + """ + log.trace(f"Retrieving instructions message for ID {payload.message_id}") channel = self.bot.get_channel(int(payload.data.get("channel_id"))) - return await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + + try: + return await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + except discord.NotFound: + log.debug("Could not find instructions message; it was probably deleted.") + return None @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: @@ -134,7 +144,10 @@ class CodeBlockCog(Cog, name="Code Block"): # Parse the message to see if the code blocks have been fixed. content = payload.data.get("content") instructions = get_instructions(content) + bot_message = await self.get_sent_instructions(payload) + if not bot_message: + return if not instructions: log.trace("User's incorrect code block has been fixed. Removing instructions message.") -- cgit v1.2.3 From 2694cbff786154fb8ba1211b0954f12312b71016 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 12:55:16 -0700 Subject: Code block: refactor `send_guide_embed` * Rename to `send_instructions` to be consistent with the use of "instructions" rather than "guide" elsewhere * Rename the `description` parameter to `instructions` --- bot/cogs/codeblock/cog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index f64ac8c45..38daa7974 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -76,15 +76,15 @@ class CodeBlockCog(Cog, name="Code Block"): or channel.id in self.channel_whitelist ) - async def send_guide_embed(self, message: discord.Message, description: str) -> None: + async def send_instructions(self, message: discord.Message, instructions: str) -> None: """ - Send an embed with `description` as a guide for an improperly formatted `message`. + Send an embed with `instructions` on fixing an incorrect code block in a `message`. The embed will be deleted automatically after 5 minutes. """ log.trace("Sending an embed with code block formatting instructions.") - embed = Embed(description=description) + embed = Embed(description=instructions) bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed) self.codeblock_message_ids[message.id] = bot_message.id @@ -124,7 +124,7 @@ class CodeBlockCog(Cog, name="Code Block"): instructions = get_instructions(msg.content) if instructions: - await self.send_guide_embed(msg, instructions) + await self.send_instructions(msg, instructions) if msg.channel.id not in self.channel_whitelist: log.trace(f"Adding #{msg.channel} to the channel cooldowns.") -- cgit v1.2.3 From 7468aff92bc6cd658b334d89e7049c98b8ae0439 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 16:26:17 -0700 Subject: Code block: rename some things to be "private" --- bot/cogs/codeblock/instructions.py | 44 +++++++++++++++++++------------------- bot/cogs/codeblock/parsing.py | 8 +++---- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index d331dd2ee..abdf092fe 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -5,16 +5,16 @@ from . import parsing log = logging.getLogger(__name__) -PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. -EXAMPLE_PY = "{lang}\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. -EXAMPLE_CODE_BLOCKS = ( +_PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. +_EXAMPLE_PY = "{lang}\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. +_EXAMPLE_CODE_BLOCKS = ( "\\`\\`\\`{content}\n\\`\\`\\`\n\n" "**This will result in the following:**\n" "```{content}```" ) -def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: +def _get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: """Return instructions on using the correct ticks for `code_block`.""" log.trace("Creating instructions for incorrect code block ticks.") valid_ticks = f"\\{parsing.BACKTICK}" * 3 @@ -27,9 +27,9 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: ) log.trace("Check if the bad ticks code block also has issues with the language specifier.") - addition_msg = get_bad_lang_message(code_block.content) + addition_msg = _get_bad_lang_message(code_block.content) if not addition_msg: - addition_msg = get_no_lang_message(code_block.content) + addition_msg = _get_no_lang_message(code_block.content) # Combine the back ticks message with the language specifier message. The latter will # already have an example code block. @@ -45,9 +45,9 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: log.trace("No issues with the language specifier found.") # Determine the example code to put in the code block based on the language specifier. - if code_block.language.lower() in PY_LANG_CODES: + if code_block.language.lower() in _PY_LANG_CODES: log.trace(f"Code block has a Python language specifier `{code_block.language}`.") - content = EXAMPLE_PY.format(lang=code_block.language) + content = _EXAMPLE_PY.format(lang=code_block.language) elif code_block.language: log.trace(f"Code block has a foreign language specifier `{code_block.language}`.") # It's not feasible to determine what would be a valid example for other languages. @@ -56,18 +56,18 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: log.trace("Code block has no language specifier (and the code isn't valid Python).") content = "Hello, world!" - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=content) + example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=content) instructions += f"\n\n**Here is an example of how it should look:**\n{example_blocks}" return instructions -def get_no_ticks_message(content: str) -> Optional[str]: +def _get_no_ticks_message(content: str) -> Optional[str]: """If `content` is Python/REPL code, return instructions on using code blocks.""" log.trace("Creating instructions for a missing code block.") if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang="python")) + example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=_EXAMPLE_PY.format(lang="python")) return ( "It looks like you're trying to paste code into this channel.\n\n" "Discord has support for Markdown, which allows you to post code with full " @@ -79,7 +79,7 @@ def get_no_ticks_message(content: str) -> Optional[str]: log.trace("Aborting missing code block instructions: content is not Python code.") -def get_bad_lang_message(content: str) -> Optional[str]: +def _get_bad_lang_message(content: str) -> Optional[str]: """ Return instructions on fixing the Python language specifier for a code block. @@ -88,10 +88,10 @@ def get_bad_lang_message(content: str) -> Optional[str]: log.trace("Creating instructions for a poorly specified language.") stripped = content.lstrip().lower() - lang = next((lang for lang in PY_LANG_CODES if stripped.startswith(lang)), None) + lang = next((lang for lang in _PY_LANG_CODES if stripped.startswith(lang)), None) if lang: - # Note that get_bad_ticks_message expects the first line to have an extra newline. + # Note that _get_bad_ticks_message expects the first line to have an extra newline. lines = ["It looks like you incorrectly specified a language for your code block.\n"] if content.startswith(" "): @@ -105,7 +105,7 @@ def get_bad_lang_message(content: str) -> Optional[str]: f"There must not be any spaces after `{lang}`." ) - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang=lang)) + example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=_EXAMPLE_PY.format(lang=lang)) lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") return "\n".join(lines) @@ -113,7 +113,7 @@ def get_bad_lang_message(content: str) -> Optional[str]: log.trace("Aborting bad language instructions: language specified isn't Python.") -def get_no_lang_message(content: str) -> Optional[str]: +def _get_no_lang_message(content: str) -> Optional[str]: """ Return instructions on specifying a language for a code block. @@ -122,9 +122,9 @@ def get_no_lang_message(content: str) -> Optional[str]: log.trace("Creating instructions for a missing language.") if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang="python")) + example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=_EXAMPLE_PY.format(lang="python")) - # Note that get_bad_ticks_message expects the first line to have an extra newline. + # Note that _get_bad_ticks_message expects the first line to have an extra newline. return ( "It looks like you pasted Python code without syntax highlighting.\n\n" "Please use syntax highlighting to improve the legibility of your code and make " @@ -146,21 +146,21 @@ def get_instructions(content: str) -> Optional[str]: if not blocks: log.trace(f"No code blocks were found in message.") - return get_no_ticks_message(content) + return _get_no_ticks_message(content) else: log.trace("Searching results for a code block with invalid ticks.") block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) if block: log.trace(f"A code block exists but has invalid ticks.") - return get_bad_ticks_message(block) + return _get_bad_ticks_message(block) else: log.trace(f"A code block exists but is missing a language.") block = blocks[0] # Check for a bad language first to avoid parsing content into an AST. - description = get_bad_lang_message(block.content) + description = _get_bad_lang_message(block.content) if not description: - description = get_no_lang_message(block.content) + description = _get_no_lang_message(block.content) return description diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 055c21118..a49ecc8f7 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -6,7 +6,7 @@ from typing import NamedTuple, Optional, Sequence log = logging.getLogger(__name__) BACKTICK = "`" -TICKS = { +_TICKS = { BACKTICK, "'", '"', @@ -19,10 +19,10 @@ TICKS = { "\u2033", # DOUBLE PRIME "\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF } -RE_CODE_BLOCK = re.compile( +_RE_CODE_BLOCK = re.compile( fr""" (?P - (?P[{''.join(TICKS)}]) # Put all ticks into a character class within a group. + (?P[{''.join(_TICKS)}]) # Put all ticks into a character class within a group. \2{{2}} # Match previous group 2 more times to ensure the same char. ) (?P[^\W_]+\n)? # Optionally match a language specifier followed by a newline. @@ -54,7 +54,7 @@ def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: log.trace("Finding all code blocks in a message.") code_blocks = [] - for match in RE_CODE_BLOCK.finditer(message): + for match in _RE_CODE_BLOCK.finditer(message): # Used to ensure non-matched groups have an empty string as the default value. groups = match.groupdict("") language = groups["lang"].strip() # Strip the newline cause it's included in the group. -- cgit v1.2.3 From c98666d42e325cc8de11d6a271015b2a546a65b1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 17:22:23 -0700 Subject: Code block: create a function to format the example code blocks First, this reduces code redundancy. Furthermore, it moves the relatively big block of code for checking the language away from `_get_bad_ticks_message` and into its own, smaller unit. --- bot/cogs/codeblock/instructions.py | 40 ++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index abdf092fe..bba84c66a 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -14,6 +14,25 @@ _EXAMPLE_CODE_BLOCKS = ( ) +def _get_example(language: str) -> str: + """Return an example of a correct code block using `language` for syntax highlighting.""" + language_lower = language.lower() # It's only valid if it's all lowercase. + + # Determine the example code to put in the code block based on the language specifier. + if language_lower in _PY_LANG_CODES: + log.trace(f"Code block has a Python language specifier `{language}`.") + content = _EXAMPLE_PY.format(lang=language_lower) + elif language_lower: + log.trace(f"Code block has a foreign language specifier `{language}`.") + # It's not feasible to determine what would be a valid example for other languages. + content = f"{language_lower}\n..." + else: + log.trace("Code block has no language specifier.") + content = "Hello, world!" + + return _EXAMPLE_CODE_BLOCKS.format(content=content) + + def _get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: """Return instructions on using the correct ticks for `code_block`.""" log.trace("Creating instructions for incorrect code block ticks.") @@ -43,20 +62,7 @@ def _get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: instructions += "\n\nFurthermore, " + addition_msg[0].lower() + addition_msg[1:] else: log.trace("No issues with the language specifier found.") - - # Determine the example code to put in the code block based on the language specifier. - if code_block.language.lower() in _PY_LANG_CODES: - log.trace(f"Code block has a Python language specifier `{code_block.language}`.") - content = _EXAMPLE_PY.format(lang=code_block.language) - elif code_block.language: - log.trace(f"Code block has a foreign language specifier `{code_block.language}`.") - # It's not feasible to determine what would be a valid example for other languages. - content = f"{code_block.language}\n..." - else: - log.trace("Code block has no language specifier (and the code isn't valid Python).") - content = "Hello, world!" - - example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=content) + example_blocks = _get_example(code_block.language) instructions += f"\n\n**Here is an example of how it should look:**\n{example_blocks}" return instructions @@ -67,7 +73,7 @@ def _get_no_ticks_message(content: str) -> Optional[str]: log.trace("Creating instructions for a missing code block.") if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=_EXAMPLE_PY.format(lang="python")) + example_blocks = _get_example("python") return ( "It looks like you're trying to paste code into this channel.\n\n" "Discord has support for Markdown, which allows you to post code with full " @@ -105,7 +111,7 @@ def _get_bad_lang_message(content: str) -> Optional[str]: f"There must not be any spaces after `{lang}`." ) - example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=_EXAMPLE_PY.format(lang=lang)) + example_blocks = _get_example(lang) lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") return "\n".join(lines) @@ -122,7 +128,7 @@ def _get_no_lang_message(content: str) -> Optional[str]: log.trace("Creating instructions for a missing language.") if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=_EXAMPLE_PY.format(lang="python")) + example_blocks = _get_example("python") # Note that _get_bad_ticks_message expects the first line to have an extra newline. return ( -- cgit v1.2.3 From 2bfac307c4b06682db93e2a75108012a586d1c7d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 18:33:34 -0700 Subject: Code block: use regex to parse incorrect languages Regex is simpler and more versatile in this case. The functions in the `instructions` module should be more focused on formatting than parsing, so the parsing was moved to the `parsing` module. * Move _PY_LANG_CODES to the `parsing` module * Create a separate function in the `parsing` module to parse bad languages --- bot/cogs/codeblock/instructions.py | 30 +++++++++++++---------------- bot/cogs/codeblock/parsing.py | 39 +++++++++++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 18 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index bba84c66a..c1a6645b3 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -5,7 +5,6 @@ from . import parsing log = logging.getLogger(__name__) -_PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. _EXAMPLE_PY = "{lang}\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. _EXAMPLE_CODE_BLOCKS = ( "\\`\\`\\`{content}\n\\`\\`\\`\n\n" @@ -16,16 +15,14 @@ _EXAMPLE_CODE_BLOCKS = ( def _get_example(language: str) -> str: """Return an example of a correct code block using `language` for syntax highlighting.""" - language_lower = language.lower() # It's only valid if it's all lowercase. - # Determine the example code to put in the code block based on the language specifier. - if language_lower in _PY_LANG_CODES: + if language.lower() in parsing.PY_LANG_CODES: log.trace(f"Code block has a Python language specifier `{language}`.") - content = _EXAMPLE_PY.format(lang=language_lower) - elif language_lower: + content = _EXAMPLE_PY.format(lang=language) + elif language: log.trace(f"Code block has a foreign language specifier `{language}`.") # It's not feasible to determine what would be a valid example for other languages. - content = f"{language_lower}\n..." + content = f"{language}\n..." else: log.trace("Code block has no language specifier.") content = "Hello, world!" @@ -92,26 +89,25 @@ def _get_bad_lang_message(content: str) -> Optional[str]: If `content` doesn't start with "python" or "py" as the language specifier, return None. """ log.trace("Creating instructions for a poorly specified language.") + info = parsing.parse_bad_language(content) - stripped = content.lstrip().lower() - lang = next((lang for lang in _PY_LANG_CODES if stripped.startswith(lang)), None) - - if lang: + if info: # Note that _get_bad_ticks_message expects the first line to have an extra newline. lines = ["It looks like you incorrectly specified a language for your code block.\n"] + language = info.language - if content.startswith(" "): + if info.leading_spaces: log.trace("Language specifier was preceded by a space.") - lines.append(f"Make sure there are no spaces between the back ticks and `{lang}`.") + lines.append(f"Make sure there are no spaces between the back ticks and `{language}`.") - if stripped[len(lang)] != "\n": + if not info.terminal_newline: log.trace("Language specifier was not followed by a newline.") lines.append( - f"Make sure you put your code on a new line following `{lang}`. " - f"There must not be any spaces after `{lang}`." + f"Make sure you put your code on a new line following `{language}`. " + f"There must not be any spaces after `{language}`." ) - example_blocks = _get_example(lang) + example_blocks = _get_example(language) lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") return "\n".join(lines) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index a49ecc8f7..6fa6811cc 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -22,7 +22,7 @@ _TICKS = { _RE_CODE_BLOCK = re.compile( fr""" (?P - (?P[{''.join(_TICKS)}]) # Put all ticks into a character class within a group. + (?P[{''.join(_TICKS)}]) # Put all ticks into a character class within a group. \2{{2}} # Match previous group 2 more times to ensure the same char. ) (?P[^\W_]+\n)? # Optionally match a language specifier followed by a newline. @@ -32,6 +32,16 @@ _RE_CODE_BLOCK = re.compile( re.DOTALL | re.VERBOSE ) +PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. +_RE_LANGUAGE = re.compile( + fr""" + ^(?P\s+)? # Optionally match leading spaces from the beginning. + (?P{'|'.join(PY_LANG_CODES)}) # Match a Python language. + (?P\n)? # Optionally match a newline following the language. + """, + re.IGNORECASE | re.VERBOSE +) + class CodeBlock(NamedTuple): """Represents a Markdown code block.""" @@ -41,6 +51,14 @@ class CodeBlock(NamedTuple): tick: str +class BadLanguage(NamedTuple): + """Parsed information about a poorly formatted language specifier.""" + + language: str + leading_spaces: bool + terminal_newline: bool + + def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: """ Find and return all Markdown code blocks in the `message`. @@ -108,3 +126,22 @@ def is_repl_code(content: str, threshold: int = 3) -> bool: log.trace("Content is not Python REPL code.") return False + + +def parse_bad_language(content: str) -> Optional[BadLanguage]: + """ + Return information about a poorly formatted Python language in code block `content`. + + If the language is not Python, return None. + """ + log.trace("Parsing bad language.") + + match = _RE_LANGUAGE.match(content) + if not match: + return None + + return BadLanguage( + language=match["lang"], + leading_spaces=match["spaces"] is not None, + terminal_newline=match["newline"] is not None, + ) -- cgit v1.2.3 From ae0f29ee8680c75d59eefa2f1563f6c906539aa9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 19:18:47 -0700 Subject: Code block: add function to create the instructions embed While it may be simple now, if the embed needs to changed later, it won't need to be done in multiple places since everything can rely on this function to create the embed. --- bot/cogs/codeblock/cog.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 38daa7974..ca787b181 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -3,7 +3,7 @@ import time from typing import Optional import discord -from discord import Embed, Message, RawMessageUpdateEvent +from discord import Message, RawMessageUpdateEvent from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover @@ -33,6 +33,11 @@ class CodeBlockCog(Cog, name="Code Block"): # Stores improperly formatted Python codeblock message ids and the corresponding bot message self.codeblock_message_ids = {} + @staticmethod + def create_embed(instructions: str) -> discord.Embed: + """Return an embed which displays code block formatting `instructions`.""" + return discord.Embed(description=instructions) + async def get_sent_instructions(self, payload: RawMessageUpdateEvent) -> Optional[Message]: """ Return the bot's sent instructions message associated with a user's message `payload`. @@ -84,7 +89,7 @@ class CodeBlockCog(Cog, name="Code Block"): """ log.trace("Sending an embed with code block formatting instructions.") - embed = Embed(description=instructions) + embed = self.create_embed(instructions) bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed) self.codeblock_message_ids[message.id] = bot_message.id -- cgit v1.2.3 From cad6957b233ed905ed76d066517866255c8ae7a4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 19:19:46 -0700 Subject: Code block: fix message content being edited instead of the embed --- bot/cogs/codeblock/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index ca787b181..80d5adff3 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -160,4 +160,4 @@ class CodeBlockCog(Cog, name="Code Block"): del self.codeblock_message_ids[payload.message_id] else: log.trace("Message edited but still has invalid code blocks; editing the instructions.") - await bot_message.edit(content=instructions) + await bot_message.edit(embed=self.create_embed(instructions)) -- cgit v1.2.3 From 4b1a1cdd91023baa0da9959e1cc8b811c0aa9795 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 19:34:20 -0700 Subject: Code block: join bad language instructions by spaces It was a mistake to join them by newlines in the first place. It looks and reads better as a paragraph. * Remove extra space after bad ticks instructions --- bot/cogs/codeblock/instructions.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index c1a6645b3..3cc955a1a 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -33,13 +33,12 @@ def _get_example(language: str) -> str: def _get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: """Return instructions on using the correct ticks for `code_block`.""" log.trace("Creating instructions for incorrect code block ticks.") - valid_ticks = f"\\{parsing.BACKTICK}" * 3 - # The space at the end is important here because something may be appended! + valid_ticks = f"\\{parsing.BACKTICK}" * 3 instructions = ( "It looks like you are trying to paste code into this channel.\n\n" "You seem to be using the wrong symbols to indicate where the code block should start. " - f"The correct symbols would be {valid_ticks}, not `{code_block.tick * 3}`. " + f"The correct symbols would be {valid_ticks}, not `{code_block.tick * 3}`." ) log.trace("Check if the bad ticks code block also has issues with the language specifier.") @@ -52,7 +51,7 @@ def _get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: if addition_msg: log.trace("Language specifier issue found; appending additional instructions.") - # The first line has a double line break which is not desirable when appending the msg. + # The first line has double newlines which are not desirable when appending the msg. addition_msg = addition_msg.replace("\n\n", " ", 1) # Make the first character of the addition lower case. @@ -92,8 +91,7 @@ def _get_bad_lang_message(content: str) -> Optional[str]: info = parsing.parse_bad_language(content) if info: - # Note that _get_bad_ticks_message expects the first line to have an extra newline. - lines = ["It looks like you incorrectly specified a language for your code block.\n"] + lines = [] language = info.language if info.leading_spaces: @@ -107,10 +105,14 @@ def _get_bad_lang_message(content: str) -> Optional[str]: f"There must not be any spaces after `{language}`." ) + lines = " ".join(lines) example_blocks = _get_example(language) - lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") - return "\n".join(lines) + # Note that _get_bad_ticks_message expects the first line to have two newlines. + return ( + f"It looks like you incorrectly specified a language for your code block.\n\n{lines}" + f"\n\n**Here is an example of how it should look:**\n{example_blocks}" + ) else: log.trace("Aborting bad language instructions: language specified isn't Python.") @@ -126,7 +128,7 @@ def _get_no_lang_message(content: str) -> Optional[str]: if parsing.is_repl_code(content) or parsing.is_python_code(content): example_blocks = _get_example("python") - # Note that _get_bad_ticks_message expects the first line to have an extra newline. + # Note that _get_bad_ticks_message expects the first line to have two newlines. return ( "It looks like you pasted Python code without syntax highlighting.\n\n" "Please use syntax highlighting to improve the legibility of your code and make " -- cgit v1.2.3 From b160119bbdcde230da44279ce3698fb800f5743e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 20:37:37 -0700 Subject: Code block: don't return bad language instructions if nothing's wrong --- bot/cogs/codeblock/instructions.py | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 3cc955a1a..0c97d2ad4 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -85,26 +85,31 @@ def _get_bad_lang_message(content: str) -> Optional[str]: """ Return instructions on fixing the Python language specifier for a code block. - If `content` doesn't start with "python" or "py" as the language specifier, return None. + If `code_block` does not have a Python language specifier, return None. + If there's nothing wrong with the language specifier, return None. """ log.trace("Creating instructions for a poorly specified language.") + info = parsing.parse_bad_language(content) + if not info: + log.trace("Aborting bad language instructions: language specified isn't Python.") + return - if info: - lines = [] - language = info.language + lines = [] + language = info.language - if info.leading_spaces: - log.trace("Language specifier was preceded by a space.") - lines.append(f"Make sure there are no spaces between the back ticks and `{language}`.") + if info.leading_spaces: + log.trace("Language specifier was preceded by a space.") + lines.append(f"Make sure there are no spaces between the back ticks and `{language}`.") - if not info.terminal_newline: - log.trace("Language specifier was not followed by a newline.") - lines.append( - f"Make sure you put your code on a new line following `{language}`. " - f"There must not be any spaces after `{language}`." - ) + if not info.terminal_newline: + log.trace("Language specifier was not followed by a newline.") + lines.append( + f"Make sure you put your code on a new line following `{language}`. " + f"There must not be any spaces after `{language}`." + ) + if lines: lines = " ".join(lines) example_blocks = _get_example(language) @@ -114,7 +119,7 @@ def _get_bad_lang_message(content: str) -> Optional[str]: f"\n\n**Here is an example of how it should look:**\n{example_blocks}" ) else: - log.trace("Aborting bad language instructions: language specified isn't Python.") + log.trace("Nothing wrong with the language specifier; no instructions to return.") def _get_no_lang_message(content: str) -> Optional[str]: -- cgit v1.2.3 From 7b2fff794907fed5e000998e876b7326fb938ca8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 20:46:09 -0700 Subject: Code block: fix wrong message shown for bad ticks with a valid language When the code block had invalid ticks, instructions for syntax highlighting were being shown despite the code block having a valid language. --- bot/cogs/codeblock/instructions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 0c97d2ad4..880572d58 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -43,7 +43,7 @@ def _get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: log.trace("Check if the bad ticks code block also has issues with the language specifier.") addition_msg = _get_bad_lang_message(code_block.content) - if not addition_msg: + if not addition_msg and not code_block.language: addition_msg = _get_no_lang_message(code_block.content) # Combine the back ticks message with the language specifier message. The latter will -- cgit v1.2.3 From 8fcbad9d2ee11916e398ae9f63826a90cdc45608 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 21:27:45 -0700 Subject: Code block: document the cog * Add docstrings for modules * Rephrase some docstrings and comments * Fix the grammar of some comments --- bot/cogs/codeblock/cog.py | 43 ++++++++++++++++++++++++++++++++------ bot/cogs/codeblock/instructions.py | 2 ++ bot/cogs/codeblock/parsing.py | 4 +++- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 80d5adff3..c1b2b1c68 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -15,22 +15,53 @@ log = logging.getLogger(__name__) class CodeBlockCog(Cog, name="Code Block"): - """Detect improperly formatted code blocks and suggest proper formatting.""" + """ + Detect improperly formatted Markdown code blocks and suggest proper formatting. + + There are four basic ways in which a code block is considered improperly formatted: + + 1. The code is not within a code block at all + * Ignored if the code is not valid Python or Python REPL code + 2. Incorrect characters are used for backticks + 3. A language for syntax highlighting is not specified + * Ignored if the code is not valid Python or Python REPL code + 4. A syntax highlighting language is incorrectly specified + * Ignored if the language specified doesn't look like it was meant for Python + * This can go wrong in two ways: + 1. Spaces before the language + 2. No newline immediately following the language + + Messages with 3 or fewer lines overall are ignored. Each code block is subject to this threshold + as well i.e. the text between the ticks must be greater than 3 lines. Detecting multiple code + blocks is supported. However, if at least one code block is correct, then instructions will not + be sent even if others are incorrect. When multiple incorrect code blocks are found, only the + first one is used as the basis for the instructions sent. + + When an issue is detected, an embed is sent containing specific instructions on fixing what + is wrong. If the user edits their message to fix the code block, the instructions will be + removed. If they fail to fix the code block with an edit, the instructions will be updated to + show what is still incorrect after the user's edit. The embed can be manually deleted with a + reaction. Otherwise, it will automatically be removed after 5 minutes. + + The cog only detects messages in whitelisted channels. Channels may also have a 300-second + cooldown on the instructions being sent. See `__init__` for which channels are whitelisted or + have cooldowns enabled. Note that all help channels are also whitelisted with cooldowns enabled. + """ def __init__(self, bot: Bot): self.bot = bot - # Stores allowed channels plus epoch time since last call. + # Stores allowed channels plus epoch times since the last instructional messages sent. self.channel_cooldowns = { Channels.python_discussion: 0, } - # These channels will also work, but will not be subject to cooldown + # These channels will also work, but will not be subject to a cooldown. self.channel_whitelist = ( Channels.bot_commands, ) - # Stores improperly formatted Python codeblock message ids and the corresponding bot message + # Maps users' messages to the messages the bot sent with instructions. self.codeblock_message_ids = {} @staticmethod @@ -73,7 +104,7 @@ class CodeBlockCog(Cog, name="Code Block"): return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < 300 def is_valid_channel(self, channel: discord.TextChannel) -> bool: - """Return True if `channel` is a help channel, may be on cooldown, or is whitelisted.""" + """Return True if `channel` is a help channel, may be on a cooldown, or is whitelisted.""" log.trace(f"Checking if #{channel} qualifies for code block detection.") return ( self.is_help_channel(channel) @@ -137,7 +168,7 @@ class CodeBlockCog(Cog, name="Code Block"): @Cog.listener() async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: - """Delete the instructions message if an edited message had its code blocks fixed.""" + """Delete the instructional message if an edited message had its code blocks fixed.""" if payload.message_id not in self.codeblock_message_ids: log.trace(f"Ignoring message edit {payload.message_id}: message isn't being tracked.") return diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 880572d58..80f82ef34 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -1,3 +1,5 @@ +"""This module generates and formats instructional messages about fixing Markdown code blocks.""" + import logging from typing import Optional diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 6fa6811cc..1bdb3b492 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -1,3 +1,5 @@ +"""This module provides functions for parsing Markdown code blocks.""" + import ast import logging import re @@ -63,7 +65,7 @@ def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: """ Find and return all Markdown code blocks in the `message`. - Code blocks with 3 or less lines are excluded. + Code blocks with 3 or fewer lines are excluded. If the `message` contains at least one code block with valid ticks and a specified language, return None. This is based on the assumption that if the user managed to get one code block -- cgit v1.2.3 From 211aad8fc14ec81cb6e04cfaf70f6e50221bbc57 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 21:46:39 -0700 Subject: Move some functions into a new channel utility module * Change `is_help_channel` to`internally use `is_in_category` --- bot/cogs/codeblock/cog.py | 14 +++----------- bot/cogs/help_channels.py | 43 +++++++++++++++++-------------------------- bot/utils/channel.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 37 deletions(-) create mode 100644 bot/utils/channel.py diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index c1b2b1c68..3c119814f 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -7,7 +7,8 @@ from discord import Message, RawMessageUpdateEvent from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover -from bot.constants import Categories, Channels, DEBUG_MODE +from bot.constants import Channels, DEBUG_MODE +from bot.utils.channel import is_help_channel from bot.utils.messages import wait_for_deletion from .instructions import get_instructions @@ -85,15 +86,6 @@ class CodeBlockCog(Cog, name="Code Block"): log.debug("Could not find instructions message; it was probably deleted.") return None - @staticmethod - def is_help_channel(channel: discord.TextChannel) -> bool: - """Return True if `channel` is in one of the help categories.""" - log.trace(f"Checking if #{channel} is a help channel.") - return ( - getattr(channel, "category", None) - and channel.category.id in (Categories.help_available, Categories.help_in_use) - ) - def is_on_cooldown(self, channel: discord.TextChannel) -> bool: """ Return True if an embed was sent for `channel` in the last 300 seconds. @@ -107,7 +99,7 @@ class CodeBlockCog(Cog, name="Code Block"): """Return True if `channel` is a help channel, may be on a cooldown, or is whitelisted.""" log.trace(f"Checking if #{channel} qualifies for code block detection.") return ( - self.is_help_channel(channel) + is_help_channel(channel) or channel.id in self.channel_cooldowns or channel.id in self.channel_whitelist ) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 6ff285c37..513ce31d0 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -15,6 +15,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot +from bot.utils import channel as channel_utils from bot.utils.checks import with_role_check from bot.utils.scheduling import Scheduler @@ -370,11 +371,18 @@ class HelpChannels(Scheduler, commands.Cog): log.trace("Getting the CategoryChannel objects for the help categories.") try: - self.available_category = await self.try_get_channel( - constants.Categories.help_available + self.available_category = await channel_utils.try_get_channel( + constants.Categories.help_available, + self.bot + ) + self.in_use_category = await channel_utils.try_get_channel( + constants.Categories.help_in_use, + self.bot + ) + self.dormant_category = await channel_utils.try_get_channel( + constants.Categories.help_dormant, + self.bot ) - self.in_use_category = await self.try_get_channel(constants.Categories.help_in_use) - self.dormant_category = await self.try_get_channel(constants.Categories.help_dormant) except discord.HTTPException: log.exception("Failed to get a category; cog will be removed") self.bot.remove_cog(self.qualified_name) @@ -431,12 +439,6 @@ class HelpChannels(Scheduler, commands.Cog): embed = message.embeds[0] return message.author == self.bot.user and embed.description.strip() == description.strip() - @staticmethod - def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: - """Return True if `channel` is within a category with `category_id`.""" - actual_category = getattr(channel, "category", None) - return actual_category is not None and actual_category.id == category_id - async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: """ Make the `channel` dormant if idle or schedule the move if still active. @@ -488,7 +490,7 @@ class HelpChannels(Scheduler, commands.Cog): options should be avoided, as it may interfere with the category move we perform. """ # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. - category = await self.try_get_channel(category_id) + category = await channel_utils.try_get_channel(category_id, self.bot) payload = [{"id": c.id, "position": c.position} for c in category.channels] @@ -634,7 +636,7 @@ class HelpChannels(Scheduler, commands.Cog): channel = message.channel # Confirm the channel is an in use help channel - if self.is_in_category(channel, constants.Categories.help_in_use): + if channel_utils.is_in_category(channel, constants.Categories.help_in_use): log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") # Check if there is an entry in unanswered (does not persist across restarts) @@ -659,7 +661,8 @@ class HelpChannels(Scheduler, commands.Cog): await self.check_for_answer(message) - if not self.is_in_category(channel, constants.Categories.help_available) or self.is_excluded_channel(channel): + is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) + if not is_available or self.is_excluded_channel(channel): return # Ignore messages outside the Available category or in excluded channels. log.trace("Waiting for the cog to be ready before processing messages.") @@ -669,7 +672,7 @@ class HelpChannels(Scheduler, commands.Cog): async with self.on_message_lock: log.trace(f"on_message lock acquired for {message.id}.") - if not self.is_in_category(channel, constants.Categories.help_available): + if not channel_utils.is_in_category(channel, constants.Categories.help_available): log.debug( f"Message {message.id} will not make #{channel} ({channel.id}) in-use " f"because another message in the channel already triggered that." @@ -802,18 +805,6 @@ class HelpChannels(Scheduler, commands.Cog): log.trace(f"Dormant message not found in {channel_info}; sending a new message.") await channel.send(embed=embed) - async def try_get_channel(self, channel_id: int) -> discord.abc.GuildChannel: - """Attempt to get or fetch a channel and return it.""" - log.trace(f"Getting the channel {channel_id}.") - - channel = self.bot.get_channel(channel_id) - if not channel: - log.debug(f"Channel {channel_id} is not in cache; fetching from API.") - channel = await self.bot.fetch_channel(channel_id) - - log.trace(f"Channel #{channel} ({channel_id}) retrieved.") - return channel - async def wait_for_dormant_channel(self) -> discord.TextChannel: """Wait for a dormant channel to become available in the queue and return it.""" log.trace("Waiting for a dormant channel.") diff --git a/bot/utils/channel.py b/bot/utils/channel.py new file mode 100644 index 000000000..47f70ce31 --- /dev/null +++ b/bot/utils/channel.py @@ -0,0 +1,34 @@ +import logging + +import discord + +from bot.constants import Categories + +log = logging.getLogger(__name__) + + +def is_help_channel(channel: discord.TextChannel) -> bool: + """Return True if `channel` is in one of the help categories (excluding dormant).""" + log.trace(f"Checking if #{channel} is a help channel.") + categories = (Categories.help_available, Categories.help_in_use) + + return any(is_in_category(channel, category) for category in categories) + + +def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: + """Return True if `channel` is within a category with `category_id`.""" + actual_category = getattr(channel, "category", None) + return actual_category is not None and actual_category.id == category_id + + +async def try_get_channel(channel_id: int, client: discord.Client) -> discord.abc.GuildChannel: + """Attempt to get or fetch a channel and return it.""" + log.trace(f"Getting the channel {channel_id}.") + + channel = client.get_channel(channel_id) + if not channel: + log.debug(f"Channel {channel_id} is not in cache; fetching from API.") + channel = await client.fetch_channel(channel_id) + + log.trace(f"Channel #{channel} ({channel_id}) retrieved.") + return channel -- cgit v1.2.3 From 4cd82783b4aec4e76ecbf1abf6549da68379dc66 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 22:19:12 -0700 Subject: Code block: fix missing newline before generic example --- bot/cogs/codeblock/instructions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 80f82ef34..5c573c2ff 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -27,7 +27,7 @@ def _get_example(language: str) -> str: content = f"{language}\n..." else: log.trace("Code block has no language specifier.") - content = "Hello, world!" + content = "\nHello, world!" return _EXAMPLE_CODE_BLOCKS.format(content=content) -- cgit v1.2.3 From a219c946a92bc81363fa6acdbf007e8c3aff28b4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 22:30:00 -0700 Subject: Code block: adjust logging levels --- bot/cogs/codeblock/cog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 3c119814f..74f122936 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -110,7 +110,7 @@ class CodeBlockCog(Cog, name="Code Block"): The embed will be deleted automatically after 5 minutes. """ - log.trace("Sending an embed with code block formatting instructions.") + log.info(f"Sending code block formatting instructions for message {message.id}.") embed = self.create_embed(instructions) bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed) @@ -155,7 +155,7 @@ class CodeBlockCog(Cog, name="Code Block"): await self.send_instructions(msg, instructions) if msg.channel.id not in self.channel_whitelist: - log.trace(f"Adding #{msg.channel} to the channel cooldowns.") + log.debug(f"Adding #{msg.channel} to the channel cooldowns.") self.channel_cooldowns[msg.channel.id] = time.time() @Cog.listener() @@ -178,9 +178,9 @@ class CodeBlockCog(Cog, name="Code Block"): return if not instructions: - log.trace("User's incorrect code block has been fixed. Removing instructions message.") + log.info("User's incorrect code block has been fixed. Removing instructions message.") await bot_message.delete() del self.codeblock_message_ids[payload.message_id] else: - log.trace("Message edited but still has invalid code blocks; editing the instructions.") + log.info("Message edited but still has invalid code blocks; editing the instructions.") await bot_message.edit(embed=self.create_embed(instructions)) -- cgit v1.2.3 From a2cac1da6ae309fc8c77a019336348fb236f1bdb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 8 May 2020 17:32:34 -0700 Subject: Create a utility function to count lines in a string --- bot/cogs/codeblock/cog.py | 3 ++- bot/cogs/codeblock/parsing.py | 4 +++- bot/utils/__init__.py | 8 ++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 74f122936..ecaf51aa0 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -8,6 +8,7 @@ from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover from bot.constants import Channels, DEBUG_MODE +from bot.utils import has_lines from bot.utils.channel import is_help_channel from bot.utils.messages import wait_for_deletion from .instructions import get_instructions @@ -134,7 +135,7 @@ class CodeBlockCog(Cog, name="Code Block"): return ( not message.author.bot and self.is_valid_channel(message.channel) - and len(message.content.split("\n", 3)) > 3 + and has_lines(message.content, 4) and not TokenRemover.find_token_in_message(message) ) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 1bdb3b492..332a1deb0 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -5,6 +5,8 @@ import logging import re from typing import NamedTuple, Optional, Sequence +from bot.utils import has_lines + log = logging.getLogger(__name__) BACKTICK = "`" @@ -82,7 +84,7 @@ def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: if groups["tick"] == BACKTICK and language: log.trace("Message has a valid code block with a language; returning None.") return None - elif len(groups["code"].split("\n", 3)) > 3: + elif has_lines(groups["code"], 4): code_block = CodeBlock(groups["code"], language, groups["tick"]) code_blocks.append(code_block) else: diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 5a6e1811b..4a02dc802 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -13,6 +13,14 @@ class CogABCMeta(CogMeta, ABCMeta): pass +def has_lines(string: str, count: int) -> bool: + """Return True if `string` has at least `count` lines.""" + split = string.split("\n", count - 1) + + # Make sure the last part isn't empty, which would happen if there was a final newline. + return split[-1] and len(split) == count + + def pad_base64(data: str) -> str: """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" return data + "=" * (-len(data) % 4) -- cgit v1.2.3 From 99a1734e8c6ace3e7a6418882f8dae40a3877534 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 8 May 2020 17:43:21 -0700 Subject: Code block: add configurable variables --- bot/cogs/codeblock/cog.py | 29 +++++++++++------------------ bot/cogs/codeblock/parsing.py | 3 ++- bot/constants.py | 9 +++++++++ config-default.yml | 21 +++++++++++++++++++-- 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index ecaf51aa0..e3917751b 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -6,8 +6,8 @@ import discord from discord import Message, RawMessageUpdateEvent from discord.ext.commands import Bot, Cog +from bot import constants from bot.cogs.token_remover import TokenRemover -from bot.constants import Channels, DEBUG_MODE from bot.utils import has_lines from bot.utils.channel import is_help_channel from bot.utils.messages import wait_for_deletion @@ -33,8 +33,7 @@ class CodeBlockCog(Cog, name="Code Block"): 1. Spaces before the language 2. No newline immediately following the language - Messages with 3 or fewer lines overall are ignored. Each code block is subject to this threshold - as well i.e. the text between the ticks must be greater than 3 lines. Detecting multiple code + Messages or code blocks must meet a minimum line count to be detected. Detecting multiple code blocks is supported. However, if at least one code block is correct, then instructions will not be sent even if others are incorrect. When multiple incorrect code blocks are found, only the first one is used as the basis for the instructions sent. @@ -45,23 +44,17 @@ class CodeBlockCog(Cog, name="Code Block"): show what is still incorrect after the user's edit. The embed can be manually deleted with a reaction. Otherwise, it will automatically be removed after 5 minutes. - The cog only detects messages in whitelisted channels. Channels may also have a 300-second - cooldown on the instructions being sent. See `__init__` for which channels are whitelisted or - have cooldowns enabled. Note that all help channels are also whitelisted with cooldowns enabled. + The cog only detects messages in whitelisted channels. Channels may also have a cooldown on the + instructions being sent. Note all help channels are also whitelisted with cooldowns enabled. + + For configurable parameters, see the `code_block` section in config-default.py. """ def __init__(self, bot: Bot): self.bot = bot # Stores allowed channels plus epoch times since the last instructional messages sent. - self.channel_cooldowns = { - Channels.python_discussion: 0, - } - - # These channels will also work, but will not be subject to a cooldown. - self.channel_whitelist = ( - Channels.bot_commands, - ) + self.channel_cooldowns = {channel: 0.0 for channel in constants.CodeBlock.cooldown_channels} # Maps users' messages to the messages the bot sent with instructions. self.codeblock_message_ids = {} @@ -102,7 +95,7 @@ class CodeBlockCog(Cog, name="Code Block"): return ( is_help_channel(channel) or channel.id in self.channel_cooldowns - or channel.id in self.channel_whitelist + or channel.id in constants.CodeBlock.channel_whitelist ) async def send_instructions(self, message: discord.Message, instructions: str) -> None: @@ -135,7 +128,7 @@ class CodeBlockCog(Cog, name="Code Block"): return ( not message.author.bot and self.is_valid_channel(message.channel) - and has_lines(message.content, 4) + and has_lines(message.content, constants.CodeBlock.minimum_lines) and not TokenRemover.find_token_in_message(message) ) @@ -147,7 +140,7 @@ class CodeBlockCog(Cog, name="Code Block"): return # When debugging, ignore cooldowns. - if self.is_on_cooldown(msg.channel) and not DEBUG_MODE: + if self.is_on_cooldown(msg.channel) and not constants.DEBUG_MODE: log.trace(f"Skipping code block detection of {msg.id}: #{msg.channel} is on cooldown.") return @@ -155,7 +148,7 @@ class CodeBlockCog(Cog, name="Code Block"): if instructions: await self.send_instructions(msg, instructions) - if msg.channel.id not in self.channel_whitelist: + if msg.channel.id not in constants.CodeBlock.channel_whitelist: log.debug(f"Adding #{msg.channel} to the channel cooldowns.") self.channel_cooldowns[msg.channel.id] = time.time() diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 332a1deb0..89f8111fc 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -5,6 +5,7 @@ import logging import re from typing import NamedTuple, Optional, Sequence +from bot import constants from bot.utils import has_lines log = logging.getLogger(__name__) @@ -84,7 +85,7 @@ def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: if groups["tick"] == BACKTICK and language: log.trace("Message has a valid code block with a language; returning None.") return None - elif has_lines(groups["code"], 4): + elif has_lines(groups["code"], constants.CodeBlock.minimum_lines): code_block = CodeBlock(groups["code"], language, groups["tick"]) code_blocks.append(code_block) else: diff --git a/bot/constants.py b/bot/constants.py index 470221369..6c9654e89 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -540,6 +540,15 @@ class BigBrother(metaclass=YAMLGetter): header_message_limit: int +class CodeBlock(metaclass=YAMLGetter): + section = 'code_block' + + channel_whitelist: List[int] + cooldown_channels: List[int] + cooldown_seconds: int + minimum_lines: int + + class Free(metaclass=YAMLGetter): section = 'free' diff --git a/config-default.yml b/config-default.yml index 3388e5f78..845a20979 100644 --- a/config-default.yml +++ b/config-default.yml @@ -137,8 +137,8 @@ guild: dev_log: &DEV_LOG 622895325144940554 # Discussion - meta: 429409067623251969 - python_discussion: 267624335836053506 + meta: 429409067623251969 + python_discussion: &PY_DISCUSSION 267624335836053506 # Python Help: Available how_to_get_help: 704250143020417084 @@ -522,6 +522,23 @@ big_brother: header_message_limit: 15 +code_block: + # The channels in which code blocks will be detected. They are not subject to a cooldown. + channel_whitelist: + - *BOT_CMD + + # The channels which will be affected by a cooldown. These channels are also whitelisted. + cooldown_channels: + - *PY_DISCUSSION + + # Sending instructions triggers a cooldown on a per-channel basis. + # More instruction messages will not be sent in the same channel until the cooldown has elapsed. + cooldown_seconds: 300 + + # The minimum amount of lines a message or code block must have for instructions to be sent. + minimum_lines: 4 + + free: # Seconds to elapse for a channel # to be considered inactive. -- cgit v1.2.3 From da816921db5295a33d7af918f329e770c03d73a2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 13 May 2020 18:51:31 -0700 Subject: Code block: simplify retrieval of channel ID from payload --- bot/cogs/codeblock/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index e3917751b..20b86eb24 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -72,7 +72,7 @@ class CodeBlockCog(Cog, name="Code Block"): deleted either manually via a reaction or automatically by a timer. """ log.trace(f"Retrieving instructions message for ID {payload.message_id}") - channel = self.bot.get_channel(int(payload.data.get("channel_id"))) + channel = self.bot.get_channel(payload.channel_id) try: return await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) -- cgit v1.2.3 From e98100fed8b3c62e337a1c0abeeaee30bc08befa Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 3 Jun 2020 12:26:27 -0700 Subject: Code block: add stats * Increment `codeblock_corrections` when instructions are sent * Import our Bot subclass instead of discord.py's --- bot/cogs/codeblock/cog.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 20b86eb24..6032e911c 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -4,9 +4,10 @@ from typing import Optional import discord from discord import Message, RawMessageUpdateEvent -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Cog from bot import constants +from bot.bot import Bot from bot.cogs.token_remover import TokenRemover from bot.utils import has_lines from bot.utils.channel import is_help_channel @@ -114,6 +115,9 @@ class CodeBlockCog(Cog, name="Code Block"): wait_for_deletion(bot_message, user_ids=(message.author.id,), client=self.bot) ) + # Increase amount of codeblock correction in stats + self.bot.stats.incr("codeblock_corrections") + def should_parse(self, message: discord.Message) -> bool: """ Return True if `message` should be parsed. -- cgit v1.2.3 From cb0529b327000a39d0329143fb5c3db2504d0219 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 10 Jun 2020 21:42:26 -0700 Subject: Code block: remove needless f-strings --- bot/cogs/codeblock/instructions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 5c573c2ff..c9db80deb 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -156,17 +156,17 @@ def get_instructions(content: str) -> Optional[str]: return if not blocks: - log.trace(f"No code blocks were found in message.") + log.trace("No code blocks were found in message.") return _get_no_ticks_message(content) else: log.trace("Searching results for a code block with invalid ticks.") block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) if block: - log.trace(f"A code block exists but has invalid ticks.") + log.trace("A code block exists but has invalid ticks.") return _get_bad_ticks_message(block) else: - log.trace(f"A code block exists but is missing a language.") + log.trace("A code block exists but is missing a language.") block = blocks[0] # Check for a bad language first to avoid parsing content into an AST. -- cgit v1.2.3 From 674d976b706ff42039ea1ea12e0b6150f180e874 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 22 Jun 2020 18:24:40 +0300 Subject: PEP: Define PEP region for grouping functions --- bot/cogs/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 73337f012..d4015e235 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -192,7 +192,7 @@ class Utils(Cog): for reaction in options: await message.add_reaction(reaction) - # PEPs area + # region: PEP async def refresh_peps_urls(self) -> None: """Refresh PEP URLs listing in every 3 hours.""" @@ -292,6 +292,7 @@ class Utils(Cog): embed = Embed(title="Unexpected error", description=error_message, colour=Colour.red()) await ctx.send(embed=embed) return + # endregion def setup(bot: Bot) -> None: -- cgit v1.2.3 From 61ccb8230913e3eff8285c1387c20354e15fcc55 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 22 Jun 2020 18:44:53 +0300 Subject: Async Cache: Make cache handle different caches better --- bot/cogs/doc.py | 2 +- bot/utils/cache.py | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index ff60fc80a..173585976 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -187,7 +187,7 @@ class Doc(commands.Cog): self.base_urls.clear() self.inventories.clear() self.renamed_symbols.clear() - async_cache.cache = OrderedDict() + async_cache.cache["get_symbol_embed"] = OrderedDict() # Run all coroutines concurrently - since each of them performs a HTTP # request, this speeds up fetching the inventory data heavily. diff --git a/bot/utils/cache.py b/bot/utils/cache.py index 96e1aef95..37c2b199c 100644 --- a/bot/utils/cache.py +++ b/bot/utils/cache.py @@ -11,21 +11,23 @@ def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key. """ - # Assign the cache to the function itself so we can clear it from outside. - async_cache.cache = OrderedDict() + # Make global cache as dictionary to allow multiple function caches + async_cache.cache = {} def decorator(function: Callable) -> Callable: """Define the async_cache decorator.""" + async_cache.cache[function.__name__] = OrderedDict() + @functools.wraps(function) async def wrapper(*args) -> Any: """Decorator wrapper for the caching logic.""" key = ':'.join(str(args[arg_offset:])) if key not in async_cache.cache: - if len(async_cache.cache) > max_size: - async_cache.cache.popitem(last=False) + if len(async_cache.cache[function.__name__]) > max_size: + async_cache.cache[function.__name__].popitem(last=False) - async_cache.cache[key] = await function(*args) - return async_cache.cache[key] + async_cache.cache[function.__name__][key] = await function(*args) + return async_cache.cache[function.__name__][key] return wrapper return decorator -- cgit v1.2.3 From df3142485af13605d9663b055c39e558f7149a0f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 22 Jun 2020 18:48:34 +0300 Subject: PEP: Filter out too big PEP numbers --- bot/cogs/utils.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index d4015e235..7dbc5b014 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -248,7 +248,11 @@ class Utils(Cog): @async_cache(arg_offset=2) async def get_pep_embed(self, ctx: Context, pep_nr: int) -> Optional[Embed]: """Fetch, generate and return PEP embed.""" - if pep_nr not in self.peps and (self.last_refreshed_peps + timedelta(minutes=30)) <= datetime.now(): + if ( + pep_nr not in self.peps + and (self.last_refreshed_peps + timedelta(minutes=30)) <= datetime.now() + and len(str(pep_nr)) < 5 + ): await self.refresh_peps_urls() if pep_nr not in self.peps: -- cgit v1.2.3 From 3be09a656d0d904187306b1e15fd02c22378b265 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 22 Jun 2020 18:54:26 +0300 Subject: PEP: Move PEP error message sending to another function --- bot/cogs/utils.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 7dbc5b014..2605a6226 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -247,7 +247,7 @@ class Utils(Cog): @async_cache(arg_offset=2) async def get_pep_embed(self, ctx: Context, pep_nr: int) -> Optional[Embed]: - """Fetch, generate and return PEP embed.""" + """Fetch, generate and return PEP embed. When any error occur, use `self.send_pep_error_embed`.""" if ( pep_nr not in self.peps and (self.last_refreshed_peps + timedelta(minutes=30)) <= datetime.now() @@ -258,9 +258,7 @@ class Utils(Cog): if pep_nr not in self.peps: log.trace(f"PEP {pep_nr} was not found") not_found = f"PEP {pep_nr} does not exist." - embed = Embed(title="PEP not found", description=not_found, colour=Colour.red()) - await ctx.send(embed=embed) - return + return await self.send_pep_error_embed(ctx, "PEP not found", not_found) response = await self.bot.http_session.get(self.peps[pep_nr]) @@ -291,11 +289,14 @@ class Utils(Cog): log.trace( f"The user requested PEP {pep_nr}, but the response had an unexpected status code: {response.status}." ) - error_message = "Unexpected HTTP error during PEP search. Please let us know." - embed = Embed(title="Unexpected error", description=error_message, colour=Colour.red()) - await ctx.send(embed=embed) - return + return await self.send_pep_error_embed(ctx, "Unexpected error", error_message) + + @staticmethod + async def send_pep_error_embed(ctx: Context, title: str, description: str) -> None: + """Send error PEP embed with `ctx.send`.""" + embed = Embed(title=title, description=description, colour=Colour.red()) + await ctx.send(embed=embed) # endregion -- cgit v1.2.3 From 94017fdf0e3c9805e3ead81823f3870d3834edd5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:09:54 -0700 Subject: Code block: rename BadLanguage attributes The `has_` prefix it clarifies that they're booleans. Co-authored-by: Numerlor --- bot/cogs/codeblock/instructions.py | 4 ++-- bot/cogs/codeblock/parsing.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index c9db80deb..4ea5ca094 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -100,11 +100,11 @@ def _get_bad_lang_message(content: str) -> Optional[str]: lines = [] language = info.language - if info.leading_spaces: + if info.has_leading_spaces: log.trace("Language specifier was preceded by a space.") lines.append(f"Make sure there are no spaces between the back ticks and `{language}`.") - if not info.terminal_newline: + if not info.has_terminal_newline: log.trace("Language specifier was not followed by a newline.") lines.append( f"Make sure you put your code on a new line following `{language}`. " diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 89f8111fc..73b6a874e 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -60,8 +60,8 @@ class BadLanguage(NamedTuple): """Parsed information about a poorly formatted language specifier.""" language: str - leading_spaces: bool - terminal_newline: bool + has_leading_spaces: bool + has_terminal_newline: bool def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: -- cgit v1.2.3 From 8f37b6c5aef955bb4fab4f30cdcbea6c3c4888c2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:12:14 -0700 Subject: Code block: make PY_LANG_CODES more visible The declaration was a bit hidden between the two regular expressions. --- bot/cogs/codeblock/parsing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 73b6a874e..31cbd09b9 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -11,6 +11,7 @@ from bot.utils import has_lines log = logging.getLogger(__name__) BACKTICK = "`" +PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. _TICKS = { BACKTICK, "'", @@ -24,6 +25,7 @@ _TICKS = { "\u2033", # DOUBLE PRIME "\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF } + _RE_CODE_BLOCK = re.compile( fr""" (?P @@ -37,7 +39,6 @@ _RE_CODE_BLOCK = re.compile( re.DOTALL | re.VERBOSE ) -PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. _RE_LANGUAGE = re.compile( fr""" ^(?P\s+)? # Optionally match leading spaces from the beginning. -- cgit v1.2.3 From b209997a294c8dd07f08e9f2e3ffdb5afc265285 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:16:25 -0700 Subject: Code block: use config constant for cooldown --- bot/cogs/codeblock/cog.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 6032e911c..2576be966 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -83,12 +83,14 @@ class CodeBlockCog(Cog, name="Code Block"): def is_on_cooldown(self, channel: discord.TextChannel) -> bool: """ - Return True if an embed was sent for `channel` in the last 300 seconds. + Return True if an embed was sent too recently for `channel`. + The cooldown is configured by `constants.CodeBlock.cooldown_seconds`. Note: only channels in the `channel_cooldowns` have cooldowns enabled. """ log.trace(f"Checking if #{channel} is on cooldown.") - return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < 300 + cooldown = constants.CodeBlock.cooldown_seconds + return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < cooldown def is_valid_channel(self, channel: discord.TextChannel) -> bool: """Return True if `channel` is a help channel, may be on a cooldown, or is whitelisted.""" -- cgit v1.2.3 From 50757197956e3bba99dc845cdc264d759cbc8a71 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:17:49 -0700 Subject: Code block: simplify channel cooldown dict creation --- bot/cogs/codeblock/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 2576be966..63b971b84 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -55,7 +55,7 @@ class CodeBlockCog(Cog, name="Code Block"): self.bot = bot # Stores allowed channels plus epoch times since the last instructional messages sent. - self.channel_cooldowns = {channel: 0.0 for channel in constants.CodeBlock.cooldown_channels} + self.channel_cooldowns = dict.fromkeys(constants.CodeBlock.cooldown_channels, 0.0) # Maps users' messages to the messages the bot sent with instructions. self.codeblock_message_ids = {} -- cgit v1.2.3 From 621043a7ebc7574455394959a690913064100101 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:23:34 -0700 Subject: Code block: clarify get_instructions's docstring It wasn't clear that it also parses the message content. --- bot/cogs/codeblock/instructions.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 4ea5ca094..c25b2af5d 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -147,7 +147,11 @@ def _get_no_lang_message(content: str) -> Optional[str]: def get_instructions(content: str) -> Optional[str]: - """Return code block formatting instructions for `content` or None if nothing's wrong.""" + """ + Parse `content` and return code block formatting instructions if something is wrong. + + Return None if `content` lacks code block formatting issues. + """ log.trace("Getting formatting instructions.") blocks = parsing.find_code_blocks(content) -- cgit v1.2.3 From 201895180ffbe88c01e4dbc40dd9cd6c043e2be7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:28:19 -0700 Subject: HelpChannels: fix is_in_category call It was still using it like it was a method of the class rather than calling it from the channel utils module. --- bot/cogs/help_channels.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 927d05da8..f0945b83c 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -715,7 +715,7 @@ class HelpChannels(Scheduler, commands.Cog): The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. """ - if not self.is_in_category(msg.channel, constants.Categories.help_in_use): + if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use): return if not await self.is_empty(msg.channel): -- cgit v1.2.3 From c7d466a36d5775eb0a373242b7e4214b4534ad20 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:50:16 -0700 Subject: Code block: fix BadLanguage creation Forgot to change the kwarg names when the attributes were renamed. --- bot/cogs/codeblock/parsing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 31cbd09b9..112ca12b6 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -148,6 +148,6 @@ def parse_bad_language(content: str) -> Optional[BadLanguage]: return BadLanguage( language=match["lang"], - leading_spaces=match["spaces"] is not None, - terminal_newline=match["newline"] is not None, + has_leading_spaces=match["spaces"] is not None, + has_terminal_newline=match["newline"] is not None, ) -- cgit v1.2.3 From de592dc5eb22d061c9b988844e8c7d695a37fa58 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 20:05:20 -0700 Subject: Code block: support IPython REPL detection --- LICENSE-THIRD-PARTY | 36 ++++++++++++++++++++++++++++++++++++ bot/cogs/codeblock/parsing.py | 23 +++++++++++++++++------ 2 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 LICENSE-THIRD-PARTY diff --git a/LICENSE-THIRD-PARTY b/LICENSE-THIRD-PARTY new file mode 100644 index 000000000..3349d7c05 --- /dev/null +++ b/LICENSE-THIRD-PARTY @@ -0,0 +1,36 @@ +BSD 3-Clause License + +Applies to: +- _RE_PYTHON_REPL and portions of _RE_IPYTHON_REPL in bot/cogs/codeblock/parsing.py + +- Copyright (c) 2008-Present, IPython Development Team +- Copyright (c) 2001-2007, Fernando Perez +- Copyright (c) 2001, Janko Hauser +- Copyright (c) 2001, Nathaniel Gray + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 112ca12b6..757acdd0f 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -26,6 +26,9 @@ _TICKS = { "\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF } +_RE_PYTHON_REPL = re.compile(r"^(>>>|\.\.\.)( |$)") +_RE_IPYTHON_REPL = re.compile(r"^((In|Out) \[\d+\]: |\s*\.{3,}: ?)") + _RE_CODE_BLOCK = re.compile( fr""" (?P @@ -118,19 +121,27 @@ def is_python_code(content: str) -> bool: def is_repl_code(content: str, threshold: int = 3) -> bool: - """Return True if `content` has at least `threshold` number of Python REPL-like lines.""" - log.trace(f"Checking if content is Python REPL code using a threshold of {threshold}.") + """Return True if `content` has at least `threshold` number of (I)Python REPL-like lines.""" + log.trace(f"Checking if content is (I)Python REPL code using a threshold of {threshold}.") repl_lines = 0 + patterns = (_RE_PYTHON_REPL, _RE_IPYTHON_REPL) + for line in content.splitlines(): - if line.startswith(">>> ") or line.startswith("... "): - repl_lines += 1 + # Check the line against all patterns. + for pattern in patterns: + if pattern.match(line): + repl_lines += 1 + + # Once a pattern is matched, only use that pattern for the remaining lines. + patterns = (pattern,) + break if repl_lines == threshold: - log.trace("Content is Python REPL code.") + log.trace("Content is (I)Python REPL code.") return True - log.trace("Content is not Python REPL code.") + log.trace("Content is not (I)Python REPL code.") return False -- cgit v1.2.3 From d41f3568542528580e0fe0ff5b43bfbae2dde584 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 28 Jun 2020 18:15:14 -0700 Subject: Code block: re-add indentation fixing function It's still useful to fix indentation to ensure AST is correctly parsed. This function deals with the relatively common case of a the leading spaces of the first line being left out when copy-pasting. --- bot/cogs/codeblock/parsing.py | 49 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 757acdd0f..5b4cb9fdd 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -162,3 +162,52 @@ def parse_bad_language(content: str) -> Optional[BadLanguage]: has_leading_spaces=match["spaces"] is not None, has_terminal_newline=match["newline"] is not None, ) + + +def _get_leading_spaces(content: str) -> int: + """Return the number of spaces at the start of the first line in `content`.""" + current = content[0] + leading_spaces = 0 + + while current == " ": + leading_spaces += 1 + current = content[leading_spaces] + + return leading_spaces + + +def _fix_indentation(content: str) -> str: + """ + Attempt to fix badly indented code in `content`. + + In most cases, this works like textwrap.dedent. However, if the first line ends with a colon, + all subsequent lines are re-indented to only be one level deep relative to the first line. + The intent is to fix cases where the leading spaces of the first line of code were accidentally + not copied, which makes the first line appear not indented. + + This is fairly naïve and inaccurate. Therefore, it may break some code that was otherwise valid. + It's meant to catch really common cases, so that's acceptable. Its flaws are: + + - It assumes that if the first line ends with a colon, it is the start of an indented block + - It uses 4 spaces as the indentation, regardless of what the rest of the code uses + """ + lines = content.splitlines(keepends=True) + + # Dedent the first line + first_indent = _get_leading_spaces(content) + first_line = lines[0][first_indent:] + + second_indent = _get_leading_spaces(lines[1]) + + # If the first line ends with a colon, all successive lines need to be indented one + # additional level (assumes an indent width of 4). + if first_line.rstrip().endswith(":"): + second_indent -= 4 + + # All lines must be dedented at least by the same amount as the first line. + first_indent = max(first_indent, second_indent) + + # Dedent the rest of the lines and join them together with the first line. + content = first_line + "".join(line[first_indent:] for line in lines[1:]) + + return content -- cgit v1.2.3 From d8b8c518db9fd8bc0d0eb43afe38845c710af9a2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 28 Jun 2020 18:21:54 -0700 Subject: Code block: dedent code before validating it If it's indented too far, the AST parser will fail. --- bot/cogs/codeblock/instructions.py | 4 ++-- bot/cogs/codeblock/parsing.py | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index c25b2af5d..56b85a34f 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -70,7 +70,7 @@ def _get_no_ticks_message(content: str) -> Optional[str]: """If `content` is Python/REPL code, return instructions on using code blocks.""" log.trace("Creating instructions for a missing code block.") - if parsing.is_repl_code(content) or parsing.is_python_code(content): + if parsing.is_python_code(content): example_blocks = _get_example("python") return ( "It looks like you're trying to paste code into this channel.\n\n" @@ -132,7 +132,7 @@ def _get_no_lang_message(content: str) -> Optional[str]: """ log.trace("Creating instructions for a missing language.") - if parsing.is_repl_code(content) or parsing.is_python_code(content): + if parsing.is_python_code(content): example_blocks = _get_example("python") # Note that _get_bad_ticks_message expects the first line to have two newlines. diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 5b4cb9fdd..ea007b6f1 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -3,6 +3,7 @@ import ast import logging import re +import textwrap from typing import NamedTuple, Optional, Sequence from bot import constants @@ -98,7 +99,7 @@ def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: return code_blocks -def is_python_code(content: str) -> bool: +def _is_python_code(content: str) -> bool: """Return True if `content` is valid Python consisting of more than just expressions.""" log.trace("Checking if content is Python code.") try: @@ -120,7 +121,7 @@ def is_python_code(content: str) -> bool: return False -def is_repl_code(content: str, threshold: int = 3) -> bool: +def _is_repl_code(content: str, threshold: int = 3) -> bool: """Return True if `content` has at least `threshold` number of (I)Python REPL-like lines.""" log.trace(f"Checking if content is (I)Python REPL code using a threshold of {threshold}.") @@ -145,6 +146,18 @@ def is_repl_code(content: str, threshold: int = 3) -> bool: return False +def is_python_code(content: str) -> bool: + """Return True if `content` is valid Python code or (I)Python REPL output.""" + dedented = textwrap.dedent(content) + + # Parse AST twice in case _fix_indentation ends up breaking code due to its inaccuracies. + return ( + _is_python_code(dedented) + or _is_repl_code(dedented) + or _is_python_code(_fix_indentation(content)) + ) + + def parse_bad_language(content: str) -> Optional[BadLanguage]: """ Return information about a poorly formatted Python language in code block `content`. -- cgit v1.2.3 From ef24f6dcce1ed527f8561c4bfa41f390bde692bc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 18 Mar 2020 18:58:56 -0700 Subject: Reminders: remove duplicate deletion in scheduled task `send_reminder` already deletes the reminder so it's redundant to delete it in the scheduled task too. --- bot/cogs/reminders.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index b5998cc0e..cbc7d6920 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -144,16 +144,8 @@ class Reminders(Cog): def schedule_reminder(self, reminder: dict) -> None: """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" - reminder_id = reminder["id"] reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) - - async def _remind() -> None: - await self.send_reminder(reminder) - - log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") - await self._delete_reminder(reminder_id) - - self.scheduler.schedule_at(reminder_datetime, reminder_id, _remind()) + self.scheduler.schedule_at(reminder_datetime, reminder["id"], self.send_reminder(reminder)) async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) -> None: """Delete a reminder from the database, given its ID, and cancel the running task.""" -- cgit v1.2.3 From 96729f9979a0e3db8ba86b86bb25026c212c35dc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 18 Mar 2020 19:10:46 -0700 Subject: Reminders: remove _delete_reminder function Only one call was benefiting from that function also cancelling the task. Therefore, the function was redundant and has been replaced with a direct request to delete. This change has the consequence of also fixing reminder tasks cancelling themselves. That issue was potentially suppressing errors (such as the duplicate DELETE request which was fixed earlier). Under normal circumstances, the scheduler will automatically removed finished tasks so tasks won't need to cancel/remove themselves. --- bot/cogs/reminders.py | 32 +++++++++++--------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index cbc7d6920..14fe43efb 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -48,7 +48,7 @@ class Reminders(Cog): now = datetime.utcnow() for reminder in response: - is_valid, *_ = self.ensure_valid_reminder(reminder, cancel_task=False) + is_valid, *_ = self.ensure_valid_reminder(reminder) if not is_valid: continue @@ -61,11 +61,7 @@ class Reminders(Cog): else: self.schedule_reminder(reminder) - def ensure_valid_reminder( - self, - reminder: dict, - cancel_task: bool = True - ) -> t.Tuple[bool, discord.User, discord.TextChannel]: + def ensure_valid_reminder(self, reminder: dict) -> t.Tuple[bool, discord.User, discord.TextChannel]: """Ensure reminder author and channel can be fetched otherwise delete the reminder.""" user = self.bot.get_user(reminder['author']) channel = self.bot.get_channel(reminder['channel_id']) @@ -76,7 +72,7 @@ class Reminders(Cog): f"Reminder {reminder['id']} invalid: " f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}." ) - asyncio.create_task(self._delete_reminder(reminder['id'], cancel_task)) + asyncio.create_task(self.bot.api_client.delete(f"bot/reminders/{reminder['id']}")) return is_valid, user, channel @@ -147,14 +143,6 @@ class Reminders(Cog): reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) self.scheduler.schedule_at(reminder_datetime, reminder["id"], self.send_reminder(reminder)) - async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) -> None: - """Delete a reminder from the database, given its ID, and cancel the running task.""" - await self.bot.api_client.delete('bot/reminders/' + str(reminder_id)) - - if cancel_task: - # Now we can remove it from the schedule list - self.scheduler.cancel(reminder_id) - async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict: """ Edits a reminder in the database given the ID and payload. @@ -180,6 +168,7 @@ class Reminders(Cog): """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) if not is_valid: + # No need to cancel the task too; it'll simply be done once this coroutine returns. return embed = discord.Embed() @@ -205,11 +194,10 @@ class Reminders(Cog): mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"]) ) - await channel.send( - content=f"{user.mention} {additional_mentions}", - embed=embed - ) - await self._delete_reminder(reminder["id"]) + await channel.send(content=f"{user.mention} {additional_mentions}", embed=embed) + + log.debug(f"Deleting reminder #{reminder['id']} (the user has been reminded).") + await self.bot.api_client.delete(f"bot/reminders/{reminder['id']}") @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) async def remind_group( @@ -401,7 +389,9 @@ class Reminders(Cog): @remind_group.command("delete", aliases=("remove", "cancel")) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" - await self._delete_reminder(id_) + await self.bot.api_client.delete(f"bot/reminders/{id_}") + self.scheduler.cancel(id_) + await self._send_confirmation( ctx, on_success="That reminder has been deleted successfully!", -- cgit v1.2.3 From 4d5dedc492c070b4fe98d630026ef7e0504ff5a8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 12 Jul 2020 18:26:30 -0700 Subject: Reminders: fix reminder_id type annotation It's fine to accept an int since it'll get converted to a string anyway. --- bot/cogs/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 14fe43efb..a043b7d75 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -80,7 +80,7 @@ class Reminders(Cog): async def _send_confirmation( ctx: Context, on_success: str, - reminder_id: str, + reminder_id: t.Union[str, int], delivery_dt: t.Optional[datetime], ) -> None: """Send an embed confirming the reminder change was made successfully.""" -- cgit v1.2.3 From b3e1ebfb7a8d9a31bbd5eba1a14c1c132590ee86 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 10:01:56 -0700 Subject: Decorators: more accurate return type for checks --- bot/decorators.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index 500197c89..b9182f664 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -24,7 +24,7 @@ def in_whitelist( roles: Container[int] = (), redirect: Optional[int] = Channels.bot_commands, fail_silently: bool = False, -) -> Callable: +) -> commands.Command: """ Check if a command was issued in a whitelisted context. @@ -45,7 +45,7 @@ def in_whitelist( return commands.check(predicate) -def with_role(*role_ids: int) -> Callable: +def with_role(*role_ids: int) -> commands.Command: """Returns True if the user has any one of the roles in role_ids.""" async def predicate(ctx: Context) -> bool: """With role checker predicate.""" @@ -53,7 +53,7 @@ def with_role(*role_ids: int) -> Callable: return commands.check(predicate) -def without_role(*role_ids: int) -> Callable: +def without_role(*role_ids: int) -> commands.Command: """Returns True if the user does not have any of the roles in role_ids.""" async def predicate(ctx: Context) -> bool: return without_role_check(ctx, *role_ids) -- cgit v1.2.3 From da33c330a02f2ff10838d0827e8c26a045729449 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 10:08:05 -0700 Subject: Decorators: clean up imports --- bot/decorators.py | 50 +++++++++++++++++++++----------------------- tests/bot/test_decorators.py | 4 ++-- 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index b9182f664..d9e5e3a83 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,15 +1,13 @@ +import asyncio import logging import random -from asyncio import Lock, create_task, sleep +import typing as t from contextlib import suppress from functools import wraps -from typing import Callable, Container, Optional, Union from weakref import WeakValueDictionary -from discord import Colour, Embed, Member -from discord.errors import NotFound -from discord.ext import commands -from discord.ext.commands import Cog, Context +from discord import Colour, Embed, Member, NotFound +from discord.ext.commands import Cog, Command, Context, check from bot.constants import Channels, ERROR_REPLIES, RedirectOutput from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check @@ -19,12 +17,12 @@ log = logging.getLogger(__name__) def in_whitelist( *, - channels: Container[int] = (), - categories: Container[int] = (), - roles: Container[int] = (), - redirect: Optional[int] = Channels.bot_commands, + channels: t.Container[int] = (), + categories: t.Container[int] = (), + roles: t.Container[int] = (), + redirect: t.Optional[int] = Channels.bot_commands, fail_silently: bool = False, -) -> commands.Command: +) -> Command: """ Check if a command was issued in a whitelisted context. @@ -42,25 +40,25 @@ def in_whitelist( """Check if command was issued in a whitelisted context.""" return in_whitelist_check(ctx, channels, categories, roles, redirect, fail_silently) - return commands.check(predicate) + return check(predicate) -def with_role(*role_ids: int) -> commands.Command: +def with_role(*role_ids: int) -> Command: """Returns True if the user has any one of the roles in role_ids.""" async def predicate(ctx: Context) -> bool: """With role checker predicate.""" return with_role_check(ctx, *role_ids) - return commands.check(predicate) + return check(predicate) -def without_role(*role_ids: int) -> commands.Command: +def without_role(*role_ids: int) -> Command: """Returns True if the user does not have any of the roles in role_ids.""" async def predicate(ctx: Context) -> bool: return without_role_check(ctx, *role_ids) - return commands.check(predicate) + return check(predicate) -def locked() -> Callable: +def locked() -> t.Callable: """ Allows the user to only run one instance of the decorated command at a time. @@ -68,12 +66,12 @@ def locked() -> Callable: This decorator must go before (below) the `command` decorator. """ - def wrap(func: Callable) -> Callable: + def wrap(func: t.Callable) -> t.Callable: func.__locks = WeakValueDictionary() @wraps(func) async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: - lock = func.__locks.setdefault(ctx.author.id, Lock()) + lock = func.__locks.setdefault(ctx.author.id, asyncio.Lock()) if lock.locked(): embed = Embed() embed.colour = Colour.red() @@ -86,13 +84,13 @@ def locked() -> Callable: await ctx.send(embed=embed) return - async with func.__locks.setdefault(ctx.author.id, Lock()): + async with func.__locks.setdefault(ctx.author.id, asyncio.Lock()): await func(self, ctx, *args, **kwargs) return inner return wrap -def redirect_output(destination_channel: int, bypass_roles: Container[int] = None) -> Callable: +def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = None) -> t.Callable: """ Changes the channel in the context of the command to redirect the output to a certain channel. @@ -100,7 +98,7 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non This decorator must go before (below) the `command` decorator. """ - def wrap(func: Callable) -> Callable: + def wrap(func: t.Callable) -> t.Callable: @wraps(func) async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: if ctx.channel.id == destination_channel: @@ -119,14 +117,14 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non log.trace(f"Redirecting output of {ctx.author}'s command '{ctx.command.name}' to {redirect_channel.name}") ctx.channel = redirect_channel await ctx.channel.send(f"Here's the output of your command, {ctx.author.mention}") - create_task(func(self, ctx, *args, **kwargs)) + asyncio.create_task(func(self, ctx, *args, **kwargs)) message = await old_channel.send( f"Hey, {ctx.author.mention}, you can find the output of your command here: " f"{redirect_channel.mention}" ) if RedirectOutput.delete_invocation: - await sleep(RedirectOutput.delete_delay) + await asyncio.sleep(RedirectOutput.delete_delay) with suppress(NotFound): await message.delete() @@ -140,7 +138,7 @@ def redirect_output(destination_channel: int, bypass_roles: Container[int] = Non return wrap -def respect_role_hierarchy(target_arg: Union[int, str] = 0) -> Callable: +def respect_role_hierarchy(target_arg: t.Union[int, str] = 0) -> t.Callable: """ Ensure the highest role of the invoking member is greater than that of the target member. @@ -152,7 +150,7 @@ def respect_role_hierarchy(target_arg: Union[int, str] = 0) -> Callable: This decorator must go before (below) the `command` decorator. """ - def wrap(func: Callable) -> Callable: + def wrap(func: t.Callable) -> t.Callable: @wraps(func) async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: try: diff --git a/tests/bot/test_decorators.py b/tests/bot/test_decorators.py index 3d450caa0..22e93c1c4 100644 --- a/tests/bot/test_decorators.py +++ b/tests/bot/test_decorators.py @@ -67,7 +67,7 @@ class InWhitelistTests(unittest.TestCase): for test_case in test_cases: # patch `commands.check` with a no-op lambda that just returns the predicate passed to it # so we can test the predicate that was generated from the specified kwargs. - with unittest.mock.patch("bot.decorators.commands.check", new=lambda predicate: predicate): + with unittest.mock.patch("bot.decorators.check", new=lambda predicate: predicate): predicate = in_whitelist(**test_case.kwargs) with self.subTest(test_description=test_case.description): @@ -139,7 +139,7 @@ class InWhitelistTests(unittest.TestCase): # patch `commands.check` with a no-op lambda that just returns the predicate passed to it # so we can test the predicate that was generated from the specified kwargs. - with unittest.mock.patch("bot.decorators.commands.check", new=lambda predicate: predicate): + with unittest.mock.patch("bot.decorators.check", new=lambda predicate: predicate): predicate = in_whitelist(**test_case.kwargs) with self.subTest(test_description=test_case.description): -- cgit v1.2.3 From 5bc3c1e9732955020d8a5f92a8c1952bca3dae0c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 10:13:52 -0700 Subject: Decorators: create helper function to get arg value --- bot/decorators.py | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index d9e5e3a83..1fe082b6e 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -14,6 +14,8 @@ from bot.utils.checks import in_whitelist_check, with_role_check, without_role_c log = logging.getLogger(__name__) +Argument = t.Union[int, str] + def in_whitelist( *, @@ -138,7 +140,7 @@ def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = N return wrap -def respect_role_hierarchy(target_arg: t.Union[int, str] = 0) -> t.Callable: +def respect_role_hierarchy(target_arg: Argument = 0) -> t.Callable: """ Ensure the highest role of the invoking member is greater than that of the target member. @@ -153,15 +155,7 @@ def respect_role_hierarchy(target_arg: t.Union[int, str] = 0) -> t.Callable: def wrap(func: t.Callable) -> t.Callable: @wraps(func) async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: - try: - target = kwargs[target_arg] - except KeyError: - try: - target = args[target_arg] - except IndexError: - raise ValueError(f"Could not find target argument at position {target_arg}") - except TypeError: - raise ValueError(f"Could not find target kwarg with key {target_arg!r}") + target = _get_arg_value(target_arg, args, kwargs) if not isinstance(target, Member): log.trace("The target is not a discord.Member; skipping role hierarchy check.") @@ -183,3 +177,23 @@ def respect_role_hierarchy(target_arg: t.Union[int, str] = 0) -> t.Callable: await func(self, ctx, *args, **kwargs) return inner return wrap + + +def _get_arg_value(target_arg: Argument, args: t.Tuple, kwargs: t.Dict[str, t.Any]) -> t.Any: + """ + Return the value of the arg at the given position or name `target_arg`. + + Use an integer as a position if the target argument is positional. + Use a string as a parameter name if the target argument is a keyword argument. + + Raise ValueError if `target_arg` cannot be found. + """ + try: + return kwargs[target_arg] + except KeyError: + try: + return args[target_arg] + except IndexError: + raise ValueError(f"Could not find target argument at position {target_arg}") + except TypeError: + raise ValueError(f"Could not find target kwarg with key {target_arg!r}") -- cgit v1.2.3 From be93601a31dcfa8acb03996eaaf2edcb654712f5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 10:33:21 -0700 Subject: Decorators: add mutually exclusive decorator This will be used to prevent race conditions on a resource by stopping all other access to the resource once its been acquired. --- bot/decorators.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/bot/decorators.py b/bot/decorators.py index 1fe082b6e..cae0870b6 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -2,6 +2,7 @@ import asyncio import logging import random import typing as t +from collections import defaultdict from contextlib import suppress from functools import wraps from weakref import WeakValueDictionary @@ -13,8 +14,10 @@ from bot.constants import Channels, ERROR_REPLIES, RedirectOutput from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check log = logging.getLogger(__name__) +__lock_dicts = defaultdict(WeakValueDictionary) Argument = t.Union[int, str] +ResourceId = t.Union[Argument, t.Callable[..., t.Hashable]] def in_whitelist( @@ -92,6 +95,42 @@ def locked() -> t.Callable: return wrap +def mutually_exclusive(namespace: t.Hashable, resource_arg: ResourceId) -> t.Callable: + """ + Turn the decorated coroutine function into a mutually exclusive operation on a resource. + + If any other mutually exclusive function currently holds the lock for a resource, do not run the + decorated function and return None. + + `namespace` is an identifier used to prevent collisions among resource IDs. + + `resource_arg` is the positional index or name of the parameter of the decorated function whose + value will be the resource ID. It may also be a callable which will return the resource ID + given the decorated function's args and kwargs. + """ + def decorator(func: t.Callable) -> t.Callable: + @wraps(func) + async def wrapper(*args, **kwargs) -> t.Any: + if callable(resource_arg): + # Call to get the ID if a callable was given. + id_ = resource_arg(*args, **kwargs) + else: + # Retrieve the ID from the args via position or name. + id_ = _get_arg_value(resource_arg, args, kwargs) + + # Get the lock for the ID. Create a Lock if one doesn't exist yet. + locks = __lock_dicts[namespace] + lock = locks.setdefault(id_, asyncio.Lock) + + if not lock.locked(): + # Resource is free; acquire it. + async with lock: + return await func(*args, **kwargs) + + return wrapper + return decorator + + def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = None) -> t.Callable: """ Changes the channel in the context of the command to redirect the output to a certain channel. -- cgit v1.2.3 From a16ba995944e213f90fbe78d2ec534f99f70b9f8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 11:06:17 -0700 Subject: Decorators: drop arg pos/name support for mutually_exclusive Supporting ID retrieval by arg name or position made for a confusing interface. I also doubt it would have been used much. A callable can achieve the same thing, albeit with a little more code. Now the decorator instead supports passing an ID directly or a callable. --- bot/decorators.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index cae0870b6..f49499856 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -17,7 +17,7 @@ log = logging.getLogger(__name__) __lock_dicts = defaultdict(WeakValueDictionary) Argument = t.Union[int, str] -ResourceId = t.Union[Argument, t.Callable[..., t.Hashable]] +ResourceId = t.Union[t.Hashable, t.Callable[..., t.Hashable]] def in_whitelist( @@ -95,28 +95,27 @@ def locked() -> t.Callable: return wrap -def mutually_exclusive(namespace: t.Hashable, resource_arg: ResourceId) -> t.Callable: +def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Callable: """ - Turn the decorated coroutine function into a mutually exclusive operation on a resource. + Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. If any other mutually exclusive function currently holds the lock for a resource, do not run the decorated function and return None. `namespace` is an identifier used to prevent collisions among resource IDs. - `resource_arg` is the positional index or name of the parameter of the decorated function whose - value will be the resource ID. It may also be a callable which will return the resource ID - given the decorated function's args and kwargs. + `resource_id` identifies a resource on which to perform a mutually exclusive operation. It may + also be a callable which will return the resource ID given the decorated function's args and + kwargs. """ def decorator(func: t.Callable) -> t.Callable: @wraps(func) async def wrapper(*args, **kwargs) -> t.Any: - if callable(resource_arg): + if callable(resource_id): # Call to get the ID if a callable was given. - id_ = resource_arg(*args, **kwargs) + id_ = resource_id(*args, **kwargs) else: - # Retrieve the ID from the args via position or name. - id_ = _get_arg_value(resource_arg, args, kwargs) + id_ = resource_id # Get the lock for the ID. Create a Lock if one doesn't exist yet. locks = __lock_dicts[namespace] -- cgit v1.2.3 From 226d5e17b74d711776f7e7f49a8712ba820ac5ba Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 11:22:00 -0700 Subject: Decorators: support awaitables for resource ID --- bot/decorators.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index f49499856..063368dda 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,4 +1,5 @@ import asyncio +import inspect import logging import random import typing as t @@ -17,7 +18,9 @@ log = logging.getLogger(__name__) __lock_dicts = defaultdict(WeakValueDictionary) Argument = t.Union[int, str] -ResourceId = t.Union[t.Hashable, t.Callable[..., t.Hashable]] +_IdCallable = t.Callable[..., t.Hashable] +_IdAwaitable = t.Callable[..., t.Awaitable[t.Hashable]] +ResourceId = t.Union[t.Hashable, _IdCallable, _IdAwaitable] def in_whitelist( @@ -104,9 +107,9 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call `namespace` is an identifier used to prevent collisions among resource IDs. - `resource_id` identifies a resource on which to perform a mutually exclusive operation. It may - also be a callable which will return the resource ID given the decorated function's args and - kwargs. + `resource_id` identifies a resource on which to perform a mutually exclusive operation. + It may also be a callable or awaitable which will return the resource ID given the decorated + function's args and kwargs. """ def decorator(func: t.Callable) -> t.Callable: @wraps(func) @@ -114,6 +117,10 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call if callable(resource_id): # Call to get the ID if a callable was given. id_ = resource_id(*args, **kwargs) + + if inspect.isawaitable(id_): + # Await to get the ID if an awaitable was given. + id_ = await id_ else: id_ = resource_id -- cgit v1.2.3 From 66ca3a8313183bcb245804d68a6f09abd2724245 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 12:04:45 -0700 Subject: Decorators: fix lock creation --- bot/decorators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/decorators.py b/bot/decorators.py index 063368dda..abf7474ef 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -126,7 +126,7 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call # Get the lock for the ID. Create a Lock if one doesn't exist yet. locks = __lock_dicts[namespace] - lock = locks.setdefault(id_, asyncio.Lock) + lock = locks.setdefault(id_, asyncio.Lock()) if not lock.locked(): # Resource is free; acquire it. -- cgit v1.2.3 From 6e2d3dfaacf03435c18e843fb366758f49e09181 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 12:23:31 -0700 Subject: Decorators: add logging for mutually_exclusive --- bot/decorators.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index abf7474ef..91104fc6c 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -112,26 +112,34 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call function's args and kwargs. """ def decorator(func: t.Callable) -> t.Callable: + name = func.__name__ + @wraps(func) async def wrapper(*args, **kwargs) -> t.Any: + log.trace(f"{name}: mutually exclusive decorator called") + if callable(resource_id): - # Call to get the ID if a callable was given. + log.trace(f"{name}: calling the given callable to get the resource ID") id_ = resource_id(*args, **kwargs) if inspect.isawaitable(id_): - # Await to get the ID if an awaitable was given. + log.trace(f"{name}: awaiting to get resource ID") id_ = await id_ else: id_ = resource_id - # Get the lock for the ID. Create a Lock if one doesn't exist yet. + log.trace(f"{name}: getting lock for resource {id_!r} under namespace {namespace!r}") + + # Get the lock for the ID. Create a lock if one doesn't exist yet. locks = __lock_dicts[namespace] lock = locks.setdefault(id_, asyncio.Lock()) if not lock.locked(): - # Resource is free; acquire it. + log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") async with lock: return await func(*args, **kwargs) + else: + log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") return wrapper return decorator -- cgit v1.2.3 From 0c03950cd4a4d1c8734e2a7172344dd073d3d188 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 13:22:40 -0700 Subject: Decorators: clarify use of mutually_exclusive with commands --- bot/decorators.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/decorators.py b/bot/decorators.py index 91104fc6c..f581e66d2 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -110,6 +110,8 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call `resource_id` identifies a resource on which to perform a mutually exclusive operation. It may also be a callable or awaitable which will return the resource ID given the decorated function's args and kwargs. + + If decorating a command, this decorator must go before (below) the `command` decorator. """ def decorator(func: t.Callable) -> t.Callable: name = func.__name__ -- cgit v1.2.3 From a30b640a61943c1992b8614b98667d49653d8b71 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 23:12:07 -0700 Subject: Decorators: pass bound arguments to callable Bound arguments are more convenient to work with than the raw args and kwargs. --- bot/decorators.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index f581e66d2..7f58abd1c 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -18,9 +18,10 @@ log = logging.getLogger(__name__) __lock_dicts = defaultdict(WeakValueDictionary) Argument = t.Union[int, str] -_IdCallable = t.Callable[..., t.Hashable] -_IdAwaitable = t.Callable[..., t.Awaitable[t.Hashable]] -ResourceId = t.Union[t.Hashable, _IdCallable, _IdAwaitable] +BoundArgs = t.OrderedDict[str, t.Any] +_IdCallableReturn = t.Union[t.Hashable, t.Awaitable[t.Hashable]] +_IdCallable = t.Callable[[BoundArgs], _IdCallableReturn] +ResourceId = t.Union[t.Hashable, _IdCallable] def in_whitelist( @@ -108,8 +109,8 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call `namespace` is an identifier used to prevent collisions among resource IDs. `resource_id` identifies a resource on which to perform a mutually exclusive operation. - It may also be a callable or awaitable which will return the resource ID given the decorated - function's args and kwargs. + It may also be a callable or awaitable which will return the resource ID given an ordered + mapping of the parameters' names to arguments' values. If decorating a command, this decorator must go before (below) the `command` decorator. """ @@ -121,8 +122,13 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call log.trace(f"{name}: mutually exclusive decorator called") if callable(resource_id): + log.trace(f"{name}: binding args to signature") + sig = inspect.signature(func) + bound_args = sig.bind(*args, **kwargs) + bound_args.apply_defaults() + log.trace(f"{name}: calling the given callable to get the resource ID") - id_ = resource_id(*args, **kwargs) + id_ = resource_id(bound_args.arguments) if inspect.isawaitable(id_): log.trace(f"{name}: awaiting to get resource ID") -- cgit v1.2.3 From dc2c1c7f44de99ad9cbc69edc90607c625562760 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Jul 2020 23:53:17 -0700 Subject: Add util function to get value from arg This is a more advanced version meant to eventually replace the `_get_arg_values` in decorators.py. --- bot/utils/function.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 bot/utils/function.py diff --git a/bot/utils/function.py b/bot/utils/function.py new file mode 100644 index 000000000..7c5949122 --- /dev/null +++ b/bot/utils/function.py @@ -0,0 +1,34 @@ +"""Utilities for interaction with functions.""" + +import typing as t + +Argument = t.Union[int, str] + + +def get_arg_value(name_or_pos: Argument, arguments: t.OrderedDict[str, t.Any]) -> t.Any: + """ + Return a value from `arguments` based on a name or position. + + `arguments` is an ordered mapping of parameter names to argument values. + + Raise TypeError if `name_or_pos` isn't a str or int. + Raise ValueError if `name_or_pos` does not match any argument. + """ + if isinstance(name_or_pos, int): + # Convert arguments to a tuple to make them indexable. + arg_values = tuple(arguments.items()) + arg_pos = name_or_pos + + try: + name, value = arg_values[arg_pos] + return value + except IndexError: + raise ValueError(f"Argument position {arg_pos} is out of bounds.") + elif isinstance(name_or_pos, str): + arg_name = name_or_pos + try: + return arguments[arg_name] + except KeyError: + raise ValueError(f"Argument {arg_name!r} doesn't exist.") + else: + raise TypeError("'arg' must either be an int (positional index) or a str (keyword).") -- cgit v1.2.3 From 022b43c2651cd568592327e8b493fbefaa4f332a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 10:31:35 -0700 Subject: Reminders: make operations mutually exclusive This fixes race conditions between editing, deleting, and sending a reminder. If one operation is already happening, the others will be aborted. --- bot/cogs/reminders.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index a043b7d75..1a0a9d303 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -4,6 +4,7 @@ import random import textwrap import typing as t from datetime import datetime, timedelta +from functools import partial from operator import itemgetter import discord @@ -14,14 +15,17 @@ from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import Duration +from bot.decorators import mutually_exclusive from bot.pagination import LinePaginator from bot.utils.checks import without_role_check +from bot.utils.function import get_arg_value from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler from bot.utils.time import humanize_delta log = logging.getLogger(__name__) +NAMESPACE = "reminders" # Used for the mutually_exclusive decorator; constant to prevent typos WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 @@ -164,6 +168,7 @@ class Reminders(Cog): log.trace(f"Scheduling new task #{reminder['id']}") self.schedule_reminder(reminder) + @mutually_exclusive(NAMESPACE, lambda args: get_arg_value("reminder", args)["id"]) async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) @@ -370,6 +375,7 @@ class Reminders(Cog): mention_ids = [mention.id for mention in mentions] await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) + @mutually_exclusive(NAMESPACE, partial(get_arg_value, "id_")) async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: """Edits a reminder with the given payload, then sends a confirmation message.""" reminder = await self._edit_reminder(id_, payload) @@ -387,6 +393,7 @@ class Reminders(Cog): await self._reschedule_reminder(reminder) @remind_group.command("delete", aliases=("remove", "cancel")) + @mutually_exclusive(NAMESPACE, partial(get_arg_value, "id_")) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" await self.bot.api_client.delete(f"bot/reminders/{id_}") -- cgit v1.2.3 From 8620e5541b89fb42b396dde0dbc5439de103417a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 12:29:40 -0700 Subject: Add a function to wrap a decorator to use get_arg_value --- bot/utils/function.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/bot/utils/function.py b/bot/utils/function.py index 7c5949122..23188e79e 100644 --- a/bot/utils/function.py +++ b/bot/utils/function.py @@ -3,9 +3,12 @@ import typing as t Argument = t.Union[int, str] +BoundArgs = t.OrderedDict[str, t.Any] +Decorator = t.Callable[[t.Callable], t.Callable] +ArgValGetter = t.Callable[[BoundArgs], t.Any] -def get_arg_value(name_or_pos: Argument, arguments: t.OrderedDict[str, t.Any]) -> t.Any: +def get_arg_value(name_or_pos: Argument, arguments: BoundArgs) -> t.Any: """ Return a value from `arguments` based on a name or position. @@ -32,3 +35,27 @@ def get_arg_value(name_or_pos: Argument, arguments: t.OrderedDict[str, t.Any]) - raise ValueError(f"Argument {arg_name!r} doesn't exist.") else: raise TypeError("'arg' must either be an int (positional index) or a str (keyword).") + + +def get_arg_value_wrapper( + decorator_func: t.Callable[[ArgValGetter], Decorator], + name_or_pos: Argument, + func: t.Callable[[t.Any], t.Any] = None, +) -> Decorator: + """ + Call `decorator_func` with the value of the arg at the given name/position. + + `decorator_func` must accept a callable as a parameter to which it will pass a mapping of + parameter names to argument values of the function it's decorating. + + `func` is an optional callable which will return a new value given the argument's value. + + Return the decorator returned by `decorator_func`. + """ + def wrapper(args: BoundArgs) -> t.Any: + value = get_arg_value(name_or_pos, args) + if func: + value = func(value) + return value + + return decorator_func(wrapper) -- cgit v1.2.3 From d0e92aeaf9142c80aac949e8a01ff31c40e8ca96 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:11:21 -0700 Subject: Add a function to get bound args --- bot/decorators.py | 7 +++---- bot/utils/function.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index 7f58abd1c..ef4951141 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -12,6 +12,7 @@ from discord import Colour, Embed, Member, NotFound from discord.ext.commands import Cog, Command, Context, check from bot.constants import Channels, ERROR_REPLIES, RedirectOutput +from bot.utils import function from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check log = logging.getLogger(__name__) @@ -123,12 +124,10 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call if callable(resource_id): log.trace(f"{name}: binding args to signature") - sig = inspect.signature(func) - bound_args = sig.bind(*args, **kwargs) - bound_args.apply_defaults() + bound_args = function.get_bound_args(func, args, kwargs) log.trace(f"{name}: calling the given callable to get the resource ID") - id_ = resource_id(bound_args.arguments) + id_ = resource_id(bound_args) if inspect.isawaitable(id_): log.trace(f"{name}: awaiting to get resource ID") diff --git a/bot/utils/function.py b/bot/utils/function.py index 23188e79e..3ab32fe3c 100644 --- a/bot/utils/function.py +++ b/bot/utils/function.py @@ -1,5 +1,6 @@ """Utilities for interaction with functions.""" +import inspect import typing as t Argument = t.Union[int, str] @@ -59,3 +60,16 @@ def get_arg_value_wrapper( return value return decorator_func(wrapper) + + +def get_bound_args(func: t.Callable, args: t.Tuple, kwargs: t.Dict[str, t.Any]) -> BoundArgs: + """ + Bind `args` and `kwargs` to `func` and return a mapping of parameter names to argument values. + + Default parameter values are also set. + """ + sig = inspect.signature(func) + bound_args = sig.bind(*args, **kwargs) + bound_args.apply_defaults() + + return bound_args.arguments -- cgit v1.2.3 From e0d6138d89112740e1407873a8eb903b9f49ac0a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:28:34 -0700 Subject: Decorators: use new func utils in respect_role_hierarchy Replace the `_get_arg_value` call with `function.get_arg_value` cause the latter makes use of bound arguments, which are more accurate. --- bot/decorators.py | 43 +++++++++++++------------------------------ 1 file changed, 13 insertions(+), 30 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index ef4951141..b4bf9ba05 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -200,30 +200,33 @@ def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = N return wrap -def respect_role_hierarchy(target_arg: Argument = 0) -> t.Callable: +def respect_role_hierarchy(name_or_pos: Argument = 2) -> t.Callable: """ Ensure the highest role of the invoking member is greater than that of the target member. If the condition fails, a warning is sent to the invoking context. A target which is not an instance of discord.Member will always pass. - A value of 0 (i.e. position 0) for `target_arg` corresponds to the argument which comes after - `ctx`. If the target argument is a kwarg, its name can instead be given. + `name_or_pos` is the keyword name or position index of the parameter of the decorated command + whose value is the target member. This decorator must go before (below) the `command` decorator. """ - def wrap(func: t.Callable) -> t.Callable: + def decorator(func: t.Callable) -> t.Callable: @wraps(func) - async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: - target = _get_arg_value(target_arg, args, kwargs) + async def wrapper(*args, **kwargs) -> None: + bound_args = function.get_bound_args(func, args, kwargs) + target = function.get_arg_value(name_or_pos, bound_args) if not isinstance(target, Member): log.trace("The target is not a discord.Member; skipping role hierarchy check.") - await func(self, ctx, *args, **kwargs) + await func(*args, **kwargs) return + ctx = function.get_arg_value(1, bound_args) cmd = ctx.command.name actor = ctx.author + if target.top_role >= actor.top_role: log.info( f"{actor} ({actor.id}) attempted to {cmd} " @@ -234,26 +237,6 @@ def respect_role_hierarchy(target_arg: Argument = 0) -> t.Callable: "someone with an equal or higher top role." ) else: - await func(self, ctx, *args, **kwargs) - return inner - return wrap - - -def _get_arg_value(target_arg: Argument, args: t.Tuple, kwargs: t.Dict[str, t.Any]) -> t.Any: - """ - Return the value of the arg at the given position or name `target_arg`. - - Use an integer as a position if the target argument is positional. - Use a string as a parameter name if the target argument is a keyword argument. - - Raise ValueError if `target_arg` cannot be found. - """ - try: - return kwargs[target_arg] - except KeyError: - try: - return args[target_arg] - except IndexError: - raise ValueError(f"Could not find target argument at position {target_arg}") - except TypeError: - raise ValueError(f"Could not find target kwarg with key {target_arg!r}") + await func(*args, **kwargs) + return wrapper + return decorator -- cgit v1.2.3 From 578477da164ef8c3ff77400b22e608a7d4c6d5f2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:29:51 -0700 Subject: Decorators: remove default value for respect_role_hierarchy Explicit is better than implicit, and this default value wasn't much of a convenience. --- bot/cogs/moderation/infractions.py | 4 ++-- bot/decorators.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 8df642428..d720c2911 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -230,7 +230,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user, action()) - @respect_role_hierarchy() + @respect_role_hierarchy(2) async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: """Apply a kick infraction with kwargs passed to `post_infraction`.""" infraction = await utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) @@ -245,7 +245,7 @@ class Infractions(InfractionScheduler, commands.Cog): action = user.kick(reason=reason) await self.apply_infraction(ctx, infraction, user, action) - @respect_role_hierarchy() + @respect_role_hierarchy(2) async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: """ Apply a ban infraction with kwargs passed to `post_infraction`. diff --git a/bot/decorators.py b/bot/decorators.py index b4bf9ba05..6b2214f53 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -200,7 +200,7 @@ def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = N return wrap -def respect_role_hierarchy(name_or_pos: Argument = 2) -> t.Callable: +def respect_role_hierarchy(name_or_pos: Argument) -> t.Callable: """ Ensure the highest role of the invoking member is greater than that of the target member. -- cgit v1.2.3 From bb0cb84546820b9399c2b08edda641b079898538 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:36:45 -0700 Subject: Decorators: use type aliases from function module --- bot/decorators.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index 6b2214f53..eefe2f9ba 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -18,10 +18,8 @@ from bot.utils.checks import in_whitelist_check, with_role_check, without_role_c log = logging.getLogger(__name__) __lock_dicts = defaultdict(WeakValueDictionary) -Argument = t.Union[int, str] -BoundArgs = t.OrderedDict[str, t.Any] _IdCallableReturn = t.Union[t.Hashable, t.Awaitable[t.Hashable]] -_IdCallable = t.Callable[[BoundArgs], _IdCallableReturn] +_IdCallable = t.Callable[[function.BoundArgs], _IdCallableReturn] ResourceId = t.Union[t.Hashable, _IdCallable] @@ -200,7 +198,7 @@ def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = N return wrap -def respect_role_hierarchy(name_or_pos: Argument) -> t.Callable: +def respect_role_hierarchy(name_or_pos: function.Argument) -> t.Callable: """ Ensure the highest role of the invoking member is greater than that of the target member. -- cgit v1.2.3 From c9a3a73c93d553b65df282dd5a42fa694dd3dfe2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:37:38 -0700 Subject: Decorators: remove redundant word in docstring --- bot/decorators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/decorators.py b/bot/decorators.py index eefe2f9ba..cffd97440 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -38,7 +38,7 @@ def in_whitelist( - `channels`: a container with channel ids for whitelisted channels - `categories`: a container with category ids for whitelisted categories - - `roles`: a container with with role ids for whitelisted roles + - `roles`: a container with role ids for whitelisted roles If the command was invoked in a context that was not whitelisted, the member is either redirected to the `redirect` channel that was passed (default: #bot-commands) or simply -- cgit v1.2.3 From 0010d2d332f64bebd0ca4eadeac682c7f83014f9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:48:56 -0700 Subject: Decorators: wrap mutually_exclusive to use get_arg_value Instead of taking a callable, this wrapper just takes a name or position to get the resource ID. --- bot/cogs/reminders.py | 10 ++++------ bot/decorators.py | 17 ++++++++++++++++- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 1a0a9d303..30f7c8876 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -4,7 +4,6 @@ import random import textwrap import typing as t from datetime import datetime, timedelta -from functools import partial from operator import itemgetter import discord @@ -15,10 +14,9 @@ from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import Duration -from bot.decorators import mutually_exclusive +from bot.decorators import mutually_exclusive_arg from bot.pagination import LinePaginator from bot.utils.checks import without_role_check -from bot.utils.function import get_arg_value from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler from bot.utils.time import humanize_delta @@ -168,7 +166,7 @@ class Reminders(Cog): log.trace(f"Scheduling new task #{reminder['id']}") self.schedule_reminder(reminder) - @mutually_exclusive(NAMESPACE, lambda args: get_arg_value("reminder", args)["id"]) + @mutually_exclusive_arg(NAMESPACE, "reminder", itemgetter("id")) async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) @@ -375,7 +373,7 @@ class Reminders(Cog): mention_ids = [mention.id for mention in mentions] await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) - @mutually_exclusive(NAMESPACE, partial(get_arg_value, "id_")) + @mutually_exclusive_arg(NAMESPACE, "id_") async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: """Edits a reminder with the given payload, then sends a confirmation message.""" reminder = await self._edit_reminder(id_, payload) @@ -393,7 +391,7 @@ class Reminders(Cog): await self._reschedule_reminder(reminder) @remind_group.command("delete", aliases=("remove", "cancel")) - @mutually_exclusive(NAMESPACE, partial(get_arg_value, "id_")) + @mutually_exclusive_arg(NAMESPACE, "id_") async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" await self.bot.api_client.delete(f"bot/reminders/{id_}") diff --git a/bot/decorators.py b/bot/decorators.py index cffd97440..c9e4a0560 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -5,7 +5,7 @@ import random import typing as t from collections import defaultdict from contextlib import suppress -from functools import wraps +from functools import partial, wraps from weakref import WeakValueDictionary from discord import Colour, Embed, Member, NotFound @@ -150,6 +150,21 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call return decorator +def mutually_exclusive_arg( + namespace: t.Hashable, + name_or_pos: function.Argument, + func: t.Callable[[t.Any], _IdCallableReturn] = None +) -> t.Callable: + """ + Apply `mutually_exclusive` using the value of the arg at the given name/position as the ID. + + `func` is an optional callable or awaitable which will return the ID given the argument value. + See `mutually_exclusive` docs for more information. + """ + decorator_func = partial(mutually_exclusive, namespace) + return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) + + def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = None) -> t.Callable: """ Changes the channel in the context of the command to redirect the output to a certain channel. -- cgit v1.2.3 From 90e0a3707c77b41144964dcfd3eb82714ac26b25 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 18 Jul 2020 13:59:38 -0700 Subject: Decorators: add some trace logging --- bot/decorators.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/decorators.py b/bot/decorators.py index c9e4a0560..e370bf834 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -228,6 +228,8 @@ def respect_role_hierarchy(name_or_pos: function.Argument) -> t.Callable: def decorator(func: t.Callable) -> t.Callable: @wraps(func) async def wrapper(*args, **kwargs) -> None: + log.trace(f"{func.__name__}: respect role hierarchy decorator called") + bound_args = function.get_bound_args(func, args, kwargs) target = function.get_arg_value(name_or_pos, bound_args) @@ -250,6 +252,7 @@ def respect_role_hierarchy(name_or_pos: function.Argument) -> t.Callable: "someone with an equal or higher top role." ) else: + log.trace(f"{func.__name__}: {target.top_role=} < {actor.top_role=}; calling func") await func(*args, **kwargs) return wrapper return decorator -- cgit v1.2.3 From 44fa8d7aef62b0cac5edede8002ae8cf8ac8c74b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jul 2020 22:22:13 -0700 Subject: Decorators: optionally raise an exception if resource is locked The exception will facilitate user feedback for commands which use the decorator. --- bot/decorators.py | 19 +++++++++++++++---- bot/errors.py | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 bot/errors.py diff --git a/bot/decorators.py b/bot/decorators.py index e370bf834..15386e506 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -12,6 +12,7 @@ from discord import Colour, Embed, Member, NotFound from discord.ext.commands import Cog, Command, Context, check from bot.constants import Channels, ERROR_REPLIES, RedirectOutput +from bot.errors import LockedResourceError from bot.utils import function from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check @@ -98,12 +99,18 @@ def locked() -> t.Callable: return wrap -def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Callable: +def mutually_exclusive( + namespace: t.Hashable, + resource_id: ResourceId, + *, + raise_error: bool = False, +) -> t.Callable: """ Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. If any other mutually exclusive function currently holds the lock for a resource, do not run the - decorated function and return None. + decorated function and return None. If `raise_error` is True, raise `LockedResourceError` if + the lock cannot be acquired. `namespace` is an identifier used to prevent collisions among resource IDs. @@ -145,6 +152,8 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call return await func(*args, **kwargs) else: log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") + if raise_error: + raise LockedResourceError(str(namespace), id_) return wrapper return decorator @@ -153,7 +162,9 @@ def mutually_exclusive(namespace: t.Hashable, resource_id: ResourceId) -> t.Call def mutually_exclusive_arg( namespace: t.Hashable, name_or_pos: function.Argument, - func: t.Callable[[t.Any], _IdCallableReturn] = None + func: t.Callable[[t.Any], _IdCallableReturn] = None, + *, + raise_error: bool = False, ) -> t.Callable: """ Apply `mutually_exclusive` using the value of the arg at the given name/position as the ID. @@ -161,7 +172,7 @@ def mutually_exclusive_arg( `func` is an optional callable or awaitable which will return the ID given the argument value. See `mutually_exclusive` docs for more information. """ - decorator_func = partial(mutually_exclusive, namespace) + decorator_func = partial(mutually_exclusive, namespace, raise_error=raise_error) return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) diff --git a/bot/errors.py b/bot/errors.py new file mode 100644 index 000000000..34de3c2b1 --- /dev/null +++ b/bot/errors.py @@ -0,0 +1,20 @@ +from typing import Hashable + + +class LockedResourceError(RuntimeError): + """ + Exception raised when an operation is attempted on a locked resource. + + Attributes: + `type` -- name of the locked resource's type + `resource_id` -- ID of the locked resource + """ + + def __init__(self, resource_type: str, resource_id: Hashable): + self.type = resource_type + self.id = resource_id + + super().__init__( + f"Cannot operate on {self.type.lower()} `{self.id}`; " + "it is currently locked and in use by another operation." + ) -- cgit v1.2.3 From b1a677cd0a64b2ad4da400a492d9d5157d558546 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jul 2020 22:26:59 -0700 Subject: Send users an error message if command raises LockedResourceError --- bot/cogs/error_handler.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 233851e41..a9c6d50b7 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -9,6 +9,7 @@ from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels from bot.converters import TagNameConverter +from bot.errors import LockedResourceError from bot.utils.checks import InWhitelistCheckFailure log = logging.getLogger(__name__) @@ -66,6 +67,8 @@ class ErrorHandler(Cog): elif isinstance(e, errors.CommandInvokeError): if isinstance(e.original, ResponseCodeError): await self.handle_api_error(ctx, e.original) + elif isinstance(e.original, LockedResourceError): + await ctx.send(f"{e.original} Please wait for it to finish and try again later.") else: await self.handle_unexpected_error(ctx, e.original) return # Exit early to avoid logging. -- cgit v1.2.3 From ea74cd51b3821caf0298eacd451d0519cb4d1b9a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jul 2020 22:29:42 -0700 Subject: Reminders: show error to users if reminder is in use Silent failure is confusing to users. Showing an error message clears up why nothing happened with their command. --- bot/cogs/reminders.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 30f7c8876..292435f24 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -166,7 +166,7 @@ class Reminders(Cog): log.trace(f"Scheduling new task #{reminder['id']}") self.schedule_reminder(reminder) - @mutually_exclusive_arg(NAMESPACE, "reminder", itemgetter("id")) + @mutually_exclusive_arg(NAMESPACE, "reminder", itemgetter("id"), raise_error=True) async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) @@ -373,7 +373,7 @@ class Reminders(Cog): mention_ids = [mention.id for mention in mentions] await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) - @mutually_exclusive_arg(NAMESPACE, "id_") + @mutually_exclusive_arg(NAMESPACE, "id_", raise_error=True) async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: """Edits a reminder with the given payload, then sends a confirmation message.""" reminder = await self._edit_reminder(id_, payload) @@ -391,7 +391,7 @@ class Reminders(Cog): await self._reschedule_reminder(reminder) @remind_group.command("delete", aliases=("remove", "cancel")) - @mutually_exclusive_arg(NAMESPACE, "id_") + @mutually_exclusive_arg(NAMESPACE, "id_", raise_error=True) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" await self.bot.api_client.delete(f"bot/reminders/{id_}") -- cgit v1.2.3 From 6618359ac3484544fe4e88d66dd8b5669254c154 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 31 Jul 2020 22:40:18 -0700 Subject: Reminders: use singular form for mutually exclusive namespace The exception it raises reads better if the singular form of the word is used. --- bot/cogs/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 292435f24..be97d34b6 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -23,7 +23,7 @@ from bot.utils.time import humanize_delta log = logging.getLogger(__name__) -NAMESPACE = "reminders" # Used for the mutually_exclusive decorator; constant to prevent typos +NAMESPACE = "reminder" # Used for the mutually_exclusive decorator; constant to prevent typos WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 -- cgit v1.2.3 From 675d9a2abf7212f4680d124c72da1a914c87756c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 1 Aug 2020 08:54:42 -0700 Subject: Decorators: fix type annotations for checks The annotation was previously changed on the basis of an incorrect return annotation PyCharm inferred for `check()`. --- bot/decorators.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index 15386e506..96f0d1408 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -9,7 +9,7 @@ from functools import partial, wraps from weakref import WeakValueDictionary from discord import Colour, Embed, Member, NotFound -from discord.ext.commands import Cog, Command, Context, check +from discord.ext.commands import Cog, Context, check from bot.constants import Channels, ERROR_REPLIES, RedirectOutput from bot.errors import LockedResourceError @@ -31,7 +31,7 @@ def in_whitelist( roles: t.Container[int] = (), redirect: t.Optional[int] = Channels.bot_commands, fail_silently: bool = False, -) -> Command: +) -> t.Callable: """ Check if a command was issued in a whitelisted context. @@ -52,7 +52,7 @@ def in_whitelist( return check(predicate) -def with_role(*role_ids: int) -> Command: +def with_role(*role_ids: int) -> t.Callable: """Returns True if the user has any one of the roles in role_ids.""" async def predicate(ctx: Context) -> bool: """With role checker predicate.""" @@ -60,7 +60,7 @@ def with_role(*role_ids: int) -> Command: return check(predicate) -def without_role(*role_ids: int) -> Command: +def without_role(*role_ids: int) -> t.Callable: """Returns True if the user does not have any of the roles in role_ids.""" async def predicate(ctx: Context) -> bool: return without_role_check(ctx, *role_ids) -- cgit v1.2.3 From d96e605162d40f8890bdc57734631a0cc47b8e13 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 1 Aug 2020 09:00:00 -0700 Subject: Explicitly use kwarg with respect_role_hierarchy Clarify the significance of the argument being passed. --- bot/cogs/moderation/infractions.py | 4 ++-- bot/decorators.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index d720c2911..b68b5f117 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -230,7 +230,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user, action()) - @respect_role_hierarchy(2) + @respect_role_hierarchy(member_arg=2) async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: """Apply a kick infraction with kwargs passed to `post_infraction`.""" infraction = await utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) @@ -245,7 +245,7 @@ class Infractions(InfractionScheduler, commands.Cog): action = user.kick(reason=reason) await self.apply_infraction(ctx, infraction, user, action) - @respect_role_hierarchy(2) + @respect_role_hierarchy(member_arg=2) async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: """ Apply a ban infraction with kwargs passed to `post_infraction`. diff --git a/bot/decorators.py b/bot/decorators.py index 96f0d1408..0e84cf37e 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -224,14 +224,14 @@ def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = N return wrap -def respect_role_hierarchy(name_or_pos: function.Argument) -> t.Callable: +def respect_role_hierarchy(member_arg: function.Argument) -> t.Callable: """ Ensure the highest role of the invoking member is greater than that of the target member. If the condition fails, a warning is sent to the invoking context. A target which is not an instance of discord.Member will always pass. - `name_or_pos` is the keyword name or position index of the parameter of the decorated command + `member_arg` is the keyword name or position index of the parameter of the decorated command whose value is the target member. This decorator must go before (below) the `command` decorator. @@ -242,7 +242,7 @@ def respect_role_hierarchy(name_or_pos: function.Argument) -> t.Callable: log.trace(f"{func.__name__}: respect role hierarchy decorator called") bound_args = function.get_bound_args(func, args, kwargs) - target = function.get_arg_value(name_or_pos, bound_args) + target = function.get_arg_value(member_arg, bound_args) if not isinstance(target, Member): log.trace("The target is not a discord.Member; skipping role hierarchy check.") -- cgit v1.2.3 From 45580f7e98956309681820d23df3d70eb8312f4d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 16:34:48 -0700 Subject: Silence: revoke permissions to add reactions No longer assume default values for the overwrites which are modified. Save and restore previous values `add_reactions` and `send_messages` via redis. When unsilencing, check if a channel is silenced via the redis cache rather than the channel's current overwrites to ensure the task is cancelled even if overwrites were manually edited. --- bot/cogs/moderation/silence.py | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index f8a6592bc..0f3c98306 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -1,4 +1,5 @@ import asyncio +import json import logging from contextlib import suppress from typing import Optional @@ -10,6 +11,7 @@ from discord.ext.commands import Context from bot.bot import Bot from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles from bot.converters import HushDurationConverter +from bot.utils import RedisCache from bot.utils.checks import with_role_check from bot.utils.scheduling import Scheduler @@ -57,10 +59,13 @@ class SilenceNotifier(tasks.Loop): class Silence(commands.Cog): """Commands for stopping channel messages for `verified` role in a channel.""" + # Maps muted channel IDs to their previous overwrites for send_message and add_reactions. + # Overwrites are stored as JSON. + muted_channel_perms = RedisCache() + def __init__(self, bot: Bot): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) - self.muted_channels = set() self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) self._get_instance_vars_event = asyncio.Event() @@ -118,12 +123,17 @@ class Silence(commands.Cog): `duration` is only used for logging; if None is passed `persistent` should be True to not log None. Return `True` if channel permissions were changed, `False` otherwise. """ - current_overwrite = channel.overwrites_for(self._verified_role) - if current_overwrite.send_messages is False: + overwrite = channel.overwrites_for(self._verified_role) + prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) + + if all(val is False for val in prev_overwrites.values()): log.info(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") return False - await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=False)) - self.muted_channels.add(channel) + + overwrite.update(send_messages=False, add_reactions=False) + await channel.set_permissions(self._verified_role, overwrite=overwrite) + await self.muted_channel_perms.set(channel.id, json.dumps(prev_overwrites)) + if persistent: log.info(f"Silenced #{channel} ({channel.id}) indefinitely.") self.notifier.add_channel(channel) @@ -140,22 +150,28 @@ class Silence(commands.Cog): if it is unsilence it and remove it from the notifier. Return `True` if channel permissions were changed, `False` otherwise. """ - current_overwrite = channel.overwrites_for(self._verified_role) - if current_overwrite.send_messages is False: - await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=None)) + prev_overwrites = await self.muted_channel_perms.get(channel.id) + if prev_overwrites is not None: + overwrite = channel.overwrites_for(self._verified_role) + overwrite.update(**json.loads(prev_overwrites)) + + await channel.set_permissions(self._verified_role, overwrite=overwrite) log.info(f"Unsilenced channel #{channel} ({channel.id}).") + self.scheduler.cancel(channel.id) self.notifier.remove_channel(channel) - self.muted_channels.discard(channel) + await self.muted_channel_perms.delete(channel.id) + return True + log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") return False def cog_unload(self) -> None: """Send alert with silenced channels and cancel scheduled tasks on unload.""" self.scheduler.cancel_all() - if self.muted_channels: - channels_string = ''.join(channel.mention for channel in self.muted_channels) + if self.muted_channel_perms: + channels_string = ''.join(channel.mention for channel in self.muted_channel_perms) message = f"<@&{Roles.moderators}> channels left silenced on cog unload: {channels_string}" asyncio.create_task(self._mod_alerts_channel.send(message)) -- cgit v1.2.3 From 1ff8559dda1caa9c8479da5d371ff96aa4797e7c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 18:45:32 -0700 Subject: Silence: abort silence if there's already a scheduled task Overwrites can be edited during a silence, which can result in the overwrites check failing. Checking the scheduler too ensures that a duplicate silence won't be scheduled. --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 0f3c98306..0f64301c4 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -126,7 +126,7 @@ class Silence(commands.Cog): overwrite = channel.overwrites_for(self._verified_role) prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) - if all(val is False for val in prev_overwrites.values()): + if channel.id in self.scheduler or all(val is False for val in prev_overwrites.values()): log.info(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") return False -- cgit v1.2.3 From 68f2fbcb5afd474bc06c50267655177cf3617c5f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 19:18:24 -0700 Subject: Silence: notify admins if previous overwrites were not cached Admins will have to manually check the default values used and adjust them if they aren't the desired values for that particular channel. --- bot/cogs/moderation/silence.py | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 0f64301c4..ea2f51574 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -146,26 +146,39 @@ class Silence(commands.Cog): """ Unsilence `channel`. - Check if `channel` is silenced through a `PermissionOverwrite`, - if it is unsilence it and remove it from the notifier. + If `channel` has a silence task scheduled or has its previous overwrites cached, unsilence + it, cancel the task, and remove it from the notifier. Notify admins if it has a task but + not cached overwrites. + Return `True` if channel permissions were changed, `False` otherwise. """ prev_overwrites = await self.muted_channel_perms.get(channel.id) - if prev_overwrites is not None: - overwrite = channel.overwrites_for(self._verified_role) + if channel.id not in self.scheduler and prev_overwrites is None: + log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") + return False + + overwrite = channel.overwrites_for(self._verified_role) + if prev_overwrites is None: + log.info(f"Missing previous overwrites for #{channel} ({channel.id}); defaulting to None.") + overwrite.update(send_messages=None, add_reactions=None) + else: overwrite.update(**json.loads(prev_overwrites)) - await channel.set_permissions(self._verified_role, overwrite=overwrite) - log.info(f"Unsilenced channel #{channel} ({channel.id}).") + await channel.set_permissions(self._verified_role, overwrite=overwrite) + log.info(f"Unsilenced channel #{channel} ({channel.id}).") - self.scheduler.cancel(channel.id) - self.notifier.remove_channel(channel) - await self.muted_channel_perms.delete(channel.id) + self.scheduler.cancel(channel.id) + self.notifier.remove_channel(channel) + await self.muted_channel_perms.delete(channel.id) - return True + if prev_overwrites is None: + await self._mod_alerts_channel.send( + f"<@&{Roles.admins}> Restored overwrites with default values after unsilencing " + f"{channel.mention}. Please check that the `Send Messages` and `Add Reactions` " + f"overwrites for {self._verified_role.mention} are at their desired values." + ) - log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") - return False + return True def cog_unload(self) -> None: """Send alert with silenced channels and cancel scheduled tasks on unload.""" -- cgit v1.2.3 From c5617ec5d9ed9f0a76b2b8eb89ed28001a0542f9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 20:21:13 -0700 Subject: Silence: add separate unsilence error for manually-silenced channels It was confusing to reject a silence and an unsilence when overwrites were manually set to False. That's because it's contradictory to show an error stating it's already silence but then reject an unsilence with an error stating the channel isn't silenced. --- bot/cogs/moderation/silence.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index ea2f51574..9863730f4 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -110,8 +110,16 @@ class Silence(commands.Cog): """ await self._get_instance_vars_event.wait() log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") + if not await self._unsilence(ctx.channel): - await ctx.send(f"{Emojis.cross_mark} current channel was not silenced.") + overwrite = ctx.channel.overwrites_for(self._verified_role) + if overwrite.send_messages is False and overwrite.add_reactions is False: + await ctx.send( + f"{Emojis.cross_mark} current channel was not unsilenced because the current " + f"overwrites were set manually. Please edit them manually to unsilence." + ) + else: + await ctx.send(f"{Emojis.cross_mark} current channel was not silenced.") else: await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") -- cgit v1.2.3 From 72050ffa47b52daf2fd12ff414b68224e0678fe5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 21:03:10 -0700 Subject: Silence: persist silenced channels Can be used to support rescheduling. --- bot/cogs/moderation/silence.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 9863730f4..c43194511 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -2,6 +2,7 @@ import asyncio import json import logging from contextlib import suppress +from datetime import datetime, timedelta from typing import Optional from discord import TextChannel @@ -63,6 +64,10 @@ class Silence(commands.Cog): # Overwrites are stored as JSON. muted_channel_perms = RedisCache() + # Maps muted channel IDs to POSIX timestamps of when they'll be unsilenced. + # A timestamp equal to -1 means it's indefinite. + muted_channel_times = RedisCache() + def __init__(self, bot: Bot): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) @@ -90,16 +95,21 @@ class Silence(commands.Cog): """ await self._get_instance_vars_event.wait() log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") + if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): await ctx.send(f"{Emojis.cross_mark} current channel is already silenced.") return + if duration is None: await ctx.send(f"{Emojis.check_mark} silenced current channel indefinitely.") + await self.muted_channel_times.set(ctx.channel.id, -1) return await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) + unsilence_time = (datetime.utcnow() + timedelta(minutes=duration)) + await self.muted_channel_times.set(ctx.channel.id, unsilence_time.timestamp()) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context) -> None: @@ -178,6 +188,7 @@ class Silence(commands.Cog): self.scheduler.cancel(channel.id) self.notifier.remove_channel(channel) await self.muted_channel_perms.delete(channel.id) + await self.muted_channel_times.delete(channel.id) if prev_overwrites is None: await self._mod_alerts_channel.send( -- cgit v1.2.3 From 8f4ef1fc0f591f294f46a850d7047fe51a391f1f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 23:11:49 -0700 Subject: Silence: reschedule silences on startup Remove the moderator notification when unloading the cog because. Its purpose was to remind to manually unsilence channels. However, this purpose is now obsolete due to automatic rescheduling. The notification was buggy anyway due to a race condition with the bot shutting down, and that'd be further complicated by having to asynchronously retrieve channels from the redis cache too. Fixes #1053 --- bot/cogs/moderation/silence.py | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index c43194511..02d8de29e 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -78,11 +78,14 @@ class Silence(commands.Cog): async def _get_instance_vars(self) -> None: """Get instance variables after they're available to get from the guild.""" await self.bot.wait_until_guild_available() + guild = self.bot.get_guild(Guild.id) self._verified_role = guild.get_role(Roles.verified) self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) self._mod_log_channel = self.bot.get_channel(Channels.mod_log) self.notifier = SilenceNotifier(self._mod_log_channel) + await self._reschedule() + self._get_instance_vars_event.set() @commands.command(aliases=("hush",)) @@ -120,18 +123,21 @@ class Silence(commands.Cog): """ await self._get_instance_vars_event.wait() log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") + await self._unsilence_wrapper(ctx.channel) - if not await self._unsilence(ctx.channel): - overwrite = ctx.channel.overwrites_for(self._verified_role) + async def _unsilence_wrapper(self, channel: TextChannel) -> None: + """Unsilence `channel` and send a success/failure message.""" + if not await self._unsilence(channel): + overwrite = channel.overwrites_for(self._verified_role) if overwrite.send_messages is False and overwrite.add_reactions is False: - await ctx.send( + await channel.send( f"{Emojis.cross_mark} current channel was not unsilenced because the current " f"overwrites were set manually. Please edit them manually to unsilence." ) else: - await ctx.send(f"{Emojis.cross_mark} current channel was not silenced.") + await channel.send(f"{Emojis.cross_mark} current channel was not silenced.") else: - await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") + await channel.send(f"{Emojis.check_mark} unsilenced current channel.") async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: """ @@ -199,13 +205,29 @@ class Silence(commands.Cog): return True + async def _reschedule(self) -> None: + """Reschedule unsilencing of active silences and add permanent ones to the notifier.""" + for channel_id, timestamp in await self.muted_channel_times.items(): + channel = self.bot.get_channel(channel_id) + if channel is None: + log.info(f"Can't reschedule silence for {channel_id}: channel not found.") + continue + + if timestamp == -1: + log.info(f"Adding permanent silence for #{channel} ({channel.id}) to the notifier.") + self.notifier.add_channel(channel) + continue + + dt = datetime.utcfromtimestamp(timestamp) + if dt <= datetime.utcnow(): + await self._unsilence_wrapper(channel) + else: + log.info(f"Rescheduling silence for #{channel} ({channel.id}).") + self.scheduler.schedule_at(dt, channel_id, self._unsilence_wrapper(channel)) + def cog_unload(self) -> None: - """Send alert with silenced channels and cancel scheduled tasks on unload.""" + """Cancel scheduled tasks.""" self.scheduler.cancel_all() - if self.muted_channel_perms: - channels_string = ''.join(channel.mention for channel in self.muted_channel_perms) - message = f"<@&{Roles.moderators}> channels left silenced on cog unload: {channels_string}" - asyncio.create_task(self._mod_alerts_channel.send(message)) # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: -- cgit v1.2.3 From bebe09b74b24e140581cb317f300d2bbad8999df Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 11:06:47 -0700 Subject: Silence: use aware datetimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `datetime.timestamp()` assumes naïve `datetime`s are in local time, so getting POSIX timestamps in UTC isn't easy for naïve ones. Technically, the timestamp's timezone doesn't matter if all code is on the same page and parsing it with the same timezone. Keeping it in the local timezone would be okay then, but I feel safer locking it to UTC explicitly. --- bot/cogs/moderation/silence.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 02d8de29e..adf469661 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -2,7 +2,7 @@ import asyncio import json import logging from contextlib import suppress -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional from discord import TextChannel @@ -111,7 +111,7 @@ class Silence(commands.Cog): await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) - unsilence_time = (datetime.utcnow() + timedelta(minutes=duration)) + unsilence_time = (datetime.now(tz=timezone.utc) + timedelta(minutes=duration)) await self.muted_channel_times.set(ctx.channel.id, unsilence_time.timestamp()) @commands.command(aliases=("unhush",)) @@ -218,12 +218,13 @@ class Silence(commands.Cog): self.notifier.add_channel(channel) continue - dt = datetime.utcfromtimestamp(timestamp) - if dt <= datetime.utcnow(): + dt = datetime.fromtimestamp(timestamp, tz=timezone.utc) + delta = (dt - datetime.now(tz=timezone.utc)).total_seconds() + if delta <= 0: await self._unsilence_wrapper(channel) else: log.info(f"Rescheduling silence for #{channel} ({channel.id}).") - self.scheduler.schedule_at(dt, channel_id, self._unsilence_wrapper(channel)) + self.scheduler.schedule_later(delta, channel_id, self._unsilence_wrapper(channel)) def cog_unload(self) -> None: """Cancel scheduled tasks.""" -- cgit v1.2.3 From 235719b45899f3fb832b86dc578ca4430eeff259 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 11:41:15 -0700 Subject: Silence: remove event and await _get_instance_vars_task directly The event is redundant because the task can be awaited directly to block until it's complete. If the task is already done, the await will instantly finish. --- bot/cogs/moderation/silence.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index adf469661..4910c7009 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -1,4 +1,3 @@ -import asyncio import json import logging from contextlib import suppress @@ -73,7 +72,6 @@ class Silence(commands.Cog): self.scheduler = Scheduler(self.__class__.__name__) self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) - self._get_instance_vars_event = asyncio.Event() async def _get_instance_vars(self) -> None: """Get instance variables after they're available to get from the guild.""" @@ -86,8 +84,6 @@ class Silence(commands.Cog): self.notifier = SilenceNotifier(self._mod_log_channel) await self._reschedule() - self._get_instance_vars_event.set() - @commands.command(aliases=("hush",)) async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: """ @@ -96,7 +92,7 @@ class Silence(commands.Cog): Duration is capped at 15 minutes, passing forever makes the silence indefinite. Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. """ - await self._get_instance_vars_event.wait() + await self._get_instance_vars_task log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): @@ -121,7 +117,7 @@ class Silence(commands.Cog): If the channel was silenced indefinitely, notifications for the channel will stop. """ - await self._get_instance_vars_event.wait() + await self._get_instance_vars_task log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") await self._unsilence_wrapper(ctx.channel) -- cgit v1.2.3 From 0a6415068b782d511cafa32002922061a61b9f67 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 11:43:33 -0700 Subject: Silence: rename _get_instance_vars to _init_cog It's a more accurate name since it also reschedules unsilences now. --- bot/cogs/moderation/silence.py | 10 +++++----- tests/bot/cogs/moderation/test_silence.py | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 4910c7009..de799f64f 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -71,10 +71,10 @@ class Silence(commands.Cog): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) - self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) + self._init_task = self.bot.loop.create_task(self._init_cog()) - async def _get_instance_vars(self) -> None: - """Get instance variables after they're available to get from the guild.""" + async def _init_cog(self) -> None: + """Set instance attributes once the guild is available and reschedule unsilences.""" await self.bot.wait_until_guild_available() guild = self.bot.get_guild(Guild.id) @@ -92,7 +92,7 @@ class Silence(commands.Cog): Duration is capped at 15 minutes, passing forever makes the silence indefinite. Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. """ - await self._get_instance_vars_task + await self._init_task log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): @@ -117,7 +117,7 @@ class Silence(commands.Cog): If the channel was silenced indefinitely, notifications for the channel will stop. """ - await self._get_instance_vars_task + await self._init_task log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") await self._unsilence_wrapper(ctx.channel) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index ab3d0742a..7c6efbfe4 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -83,19 +83,19 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_instance_vars_got_guild(self): """Bot got guild after it became available.""" - await self.cog._get_instance_vars() + await self.cog._init_cog() self.bot.wait_until_guild_available.assert_called_once() self.bot.get_guild.assert_called_once_with(Guild.id) async def test_instance_vars_got_role(self): """Got `Roles.verified` role from guild.""" - await self.cog._get_instance_vars() + await self.cog._init_cog() guild = self.bot.get_guild() guild.get_role.assert_called_once_with(Roles.verified) async def test_instance_vars_got_channels(self): """Got channels from bot.""" - await self.cog._get_instance_vars() + await self.cog._init_cog() self.bot.get_channel.called_once_with(Channels.mod_alerts) self.bot.get_channel.called_once_with(Channels.mod_log) @@ -104,7 +104,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): """Notifier was started with channel.""" mod_log = MockTextChannel() self.bot.get_channel.side_effect = (None, mod_log) - await self.cog._get_instance_vars() + await self.cog._init_cog() notifier.assert_called_once_with(mod_log) self.bot.get_channel.side_effect = None -- cgit v1.2.3 From 2300b66c09ac853fbf332ad1bbdd291d6d0c1d87 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 12:27:24 -0700 Subject: Tests: optionally prevent autospec helper from passing mocks Not everything that's decorated needs the mocks that are patched. Being required to add the args to the test function anyway is annoying. It's especially bad if trying to decorate an entire test suite, as every test would need the args. Move the definition to a separate module to keep things cleaner. --- tests/_autospec.py | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/helpers.py | 21 ++---------------- 2 files changed, 66 insertions(+), 19 deletions(-) create mode 100644 tests/_autospec.py diff --git a/tests/_autospec.py b/tests/_autospec.py new file mode 100644 index 000000000..ee2fc1973 --- /dev/null +++ b/tests/_autospec.py @@ -0,0 +1,64 @@ +import contextlib +import functools +import unittest.mock +from typing import Callable + + +@functools.wraps(unittest.mock._patch.decoration_helper) +@contextlib.contextmanager +def _decoration_helper(self, patched, args, keywargs): + """Skips adding patchings as args if their `dont_pass` attribute is True.""" + # Don't ask what this does. It's just a copy from stdlib, but with the dont_pass check added. + extra_args = [] + with contextlib.ExitStack() as exit_stack: + for patching in patched.patchings: + arg = exit_stack.enter_context(patching) + if not getattr(patching, "dont_pass", False): + # Only add the patching as an arg if dont_pass is False. + if patching.attribute_name is not None: + keywargs.update(arg) + elif patching.new is unittest.mock.DEFAULT: + extra_args.append(arg) + + args += tuple(extra_args) + yield args, keywargs + + +@functools.wraps(unittest.mock._patch.copy) +def _copy(self): + """Copy the `dont_pass` attribute along with the standard copy operation.""" + patcher_copy = _copy.original(self) + patcher_copy.dont_pass = getattr(self, "dont_pass", False) + return patcher_copy + + +# Monkey-patch the patcher class :) +_copy.original = unittest.mock._patch.copy +unittest.mock._patch.copy = _copy +unittest.mock._patch.decoration_helper = _decoration_helper + + +def autospec(target, *attributes: str, pass_mocks: bool = True, **patch_kwargs) -> Callable: + """ + Patch multiple `attributes` of a `target` with autospecced mocks and `spec_set` as True. + + If `pass_mocks` is True, pass the autospecced mocks as arguments to the decorated object. + """ + # Caller's kwargs should take priority and overwrite the defaults. + kwargs = dict(spec_set=True, autospec=True) + kwargs.update(patch_kwargs) + + # Import the target if it's a string. + # This is to support both object and string targets like patch.multiple. + if type(target) is str: + target = unittest.mock._importer(target) + + def decorator(func): + for attribute in attributes: + patcher = unittest.mock.patch.object(target, attribute, **kwargs) + if not pass_mocks: + # A custom attribute to keep track of which patchings should be skipped. + patcher.dont_pass = True + func = patcher(func) + return func + return decorator diff --git a/tests/helpers.py b/tests/helpers.py index facc4e1af..6cf5d12bd 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -5,7 +5,7 @@ import itertools import logging import unittest.mock from asyncio import AbstractEventLoop -from typing import Callable, Iterable, Optional +from typing import Iterable, Optional import discord from aiohttp import ClientSession @@ -14,6 +14,7 @@ from discord.ext.commands import Context from bot.api import APIClient from bot.async_stats import AsyncStatsClient from bot.bot import Bot +from tests._autospec import autospec # noqa: F401 other modules import it via this module for logger in logging.Logger.manager.loggerDict.values(): @@ -26,24 +27,6 @@ for logger in logging.Logger.manager.loggerDict.values(): logger.setLevel(logging.CRITICAL) -def autospec(target, *attributes: str, **kwargs) -> Callable: - """Patch multiple `attributes` of a `target` with autospecced mocks and `spec_set` as True.""" - # Caller's kwargs should take priority and overwrite the defaults. - kwargs = {'spec_set': True, 'autospec': True, **kwargs} - - # Import the target if it's a string. - # This is to support both object and string targets like patch.multiple. - if type(target) is str: - target = unittest.mock._importer(target) - - def decorator(func): - for attribute in attributes: - patcher = unittest.mock.patch.object(target, attribute, **kwargs) - func = patcher(func) - return func - return decorator - - class HashableMixin(discord.mixins.EqualityComparable): """ Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin. -- cgit v1.2.3 From 0ffa80717d9db89ab6ed8865741c39820d33b392 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 12:11:21 -0700 Subject: Silence tests: mock RedisCaches --- tests/bot/cogs/moderation/test_silence.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 7c6efbfe4..8dfebb95c 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -6,7 +6,7 @@ from discord import PermissionOverwrite from bot.cogs.moderation.silence import Silence, SilenceNotifier from bot.constants import Channels, Emojis, Guild, Roles -from tests.helpers import MockBot, MockContext, MockTextChannel +from tests.helpers import MockBot, MockContext, MockTextChannel, autospec class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): @@ -72,14 +72,13 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.alert_channel.send.assert_not_called() +@autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.bot = MockBot() self.cog = Silence(self.bot) self.ctx = MockContext() self.cog._verified_role = None - # Set event so command callbacks can continue. - self.cog._get_instance_vars_event.set() async def test_instance_vars_got_guild(self): """Bot got guild after it became available.""" -- cgit v1.2.3 From 19f801289b14740017687a72d106875276507a1c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 12:12:34 -0700 Subject: Silence tests: rename test_instance_vars to test_init_cog --- tests/bot/cogs/moderation/test_silence.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 8dfebb95c..67a61382c 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -80,26 +80,26 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.ctx = MockContext() self.cog._verified_role = None - async def test_instance_vars_got_guild(self): + async def test_init_cog_got_guild(self): """Bot got guild after it became available.""" await self.cog._init_cog() - self.bot.wait_until_guild_available.assert_called_once() + self.bot.wait_until_guild_available.assert_awaited_once() self.bot.get_guild.assert_called_once_with(Guild.id) - async def test_instance_vars_got_role(self): + async def test_init_cog_got_role(self): """Got `Roles.verified` role from guild.""" await self.cog._init_cog() guild = self.bot.get_guild() guild.get_role.assert_called_once_with(Roles.verified) - async def test_instance_vars_got_channels(self): + async def test_init_cog_got_channels(self): """Got channels from bot.""" await self.cog._init_cog() self.bot.get_channel.called_once_with(Channels.mod_alerts) self.bot.get_channel.called_once_with(Channels.mod_log) @mock.patch("bot.cogs.moderation.silence.SilenceNotifier") - async def test_instance_vars_got_notifier(self, notifier): + async def test_init_cog_got_notifier(self, notifier): """Notifier was started with channel.""" mod_log = MockTextChannel() self.bot.get_channel.side_effect = (None, mod_log) -- cgit v1.2.3 From de92d8553de25cc1ec45a5e4dbee8fd4d3426fa8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 16 Aug 2020 12:15:02 -0700 Subject: Silence tests: replace obsolete cog_unload tests Moderation notifications are no longer sent so that doesn't need to be tested. --- tests/bot/cogs/moderation/test_silence.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 67a61382c..807bb09a0 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -74,6 +74,7 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): @autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): + @autospec("bot.cogs.moderation.silence", "Scheduler", pass_mocks=False) def setUp(self) -> None: self.bot = MockBot() self.cog = Silence(self.bot) @@ -237,20 +238,10 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): del mock_permissions_dict['send_messages'] self.assertDictEqual(mock_permissions_dict, new_permissions) - @mock.patch("bot.cogs.moderation.silence.asyncio") - @mock.patch.object(Silence, "_mod_alerts_channel", create=True) - def test_cog_unload_starts_task(self, alert_channel, asyncio_mock): - """Task for sending an alert was created with present `muted_channels`.""" - with mock.patch.object(self.cog, "muted_channels"): - self.cog.cog_unload() - alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> channels left silenced on cog unload: ") - asyncio_mock.create_task.assert_called_once_with(alert_channel.send()) - - @mock.patch("bot.cogs.moderation.silence.asyncio") - def test_cog_unload_skips_task_start(self, asyncio_mock): - """No task created with no channels.""" + def test_cog_unload_cancels_tasks(self): + """All scheduled tasks should be cancelled.""" self.cog.cog_unload() - asyncio_mock.create_task.assert_not_called() + self.cog.scheduler.cancel_all.assert_called_once_with() @mock.patch("bot.cogs.moderation.silence.with_role_check") @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) -- cgit v1.2.3 From 3182bacc342fd87313ffb22742947576d887f73b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 13:18:58 -0700 Subject: Silence tests: fix silence cache test for overwrites --- tests/bot/cogs/moderation/test_silence.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 807bb09a0..6f0cd880d 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -184,12 +184,15 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._silence(channel, False, None) self.cog.notifier.add_channel.assert_not_called() - async def test_silence_private_added_muted_channel(self): - """Channel was added to `muted_channels` on silence.""" + async def test_silence_private_cached_perms(self): + """Channel's previous overwrites were cached when silenced.""" channel = MockTextChannel() - with mock.patch.object(self.cog, "muted_channels") as muted_channels: - await self.cog._silence(channel, False, None) - muted_channels.add.assert_called_once_with(channel) + overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) + overwrite_json = '{"send_messages": true, "add_reactions": null}' + channel.overwrites_for.return_value = overwrite + + await self.cog._silence(channel, False, None) + self.cog.muted_channel_perms.set.assert_called_once_with(channel.id, overwrite_json) async def test_unsilence_private_for_false(self): """Permissions are not set and `False` is returned in an unsilenced channel.""" -- cgit v1.2.3 From ccbc515cfedd5938f4d1d50404a1b32d66bd5511 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 13:29:16 -0700 Subject: Silence tests: fix test_unsilence_private_for_false --- tests/bot/cogs/moderation/test_silence.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6f0cd880d..c1039f85c 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -196,7 +196,10 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_unsilence_private_for_false(self): """Permissions are not set and `False` is returned in an unsilenced channel.""" - channel = Mock() + self.cog.scheduler.__contains__.return_value = False + self.cog.muted_channel_perms.get.return_value = None + channel = MockTextChannel() + self.assertFalse(await self.cog._unsilence(channel)) channel.set_permissions.assert_not_called() -- cgit v1.2.3 From 28f7f66e4e9543d79f5a64ee6a0e0bbc80088804 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 13:33:47 -0700 Subject: Silence tests: fix test_silence_private_notifier --- tests/bot/cogs/moderation/test_silence.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index c1039f85c..e2e3ca9c1 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -174,6 +174,9 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_silence_private_notifier(self): """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" channel = MockTextChannel() + overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) + channel.overwrites_for.return_value = overwrite + with mock.patch.object(self.cog, "notifier", create=True): with self.subTest(persistent=True): await self.cog._silence(channel, True, None) -- cgit v1.2.3 From c24fa4371c9e8b13431b5d5f3808401dda8d449a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 14:20:05 -0700 Subject: Silence tests: fix test_silence_private_silenced_channel --- tests/bot/cogs/moderation/test_silence.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index e2e3ca9c1..75b4ef470 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -151,11 +151,18 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel.set_permissions.assert_not_called() async def test_silence_private_silenced_channel(self): - """Channel had `send_message` permissions revoked.""" + """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" channel = MockTextChannel() + overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) + channel.overwrites_for.return_value = overwrite + self.assertTrue(await self.cog._silence(channel, False, None)) - channel.set_permissions.assert_called_once() - self.assertFalse(channel.set_permissions.call_args.kwargs['send_messages']) + self.assertFalse(overwrite.send_messages) + self.assertFalse(overwrite.add_reactions) + channel.set_permissions.assert_awaited_once_with( + self.cog._verified_role, + overwrite=overwrite + ) async def test_silence_private_preserves_permissions(self): """Previous permissions were preserved when channel was silenced.""" -- cgit v1.2.3 From 830bb36654103474fa74505f3e0ff4bdf91656fd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 14:28:02 -0700 Subject: Silence tests: fix test_silence_private_for_false --- tests/bot/cogs/moderation/test_silence.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 75b4ef470..5763c4cdd 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -144,11 +144,20 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_silence_private_for_false(self): """Permissions are not set and `False` is returned in an already silenced channel.""" - perm_overwrite = Mock(send_messages=False) - channel = Mock(overwrites_for=Mock(return_value=perm_overwrite)) + subtests = ( + (False, PermissionOverwrite(send_messages=False, add_reactions=False)), + (True, PermissionOverwrite(send_messages=True, add_reactions=True)), + (True, PermissionOverwrite(send_messages=False, add_reactions=False)), + ) - self.assertFalse(await self.cog._silence(channel, True, None)) - channel.set_permissions.assert_not_called() + for contains, overwrite in subtests: + with self.subTest(contains=contains, overwrite=overwrite): + self.cog.scheduler.__contains__.return_value = contains + channel = MockTextChannel() + channel.overwrites_for.return_value = overwrite + + self.assertFalse(await self.cog._silence(channel, True, None)) + channel.set_permissions.assert_not_called() async def test_silence_private_silenced_channel(self): """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" -- cgit v1.2.3 From f8485f17ac0263c47365893438b3f1da609cb259 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 14:47:28 -0700 Subject: Silence tests: fix command message tests --- tests/bot/cogs/moderation/test_silence.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 5763c4cdd..02964d7ab 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -115,15 +115,12 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): (None, f"{Emojis.check_mark} silenced current channel indefinitely.", True,), (5, f"{Emojis.cross_mark} current channel is already silenced.", False,), ) - for duration, result_message, _silence_patch_return in test_cases: - with self.subTest( - silence_duration=duration, - result_message=result_message, - starting_unsilenced_state=_silence_patch_return - ): - with mock.patch.object(self.cog, "_silence", return_value=_silence_patch_return): + for duration, message, was_silenced in test_cases: + self.cog._init_task = mock.AsyncMock()() + with self.subTest(was_silenced=was_silenced, message=message, duration=duration): + with mock.patch.object(self.cog, "_silence", return_value=was_silenced): await self.cog.silence.callback(self.cog, self.ctx, duration) - self.ctx.send.assert_called_once_with(result_message) + self.ctx.send.assert_called_once_with(message) self.ctx.reset_mock() async def test_unsilence_sent_correct_discord_message(self): @@ -132,14 +129,12 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): (True, f"{Emojis.check_mark} unsilenced current channel."), (False, f"{Emojis.cross_mark} current channel was not silenced.") ) - for _unsilence_patch_return, result_message in test_cases: - with self.subTest( - starting_silenced_state=_unsilence_patch_return, - result_message=result_message - ): - with mock.patch.object(self.cog, "_unsilence", return_value=_unsilence_patch_return): + for was_unsilenced, message in test_cases: + self.cog._init_task = mock.AsyncMock()() + with self.subTest(was_unsilenced=was_unsilenced, message=message): + with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced): await self.cog.unsilence.callback(self.cog, self.ctx) - self.ctx.send.assert_called_once_with(result_message) + self.ctx.channel.send.assert_called_once_with(message) self.ctx.reset_mock() async def test_silence_private_for_false(self): -- cgit v1.2.3 From 89107eccca3213c436028b997bdc6785fa9ce02d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 15:09:31 -0700 Subject: Silence tests: fix overwrite preservation test for silences --- tests/bot/cogs/moderation/test_silence.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 02964d7ab..765c324d2 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -168,19 +168,23 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): overwrite=overwrite ) - async def test_silence_private_preserves_permissions(self): - """Previous permissions were preserved when channel was silenced.""" + async def test_silence_private_preserves_other_overwrites(self): + """Channel's other unrelated overwrites were not changed when it was silenced.""" channel = MockTextChannel() - # Set up mock channel permission state. - mock_permissions = PermissionOverwrite() - mock_permissions_dict = dict(mock_permissions) - channel.overwrites_for.return_value = mock_permissions + overwrite = PermissionOverwrite(stream=True, attach_files=False) + channel.overwrites_for.return_value = overwrite + + prev_overwrite_dict = dict(overwrite) await self.cog._silence(channel, False, None) - new_permissions = channel.set_permissions.call_args.kwargs - # Remove 'send_messages' key because it got changed in the method. - del new_permissions['send_messages'] - del mock_permissions_dict['send_messages'] - self.assertDictEqual(mock_permissions_dict, new_permissions) + new_overwrite_dict = dict(overwrite) + + # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. + del prev_overwrite_dict['send_messages'] + del prev_overwrite_dict['add_reactions'] + del new_overwrite_dict['send_messages'] + del new_overwrite_dict['add_reactions'] + + self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) async def test_silence_private_notifier(self): """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" -- cgit v1.2.3 From eff788aab84ee96408823eb61ebafba2d9e9cbcb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 15:39:00 -0700 Subject: Silence tests: fix test_unsilence_private_removed_notifier --- tests/bot/cogs/moderation/test_silence.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 765c324d2..b21f5f61a 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -233,8 +233,11 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): @mock.patch.object(Silence, "notifier", create=True) async def test_unsilence_private_removed_notifier(self, notifier): """Channel was removed from `notifier` on unsilence.""" - perm_overwrite = MagicMock(send_messages=False) - channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) + overwrite_json = '{"send_messages": true, "add_reactions": null}' + self.cog.muted_channel_perms.get.return_value = overwrite_json + channel = MockTextChannel() + channel.overwrites_for.return_value = PermissionOverwrite() + await self.cog._unsilence(channel) notifier.remove_channel.assert_called_once_with(channel) -- cgit v1.2.3 From 3ec17dac3fe8e00f34489a5e3dd927bec39e91e6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 15:58:31 -0700 Subject: Silence tests: fix tests for _unsilence Add a fixture to set up mocks for a successful `unsilence` call. This reduces code redundancy. --- tests/bot/cogs/moderation/test_silence.py | 75 ++++++++++++++++++------------- 1 file changed, 45 insertions(+), 30 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index b21f5f61a..ff32e9a05 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,6 +1,6 @@ import unittest from unittest import mock -from unittest.mock import MagicMock, Mock +from unittest.mock import Mock from discord import PermissionOverwrite @@ -81,6 +81,18 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.ctx = MockContext() self.cog._verified_role = None + def unsilence_fixture(self) -> MockTextChannel: + """Setup mocks for a successful `_unsilence` call. Return the mocked channel.""" + overwrite_json = '{"send_messages": true, "add_reactions": null}' + self.cog.muted_channel_perms.get.return_value = overwrite_json + + # stream=True just to have at least one other overwrite not be the default value. + channel = MockTextChannel() + overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) + channel.overwrites_for.return_value = overwrite + + return channel + async def test_init_cog_got_guild(self): """Bot got guild after it became available.""" await self.cog._init_cog() @@ -223,47 +235,50 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): @mock.patch.object(Silence, "notifier", create=True) async def test_unsilence_private_unsilenced_channel(self, _): - """Channel had `send_message` permissions restored""" - perm_overwrite = MagicMock(send_messages=False) - channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) - self.assertTrue(await self.cog._unsilence(channel)) - channel.set_permissions.assert_called_once() - self.assertIsNone(channel.set_permissions.call_args.kwargs['send_messages']) + """Channel had `send_message` permissions restored.""" + channel = self.unsilence_fixture() + overwrite = channel.overwrites_for.return_value + + await self.cog._unsilence(channel) + channel.set_permissions.assert_awaited_once_with( + self.cog._verified_role, overwrite=overwrite + ) + + # Recall that these values are determined by the fixture. + self.assertTrue(overwrite.send_messages) + self.assertIsNone(overwrite.add_reactions) @mock.patch.object(Silence, "notifier", create=True) async def test_unsilence_private_removed_notifier(self, notifier): """Channel was removed from `notifier` on unsilence.""" - overwrite_json = '{"send_messages": true, "add_reactions": null}' - self.cog.muted_channel_perms.get.return_value = overwrite_json - channel = MockTextChannel() - channel.overwrites_for.return_value = PermissionOverwrite() - + channel = self.unsilence_fixture() await self.cog._unsilence(channel) notifier.remove_channel.assert_called_once_with(channel) @mock.patch.object(Silence, "notifier", create=True) async def test_unsilence_private_removed_muted_channel(self, _): - """Channel was removed from `muted_channels` on unsilence.""" - perm_overwrite = MagicMock(send_messages=False) - channel = MockTextChannel(overwrites_for=Mock(return_value=perm_overwrite)) - with mock.patch.object(self.cog, "muted_channels") as muted_channels: - await self.cog._unsilence(channel) - muted_channels.discard.assert_called_once_with(channel) + """Channel was removed from overwrites cache on unsilence.""" + channel = self.unsilence_fixture() + await self.cog._unsilence(channel) + self.cog.muted_channel_perms.delete.assert_awaited_once_with(channel.id) @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_preserves_permissions(self, _): - """Previous permissions were preserved when channel was unsilenced.""" - channel = MockTextChannel() - # Set up mock channel permission state. - mock_permissions = PermissionOverwrite(send_messages=False) - mock_permissions_dict = dict(mock_permissions) - channel.overwrites_for.return_value = mock_permissions + async def test_unsilence_private_preserves_other_overwrites(self, _): + """Channel's other unrelated overwrites were not changed when it was unsilenced.""" + channel = self.unsilence_fixture() + overwrite = channel.overwrites_for.return_value + + prev_overwrite_dict = dict(overwrite) await self.cog._unsilence(channel) - new_permissions = channel.set_permissions.call_args.kwargs - # Remove 'send_messages' key because it got changed in the method. - del new_permissions['send_messages'] - del mock_permissions_dict['send_messages'] - self.assertDictEqual(mock_permissions_dict, new_permissions) + new_overwrite_dict = dict(overwrite) + + # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. + del prev_overwrite_dict['send_messages'] + del prev_overwrite_dict['add_reactions'] + del new_overwrite_dict['send_messages'] + del new_overwrite_dict['add_reactions'] + + self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) def test_cog_unload_cancels_tasks(self): """All scheduled tasks should be cancelled.""" -- cgit v1.2.3 From 0adaf09af5c7a76566f874eda429dd6f263189be Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 16:33:39 -0700 Subject: Silence tests: separate test cases; refactor names & docstrings --- tests/bot/cogs/moderation/test_silence.py | 166 +++++++++++++++++------------- 1 file changed, 95 insertions(+), 71 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index ff32e9a05..f3ee184d4 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,3 +1,4 @@ +import asyncio import unittest from unittest import mock from unittest.mock import Mock @@ -73,25 +74,13 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): @autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) -class SilenceTests(unittest.IsolatedAsyncioTestCase): +class SilenceCogTests(unittest.IsolatedAsyncioTestCase): + """Tests for the general functionality of the Silence cog.""" + @autospec("bot.cogs.moderation.silence", "Scheduler", pass_mocks=False) def setUp(self) -> None: self.bot = MockBot() self.cog = Silence(self.bot) - self.ctx = MockContext() - self.cog._verified_role = None - - def unsilence_fixture(self) -> MockTextChannel: - """Setup mocks for a successful `_unsilence` call. Return the mocked channel.""" - overwrite_json = '{"send_messages": true, "add_reactions": null}' - self.cog.muted_channel_perms.get.return_value = overwrite_json - - # stream=True just to have at least one other overwrite not be the default value. - channel = MockTextChannel() - overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) - channel.overwrites_for.return_value = overwrite - - return channel async def test_init_cog_got_guild(self): """Bot got guild after it became available.""" @@ -120,37 +109,49 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): notifier.assert_called_once_with(mod_log) self.bot.get_channel.side_effect = None - async def test_silence_sent_correct_discord_message(self): - """Check if proper message was sent when called with duration in channel with previous state.""" + def test_cog_unload_cancelled_tasks(self): + """All scheduled tasks were cancelled.""" + self.cog.cog_unload() + self.cog.scheduler.cancel_all.assert_called_once_with() + + @mock.patch("bot.cogs.moderation.silence.with_role_check") + @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) + def test_cog_check(self, role_check): + """Role check was called with `MODERATION_ROLES`""" + ctx = MockContext() + self.cog.cog_check(ctx) + role_check.assert_called_once_with(ctx, *(1, 2, 3)) + + +@autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +class SilenceTests(unittest.IsolatedAsyncioTestCase): + """Tests for the silence command and its related helper methods.""" + + @autospec(Silence, "_reschedule", pass_mocks=False) + @autospec("bot.cogs.moderation.silence", "Scheduler", "SilenceNotifier", pass_mocks=False) + def setUp(self) -> None: + self.bot = MockBot() + self.cog = Silence(self.bot) + self.cog._init_task = mock.AsyncMock()() + + asyncio.run(self.cog._init_cog()) # Populate instance attributes. + + async def test_sent_correct_message(self): + """Appropriate failure/success message was sent by the command.""" test_cases = ( (0.0001, f"{Emojis.check_mark} silenced current channel for 0.0001 minute(s).", True,), (None, f"{Emojis.check_mark} silenced current channel indefinitely.", True,), (5, f"{Emojis.cross_mark} current channel is already silenced.", False,), ) for duration, message, was_silenced in test_cases: - self.cog._init_task = mock.AsyncMock()() + ctx = MockContext() with self.subTest(was_silenced=was_silenced, message=message, duration=duration): with mock.patch.object(self.cog, "_silence", return_value=was_silenced): - await self.cog.silence.callback(self.cog, self.ctx, duration) - self.ctx.send.assert_called_once_with(message) - self.ctx.reset_mock() + await self.cog.silence.callback(self.cog, ctx, duration) + ctx.send.assert_called_once_with(message) - async def test_unsilence_sent_correct_discord_message(self): - """Check if proper message was sent when unsilencing channel.""" - test_cases = ( - (True, f"{Emojis.check_mark} unsilenced current channel."), - (False, f"{Emojis.cross_mark} current channel was not silenced.") - ) - for was_unsilenced, message in test_cases: - self.cog._init_task = mock.AsyncMock()() - with self.subTest(was_unsilenced=was_unsilenced, message=message): - with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced): - await self.cog.unsilence.callback(self.cog, self.ctx) - self.ctx.channel.send.assert_called_once_with(message) - self.ctx.reset_mock() - - async def test_silence_private_for_false(self): - """Permissions are not set and `False` is returned in an already silenced channel.""" + async def test_skipped_already_silenced(self): + """Permissions were not set and `False` was returned for an already silenced channel.""" subtests = ( (False, PermissionOverwrite(send_messages=False, add_reactions=False)), (True, PermissionOverwrite(send_messages=True, add_reactions=True)), @@ -166,7 +167,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(await self.cog._silence(channel, True, None)) channel.set_permissions.assert_not_called() - async def test_silence_private_silenced_channel(self): + async def test_silenced_channel(self): """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" channel = MockTextChannel() overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) @@ -180,8 +181,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): overwrite=overwrite ) - async def test_silence_private_preserves_other_overwrites(self): - """Channel's other unrelated overwrites were not changed when it was silenced.""" + async def test_preserved_other_overwrites(self): + """Channel's other unrelated overwrites were not changed.""" channel = MockTextChannel() overwrite = PermissionOverwrite(stream=True, attach_files=False) channel.overwrites_for.return_value = overwrite @@ -198,8 +199,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) - async def test_silence_private_notifier(self): - """Channel should be added to notifier with `persistent` set to `True`, and the other way around.""" + async def test_added_removed_notifier(self): + """Channel was added to notifier if `persistent` was `True`, and removed if `False`.""" channel = MockTextChannel() overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) channel.overwrites_for.return_value = overwrite @@ -214,8 +215,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._silence(channel, False, None) self.cog.notifier.add_channel.assert_not_called() - async def test_silence_private_cached_perms(self): - """Channel's previous overwrites were cached when silenced.""" + async def test_cached_previous_overwrites(self): + """Channel's previous overwrites were cached.""" channel = MockTextChannel() overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) overwrite_json = '{"send_messages": true, "add_reactions": null}' @@ -224,8 +225,47 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._silence(channel, False, None) self.cog.muted_channel_perms.set.assert_called_once_with(channel.id, overwrite_json) - async def test_unsilence_private_for_false(self): - """Permissions are not set and `False` is returned in an unsilenced channel.""" + +@autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +class UnsilenceTests(unittest.IsolatedAsyncioTestCase): + """Tests for the unsilence command and its related helper methods.""" + + @autospec(Silence, "_reschedule", pass_mocks=False) + @autospec("bot.cogs.moderation.silence", "Scheduler", "SilenceNotifier", pass_mocks=False) + def setUp(self) -> None: + self.bot = MockBot() + self.cog = Silence(self.bot) + self.cog._init_task = mock.AsyncMock()() + + asyncio.run(self.cog._init_cog()) # Populate instance attributes. + + def unsilence_fixture(self) -> MockTextChannel: + """Setup mocks for a successful `_unsilence` call. Return the mocked channel.""" + overwrite_json = '{"send_messages": true, "add_reactions": null}' + self.cog.muted_channel_perms.get.return_value = overwrite_json + + # stream=True just to have at least one other overwrite not be the default value. + channel = MockTextChannel() + overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) + channel.overwrites_for.return_value = overwrite + + return channel + + async def test_sent_correct_message(self): + """Appropriate failure/success message was sent by the command.""" + test_cases = ( + (True, f"{Emojis.check_mark} unsilenced current channel."), + (False, f"{Emojis.cross_mark} current channel was not silenced.") + ) + for was_unsilenced, message in test_cases: + ctx = MockContext() + with self.subTest(was_unsilenced=was_unsilenced, message=message): + with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced): + await self.cog.unsilence.callback(self.cog, ctx) + ctx.channel.send.assert_called_once_with(message) + + async def test_skipped_already_unsilenced(self): + """Permissions were not set and `False` was returned for an already unsilenced channel.""" self.cog.scheduler.__contains__.return_value = False self.cog.muted_channel_perms.get.return_value = None channel = MockTextChannel() @@ -233,9 +273,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(await self.cog._unsilence(channel)) channel.set_permissions.assert_not_called() - @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_unsilenced_channel(self, _): - """Channel had `send_message` permissions restored.""" + async def test_unsilenced_channel(self): + """Channel's `send_message` and `add_reactions` overwrites were restored.""" channel = self.unsilence_fixture() overwrite = channel.overwrites_for.return_value @@ -248,23 +287,20 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(overwrite.send_messages) self.assertIsNone(overwrite.add_reactions) - @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_removed_notifier(self, notifier): - """Channel was removed from `notifier` on unsilence.""" + async def test_removed_notifier(self): + """Channel was removed from `notifier`.""" channel = self.unsilence_fixture() await self.cog._unsilence(channel) - notifier.remove_channel.assert_called_once_with(channel) + self.cog.notifier.remove_channel.assert_called_once_with(channel) - @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_removed_muted_channel(self, _): - """Channel was removed from overwrites cache on unsilence.""" + async def test_deleted_cached_overwrite(self): + """Channel was deleted from the overwrites cache.""" channel = self.unsilence_fixture() await self.cog._unsilence(channel) self.cog.muted_channel_perms.delete.assert_awaited_once_with(channel.id) - @mock.patch.object(Silence, "notifier", create=True) - async def test_unsilence_private_preserves_other_overwrites(self, _): - """Channel's other unrelated overwrites were not changed when it was unsilenced.""" + async def test_preserved_other_overwrites(self): + """Channel's other unrelated overwrites were not changed.""" channel = self.unsilence_fixture() overwrite = channel.overwrites_for.return_value @@ -279,15 +315,3 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): del new_overwrite_dict['add_reactions'] self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) - - def test_cog_unload_cancels_tasks(self): - """All scheduled tasks should be cancelled.""" - self.cog.cog_unload() - self.cog.scheduler.cancel_all.assert_called_once_with() - - @mock.patch("bot.cogs.moderation.silence.with_role_check") - @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) - def test_cog_check(self, role_check): - """Role check is called with `MODERATION_ROLES`""" - self.cog.cog_check(self.ctx) - role_check.assert_called_once_with(self.ctx, *(1, 2, 3)) -- cgit v1.2.3 From 3f4d409bd951e749bdc643b0bc288cfc0f5136bd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 16:46:13 -0700 Subject: Silence tests: autospec _reschedule and SilenceNotifier for cog tests --- tests/bot/cogs/moderation/test_silence.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index f3ee184d4..ae8b59ff5 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -82,39 +82,45 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.bot = MockBot() self.cog = Silence(self.bot) + @autospec(Silence, "_reschedule", pass_mocks=False) + @autospec("bot.cogs.moderation.silence", "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_guild(self): """Bot got guild after it became available.""" await self.cog._init_cog() self.bot.wait_until_guild_available.assert_awaited_once() self.bot.get_guild.assert_called_once_with(Guild.id) + @autospec(Silence, "_reschedule", pass_mocks=False) + @autospec("bot.cogs.moderation.silence", "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_role(self): """Got `Roles.verified` role from guild.""" await self.cog._init_cog() guild = self.bot.get_guild() guild.get_role.assert_called_once_with(Roles.verified) + @autospec(Silence, "_reschedule", pass_mocks=False) + @autospec("bot.cogs.moderation.silence", "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_channels(self): """Got channels from bot.""" await self.cog._init_cog() self.bot.get_channel.called_once_with(Channels.mod_alerts) self.bot.get_channel.called_once_with(Channels.mod_log) - @mock.patch("bot.cogs.moderation.silence.SilenceNotifier") + @autospec(Silence, "_reschedule", pass_mocks=False) + @autospec("bot.cogs.moderation.silence", "SilenceNotifier") async def test_init_cog_got_notifier(self, notifier): """Notifier was started with channel.""" mod_log = MockTextChannel() self.bot.get_channel.side_effect = (None, mod_log) await self.cog._init_cog() - notifier.assert_called_once_with(mod_log) - self.bot.get_channel.side_effect = None + notifier.assert_called_once_with(self.cog._mod_log_channel) def test_cog_unload_cancelled_tasks(self): """All scheduled tasks were cancelled.""" self.cog.cog_unload() self.cog.scheduler.cancel_all.assert_called_once_with() - @mock.patch("bot.cogs.moderation.silence.with_role_check") + @autospec("bot.cogs.moderation.silence", "with_role_check") @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) def test_cog_check(self, role_check): """Role check was called with `MODERATION_ROLES`""" -- cgit v1.2.3 From a13bf79ba2aa672e52bcffe38720c7686cac67ba Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 18:40:36 -0700 Subject: Silence tests: merge unsilence fixture into setUp Now that there are separate test cases, there's no need to keep the fixtures separate. --- tests/bot/cogs/moderation/test_silence.py | 52 ++++++++++++------------------- 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index ae8b59ff5..fe6045c87 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -232,7 +232,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog.muted_channel_perms.set.assert_called_once_with(channel.id, overwrite_json) -@autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +@autospec(Silence, "muted_channel_times", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): """Tests for the unsilence command and its related helper methods.""" @@ -243,19 +243,15 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.cog = Silence(self.bot) self.cog._init_task = mock.AsyncMock()() - asyncio.run(self.cog._init_cog()) # Populate instance attributes. - - def unsilence_fixture(self) -> MockTextChannel: - """Setup mocks for a successful `_unsilence` call. Return the mocked channel.""" - overwrite_json = '{"send_messages": true, "add_reactions": null}' - self.cog.muted_channel_perms.get.return_value = overwrite_json + perms_cache = mock.create_autospec(self.cog.muted_channel_perms, spec_set=True) + self.cog.muted_channel_perms = perms_cache - # stream=True just to have at least one other overwrite not be the default value. - channel = MockTextChannel() - overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) - channel.overwrites_for.return_value = overwrite + asyncio.run(self.cog._init_cog()) # Populate instance attributes. - return channel + perms_cache.get.return_value = '{"send_messages": true, "add_reactions": null}' + self.channel = MockTextChannel() + self.overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) + self.channel.overwrites_for.return_value = self.overwrite async def test_sent_correct_message(self): """Appropriate failure/success message was sent by the command.""" @@ -281,38 +277,30 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_unsilenced_channel(self): """Channel's `send_message` and `add_reactions` overwrites were restored.""" - channel = self.unsilence_fixture() - overwrite = channel.overwrites_for.return_value - - await self.cog._unsilence(channel) - channel.set_permissions.assert_awaited_once_with( - self.cog._verified_role, overwrite=overwrite + await self.cog._unsilence(self.channel) + self.channel.set_permissions.assert_awaited_once_with( + self.cog._verified_role, overwrite=self.overwrite ) # Recall that these values are determined by the fixture. - self.assertTrue(overwrite.send_messages) - self.assertIsNone(overwrite.add_reactions) + self.assertTrue(self.overwrite.send_messages) + self.assertIsNone(self.overwrite.add_reactions) async def test_removed_notifier(self): """Channel was removed from `notifier`.""" - channel = self.unsilence_fixture() - await self.cog._unsilence(channel) - self.cog.notifier.remove_channel.assert_called_once_with(channel) + await self.cog._unsilence(self.channel) + self.cog.notifier.remove_channel.assert_called_once_with(self.channel) async def test_deleted_cached_overwrite(self): """Channel was deleted from the overwrites cache.""" - channel = self.unsilence_fixture() - await self.cog._unsilence(channel) - self.cog.muted_channel_perms.delete.assert_awaited_once_with(channel.id) + await self.cog._unsilence(self.channel) + self.cog.muted_channel_perms.delete.assert_awaited_once_with(self.channel.id) async def test_preserved_other_overwrites(self): """Channel's other unrelated overwrites were not changed.""" - channel = self.unsilence_fixture() - overwrite = channel.overwrites_for.return_value - - prev_overwrite_dict = dict(overwrite) - await self.cog._unsilence(channel) - new_overwrite_dict = dict(overwrite) + prev_overwrite_dict = dict(self.overwrite) + await self.cog._unsilence(self.channel) + new_overwrite_dict = dict(self.overwrite) # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. del prev_overwrite_dict['send_messages'] -- cgit v1.2.3 From 8c2945f9fee9f6041edaea5b5c98209478b33259 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 18:46:27 -0700 Subject: Silence tests: create channel and overwrite in setUp for silence tests Reduce code redundancy by only defining them once. --- tests/bot/cogs/moderation/test_silence.py | 46 ++++++++++++------------------- 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index fe6045c87..eba8385bc 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -142,6 +142,10 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): asyncio.run(self.cog._init_cog()) # Populate instance attributes. + self.channel = MockTextChannel() + self.overwrite = PermissionOverwrite(stream=True, send_messages=True, add_reactions=False) + self.channel.overwrites_for.return_value = self.overwrite + async def test_sent_correct_message(self): """Appropriate failure/success message was sent by the command.""" test_cases = ( @@ -175,27 +179,19 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_silenced_channel(self): """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" - channel = MockTextChannel() - overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) - channel.overwrites_for.return_value = overwrite - - self.assertTrue(await self.cog._silence(channel, False, None)) - self.assertFalse(overwrite.send_messages) - self.assertFalse(overwrite.add_reactions) - channel.set_permissions.assert_awaited_once_with( + self.assertTrue(await self.cog._silence(self.channel, False, None)) + self.assertFalse(self.overwrite.send_messages) + self.assertFalse(self.overwrite.add_reactions) + self.channel.set_permissions.assert_awaited_once_with( self.cog._verified_role, - overwrite=overwrite + overwrite=self.overwrite ) async def test_preserved_other_overwrites(self): """Channel's other unrelated overwrites were not changed.""" - channel = MockTextChannel() - overwrite = PermissionOverwrite(stream=True, attach_files=False) - channel.overwrites_for.return_value = overwrite - - prev_overwrite_dict = dict(overwrite) - await self.cog._silence(channel, False, None) - new_overwrite_dict = dict(overwrite) + prev_overwrite_dict = dict(self.overwrite) + await self.cog._silence(self.channel, False, None) + new_overwrite_dict = dict(self.overwrite) # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. del prev_overwrite_dict['send_messages'] @@ -207,29 +203,21 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_added_removed_notifier(self): """Channel was added to notifier if `persistent` was `True`, and removed if `False`.""" - channel = MockTextChannel() - overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) - channel.overwrites_for.return_value = overwrite - with mock.patch.object(self.cog, "notifier", create=True): with self.subTest(persistent=True): - await self.cog._silence(channel, True, None) + await self.cog._silence(self.channel, True, None) self.cog.notifier.add_channel.assert_called_once() with mock.patch.object(self.cog, "notifier", create=True): with self.subTest(persistent=False): - await self.cog._silence(channel, False, None) + await self.cog._silence(self.channel, False, None) self.cog.notifier.add_channel.assert_not_called() async def test_cached_previous_overwrites(self): """Channel's previous overwrites were cached.""" - channel = MockTextChannel() - overwrite = PermissionOverwrite(send_messages=True, add_reactions=None) - overwrite_json = '{"send_messages": true, "add_reactions": null}' - channel.overwrites_for.return_value = overwrite - - await self.cog._silence(channel, False, None) - self.cog.muted_channel_perms.set.assert_called_once_with(channel.id, overwrite_json) + overwrite_json = '{"send_messages": true, "add_reactions": false}' + await self.cog._silence(self.channel, False, None) + self.cog.muted_channel_perms.set.assert_called_once_with(self.channel.id, overwrite_json) @autospec(Silence, "muted_channel_times", pass_mocks=False) -- cgit v1.2.3 From 282596d1414613e05ee8b956393913da976b35e3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:13:18 -0700 Subject: Silence tests: fix mock for _init_task An `AsyncMock` fails because it returns a coroutine which may only be awaited once. However, an `asyncio.Future` is perfect because it is easy to create and can be awaited repeatedly, just like the actual `asyncio.Task` that is being mocked. --- tests/bot/cogs/moderation/test_silence.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index eba8385bc..5d42d8c36 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -138,7 +138,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.bot = MockBot() self.cog = Silence(self.bot) - self.cog._init_task = mock.AsyncMock()() + self.cog._init_task = asyncio.Future() + self.cog._init_task.set_result(None) asyncio.run(self.cog._init_cog()) # Populate instance attributes. @@ -229,7 +230,8 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.bot = MockBot() self.cog = Silence(self.bot) - self.cog._init_task = mock.AsyncMock()() + self.cog._init_task = asyncio.Future() + self.cog._init_task.set_result(None) perms_cache = mock.create_autospec(self.cog.muted_channel_perms, spec_set=True) self.cog.muted_channel_perms = perms_cache -- cgit v1.2.3 From eda342b40ccd941050a1421ef1907cb2790c1cde Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:15:53 -0700 Subject: Silence tests: add a test for the time cache --- tests/bot/cogs/moderation/test_silence.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 5d42d8c36..1ae17177f 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -1,5 +1,6 @@ import asyncio import unittest +from datetime import datetime, timezone from unittest import mock from unittest.mock import Mock @@ -220,6 +221,20 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._silence(self.channel, False, None) self.cog.muted_channel_perms.set.assert_called_once_with(self.channel.id, overwrite_json) + @autospec("bot.cogs.moderation.silence", "datetime") + async def test_cached_unsilence_time(self, datetime_mock): + """The UTC POSIX timestamp for the unsilence was cached.""" + now_timestamp = 100 + duration = 15 + timestamp = now_timestamp + duration * 60 + datetime_mock.now.return_value = datetime.fromtimestamp(now_timestamp, tz=timezone.utc) + + ctx = MockContext(channel=self.channel) + await self.cog.silence.callback(self.cog, ctx, duration) + + self.cog.muted_channel_times.set.assert_awaited_once_with(ctx.channel.id, timestamp) + datetime_mock.now.assert_called_once_with(tz=timezone.utc) # Ensure it's using an aware dt. + @autospec(Silence, "muted_channel_times", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From 5eec1c2db319ccdb1f71c1a25fa541eeb7a2707a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:17:51 -0700 Subject: Silence tests: add a test for caching permanent times --- tests/bot/cogs/moderation/test_silence.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 1ae17177f..2e756a88f 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -235,6 +235,12 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog.muted_channel_times.set.assert_awaited_once_with(ctx.channel.id, timestamp) datetime_mock.now.assert_called_once_with(tz=timezone.utc) # Ensure it's using an aware dt. + async def test_cached_indefinite_time(self): + """A value of -1 was cached for a permanent silence.""" + ctx = MockContext(channel=self.channel) + await self.cog.silence.callback(self.cog, ctx, None) + self.cog.muted_channel_times.set.assert_awaited_once_with(ctx.channel.id, -1) + @autospec(Silence, "muted_channel_times", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From 1e1d358ae38bb9d554e993fb61ee8f0b52f977b5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:25:15 -0700 Subject: Silence tests: add tests for scheduling tasks --- tests/bot/cogs/moderation/test_silence.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 2e756a88f..979b4f4e5 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -241,6 +241,18 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog.silence.callback(self.cog, ctx, None) self.cog.muted_channel_times.set.assert_awaited_once_with(ctx.channel.id, -1) + async def test_scheduled_task(self): + """An unsilence task was scheduled.""" + ctx = MockContext(channel=self.channel) + await self.cog.silence.callback(self.cog, ctx) + self.cog.scheduler.schedule_later.assert_called_once() + + async def test_permanent_not_scheduled(self): + """A task was not scheduled for a permanent silence.""" + ctx = MockContext(channel=self.channel) + await self.cog.silence.callback(self.cog, ctx, None) + self.cog.scheduler.schedule_later.assert_not_called() + @autospec(Silence, "muted_channel_times", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From d4fbd675d9803cc664909c19fcec8a430524f918 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:26:48 -0700 Subject: Silence tests: add a test for deletion from the time cache --- tests/bot/cogs/moderation/test_silence.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 979b4f4e5..6f913b8f9 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -319,6 +319,11 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._unsilence(self.channel) self.cog.muted_channel_perms.delete.assert_awaited_once_with(self.channel.id) + async def test_deleted_cached_time(self): + """Channel was deleted from the timestamp cache.""" + await self.cog._unsilence(self.channel) + self.cog.muted_channel_times.delete.assert_awaited_once_with(self.channel.id) + async def test_preserved_other_overwrites(self): """Channel's other unrelated overwrites were not changed.""" prev_overwrite_dict = dict(self.overwrite) -- cgit v1.2.3 From 67f88e0b63ec9ac198b8204d4b07e6f7ee67937b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:28:15 -0700 Subject: Silence tests: add a test for task cancellation --- tests/bot/cogs/moderation/test_silence.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6f913b8f9..9e81df9c4 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -324,6 +324,11 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._unsilence(self.channel) self.cog.muted_channel_times.delete.assert_awaited_once_with(self.channel.id) + async def test_cancelled_task(self): + """The scheduled unsilence task should be cancelled.""" + await self.cog._unsilence(self.channel) + self.cog.scheduler.cancel.assert_called_once_with(self.channel.id) + async def test_preserved_other_overwrites(self): """Channel's other unrelated overwrites were not changed.""" prev_overwrite_dict = dict(self.overwrite) -- cgit v1.2.3 From cc956f24e1f748dbe97fc6bd96383d22a494c5ed Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:50:14 -0700 Subject: Silence tests: add a test for default overwrites on cache miss Use a False for `add_reactions` in the mock overwrite rather than None to be sure the default (also None) is actually set for it. Fix channels set by `_init_cog` not being mocked properly. --- tests/bot/cogs/moderation/test_silence.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 9e81df9c4..992906a50 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -261,7 +261,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): @autospec(Silence, "_reschedule", pass_mocks=False) @autospec("bot.cogs.moderation.silence", "Scheduler", "SilenceNotifier", pass_mocks=False) def setUp(self) -> None: - self.bot = MockBot() + self.bot = MockBot(get_channel=lambda _: MockTextChannel()) self.cog = Silence(self.bot) self.cog._init_task = asyncio.Future() self.cog._init_task.set_result(None) @@ -271,7 +271,8 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): asyncio.run(self.cog._init_cog()) # Populate instance attributes. - perms_cache.get.return_value = '{"send_messages": true, "add_reactions": null}' + self.cog.scheduler.__contains__.return_value = True + perms_cache.get.return_value = '{"send_messages": true, "add_reactions": false}' self.channel = MockTextChannel() self.overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) self.channel.overwrites_for.return_value = self.overwrite @@ -298,15 +299,29 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(await self.cog._unsilence(channel)) channel.set_permissions.assert_not_called() - async def test_unsilenced_channel(self): + async def test_restored_overwrites(self): """Channel's `send_message` and `add_reactions` overwrites were restored.""" await self.cog._unsilence(self.channel) self.channel.set_permissions.assert_awaited_once_with( - self.cog._verified_role, overwrite=self.overwrite + self.cog._verified_role, + overwrite=self.overwrite, ) # Recall that these values are determined by the fixture. self.assertTrue(self.overwrite.send_messages) + self.assertFalse(self.overwrite.add_reactions) + + async def test_cache_miss_used_default_overwrites(self): + """Both overwrites were set to None due previous values not being found in the cache.""" + self.cog.muted_channel_perms.get.return_value = None + + await self.cog._unsilence(self.channel) + self.channel.set_permissions.assert_awaited_once_with( + self.cog._verified_role, + overwrite=self.overwrite, + ) + + self.assertIsNone(self.overwrite.send_messages) self.assertIsNone(self.overwrite.add_reactions) async def test_removed_notifier(self): -- cgit v1.2.3 From e4548b2505cf4765cfd2a2c1a1762212cc5cba25 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:54:04 -0700 Subject: Silence tests: add a test for a mod alert on cache miss --- tests/bot/cogs/moderation/test_silence.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 992906a50..ccc908ee4 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -324,6 +324,13 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(self.overwrite.send_messages) self.assertIsNone(self.overwrite.add_reactions) + async def test_cache_miss_sent_mod_alert(self): + """A message was sent to the mod alerts channel.""" + self.cog.muted_channel_perms.get.return_value = None + + await self.cog._unsilence(self.channel) + self.cog._mod_alerts_channel.send.assert_awaited_once() + async def test_removed_notifier(self): """Channel was removed from `notifier`.""" await self.cog._unsilence(self.channel) -- cgit v1.2.3 From 33fb55cbe431211f99acfbce22129c48a60a1e6b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 19:58:26 -0700 Subject: Silence tests: also test that cache misses preserve other overwrites --- tests/bot/cogs/moderation/test_silence.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index ccc908ee4..71608d3f9 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -352,15 +352,19 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.cog.scheduler.cancel.assert_called_once_with(self.channel.id) async def test_preserved_other_overwrites(self): - """Channel's other unrelated overwrites were not changed.""" - prev_overwrite_dict = dict(self.overwrite) - await self.cog._unsilence(self.channel) - new_overwrite_dict = dict(self.overwrite) - - # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. - del prev_overwrite_dict['send_messages'] - del prev_overwrite_dict['add_reactions'] - del new_overwrite_dict['send_messages'] - del new_overwrite_dict['add_reactions'] - - self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) + """Channel's other unrelated overwrites were not changed, including cache misses.""" + for overwrite_json in ('{"send_messages": true, "add_reactions": null}', None): + with self.subTest(overwrite_json=overwrite_json): + self.cog.muted_channel_perms.get.return_value = overwrite_json + + prev_overwrite_dict = dict(self.overwrite) + await self.cog._unsilence(self.channel) + new_overwrite_dict = dict(self.overwrite) + + # Remove these keys because they were modified by the unsilence. + del prev_overwrite_dict['send_messages'] + del prev_overwrite_dict['add_reactions'] + del new_overwrite_dict['send_messages'] + del new_overwrite_dict['add_reactions'] + + self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) -- cgit v1.2.3 From 83d74c56af2a777a2a4f6f7f5347598d0000a66b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 20:14:46 -0700 Subject: Silence tests: assert against message constants Duplicating strings in assertions is redundant, closely coupled, and less maintainable. --- bot/cogs/moderation/silence.py | 26 +++++++++++++++++--------- tests/bot/cogs/moderation/test_silence.py | 13 +++++++------ 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index de799f64f..9732248ff 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -17,6 +17,17 @@ from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) +MSG_SILENCE_FAIL = f"{Emojis.cross_mark} current channel is already silenced." +MSG_SILENCE_PERMANENT = f"{Emojis.check_mark} silenced current channel indefinitely." +MSG_SILENCE_SUCCESS = Emojis.check_mark + " silenced current channel for {duration} minute(s)." + +MSG_UNSILENCE_FAIL = f"{Emojis.cross_mark} current channel was not silenced." +MSG_UNSILENCE_MANUAL = ( + f"{Emojis.cross_mark} current channel was not unsilenced because the current " + f"overwrites were set manually. Please edit them manually to unsilence." +) +MSG_UNSILENCE_SUCCESS = f"{Emojis.check_mark} unsilenced current channel." + class SilenceNotifier(tasks.Loop): """Loop notifier for posting notices to `alert_channel` containing added channels.""" @@ -96,15 +107,15 @@ class Silence(commands.Cog): log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): - await ctx.send(f"{Emojis.cross_mark} current channel is already silenced.") + await ctx.send(MSG_SILENCE_FAIL) return if duration is None: - await ctx.send(f"{Emojis.check_mark} silenced current channel indefinitely.") + await ctx.send(MSG_SILENCE_PERMANENT) await self.muted_channel_times.set(ctx.channel.id, -1) return - await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") + await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) unsilence_time = (datetime.now(tz=timezone.utc) + timedelta(minutes=duration)) @@ -126,14 +137,11 @@ class Silence(commands.Cog): if not await self._unsilence(channel): overwrite = channel.overwrites_for(self._verified_role) if overwrite.send_messages is False and overwrite.add_reactions is False: - await channel.send( - f"{Emojis.cross_mark} current channel was not unsilenced because the current " - f"overwrites were set manually. Please edit them manually to unsilence." - ) + await channel.send(MSG_UNSILENCE_MANUAL) else: - await channel.send(f"{Emojis.cross_mark} current channel was not silenced.") + await channel.send(MSG_UNSILENCE_FAIL) else: - await channel.send(f"{Emojis.check_mark} unsilenced current channel.") + await channel.send(MSG_UNSILENCE_SUCCESS) async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: """ diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 71608d3f9..168794b6f 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -6,8 +6,9 @@ from unittest.mock import Mock from discord import PermissionOverwrite +from bot.cogs.moderation import silence from bot.cogs.moderation.silence import Silence, SilenceNotifier -from bot.constants import Channels, Emojis, Guild, Roles +from bot.constants import Channels, Guild, Roles from tests.helpers import MockBot, MockContext, MockTextChannel, autospec @@ -151,9 +152,9 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_sent_correct_message(self): """Appropriate failure/success message was sent by the command.""" test_cases = ( - (0.0001, f"{Emojis.check_mark} silenced current channel for 0.0001 minute(s).", True,), - (None, f"{Emojis.check_mark} silenced current channel indefinitely.", True,), - (5, f"{Emojis.cross_mark} current channel is already silenced.", False,), + (0.0001, silence.MSG_SILENCE_SUCCESS.format(duration=0.0001), True,), + (None, silence.MSG_SILENCE_PERMANENT, True,), + (5, silence.MSG_SILENCE_FAIL, False,), ) for duration, message, was_silenced in test_cases: ctx = MockContext() @@ -280,8 +281,8 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_sent_correct_message(self): """Appropriate failure/success message was sent by the command.""" test_cases = ( - (True, f"{Emojis.check_mark} unsilenced current channel."), - (False, f"{Emojis.cross_mark} current channel was not silenced.") + (True, silence.MSG_UNSILENCE_SUCCESS), + (False, silence.MSG_UNSILENCE_FAIL) ) for was_unsilenced, message in test_cases: ctx = MockContext() -- cgit v1.2.3 From ed30502710e805de5e3793b762a3848a0295582d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 20:18:14 -0700 Subject: Silence tests: add a subtest for the manual unsilence message --- tests/bot/cogs/moderation/test_silence.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 168794b6f..254480a6d 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -280,14 +280,17 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_sent_correct_message(self): """Appropriate failure/success message was sent by the command.""" + unsilenced_overwrite = PermissionOverwrite(send_messages=True, add_reactions=True) test_cases = ( - (True, silence.MSG_UNSILENCE_SUCCESS), - (False, silence.MSG_UNSILENCE_FAIL) + (True, silence.MSG_UNSILENCE_SUCCESS, unsilenced_overwrite), + (False, silence.MSG_UNSILENCE_FAIL, unsilenced_overwrite), + (False, silence.MSG_UNSILENCE_MANUAL, self.overwrite), ) - for was_unsilenced, message in test_cases: + for was_unsilenced, message, overwrite in test_cases: ctx = MockContext() - with self.subTest(was_unsilenced=was_unsilenced, message=message): + with self.subTest(was_unsilenced=was_unsilenced, message=message, overwrite=overwrite): with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced): + ctx.channel.overwrites_for.return_value = overwrite await self.cog.unsilence.callback(self.cog, ctx) ctx.channel.send.assert_called_once_with(message) -- cgit v1.2.3 From f9d4081efc41c7ab9f5e6362a0ab4fab5bc88cd8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 20:23:15 -0700 Subject: Silence tests: access everything via the silence module The module is imported anyway to keep imports short and clean. Using it in patch targets is shorter and allows for the two imports from the module to be removed. --- tests/bot/cogs/moderation/test_silence.py | 47 +++++++++++++++---------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 254480a6d..0a93cc623 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -7,7 +7,6 @@ from unittest.mock import Mock from discord import PermissionOverwrite from bot.cogs.moderation import silence -from bot.cogs.moderation.silence import Silence, SilenceNotifier from bot.constants import Channels, Guild, Roles from tests.helpers import MockBot, MockContext, MockTextChannel, autospec @@ -15,7 +14,7 @@ from tests.helpers import MockBot, MockContext, MockTextChannel, autospec class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.alert_channel = MockTextChannel() - self.notifier = SilenceNotifier(self.alert_channel) + self.notifier = silence.SilenceNotifier(self.alert_channel) self.notifier.stop = self.notifier_stop_mock = Mock() self.notifier.start = self.notifier_start_mock = Mock() @@ -75,41 +74,41 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.alert_channel.send.assert_not_called() -@autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceCogTests(unittest.IsolatedAsyncioTestCase): """Tests for the general functionality of the Silence cog.""" - @autospec("bot.cogs.moderation.silence", "Scheduler", pass_mocks=False) + @autospec(silence, "Scheduler", pass_mocks=False) def setUp(self) -> None: self.bot = MockBot() - self.cog = Silence(self.bot) + self.cog = silence.Silence(self.bot) - @autospec(Silence, "_reschedule", pass_mocks=False) - @autospec("bot.cogs.moderation.silence", "SilenceNotifier", pass_mocks=False) + @autospec(silence.Silence, "_reschedule", pass_mocks=False) + @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_guild(self): """Bot got guild after it became available.""" await self.cog._init_cog() self.bot.wait_until_guild_available.assert_awaited_once() self.bot.get_guild.assert_called_once_with(Guild.id) - @autospec(Silence, "_reschedule", pass_mocks=False) - @autospec("bot.cogs.moderation.silence", "SilenceNotifier", pass_mocks=False) + @autospec(silence.Silence, "_reschedule", pass_mocks=False) + @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_role(self): """Got `Roles.verified` role from guild.""" await self.cog._init_cog() guild = self.bot.get_guild() guild.get_role.assert_called_once_with(Roles.verified) - @autospec(Silence, "_reschedule", pass_mocks=False) - @autospec("bot.cogs.moderation.silence", "SilenceNotifier", pass_mocks=False) + @autospec(silence.Silence, "_reschedule", pass_mocks=False) + @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_channels(self): """Got channels from bot.""" await self.cog._init_cog() self.bot.get_channel.called_once_with(Channels.mod_alerts) self.bot.get_channel.called_once_with(Channels.mod_log) - @autospec(Silence, "_reschedule", pass_mocks=False) - @autospec("bot.cogs.moderation.silence", "SilenceNotifier") + @autospec(silence.Silence, "_reschedule", pass_mocks=False) + @autospec(silence, "SilenceNotifier") async def test_init_cog_got_notifier(self, notifier): """Notifier was started with channel.""" mod_log = MockTextChannel() @@ -122,8 +121,8 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.cog.cog_unload() self.cog.scheduler.cancel_all.assert_called_once_with() - @autospec("bot.cogs.moderation.silence", "with_role_check") - @mock.patch("bot.cogs.moderation.silence.MODERATION_ROLES", new=(1, 2, 3)) + @autospec(silence, "with_role_check") + @mock.patch.object(silence, "MODERATION_ROLES", new=(1, 2, 3)) def test_cog_check(self, role_check): """Role check was called with `MODERATION_ROLES`""" ctx = MockContext() @@ -131,15 +130,15 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): role_check.assert_called_once_with(ctx, *(1, 2, 3)) -@autospec(Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): """Tests for the silence command and its related helper methods.""" - @autospec(Silence, "_reschedule", pass_mocks=False) - @autospec("bot.cogs.moderation.silence", "Scheduler", "SilenceNotifier", pass_mocks=False) + @autospec(silence.Silence, "_reschedule", pass_mocks=False) + @autospec(silence, "Scheduler", "SilenceNotifier", pass_mocks=False) def setUp(self) -> None: self.bot = MockBot() - self.cog = Silence(self.bot) + self.cog = silence.Silence(self.bot) self.cog._init_task = asyncio.Future() self.cog._init_task.set_result(None) @@ -222,7 +221,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._silence(self.channel, False, None) self.cog.muted_channel_perms.set.assert_called_once_with(self.channel.id, overwrite_json) - @autospec("bot.cogs.moderation.silence", "datetime") + @autospec(silence, "datetime") async def test_cached_unsilence_time(self, datetime_mock): """The UTC POSIX timestamp for the unsilence was cached.""" now_timestamp = 100 @@ -255,15 +254,15 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog.scheduler.schedule_later.assert_not_called() -@autospec(Silence, "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "muted_channel_times", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): """Tests for the unsilence command and its related helper methods.""" - @autospec(Silence, "_reschedule", pass_mocks=False) - @autospec("bot.cogs.moderation.silence", "Scheduler", "SilenceNotifier", pass_mocks=False) + @autospec(silence.Silence, "_reschedule", pass_mocks=False) + @autospec(silence, "Scheduler", "SilenceNotifier", pass_mocks=False) def setUp(self) -> None: self.bot = MockBot(get_channel=lambda _: MockTextChannel()) - self.cog = Silence(self.bot) + self.cog = silence.Silence(self.bot) self.cog._init_task = asyncio.Future() self.cog._init_task.set_result(None) -- cgit v1.2.3 From cbdc14a3abbcbee644e9d4a6f3ffde125a7c91f1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 20:36:48 -0700 Subject: Silence tests: remove _reschedule patch for cog tests They don't do anything because they patch the class rather than the instance. It's too late for patching the instance to work since the `setUp` fixture, which instantiates the cog, executes before the patches do. Patching `setUp` would work (and its done in the other test cases), but some tests in this case will need the unpatched function too. Patching it doesn't serve much benefit to most tests anyway, so it's not worth the effort trying to make them work where they aren't needed. --- tests/bot/cogs/moderation/test_silence.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 0a93cc623..667d61776 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -83,7 +83,6 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.bot = MockBot() self.cog = silence.Silence(self.bot) - @autospec(silence.Silence, "_reschedule", pass_mocks=False) @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_guild(self): """Bot got guild after it became available.""" @@ -91,7 +90,6 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.bot.wait_until_guild_available.assert_awaited_once() self.bot.get_guild.assert_called_once_with(Guild.id) - @autospec(silence.Silence, "_reschedule", pass_mocks=False) @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_role(self): """Got `Roles.verified` role from guild.""" @@ -99,7 +97,6 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): guild = self.bot.get_guild() guild.get_role.assert_called_once_with(Roles.verified) - @autospec(silence.Silence, "_reschedule", pass_mocks=False) @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_got_channels(self): """Got channels from bot.""" @@ -107,7 +104,6 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.bot.get_channel.called_once_with(Channels.mod_alerts) self.bot.get_channel.called_once_with(Channels.mod_log) - @autospec(silence.Silence, "_reschedule", pass_mocks=False) @autospec(silence, "SilenceNotifier") async def test_init_cog_got_notifier(self, notifier): """Notifier was started with channel.""" -- cgit v1.2.3 From 366f975bbb22c45b9e071644ab4053416bf351fb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 17 Aug 2020 20:37:24 -0700 Subject: Silence tests: add a test for _init_cog rescheduling unsilences --- tests/bot/cogs/moderation/test_silence.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 667d61776..5deed2d0b 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -112,6 +112,13 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): await self.cog._init_cog() notifier.assert_called_once_with(self.cog._mod_log_channel) + @autospec(silence, "SilenceNotifier", pass_mocks=False) + async def test_init_cog_rescheduled(self): + """`_reschedule_` coroutine was awaited.""" + self.cog._reschedule = mock.create_autospec(self.cog._reschedule, spec_set=True) + await self.cog._init_cog() + self.cog._reschedule.assert_awaited_once_with() + def test_cog_unload_cancelled_tasks(self): """All scheduled tasks were cancelled.""" self.cog.cog_unload() -- cgit v1.2.3 From d5032459bfe1bbbc10ffc3b95809e0fc377de60c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 10:19:17 -0700 Subject: Silence tests: test the scheduler skips missing channels --- tests/bot/cogs/moderation/test_silence.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 5deed2d0b..2c8059752 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -133,6 +133,31 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): role_check.assert_called_once_with(ctx, *(1, 2, 3)) +@autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +class RescheduleTests(unittest.IsolatedAsyncioTestCase): + """Tests for the rescheduling of cached unsilences.""" + + @autospec(silence, "Scheduler", "SilenceNotifier", pass_mocks=False) + def setUp(self): + self.bot = MockBot() + self.cog = silence.Silence(self.bot) + self.cog._unsilence_wrapper = mock.create_autospec(self.cog._unsilence_wrapper, spec_set=True) + + with mock.patch.object(self.cog, "_reschedule", spec_set=True, autospec=True): + asyncio.run(self.cog._init_cog()) # Populate instance attributes. + + async def test_skipped_missing_channel(self): + """Did nothing because the channel couldn't be retrieved.""" + self.cog.muted_channel_times.items.return_value = [(123, -1), (123, 1), (123, 100000000000)] + self.bot.get_channel.return_value = None + + await self.cog._reschedule() + + self.cog.notifier.add_channel.assert_not_called() + self.cog._unsilence_wrapper.assert_not_called() + self.cog.scheduler.schedule_later.assert_not_called() + + @autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): """Tests for the silence command and its related helper methods.""" -- cgit v1.2.3 From d44e3f795f8c56a1fbbce2833b27474b263e911b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 10:53:33 -0700 Subject: Silence tests: test the rescheduler adds permanent silence to notifier --- tests/bot/cogs/moderation/test_silence.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 2c8059752..6e8c9ff38 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -157,6 +157,20 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): self.cog._unsilence_wrapper.assert_not_called() self.cog.scheduler.schedule_later.assert_not_called() + async def test_added_permanent_to_notifier(self): + """Permanently silenced channels were added to the notifier.""" + channels = [MockTextChannel(id=123), MockTextChannel(id=456)] + self.bot.get_channel.side_effect = channels + self.cog.muted_channel_times.items.return_value = [(123, -1), (456, -1)] + + await self.cog._reschedule() + + self.cog.notifier.add_channel.assert_any_call(channels[0]) + self.cog.notifier.add_channel.assert_any_call(channels[1]) + + self.cog._unsilence_wrapper.assert_not_called() + self.cog.scheduler.schedule_later.assert_not_called() + @autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From 5f23f6630cd1c44d129d23e4becd9fce7f76135d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 10:57:17 -0700 Subject: Silence tests: test the rescheduler unsilences expired silences --- tests/bot/cogs/moderation/test_silence.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6e8c9ff38..d9ff13595 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -171,6 +171,20 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): self.cog._unsilence_wrapper.assert_not_called() self.cog.scheduler.schedule_later.assert_not_called() + async def test_unsilenced_expired(self): + """Unsilenced expired silences.""" + channels = [MockTextChannel(id=123), MockTextChannel(id=456)] + self.bot.get_channel.side_effect = channels + self.cog.muted_channel_times.items.return_value = [(123, 100), (456, 200)] + + await self.cog._reschedule() + + self.cog._unsilence_wrapper.assert_any_call(channels[0]) + self.cog._unsilence_wrapper.assert_any_call(channels[1]) + + self.cog.notifier.add_channel.assert_not_called() + self.cog.scheduler.schedule_later.assert_not_called() + @autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From 7fadf2d531562dcc7e78bcb70d59d0a0575a18be Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 11:56:49 -0700 Subject: Silence tests: add a test for rescheduling active silences --- tests/bot/cogs/moderation/test_silence.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index d9ff13595..3d111341b 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -11,6 +11,13 @@ from bot.constants import Channels, Guild, Roles from tests.helpers import MockBot, MockContext, MockTextChannel, autospec +# Have to subclass it because builtins can't be patched. +class PatchedDatetime(datetime): + """A datetime object with a mocked now() function.""" + + now = mock.create_autospec(datetime, "now") + + class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.alert_channel = MockTextChannel() @@ -185,6 +192,28 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): self.cog.notifier.add_channel.assert_not_called() self.cog.scheduler.schedule_later.assert_not_called() + @mock.patch.object(silence, "datetime", new=PatchedDatetime) + async def test_rescheduled_active(self): + """Rescheduled active silences.""" + channels = [MockTextChannel(id=123), MockTextChannel(id=456)] + self.bot.get_channel.side_effect = channels + self.cog.muted_channel_times.items.return_value = [(123, 2000), (456, 3000)] + silence.datetime.now.return_value = datetime.fromtimestamp(1000, tz=timezone.utc) + + self.cog._unsilence_wrapper = mock.MagicMock() + unsilence_return = self.cog._unsilence_wrapper.return_value + + await self.cog._reschedule() + + # Yuck. + calls = [mock.call(1000, 123, unsilence_return), mock.call(2000, 456, unsilence_return)] + self.cog.scheduler.schedule_later.assert_has_calls(calls) + + unsilence_calls = [mock.call(channel) for channel in channels] + self.cog._unsilence_wrapper.assert_has_calls(unsilence_calls) + + self.cog.notifier.add_channel.assert_not_called() + @autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From a9ddbf346d95e67731196d8d822835330b6992af Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 12:02:29 -0700 Subject: Silence tests: more accurately assert the silence cmd schedule a task --- tests/bot/cogs/moderation/test_silence.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 3d111341b..bc41422ef 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -328,9 +328,13 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_scheduled_task(self): """An unsilence task was scheduled.""" - ctx = MockContext(channel=self.channel) - await self.cog.silence.callback(self.cog, ctx) - self.cog.scheduler.schedule_later.assert_called_once() + ctx = MockContext(channel=self.channel, invoke=mock.MagicMock()) + + await self.cog.silence.callback(self.cog, ctx, 5) + + args = (300, ctx.channel.id, ctx.invoke.return_value) + self.cog.scheduler.schedule_later.assert_called_once_with(*args) + ctx.invoke.assert_called_once_with(self.cog.unsilence) async def test_permanent_not_scheduled(self): """A task was not scheduled for a permanent silence.""" -- cgit v1.2.3 From 40c6f688eb0e317b1489b069f263f25b202a345c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 12:15:36 -0700 Subject: Silence tests: remove unnecessary spec_set args It's not really necessary to set to True when mocking functions. --- tests/bot/cogs/moderation/test_silence.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index bc41422ef..5c6d677ca 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -122,7 +122,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_init_cog_rescheduled(self): """`_reschedule_` coroutine was awaited.""" - self.cog._reschedule = mock.create_autospec(self.cog._reschedule, spec_set=True) + self.cog._reschedule = mock.create_autospec(self.cog._reschedule) await self.cog._init_cog() self.cog._reschedule.assert_awaited_once_with() @@ -148,9 +148,9 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.bot = MockBot() self.cog = silence.Silence(self.bot) - self.cog._unsilence_wrapper = mock.create_autospec(self.cog._unsilence_wrapper, spec_set=True) + self.cog._unsilence_wrapper = mock.create_autospec(self.cog._unsilence_wrapper) - with mock.patch.object(self.cog, "_reschedule", spec_set=True, autospec=True): + with mock.patch.object(self.cog, "_reschedule", autospec=True): asyncio.run(self.cog._init_cog()) # Populate instance attributes. async def test_skipped_missing_channel(self): -- cgit v1.2.3 From 27a00bf29193b1768c298c1455936bb7dc92aaf1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 12:18:14 -0700 Subject: Silence: rename caches --- bot/cogs/moderation/silence.py | 18 +++++++------- tests/bot/cogs/moderation/test_silence.py | 40 +++++++++++++++---------------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 9732248ff..5851be00a 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -72,11 +72,11 @@ class Silence(commands.Cog): # Maps muted channel IDs to their previous overwrites for send_message and add_reactions. # Overwrites are stored as JSON. - muted_channel_perms = RedisCache() + previous_overwrites = RedisCache() # Maps muted channel IDs to POSIX timestamps of when they'll be unsilenced. # A timestamp equal to -1 means it's indefinite. - muted_channel_times = RedisCache() + unsilence_timestamps = RedisCache() def __init__(self, bot: Bot): self.bot = bot @@ -112,14 +112,14 @@ class Silence(commands.Cog): if duration is None: await ctx.send(MSG_SILENCE_PERMANENT) - await self.muted_channel_times.set(ctx.channel.id, -1) + await self.unsilence_timestamps.set(ctx.channel.id, -1) return await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) unsilence_time = (datetime.now(tz=timezone.utc) + timedelta(minutes=duration)) - await self.muted_channel_times.set(ctx.channel.id, unsilence_time.timestamp()) + await self.unsilence_timestamps.set(ctx.channel.id, unsilence_time.timestamp()) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context) -> None: @@ -160,7 +160,7 @@ class Silence(commands.Cog): overwrite.update(send_messages=False, add_reactions=False) await channel.set_permissions(self._verified_role, overwrite=overwrite) - await self.muted_channel_perms.set(channel.id, json.dumps(prev_overwrites)) + await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites)) if persistent: log.info(f"Silenced #{channel} ({channel.id}) indefinitely.") @@ -180,7 +180,7 @@ class Silence(commands.Cog): Return `True` if channel permissions were changed, `False` otherwise. """ - prev_overwrites = await self.muted_channel_perms.get(channel.id) + prev_overwrites = await self.previous_overwrites.get(channel.id) if channel.id not in self.scheduler and prev_overwrites is None: log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") return False @@ -197,8 +197,8 @@ class Silence(commands.Cog): self.scheduler.cancel(channel.id) self.notifier.remove_channel(channel) - await self.muted_channel_perms.delete(channel.id) - await self.muted_channel_times.delete(channel.id) + await self.previous_overwrites.delete(channel.id) + await self.unsilence_timestamps.delete(channel.id) if prev_overwrites is None: await self._mod_alerts_channel.send( @@ -211,7 +211,7 @@ class Silence(commands.Cog): async def _reschedule(self) -> None: """Reschedule unsilencing of active silences and add permanent ones to the notifier.""" - for channel_id, timestamp in await self.muted_channel_times.items(): + for channel_id, timestamp in await self.unsilence_timestamps.items(): channel = self.bot.get_channel(channel_id) if channel is None: log.info(f"Can't reschedule silence for {channel_id}: channel not found.") diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 5c6d677ca..a66d27d08 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -81,7 +81,7 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.alert_channel.send.assert_not_called() -@autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) class SilenceCogTests(unittest.IsolatedAsyncioTestCase): """Tests for the general functionality of the Silence cog.""" @@ -140,7 +140,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): role_check.assert_called_once_with(ctx, *(1, 2, 3)) -@autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) class RescheduleTests(unittest.IsolatedAsyncioTestCase): """Tests for the rescheduling of cached unsilences.""" @@ -155,7 +155,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): async def test_skipped_missing_channel(self): """Did nothing because the channel couldn't be retrieved.""" - self.cog.muted_channel_times.items.return_value = [(123, -1), (123, 1), (123, 100000000000)] + self.cog.unsilence_timestamps.items.return_value = [(123, -1), (123, 1), (123, 100000000000)] self.bot.get_channel.return_value = None await self.cog._reschedule() @@ -168,7 +168,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): """Permanently silenced channels were added to the notifier.""" channels = [MockTextChannel(id=123), MockTextChannel(id=456)] self.bot.get_channel.side_effect = channels - self.cog.muted_channel_times.items.return_value = [(123, -1), (456, -1)] + self.cog.unsilence_timestamps.items.return_value = [(123, -1), (456, -1)] await self.cog._reschedule() @@ -182,7 +182,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): """Unsilenced expired silences.""" channels = [MockTextChannel(id=123), MockTextChannel(id=456)] self.bot.get_channel.side_effect = channels - self.cog.muted_channel_times.items.return_value = [(123, 100), (456, 200)] + self.cog.unsilence_timestamps.items.return_value = [(123, 100), (456, 200)] await self.cog._reschedule() @@ -197,7 +197,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): """Rescheduled active silences.""" channels = [MockTextChannel(id=123), MockTextChannel(id=456)] self.bot.get_channel.side_effect = channels - self.cog.muted_channel_times.items.return_value = [(123, 2000), (456, 3000)] + self.cog.unsilence_timestamps.items.return_value = [(123, 2000), (456, 3000)] silence.datetime.now.return_value = datetime.fromtimestamp(1000, tz=timezone.utc) self.cog._unsilence_wrapper = mock.MagicMock() @@ -215,7 +215,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): self.cog.notifier.add_channel.assert_not_called() -@autospec(silence.Silence, "muted_channel_perms", "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): """Tests for the silence command and its related helper methods.""" @@ -304,7 +304,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): """Channel's previous overwrites were cached.""" overwrite_json = '{"send_messages": true, "add_reactions": false}' await self.cog._silence(self.channel, False, None) - self.cog.muted_channel_perms.set.assert_called_once_with(self.channel.id, overwrite_json) + self.cog.previous_overwrites.set.assert_called_once_with(self.channel.id, overwrite_json) @autospec(silence, "datetime") async def test_cached_unsilence_time(self, datetime_mock): @@ -317,14 +317,14 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): ctx = MockContext(channel=self.channel) await self.cog.silence.callback(self.cog, ctx, duration) - self.cog.muted_channel_times.set.assert_awaited_once_with(ctx.channel.id, timestamp) + self.cog.unsilence_timestamps.set.assert_awaited_once_with(ctx.channel.id, timestamp) datetime_mock.now.assert_called_once_with(tz=timezone.utc) # Ensure it's using an aware dt. async def test_cached_indefinite_time(self): """A value of -1 was cached for a permanent silence.""" ctx = MockContext(channel=self.channel) await self.cog.silence.callback(self.cog, ctx, None) - self.cog.muted_channel_times.set.assert_awaited_once_with(ctx.channel.id, -1) + self.cog.unsilence_timestamps.set.assert_awaited_once_with(ctx.channel.id, -1) async def test_scheduled_task(self): """An unsilence task was scheduled.""" @@ -343,7 +343,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog.scheduler.schedule_later.assert_not_called() -@autospec(silence.Silence, "muted_channel_times", pass_mocks=False) +@autospec(silence.Silence, "unsilence_timestamps", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): """Tests for the unsilence command and its related helper methods.""" @@ -355,13 +355,13 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.cog._init_task = asyncio.Future() self.cog._init_task.set_result(None) - perms_cache = mock.create_autospec(self.cog.muted_channel_perms, spec_set=True) - self.cog.muted_channel_perms = perms_cache + overwrites_cache = mock.create_autospec(self.cog.previous_overwrites, spec_set=True) + self.cog.previous_overwrites = overwrites_cache asyncio.run(self.cog._init_cog()) # Populate instance attributes. self.cog.scheduler.__contains__.return_value = True - perms_cache.get.return_value = '{"send_messages": true, "add_reactions": false}' + overwrites_cache.get.return_value = '{"send_messages": true, "add_reactions": false}' self.channel = MockTextChannel() self.overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) self.channel.overwrites_for.return_value = self.overwrite @@ -385,7 +385,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_skipped_already_unsilenced(self): """Permissions were not set and `False` was returned for an already unsilenced channel.""" self.cog.scheduler.__contains__.return_value = False - self.cog.muted_channel_perms.get.return_value = None + self.cog.previous_overwrites.get.return_value = None channel = MockTextChannel() self.assertFalse(await self.cog._unsilence(channel)) @@ -405,7 +405,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_cache_miss_used_default_overwrites(self): """Both overwrites were set to None due previous values not being found in the cache.""" - self.cog.muted_channel_perms.get.return_value = None + self.cog.previous_overwrites.get.return_value = None await self.cog._unsilence(self.channel) self.channel.set_permissions.assert_awaited_once_with( @@ -418,7 +418,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_cache_miss_sent_mod_alert(self): """A message was sent to the mod alerts channel.""" - self.cog.muted_channel_perms.get.return_value = None + self.cog.previous_overwrites.get.return_value = None await self.cog._unsilence(self.channel) self.cog._mod_alerts_channel.send.assert_awaited_once() @@ -431,12 +431,12 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_deleted_cached_overwrite(self): """Channel was deleted from the overwrites cache.""" await self.cog._unsilence(self.channel) - self.cog.muted_channel_perms.delete.assert_awaited_once_with(self.channel.id) + self.cog.previous_overwrites.delete.assert_awaited_once_with(self.channel.id) async def test_deleted_cached_time(self): """Channel was deleted from the timestamp cache.""" await self.cog._unsilence(self.channel) - self.cog.muted_channel_times.delete.assert_awaited_once_with(self.channel.id) + self.cog.unsilence_timestamps.delete.assert_awaited_once_with(self.channel.id) async def test_cancelled_task(self): """The scheduled unsilence task should be cancelled.""" @@ -447,7 +447,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): """Channel's other unrelated overwrites were not changed, including cache misses.""" for overwrite_json in ('{"send_messages": true, "add_reactions": null}', None): with self.subTest(overwrite_json=overwrite_json): - self.cog.muted_channel_perms.get.return_value = overwrite_json + self.cog.previous_overwrites.get.return_value = overwrite_json prev_overwrite_dict = dict(self.overwrite) await self.cog._unsilence(self.channel) -- cgit v1.2.3 From 2fd2c77035e87dde009c39aa7345e4871d5b41df Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 18 Aug 2020 15:02:13 -0700 Subject: Silence: cancel init task when cog unloads --- bot/cogs/moderation/silence.py | 9 +++++++-- tests/bot/cogs/moderation/test_silence.py | 7 +++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 5851be00a..c339fd4d0 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -231,8 +231,13 @@ class Silence(commands.Cog): self.scheduler.schedule_later(delta, channel_id, self._unsilence_wrapper(channel)) def cog_unload(self) -> None: - """Cancel scheduled tasks.""" - self.scheduler.cancel_all() + """Cancel the init task and scheduled tasks.""" + # It's important to wait for _init_task (specifically for _reschedule) to be cancelled + # before cancelling scheduled tasks. Otherwise, it's possible for _reschedule to schedule + # more tasks after cancel_all has finished, despite _init_task.cancel being called first. + # This is cause cancel() on its own doesn't block until the task is cancelled. + self._init_task.cancel() + self._init_task.add_done_callback(lambda _: self.scheduler.cancel_all) # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index a66d27d08..d56a731b6 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -127,9 +127,12 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.cog._reschedule.assert_awaited_once_with() def test_cog_unload_cancelled_tasks(self): - """All scheduled tasks were cancelled.""" + """The init task was cancelled.""" + self.cog._init_task = asyncio.Future() self.cog.cog_unload() - self.cog.scheduler.cancel_all.assert_called_once_with() + + # It's too annoying to test cancel_all since it's a done callback and wrapped in a lambda. + self.assertTrue(self.cog._init_task.cancelled()) @autospec(silence, "with_role_check") @mock.patch.object(silence, "MODERATION_ROLES", new=(1, 2, 3)) -- cgit v1.2.3 From 2544d192fd7403cf92cc568537bb93aa7a859815 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 09:50:25 -0700 Subject: Decorators: replace asyncio.Lock with a custom object Concerns were raised over possible race conditions due `asyncio.Lock` internally awaiting coroutines. Does a mere `await` suspend the current coroutine, or does it have to actually await something asynchronous, like a future? Avoid answering that question by doing away with the awaits, which aren't necessary but are there as a consequence of using `asyncio.Lock`. Instead, add a custom `LockGuard` object to replace the previous locks. --- bot/decorators.py | 6 +++--- bot/utils/__init__.py | 3 ++- bot/utils/lock.py | 23 +++++++++++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 bot/utils/lock.py diff --git a/bot/decorators.py b/bot/decorators.py index 0e84cf37e..3418dfd11 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -13,7 +13,7 @@ from discord.ext.commands import Cog, Context, check from bot.constants import Channels, ERROR_REPLIES, RedirectOutput from bot.errors import LockedResourceError -from bot.utils import function +from bot.utils import LockGuard, function from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check log = logging.getLogger(__name__) @@ -144,11 +144,11 @@ def mutually_exclusive( # Get the lock for the ID. Create a lock if one doesn't exist yet. locks = __lock_dicts[namespace] - lock = locks.setdefault(id_, asyncio.Lock()) + lock = locks.setdefault(id_, LockGuard()) if not lock.locked(): log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") - async with lock: + with lock: return await func(*args, **kwargs) else: log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 5a6e1811b..0dd9605e8 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -2,9 +2,10 @@ from abc import ABCMeta from discord.ext.commands import CogMeta +from bot.utils.lock import LockGuard from bot.utils.redis_cache import RedisCache -__all__ = ['RedisCache', 'CogABCMeta'] +__all__ = ["CogABCMeta", "LockGuard", "RedisCache"] class CogABCMeta(CogMeta, ABCMeta): diff --git a/bot/utils/lock.py b/bot/utils/lock.py new file mode 100644 index 000000000..8f1b738aa --- /dev/null +++ b/bot/utils/lock.py @@ -0,0 +1,23 @@ +class LockGuard: + """ + A context manager which acquires and releases a lock (mutex). + + Raise RuntimeError if trying to acquire a locked lock. + """ + + def __init__(self): + self._locked = False + + def locked(self) -> bool: + """Return True if currently locked or False if unlocked.""" + return self._locked + + def __enter__(self): + if self._locked: + raise RuntimeError("Cannot acquire a locked lock.") + + self._locked = True + + def __exit__(self, _exc_type, _exc_value, _traceback): # noqa: ANN001 + self._locked = False + return False # Indicate any raised exception shouldn't be suppressed. -- cgit v1.2.3 From 7c97e1954503185d41ddf3cdc9c9b5b64bbb0a46 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Aug 2020 10:17:24 -0700 Subject: Code block: clarify that the original message can be edited Fix #497 --- bot/cogs/codeblock/instructions.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 56b85a34f..84c7a5ea0 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -161,21 +161,24 @@ def get_instructions(content: str) -> Optional[str]: if not blocks: log.trace("No code blocks were found in message.") - return _get_no_ticks_message(content) + instructions = _get_no_ticks_message(content) else: log.trace("Searching results for a code block with invalid ticks.") block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) if block: log.trace("A code block exists but has invalid ticks.") - return _get_bad_ticks_message(block) + instructions = _get_bad_ticks_message(block) else: log.trace("A code block exists but is missing a language.") block = blocks[0] # Check for a bad language first to avoid parsing content into an AST. - description = _get_bad_lang_message(block.content) - if not description: - description = _get_no_lang_message(block.content) + instructions = _get_bad_lang_message(block.content) + if not instructions: + instructions = _get_no_lang_message(block.content) - return description + if instructions: + instructions += "\nYou can **edit your original message** to correct your code block." + + return instructions -- cgit v1.2.3 From e1d13efb6d8871a53830860827ac2016a6cc279d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 25 Aug 2020 11:10:24 -0700 Subject: Use category_id attribute in is_in_category Simplify the code by removing the need to check if the category is None. --- bot/utils/channel.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 47f70ce31..851f9e1fe 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -17,8 +17,7 @@ def is_help_channel(channel: discord.TextChannel) -> bool: def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: """Return True if `channel` is within a category with `category_id`.""" - actual_category = getattr(channel, "category", None) - return actual_category is not None and actual_category.id == category_id + return getattr(channel, "category_id", None) == category_id async def try_get_channel(channel_id: int, client: discord.Client) -> discord.abc.GuildChannel: -- cgit v1.2.3 From d53b48b3b370bd87c0c6103cc54fef7a79c24625 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 25 Aug 2020 11:13:56 -0700 Subject: Stats: use the is_in_category util function --- bot/cogs/stats.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/stats.py b/bot/cogs/stats.py index d42f55466..7b7470d8d 100644 --- a/bot/cogs/stats.py +++ b/bot/cogs/stats.py @@ -7,6 +7,7 @@ from discord.ext.tasks import loop from bot.bot import Bot from bot.constants import Categories, Channels, Guild, Stats as StatConf +from bot.utils.channel import is_in_category CHANNEL_NAME_OVERRIDES = { @@ -36,8 +37,7 @@ class Stats(Cog): if message.guild.id != Guild.id: return - cat = getattr(message.channel, "category", None) - if cat is not None and cat.id == Categories.modmail: + if is_in_category(message.channel, Categories.modmail): if message.channel.id != Channels.incidents: # Do not report modmail channels to stats, there are too many # of them for interesting statistics to be drawn out of this. -- cgit v1.2.3 From a955b61aa7e4692a99034357c7b56d488327a2a4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 25 Aug 2020 11:18:34 -0700 Subject: Code block: make _get_leading_spaces more readable A for loop is less confusing according to reviews. --- bot/cogs/codeblock/parsing.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index ea007b6f1..01c220c61 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -179,14 +179,12 @@ def parse_bad_language(content: str) -> Optional[BadLanguage]: def _get_leading_spaces(content: str) -> int: """Return the number of spaces at the start of the first line in `content`.""" - current = content[0] leading_spaces = 0 - - while current == " ": - leading_spaces += 1 - current = content[leading_spaces] - - return leading_spaces + for char in content: + if char == " ": + leading_spaces += 1 + else: + return leading_spaces def _fix_indentation(content: str) -> str: -- cgit v1.2.3 From 876822a8db672bb59fa5009ec8af22eb186e31ef Mon Sep 17 00:00:00 2001 From: Jack92829 <62740006+Jack92829@users.noreply.github.com> Date: Sun, 6 Sep 2020 16:17:09 +1000 Subject: Add files via upload --- bot/resources/tags/ServersTag.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 bot/resources/tags/ServersTag.md diff --git a/bot/resources/tags/ServersTag.md b/bot/resources/tags/ServersTag.md new file mode 100644 index 000000000..9884580a6 --- /dev/null +++ b/bot/resources/tags/ServersTag.md @@ -0,0 +1,5 @@ +**Are you on the lookout for new servers to join?** + +If you're looking for a community dedicated to a certain tool, language or related field of interest, check out this *[awesome list](https://github.com/mhxion/awesome-discord-communities)*. A curated list of Discord communities that are dedicated to a a multitude of areas including [Programming languages](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#programming-languages), [Electricals](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#electricals), [Computer science](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#art-of-computer-science), [Operating systems](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#operating-systems) and more! + +Also consider checking out the wonderful communities this server has partnered with, either in the partners channel or the [communities](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#operating-systems) page of the python discord's website. \ No newline at end of file -- cgit v1.2.3 From d6397901b672554f3f030a2d3f9f69c0c75c2856 Mon Sep 17 00:00:00 2001 From: Jack92829 <62740006+Jack92829@users.noreply.github.com> Date: Sun, 6 Sep 2020 16:18:50 +1000 Subject: Update and rename ServersTag.md to guilds.md --- bot/resources/tags/ServersTag.md | 5 ----- bot/resources/tags/guilds.md | 5 +++++ 2 files changed, 5 insertions(+), 5 deletions(-) delete mode 100644 bot/resources/tags/ServersTag.md create mode 100644 bot/resources/tags/guilds.md diff --git a/bot/resources/tags/ServersTag.md b/bot/resources/tags/ServersTag.md deleted file mode 100644 index 9884580a6..000000000 --- a/bot/resources/tags/ServersTag.md +++ /dev/null @@ -1,5 +0,0 @@ -**Are you on the lookout for new servers to join?** - -If you're looking for a community dedicated to a certain tool, language or related field of interest, check out this *[awesome list](https://github.com/mhxion/awesome-discord-communities)*. A curated list of Discord communities that are dedicated to a a multitude of areas including [Programming languages](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#programming-languages), [Electricals](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#electricals), [Computer science](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#art-of-computer-science), [Operating systems](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#operating-systems) and more! - -Also consider checking out the wonderful communities this server has partnered with, either in the partners channel or the [communities](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#operating-systems) page of the python discord's website. \ No newline at end of file diff --git a/bot/resources/tags/guilds.md b/bot/resources/tags/guilds.md new file mode 100644 index 000000000..fc0b5faff --- /dev/null +++ b/bot/resources/tags/guilds.md @@ -0,0 +1,5 @@ +**Are you on the lookout for new guilds to join?** + +If you're looking for a community dedicated to a certain tool, language or related field of interest, check out this *[awesome list](https://github.com/mhxion/awesome-discord-communities)*. A curated list of Discord communities that are dedicated to a multitude of areas including [Programming languages](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#programming-languages), [Electricals](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#electricals), [Computer science](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#art-of-computer-science), [Operating systems](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#operating-systems) and more! + +Also consider checking out the wonderful communities this server has partnered with, either in the partners channel or the [communities](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#operating-systems) page of the python discord's website. -- cgit v1.2.3 From 5a9267f011828d29cc13515348e8ca22986dac35 Mon Sep 17 00:00:00 2001 From: Jack92829 <62740006+Jack92829@users.noreply.github.com> Date: Sun, 6 Sep 2020 19:43:32 +1000 Subject: Update guilds.md --- bot/resources/tags/guilds.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/guilds.md b/bot/resources/tags/guilds.md index fc0b5faff..d328b9e6e 100644 --- a/bot/resources/tags/guilds.md +++ b/bot/resources/tags/guilds.md @@ -2,4 +2,4 @@ If you're looking for a community dedicated to a certain tool, language or related field of interest, check out this *[awesome list](https://github.com/mhxion/awesome-discord-communities)*. A curated list of Discord communities that are dedicated to a multitude of areas including [Programming languages](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#programming-languages), [Electricals](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#electricals), [Computer science](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#art-of-computer-science), [Operating systems](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#operating-systems) and more! -Also consider checking out the wonderful communities this server has partnered with, either in the partners channel or the [communities](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#operating-systems) page of the python discord's website. +Also consider checking out the wonderful communities this server has partnered with, either in the partners channel or the [communities](https://pythondiscord.com/pages/resources/communities/) page of the python discord's website. -- cgit v1.2.3 From 7b311196613e358557a14aa75f01e8a54ab3e698 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 7 Sep 2020 15:49:31 -0700 Subject: Sync: remove confirmation The confirmation was intended to be a safe guard against cache issues that would cause a huge number of roles/users to deleted after syncing. With `wait_until_guild_available`, such cache issue shouldn't arise. Therefore, this feature is obsolete. Resolve #1075 --- bot/cogs/sync/syncers.py | 170 +------------------ bot/constants.py | 7 - config-default.yml | 4 - tests/bot/cogs/sync/test_base.py | 357 ++------------------------------------- 4 files changed, 20 insertions(+), 518 deletions(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index f7ba811bc..b3819a1e1 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -1,15 +1,11 @@ import abc -import asyncio import logging import typing as t from collections import namedtuple -from functools import partial -import discord -from discord import Guild, HTTPException, Member, Message, Reaction, User +from discord import Guild from discord.ext.commands import Context -from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot @@ -25,9 +21,6 @@ _Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) class Syncer(abc.ABC): """Base class for synchronising the database with objects in the Discord cache.""" - _CORE_DEV_MENTION = f"<@&{constants.Roles.core_developers}> " - _REACTION_EMOJIS = (constants.Emojis.check_mark, constants.Emojis.cross_mark) - def __init__(self, bot: Bot) -> None: self.bot = bot @@ -37,112 +30,6 @@ class Syncer(abc.ABC): """The name of the syncer; used in output messages and logging.""" raise NotImplementedError # pragma: no cover - async def _send_prompt(self, message: t.Optional[Message] = None) -> t.Optional[Message]: - """ - Send a prompt to confirm or abort a sync using reactions and return the sent message. - - If a message is given, it is edited to display the prompt and reactions. Otherwise, a new - message is sent to the dev-core channel and mentions the core developers role. If the - channel cannot be retrieved, return None. - """ - log.trace(f"Sending {self.name} sync confirmation prompt.") - - msg_content = ( - f'Possible cache issue while syncing {self.name}s. ' - f'More than {constants.Sync.max_diff} {self.name}s were changed. ' - f'React to confirm or abort the sync.' - ) - - # Send to core developers if it's an automatic sync. - if not message: - log.trace("Message not provided for confirmation; creating a new one in dev-core.") - channel = self.bot.get_channel(constants.Channels.dev_core) - - if not channel: - log.debug("Failed to get the dev-core channel from cache; attempting to fetch it.") - try: - channel = await self.bot.fetch_channel(constants.Channels.dev_core) - except HTTPException: - log.exception( - f"Failed to fetch channel for sending sync confirmation prompt; " - f"aborting {self.name} sync." - ) - return None - - allowed_roles = [discord.Object(constants.Roles.core_developers)] - message = await channel.send( - f"{self._CORE_DEV_MENTION}{msg_content}", - allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) - ) - else: - await message.edit(content=msg_content) - - # Add the initial reactions. - log.trace(f"Adding reactions to {self.name} syncer confirmation prompt.") - for emoji in self._REACTION_EMOJIS: - await message.add_reaction(emoji) - - return message - - def _reaction_check( - self, - author: Member, - message: Message, - reaction: Reaction, - user: t.Union[Member, User] - ) -> bool: - """ - Return True if the `reaction` is a valid confirmation or abort reaction on `message`. - - If the `author` of the prompt is a bot, then a reaction by any core developer will be - considered valid. Otherwise, the author of the reaction (`user`) will have to be the - `author` of the prompt. - """ - # For automatic syncs, check for the core dev role instead of an exact author - has_role = any(constants.Roles.core_developers == role.id for role in user.roles) - return ( - reaction.message.id == message.id - and not user.bot - and (has_role if author.bot else user == author) - and str(reaction.emoji) in self._REACTION_EMOJIS - ) - - async def _wait_for_confirmation(self, author: Member, message: Message) -> bool: - """ - Wait for a confirmation reaction by `author` on `message` and return True if confirmed. - - Uses the `_reaction_check` function to determine if a reaction is valid. - - If there is no reaction within `bot.constants.Sync.confirm_timeout` seconds, return False. - To acknowledge the reaction (or lack thereof), `message` will be edited. - """ - # Preserve the core-dev role mention in the message edits so users aren't confused about - # where notifications came from. - mention = self._CORE_DEV_MENTION if author.bot else "" - - reaction = None - try: - log.trace(f"Waiting for a reaction to the {self.name} syncer confirmation prompt.") - reaction, _ = await self.bot.wait_for( - 'reaction_add', - check=partial(self._reaction_check, author, message), - timeout=constants.Sync.confirm_timeout - ) - except asyncio.TimeoutError: - # reaction will remain none thus sync will be aborted in the finally block below. - log.debug(f"The {self.name} syncer confirmation prompt timed out.") - - if str(reaction) == constants.Emojis.check_mark: - log.trace(f"The {self.name} syncer was confirmed.") - await message.edit(content=f':ok_hand: {mention}{self.name} sync will proceed.') - return True - else: - log.info(f"The {self.name} syncer was aborted or timed out!") - await message.edit( - content=f':warning: {mention}{self.name} sync aborted or timed out!' - ) - return False - @abc.abstractmethod async def _get_diff(self, guild: Guild) -> _Diff: """Return the difference between the cache of `guild` and the database.""" @@ -153,34 +40,6 @@ class Syncer(abc.ABC): """Perform the API calls for synchronisation.""" raise NotImplementedError # pragma: no cover - async def _get_confirmation_result( - self, - diff_size: int, - author: Member, - message: t.Optional[Message] = None - ) -> t.Tuple[bool, t.Optional[Message]]: - """ - Prompt for confirmation and return a tuple of the result and the prompt message. - - `diff_size` is the size of the diff of the sync. If it is greater than - `bot.constants.Sync.max_diff`, the prompt will be sent. The `author` is the invoked of the - sync and the `message` is an extant message to edit to display the prompt. - - If confirmed or no confirmation was needed, the result is True. The returned message will - either be the given `message` or a new one which was created when sending the prompt. - """ - log.trace(f"Determining if confirmation prompt should be sent for {self.name} syncer.") - if diff_size > constants.Sync.max_diff: - message = await self._send_prompt(message) - if not message: - return False, None # Couldn't get channel. - - confirmed = await self._wait_for_confirmation(author, message) - if not confirmed: - return False, message # Sync aborted. - - return True, message - async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None: """ Synchronise the database with the cache of `guild`. @@ -191,24 +50,8 @@ class Syncer(abc.ABC): """ log.info(f"Starting {self.name} syncer.") - message = None - author = self.bot.user - if ctx: - message = await ctx.send(f"📊 Synchronising {self.name}s.") - author = ctx.author - + message = await ctx.send(f"📊 Synchronising {self.name}s.") if ctx else None diff = await self._get_diff(guild) - diff_dict = diff._asdict() # Ugly method for transforming the NamedTuple into a dict - totals = {k: len(v) for k, v in diff_dict.items() if v is not None} - diff_size = sum(totals.values()) - - confirmed, message = await self._get_confirmation_result(diff_size, author, message) - if not confirmed: - return - - # Preserve the core-dev role mention in the message edits so users aren't confused about - # where notifications came from. - mention = self._CORE_DEV_MENTION if author.bot else "" try: await self._sync(diff) @@ -217,11 +60,14 @@ class Syncer(abc.ABC): # Don't show response text because it's probably some really long HTML. results = f"status {e.status}\n```{e.response_json or 'See log output for details'}```" - content = f":x: {mention}Synchronisation of {self.name}s failed: {results}" + content = f":x: Synchronisation of {self.name}s failed: {results}" else: - results = ", ".join(f"{name} `{total}`" for name, total in totals.items()) + diff_dict = diff._asdict() # Ugly method for transforming the NamedTuple into a dict + results = (f"{name} `{len(val)}`" for name, val in diff_dict.items() if val is not None) + results = ", ".join(results) + log.info(f"{self.name} syncer finished: {results}.") - content = f":ok_hand: {mention}Synchronisation of {self.name}s complete: {results}" + content = f":ok_hand: Synchronisation of {self.name}s complete: {results}" if message: await message.edit(content=content) diff --git a/bot/constants.py b/bot/constants.py index 17fe34e95..3129354d3 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -563,13 +563,6 @@ class RedirectOutput(metaclass=YAMLGetter): delete_delay: int -class Sync(metaclass=YAMLGetter): - section = 'sync' - - confirm_timeout: int - max_diff: int - - class PythonNews(metaclass=YAMLGetter): section = 'python_news' diff --git a/config-default.yml b/config-default.yml index 6e7cff92d..d48739002 100644 --- a/config-default.yml +++ b/config-default.yml @@ -460,10 +460,6 @@ redirect_output: delete_invocation: true delete_delay: 15 -sync: - confirm_timeout: 300 - max_diff: 10 - duck_pond: threshold: 5 custom_emojis: diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index 70aea2bab..c3456f724 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -1,12 +1,9 @@ -import asyncio import unittest from unittest import mock -import discord -from bot import constants from bot.api import ResponseCodeError -from bot.cogs.sync.syncers import Syncer, _Diff +from bot.cogs.sync.syncers import Syncer from tests import helpers @@ -30,280 +27,16 @@ class SyncerBaseTests(unittest.TestCase): Syncer(self.bot) -class SyncerSendPromptTests(unittest.IsolatedAsyncioTestCase): - """Tests for sending the sync confirmation prompt.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = TestSyncer(self.bot) - - def mock_get_channel(self): - """Fixture to return a mock channel and message for when `get_channel` is used.""" - self.bot.reset_mock() - - mock_channel = helpers.MockTextChannel() - mock_message = helpers.MockMessage() - - mock_channel.send.return_value = mock_message - self.bot.get_channel.return_value = mock_channel - - return mock_channel, mock_message - - def mock_fetch_channel(self): - """Fixture to return a mock channel and message for when `fetch_channel` is used.""" - self.bot.reset_mock() - - mock_channel = helpers.MockTextChannel() - mock_message = helpers.MockMessage() - - self.bot.get_channel.return_value = None - mock_channel.send.return_value = mock_message - self.bot.fetch_channel.return_value = mock_channel - - return mock_channel, mock_message - - async def test_send_prompt_edits_and_returns_message(self): - """The given message should be edited to display the prompt and then should be returned.""" - msg = helpers.MockMessage() - ret_val = await self.syncer._send_prompt(msg) - - msg.edit.assert_called_once() - self.assertIn("content", msg.edit.call_args[1]) - self.assertEqual(ret_val, msg) - - async def test_send_prompt_gets_dev_core_channel(self): - """The dev-core channel should be retrieved if an extant message isn't given.""" - subtests = ( - (self.bot.get_channel, self.mock_get_channel), - (self.bot.fetch_channel, self.mock_fetch_channel), - ) - - for method, mock_ in subtests: - with self.subTest(method=method, msg=mock_.__name__): - mock_() - await self.syncer._send_prompt() - - method.assert_called_once_with(constants.Channels.dev_core) - - async def test_send_prompt_returns_none_if_channel_fetch_fails(self): - """None should be returned if there's an HTTPException when fetching the channel.""" - self.bot.get_channel.return_value = None - self.bot.fetch_channel.side_effect = discord.HTTPException(mock.MagicMock(), "test error!") - - ret_val = await self.syncer._send_prompt() - - self.assertIsNone(ret_val) - - async def test_send_prompt_sends_and_returns_new_message_if_not_given(self): - """A new message mentioning core devs should be sent and returned if message isn't given.""" - for mock_ in (self.mock_get_channel, self.mock_fetch_channel): - with self.subTest(msg=mock_.__name__): - mock_channel, mock_message = mock_() - ret_val = await self.syncer._send_prompt() - - mock_channel.send.assert_called_once() - self.assertIn(self.syncer._CORE_DEV_MENTION, mock_channel.send.call_args[0][0]) - self.assertEqual(ret_val, mock_message) - - async def test_send_prompt_adds_reactions(self): - """The message should have reactions for confirmation added.""" - extant_message = helpers.MockMessage() - subtests = ( - (extant_message, lambda: (None, extant_message)), - (None, self.mock_get_channel), - (None, self.mock_fetch_channel), - ) - - for message_arg, mock_ in subtests: - subtest_msg = "Extant message" if mock_.__name__ == "" else mock_.__name__ - - with self.subTest(msg=subtest_msg): - _, mock_message = mock_() - await self.syncer._send_prompt(message_arg) - - calls = [mock.call(emoji) for emoji in self.syncer._REACTION_EMOJIS] - mock_message.add_reaction.assert_has_calls(calls) - - -class SyncerConfirmationTests(unittest.IsolatedAsyncioTestCase): - """Tests for waiting for a sync confirmation reaction on the prompt.""" - - def setUp(self): - self.bot = helpers.MockBot() - self.syncer = TestSyncer(self.bot) - self.core_dev_role = helpers.MockRole(id=constants.Roles.core_developers) - - @staticmethod - def get_message_reaction(emoji): - """Fixture to return a mock message an reaction from the given `emoji`.""" - message = helpers.MockMessage() - reaction = helpers.MockReaction(emoji=emoji, message=message) - - return message, reaction - - def test_reaction_check_for_valid_emoji_and_authors(self): - """Should return True if authors are identical or are a bot and a core dev, respectively.""" - user_subtests = ( - ( - helpers.MockMember(id=77), - helpers.MockMember(id=77), - "identical users", - ), - ( - helpers.MockMember(id=77, bot=True), - helpers.MockMember(id=43, roles=[self.core_dev_role]), - "bot author and core-dev reactor", - ), - ) - - for emoji in self.syncer._REACTION_EMOJIS: - for author, user, msg in user_subtests: - with self.subTest(author=author, user=user, emoji=emoji, msg=msg): - message, reaction = self.get_message_reaction(emoji) - ret_val = self.syncer._reaction_check(author, message, reaction, user) - - self.assertTrue(ret_val) - - def test_reaction_check_for_invalid_reactions(self): - """Should return False for invalid reaction events.""" - valid_emoji = self.syncer._REACTION_EMOJIS[0] - subtests = ( - ( - helpers.MockMember(id=77), - *self.get_message_reaction(valid_emoji), - helpers.MockMember(id=43, roles=[self.core_dev_role]), - "users are not identical", - ), - ( - helpers.MockMember(id=77, bot=True), - *self.get_message_reaction(valid_emoji), - helpers.MockMember(id=43), - "reactor lacks the core-dev role", - ), - ( - helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), - *self.get_message_reaction(valid_emoji), - helpers.MockMember(id=77, bot=True, roles=[self.core_dev_role]), - "reactor is a bot", - ), - ( - helpers.MockMember(id=77), - helpers.MockMessage(id=95), - helpers.MockReaction(emoji=valid_emoji, message=helpers.MockMessage(id=26)), - helpers.MockMember(id=77), - "messages are not identical", - ), - ( - helpers.MockMember(id=77), - *self.get_message_reaction("InVaLiD"), - helpers.MockMember(id=77), - "emoji is invalid", - ), - ) - - for *args, msg in subtests: - kwargs = dict(zip(("author", "message", "reaction", "user"), args)) - with self.subTest(**kwargs, msg=msg): - ret_val = self.syncer._reaction_check(*args) - self.assertFalse(ret_val) - - async def test_wait_for_confirmation(self): - """The message should always be edited and only return True if the emoji is a check mark.""" - subtests = ( - (constants.Emojis.check_mark, True, None), - ("InVaLiD", False, None), - (None, False, asyncio.TimeoutError), - ) - - for emoji, ret_val, side_effect in subtests: - for bot in (True, False): - with self.subTest(emoji=emoji, ret_val=ret_val, side_effect=side_effect, bot=bot): - # Set up mocks - message = helpers.MockMessage() - member = helpers.MockMember(bot=bot) - - self.bot.wait_for.reset_mock() - self.bot.wait_for.return_value = (helpers.MockReaction(emoji=emoji), None) - self.bot.wait_for.side_effect = side_effect - - # Call the function - actual_return = await self.syncer._wait_for_confirmation(member, message) - - # Perform assertions - self.bot.wait_for.assert_called_once() - self.assertIn("reaction_add", self.bot.wait_for.call_args[0]) - - message.edit.assert_called_once() - kwargs = message.edit.call_args[1] - self.assertIn("content", kwargs) - - # Core devs should only be mentioned if the author is a bot. - if bot: - self.assertIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) - else: - self.assertNotIn(self.syncer._CORE_DEV_MENTION, kwargs["content"]) - - self.assertIs(actual_return, ret_val) - - class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): """Tests for main function orchestrating the sync.""" def setUp(self): self.bot = helpers.MockBot(user=helpers.MockMember(bot=True)) self.syncer = TestSyncer(self.bot) + self.guild = helpers.MockGuild() - async def test_sync_respects_confirmation_result(self): - """The sync should abort if confirmation fails and continue if confirmed.""" - mock_message = helpers.MockMessage() - subtests = ( - (True, mock_message), - (False, None), - ) - - for confirmed, message in subtests: - with self.subTest(confirmed=confirmed): - self.syncer._sync.reset_mock() - self.syncer._get_diff.reset_mock() - - diff = _Diff({1, 2, 3}, {4, 5}, None) - self.syncer._get_diff.return_value = diff - self.syncer._get_confirmation_result = mock.AsyncMock( - return_value=(confirmed, message) - ) - - guild = helpers.MockGuild() - await self.syncer.sync(guild) - - self.syncer._get_diff.assert_called_once_with(guild) - self.syncer._get_confirmation_result.assert_called_once() - - if confirmed: - self.syncer._sync.assert_called_once_with(diff) - else: - self.syncer._sync.assert_not_called() - - async def test_sync_diff_size(self): - """The diff size should be correctly calculated.""" - subtests = ( - (6, _Diff({1, 2}, {3, 4}, {5, 6})), - (5, _Diff({1, 2, 3}, None, {4, 5})), - (0, _Diff(None, None, None)), - (0, _Diff(set(), set(), set())), - ) - - for size, diff in subtests: - with self.subTest(size=size, diff=diff): - self.syncer._get_diff.reset_mock() - self.syncer._get_diff.return_value = diff - self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) - - guild = helpers.MockGuild() - await self.syncer.sync(guild) - - self.syncer._get_diff.assert_called_once_with(guild) - self.syncer._get_confirmation_result.assert_called_once() - self.assertEqual(self.syncer._get_confirmation_result.call_args[0][0], size) + # Make sure `_get_diff` returns a MagicMock, not an AsyncMock + self.syncer._get_diff.return_value = mock.MagicMock() async def test_sync_message_edited(self): """The message should be edited if one was sent, even if the sync has an API error.""" @@ -316,89 +49,23 @@ class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): for message, side_effect, should_edit in subtests: with self.subTest(message=message, side_effect=side_effect, should_edit=should_edit): self.syncer._sync.side_effect = side_effect - self.syncer._get_confirmation_result = mock.AsyncMock( - return_value=(True, message) - ) - guild = helpers.MockGuild() - await self.syncer.sync(guild) + await self.syncer.sync(self.guild) if should_edit: message.edit.assert_called_once() self.assertIn("content", message.edit.call_args[1]) - async def test_sync_confirmation_context_redirect(self): - """If ctx is given, a new message should be sent and author should be ctx's author.""" - mock_member = helpers.MockMember() + async def test_sync_message_sent(self): + """If ctx is given, a new message should be sent.""" subtests = ( - (None, self.bot.user, None), - (helpers.MockContext(author=mock_member), mock_member, helpers.MockMessage()), + (None, None), + (helpers.MockContext(), helpers.MockMessage()), ) - for ctx, author, message in subtests: - with self.subTest(ctx=ctx, author=author, message=message): - if ctx is not None: - ctx.send.return_value = message - - # Make sure `_get_diff` returns a MagicMock, not an AsyncMock - self.syncer._get_diff.return_value = mock.MagicMock() - - self.syncer._get_confirmation_result = mock.AsyncMock(return_value=(False, None)) - - guild = helpers.MockGuild() - await self.syncer.sync(guild, ctx) + for ctx, message in subtests: + with self.subTest(ctx=ctx, message=message): + await self.syncer.sync(self.guild, ctx) if ctx is not None: ctx.send.assert_called_once() - - self.syncer._get_confirmation_result.assert_called_once() - self.assertEqual(self.syncer._get_confirmation_result.call_args[0][1], author) - self.assertEqual(self.syncer._get_confirmation_result.call_args[0][2], message) - - @mock.patch.object(constants.Sync, "max_diff", new=3) - async def test_confirmation_result_small_diff(self): - """Should always return True and the given message if the diff size is too small.""" - author = helpers.MockMember() - expected_message = helpers.MockMessage() - - for size in (3, 2): # pragma: no cover - with self.subTest(size=size): - self.syncer._send_prompt = mock.AsyncMock() - self.syncer._wait_for_confirmation = mock.AsyncMock() - - coro = self.syncer._get_confirmation_result(size, author, expected_message) - result, actual_message = await coro - - self.assertTrue(result) - self.assertEqual(actual_message, expected_message) - self.syncer._send_prompt.assert_not_called() - self.syncer._wait_for_confirmation.assert_not_called() - - @mock.patch.object(constants.Sync, "max_diff", new=3) - async def test_confirmation_result_large_diff(self): - """Should return True if confirmed and False if _send_prompt fails or aborted.""" - author = helpers.MockMember() - mock_message = helpers.MockMessage() - - subtests = ( - (True, mock_message, True, "confirmed"), - (False, None, False, "_send_prompt failed"), - (False, mock_message, False, "aborted"), - ) - - for expected_result, expected_message, confirmed, msg in subtests: # pragma: no cover - with self.subTest(msg=msg): - self.syncer._send_prompt = mock.AsyncMock(return_value=expected_message) - self.syncer._wait_for_confirmation = mock.AsyncMock(return_value=confirmed) - - coro = self.syncer._get_confirmation_result(4, author) - actual_result, actual_message = await coro - - self.syncer._send_prompt.assert_called_once_with(None) # message defaults to None - self.assertIs(actual_result, expected_result) - self.assertEqual(actual_message, expected_message) - - if expected_message: - self.syncer._wait_for_confirmation.assert_called_once_with( - author, expected_message - ) -- cgit v1.2.3 From 9de2bbec0c1ab1462b5fd3b6e59b544060b3d472 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 7 Sep 2020 16:01:47 -0700 Subject: Fix test for sync message being edited --- tests/bot/cogs/sync/test_base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/sync/test_base.py b/tests/bot/cogs/sync/test_base.py index c3456f724..8d6f48333 100644 --- a/tests/bot/cogs/sync/test_base.py +++ b/tests/bot/cogs/sync/test_base.py @@ -49,8 +49,10 @@ class SyncerSyncTests(unittest.IsolatedAsyncioTestCase): for message, side_effect, should_edit in subtests: with self.subTest(message=message, side_effect=side_effect, should_edit=should_edit): self.syncer._sync.side_effect = side_effect + ctx = helpers.MockContext() + ctx.send.return_value = message - await self.syncer.sync(self.guild) + await self.syncer.sync(self.guild, ctx) if should_edit: message.edit.assert_called_once() -- cgit v1.2.3 From 6556a5cc02c391657e26950ede84a2fb7e4f679e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 9 Sep 2020 17:23:26 -0700 Subject: Decorators: remove locked() decorator It was not being used anywhere. --- bot/decorators.py | 37 ++----------------------------------- 1 file changed, 2 insertions(+), 35 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index 3418dfd11..333716cf5 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,17 +1,16 @@ import asyncio import inspect import logging -import random import typing as t from collections import defaultdict from contextlib import suppress from functools import partial, wraps from weakref import WeakValueDictionary -from discord import Colour, Embed, Member, NotFound +from discord import Member, NotFound from discord.ext.commands import Cog, Context, check -from bot.constants import Channels, ERROR_REPLIES, RedirectOutput +from bot.constants import Channels, RedirectOutput from bot.errors import LockedResourceError from bot.utils import LockGuard, function from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check @@ -67,38 +66,6 @@ def without_role(*role_ids: int) -> t.Callable: return check(predicate) -def locked() -> t.Callable: - """ - Allows the user to only run one instance of the decorated command at a time. - - Subsequent calls to the command from the same author are ignored until the command has completed invocation. - - This decorator must go before (below) the `command` decorator. - """ - def wrap(func: t.Callable) -> t.Callable: - func.__locks = WeakValueDictionary() - - @wraps(func) - async def inner(self: Cog, ctx: Context, *args, **kwargs) -> None: - lock = func.__locks.setdefault(ctx.author.id, asyncio.Lock()) - if lock.locked(): - embed = Embed() - embed.colour = Colour.red() - - log.debug("User tried to invoke a locked command.") - embed.description = ( - "You're already using this command. Please wait until it is done before you use it again." - ) - embed.title = random.choice(ERROR_REPLIES) - await ctx.send(embed=embed) - return - - async with func.__locks.setdefault(ctx.author.id, asyncio.Lock()): - await func(self, ctx, *args, **kwargs) - return inner - return wrap - - def mutually_exclusive( namespace: t.Hashable, resource_id: ResourceId, -- cgit v1.2.3 From 85e4d910da9fafb67f46330e4446e83a738d7a9b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 10:10:22 -0700 Subject: Decorators: rename mutually_exclusive decorators A mutex is the same thing as a lock. The former is a relatively esoteric contraction, so the latter is preferred. --- bot/cogs/reminders.py | 8 ++++---- bot/decorators.py | 15 +++++---------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index be97d34b6..734e0bd2d 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -14,7 +14,7 @@ from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import Duration -from bot.decorators import mutually_exclusive_arg +from bot.decorators import lock_arg from bot.pagination import LinePaginator from bot.utils.checks import without_role_check from bot.utils.messages import send_denial @@ -166,7 +166,7 @@ class Reminders(Cog): log.trace(f"Scheduling new task #{reminder['id']}") self.schedule_reminder(reminder) - @mutually_exclusive_arg(NAMESPACE, "reminder", itemgetter("id"), raise_error=True) + @lock_arg(NAMESPACE, "reminder", itemgetter("id"), raise_error=True) async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) @@ -373,7 +373,7 @@ class Reminders(Cog): mention_ids = [mention.id for mention in mentions] await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) - @mutually_exclusive_arg(NAMESPACE, "id_", raise_error=True) + @lock_arg(NAMESPACE, "id_", raise_error=True) async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: """Edits a reminder with the given payload, then sends a confirmation message.""" reminder = await self._edit_reminder(id_, payload) @@ -391,7 +391,7 @@ class Reminders(Cog): await self._reschedule_reminder(reminder) @remind_group.command("delete", aliases=("remove", "cancel")) - @mutually_exclusive_arg(NAMESPACE, "id_", raise_error=True) + @lock_arg(NAMESPACE, "id_", raise_error=True) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" await self.bot.api_client.delete(f"bot/reminders/{id_}") diff --git a/bot/decorators.py b/bot/decorators.py index 333716cf5..aabbe2cc9 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -66,12 +66,7 @@ def without_role(*role_ids: int) -> t.Callable: return check(predicate) -def mutually_exclusive( - namespace: t.Hashable, - resource_id: ResourceId, - *, - raise_error: bool = False, -) -> t.Callable: +def lock(namespace: t.Hashable, resource_id: ResourceId, *, raise_error: bool = False) -> t.Callable: """ Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. @@ -126,7 +121,7 @@ def mutually_exclusive( return decorator -def mutually_exclusive_arg( +def lock_arg( namespace: t.Hashable, name_or_pos: function.Argument, func: t.Callable[[t.Any], _IdCallableReturn] = None, @@ -134,12 +129,12 @@ def mutually_exclusive_arg( raise_error: bool = False, ) -> t.Callable: """ - Apply `mutually_exclusive` using the value of the arg at the given name/position as the ID. + Apply the `lock` decorator using the value of the arg at the given name/position as the ID. `func` is an optional callable or awaitable which will return the ID given the argument value. - See `mutually_exclusive` docs for more information. + See `lock` docs for more information. """ - decorator_func = partial(mutually_exclusive, namespace, raise_error=raise_error) + decorator_func = partial(lock, namespace, raise_error=raise_error) return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) -- cgit v1.2.3 From ac25ada30f4e43c130e0183be16ad6eef41c44d8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 10:23:21 -0700 Subject: Move lock decorators to utils/lock.py `LockGuard` was lonely and the decorators were cluttering up decorators.py. --- bot/cogs/reminders.py | 2 +- bot/decorators.py | 85 ++---------------------------------------------- bot/utils/__init__.py | 3 +- bot/utils/lock.py | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 86 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 734e0bd2d..25b2c9421 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -14,9 +14,9 @@ from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import Duration -from bot.decorators import lock_arg from bot.pagination import LinePaginator from bot.utils.checks import without_role_check +from bot.utils.lock import lock_arg from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler from bot.utils.time import humanize_delta diff --git a/bot/decorators.py b/bot/decorators.py index aabbe2cc9..2ec0cb122 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,26 +1,17 @@ import asyncio -import inspect import logging import typing as t -from collections import defaultdict from contextlib import suppress -from functools import partial, wraps -from weakref import WeakValueDictionary +from functools import wraps from discord import Member, NotFound from discord.ext.commands import Cog, Context, check from bot.constants import Channels, RedirectOutput -from bot.errors import LockedResourceError -from bot.utils import LockGuard, function +from bot.utils import function from bot.utils.checks import in_whitelist_check, with_role_check, without_role_check log = logging.getLogger(__name__) -__lock_dicts = defaultdict(WeakValueDictionary) - -_IdCallableReturn = t.Union[t.Hashable, t.Awaitable[t.Hashable]] -_IdCallable = t.Callable[[function.BoundArgs], _IdCallableReturn] -ResourceId = t.Union[t.Hashable, _IdCallable] def in_whitelist( @@ -66,78 +57,6 @@ def without_role(*role_ids: int) -> t.Callable: return check(predicate) -def lock(namespace: t.Hashable, resource_id: ResourceId, *, raise_error: bool = False) -> t.Callable: - """ - Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. - - If any other mutually exclusive function currently holds the lock for a resource, do not run the - decorated function and return None. If `raise_error` is True, raise `LockedResourceError` if - the lock cannot be acquired. - - `namespace` is an identifier used to prevent collisions among resource IDs. - - `resource_id` identifies a resource on which to perform a mutually exclusive operation. - It may also be a callable or awaitable which will return the resource ID given an ordered - mapping of the parameters' names to arguments' values. - - If decorating a command, this decorator must go before (below) the `command` decorator. - """ - def decorator(func: t.Callable) -> t.Callable: - name = func.__name__ - - @wraps(func) - async def wrapper(*args, **kwargs) -> t.Any: - log.trace(f"{name}: mutually exclusive decorator called") - - if callable(resource_id): - log.trace(f"{name}: binding args to signature") - bound_args = function.get_bound_args(func, args, kwargs) - - log.trace(f"{name}: calling the given callable to get the resource ID") - id_ = resource_id(bound_args) - - if inspect.isawaitable(id_): - log.trace(f"{name}: awaiting to get resource ID") - id_ = await id_ - else: - id_ = resource_id - - log.trace(f"{name}: getting lock for resource {id_!r} under namespace {namespace!r}") - - # Get the lock for the ID. Create a lock if one doesn't exist yet. - locks = __lock_dicts[namespace] - lock = locks.setdefault(id_, LockGuard()) - - if not lock.locked(): - log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") - with lock: - return await func(*args, **kwargs) - else: - log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") - if raise_error: - raise LockedResourceError(str(namespace), id_) - - return wrapper - return decorator - - -def lock_arg( - namespace: t.Hashable, - name_or_pos: function.Argument, - func: t.Callable[[t.Any], _IdCallableReturn] = None, - *, - raise_error: bool = False, -) -> t.Callable: - """ - Apply the `lock` decorator using the value of the arg at the given name/position as the ID. - - `func` is an optional callable or awaitable which will return the ID given the argument value. - See `lock` docs for more information. - """ - decorator_func = partial(lock, namespace, raise_error=raise_error) - return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) - - def redirect_output(destination_channel: int, bypass_roles: t.Container[int] = None) -> t.Callable: """ Changes the channel in the context of the command to redirect the output to a certain channel. diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 0dd9605e8..b73410e96 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -2,10 +2,9 @@ from abc import ABCMeta from discord.ext.commands import CogMeta -from bot.utils.lock import LockGuard from bot.utils.redis_cache import RedisCache -__all__ = ["CogABCMeta", "LockGuard", "RedisCache"] +__all__ = ["CogABCMeta", "RedisCache"] class CogABCMeta(CogMeta, ABCMeta): diff --git a/bot/utils/lock.py b/bot/utils/lock.py index 8f1b738aa..5c9dd3725 100644 --- a/bot/utils/lock.py +++ b/bot/utils/lock.py @@ -1,3 +1,21 @@ +import inspect +import logging +from collections import defaultdict +from functools import partial, wraps +from typing import Any, Awaitable, Callable, Hashable, Union +from weakref import WeakValueDictionary + +from bot.errors import LockedResourceError +from bot.utils import function + +log = logging.getLogger(__name__) +__lock_dicts = defaultdict(WeakValueDictionary) + +_IdCallableReturn = Union[Hashable, Awaitable[Hashable]] +_IdCallable = Callable[[function.BoundArgs], _IdCallableReturn] +ResourceId = Union[Hashable, _IdCallable] + + class LockGuard: """ A context manager which acquires and releases a lock (mutex). @@ -21,3 +39,75 @@ class LockGuard: def __exit__(self, _exc_type, _exc_value, _traceback): # noqa: ANN001 self._locked = False return False # Indicate any raised exception shouldn't be suppressed. + + +def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = False) -> Callable: + """ + Turn the decorated coroutine function into a mutually exclusive operation on a `resource_id`. + + If any other mutually exclusive function currently holds the lock for a resource, do not run the + decorated function and return None. If `raise_error` is True, raise `LockedResourceError` if + the lock cannot be acquired. + + `namespace` is an identifier used to prevent collisions among resource IDs. + + `resource_id` identifies a resource on which to perform a mutually exclusive operation. + It may also be a callable or awaitable which will return the resource ID given an ordered + mapping of the parameters' names to arguments' values. + + If decorating a command, this decorator must go before (below) the `command` decorator. + """ + def decorator(func: Callable) -> Callable: + name = func.__name__ + + @wraps(func) + async def wrapper(*args, **kwargs) -> Any: + log.trace(f"{name}: mutually exclusive decorator called") + + if callable(resource_id): + log.trace(f"{name}: binding args to signature") + bound_args = function.get_bound_args(func, args, kwargs) + + log.trace(f"{name}: calling the given callable to get the resource ID") + id_ = resource_id(bound_args) + + if inspect.isawaitable(id_): + log.trace(f"{name}: awaiting to get resource ID") + id_ = await id_ + else: + id_ = resource_id + + log.trace(f"{name}: getting lock for resource {id_!r} under namespace {namespace!r}") + + # Get the lock for the ID. Create a lock if one doesn't exist yet. + locks = __lock_dicts[namespace] + lock = locks.setdefault(id_, LockGuard()) + + if not lock.locked(): + log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") + with lock: + return await func(*args, **kwargs) + else: + log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") + if raise_error: + raise LockedResourceError(str(namespace), id_) + + return wrapper + return decorator + + +def lock_arg( + namespace: Hashable, + name_or_pos: function.Argument, + func: Callable[[Any], _IdCallableReturn] = None, + *, + raise_error: bool = False, +) -> Callable: + """ + Apply the `lock` decorator using the value of the arg at the given name/position as the ID. + + `func` is an optional callable or awaitable which will return the ID given the argument value. + See `lock` docs for more information. + """ + decorator_func = partial(lock, namespace, raise_error=raise_error) + return function.get_arg_value_wrapper(decorator_func, name_or_pos, func) -- cgit v1.2.3 From fe08fd275d492637abc78071efe9441e7526e588 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 9 Sep 2020 17:31:21 -0700 Subject: Fix attribute docstring for LockedResourceError --- bot/errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/errors.py b/bot/errors.py index 34de3c2b1..65d715203 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -7,7 +7,7 @@ class LockedResourceError(RuntimeError): Attributes: `type` -- name of the locked resource's type - `resource_id` -- ID of the locked resource + `id` -- ID of the locked resource """ def __init__(self, resource_type: str, resource_id: Hashable): -- cgit v1.2.3 From 57786e90cab270f8526e03414d62f42fa249a593 Mon Sep 17 00:00:00 2001 From: rohanjnr Date: Tue, 15 Sep 2020 12:22:06 +0530 Subject: Restrict nsfw subreddit(s) or similar (subreddits that require you to be over 18). Changed the return format a little bit for the fetch_posts() function, instead of returning an empty list, it returns a list with a dict holding the error message. --- bot/cogs/reddit.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 5d9e2c20b..0b002f9b6 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -141,12 +141,27 @@ class Reddit(Cog): # Got appropriate response - process and return. content = await response.json() posts = content["data"]["children"] + if posts[0]["data"]["over_18"]: + resp_not_allowed = [ + { + "error": "Oops ! Looks like this subreddit, doesn't fit in the scope of the server." + } + ] + return resp_not_allowed return posts[:amount] await asyncio.sleep(3) log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") - return list() # Failed to get appropriate response within allowed number of retries. + resp_failed = [ + { + "error": ( + "Sorry! We couldn't find any posts from that subreddit. " + "If this problem persists, please let us know." + ) + } + ] + return resp_failed # Failed to get appropriate response within allowed number of retries. async def get_top_posts(self, subreddit: Subreddit, time: str = "all", amount: int = 5) -> Embed: """ @@ -164,14 +179,10 @@ class Reddit(Cog): amount=amount, params={"t": time} ) - - if not posts: + if "error" in posts[0]: embed.title = random.choice(ERROR_REPLIES) embed.colour = Colour.red() - embed.description = ( - "Sorry! We couldn't find any posts from that subreddit. " - "If this problem persists, please let us know." - ) + embed.description = posts[0]["error"] return embed -- cgit v1.2.3 From 201efc924fb66d14592adaa229b87740043e13e2 Mon Sep 17 00:00:00 2001 From: Bast Date: Sat, 19 Sep 2020 12:15:22 -0700 Subject: Add feature to token_remover: log detected user ID, and ping if it's a user in the server Updated tests This comes with a change that a user ID must actually be able to be decoded into an integer to be considered a valid token --- bot/cogs/token_remover.py | 64 ++++++++++++++++++++++++++++-------- tests/bot/cogs/test_token_remover.py | 45 +++++++++++++++++++++---- 2 files changed, 90 insertions(+), 19 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index ef979f222..93ceda6be 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -18,6 +18,11 @@ LOG_MESSAGE = ( "Censored a seemingly valid token sent by {author} (`{author_id}`) in {channel}, " "token was `{user_id}.{timestamp}.{hmac}`" ) +DECODED_LOG_MESSAGE = "The token user_id decodes into {user_id}." +USER_TOKEN_MESSAGE = ( + "The token user_id decodes into {user_id}, " + "which matches `{user_name}` and means this is a valid USER token." +) DELETION_MESSAGE_TEMPLATE = ( "Hey {mention}! I noticed you posted a seemingly valid Discord API " "token in your message and have removed your message. " @@ -92,7 +97,14 @@ class TokenRemover(Cog): await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) - log_message = self.format_log_message(msg, found_token) + user_name = None + user_id = self.extract_user_id(found_token.user_id) + user = msg.guild.get_member(user_id) + + if user: + user_name = str(user) + + log_message = self.format_log_message(msg, found_token, user_id, user_name) log.debug(log_message) # Send pretty mod log embed to mod-alerts @@ -103,14 +115,24 @@ class TokenRemover(Cog): text=log_message, thumbnail=msg.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, + ping_everyone=user_name is not None, ) self.bot.stats.incr("tokens.removed_tokens") @staticmethod - def format_log_message(msg: Message, token: Token) -> str: - """Return the log message to send for `token` being censored in `msg`.""" - return LOG_MESSAGE.format( + def format_log_message( + msg: Message, + token: Token, + user_id: int, + user_name: t.Optional[str] = None, + ) -> str: + """ + Return the log message to send for `token` being censored in `msg`. + + Additonally, mention if the token was decodable into a user id, and if that resolves to a user on the server. + """ + message = LOG_MESSAGE.format( author=msg.author, author_id=msg.author.id, channel=msg.channel.mention, @@ -118,6 +140,11 @@ class TokenRemover(Cog): timestamp=token.timestamp, hmac='x' * len(token.hmac), ) + if user_name: + more = USER_TOKEN_MESSAGE.format(user_id=user_id, user_name=user_name) + else: + more = DECODED_LOG_MESSAGE.format(user_id=user_id) + return message + "\n" + more @classmethod def find_token_in_message(cls, msg: Message) -> t.Optional[Token]: @@ -134,23 +161,34 @@ class TokenRemover(Cog): return @staticmethod - def is_valid_user_id(b64_content: str) -> bool: - """ - Check potential token to see if it contains a valid Discord user ID. - - See: https://discordapp.com/developers/docs/reference#snowflakes - """ + def extract_user_id(b64_content: str) -> t.Optional[int]: + """Return a userid integer from part of a potential token, or None if it couldn't be decoded.""" b64_content = utils.pad_base64(b64_content) try: decoded_bytes = base64.urlsafe_b64decode(b64_content) string = decoded_bytes.decode('utf-8') - - # isdigit on its own would match a lot of other Unicode characters, hence the isascii. - return string.isascii() and string.isdigit() + if not (string.isascii() and string.isdigit()): + # This case triggers if there are fancy unicode digits in the base64 encoding, + # that means it's not a valid user id. + return None + return int(string) except (binascii.Error, ValueError): + return None + + @classmethod + def is_valid_user_id(cls, b64_content: str) -> bool: + """ + Check potential token to see if it contains a valid Discord user ID. + + See: https://discordapp.com/developers/docs/reference#snowflakes + """ + decoded_id = cls.extract_user_id(b64_content) + if not decoded_id: return False + return True + @staticmethod def is_valid_timestamp(b64_content: str) -> bool: """ diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 3349caa73..275350144 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -22,6 +22,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.msg = MockMessage(id=555, content="hello world") self.msg.channel.mention = "#lemonade-stand" + self.msg.guild.get_member = MagicMock(return_value="Bob") self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) self.msg.author.avatar_url_as.return_value = "picture-lemon.png" @@ -230,15 +231,41 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): results = [match[0] for match in results] self.assertCountEqual((token_1, token_2), results) - @autospec("bot.cogs.token_remover", "LOG_MESSAGE") - def test_format_log_message(self, log_message): + @autospec("bot.cogs.token_remover", "LOG_MESSAGE", "DECODED_LOG_MESSAGE") + def test_format_log_message(self, log_message, decoded_log_message): + """Should correctly format the log message with info from the message and token.""" + token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") + log_message.format.return_value = "Howdy" + decoded_log_message.format.return_value = " Partner" + + return_value = TokenRemover.format_log_message(self.msg, token, 472265943062413332, None) + + self.assertEqual( + return_value, + log_message.format.return_value + "\n" + decoded_log_message.format.return_value, + ) + log_message.format.assert_called_once_with( + author=self.msg.author, + author_id=self.msg.author.id, + channel=self.msg.channel.mention, + user_id=token.user_id, + timestamp=token.timestamp, + hmac="x" * len(token.hmac), + ) + + @autospec("bot.cogs.token_remover", "LOG_MESSAGE", "USER_TOKEN_MESSAGE") + def test_format_log_message_user_token(self, log_message, user_token_message): """Should correctly format the log message with info from the message and token.""" token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") log_message.format.return_value = "Howdy" + user_token_message.format.return_value = "Partner" - return_value = TokenRemover.format_log_message(self.msg, token) + return_value = TokenRemover.format_log_message(self.msg, token, 467223230650777641, "Bob") - self.assertEqual(return_value, log_message.format.return_value) + self.assertEqual( + return_value, + log_message.format.return_value + "\n" + user_token_message.format.return_value, + ) log_message.format.assert_called_once_with( author=self.msg.author, author_id=self.msg.author.id, @@ -247,6 +274,10 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): timestamp=token.timestamp, hmac="x" * len(token.hmac), ) + user_token_message.format.assert_called_once_with( + user_id=467223230650777641, + user_name="Bob", + ) @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) @autospec("bot.cogs.token_remover", "log") @@ -256,6 +287,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): cog = TokenRemover(self.bot) mod_log = mock.create_autospec(ModLog, spec_set=True, instance=True) token = mock.create_autospec(Token, spec_set=True, instance=True) + token.user_id = "no-id" log_msg = "testing123" mod_log_property.return_value = mod_log @@ -268,7 +300,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): token_remover.DELETION_MESSAGE_TEMPLATE.format(mention=self.msg.author.mention) ) - format_log_message.assert_called_once_with(self.msg, token) + format_log_message.assert_called_once_with(self.msg, token, None, "Bob") logger.debug.assert_called_with(log_msg) self.bot.stats.incr.assert_called_once_with("tokens.removed_tokens") @@ -279,7 +311,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): title="Token removed!", text=log_msg, thumbnail=self.msg.author.avatar_url_as.return_value, - channel_id=constants.Channels.mod_alerts + channel_id=constants.Channels.mod_alerts, + ping_everyone=True, ) @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) -- cgit v1.2.3 From 83e17627d3fa4e0eb135b5039decd02eaf3d060c Mon Sep 17 00:00:00 2001 From: Bast Date: Sat, 19 Sep 2020 12:16:40 -0700 Subject: Make token_remover check basic HMAC validity (not low entropy) Handles cases like xxx.xxxxx.xxxxxxxx where a user has intentionally censored part of a token, and will not consider them "valid" --- bot/cogs/token_remover.py | 21 ++++++++++++++++++- tests/bot/cogs/test_token_remover.py | 40 +++++++++++++++++++++++++++++++----- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 93ceda6be..17778b415 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -1,5 +1,6 @@ import base64 import binascii +import collections import logging import re import typing as t @@ -153,7 +154,9 @@ class TokenRemover(Cog): # token check (e.g. `message.channel.send` also matches our token pattern) for match in TOKEN_RE.finditer(msg.content): token = Token(*match.groups()) - if cls.is_valid_user_id(token.user_id) and cls.is_valid_timestamp(token.timestamp): + if cls.is_valid_user_id(token.user_id) \ + and cls.is_valid_timestamp(token.timestamp) \ + and cls.is_maybevalid_hmac(token.hmac): # Short-circuit on first match return token @@ -214,6 +217,22 @@ class TokenRemover(Cog): log.debug(f"Invalid token timestamp '{b64_content}': smaller than Discord epoch") return False + @staticmethod + def is_maybevalid_hmac(b64_content: str) -> bool: + """ + Determine if a given hmac portion of a token is potentially valid. + + If the HMAC has 3 or less characters, it's probably a dummy value like "xxxxxxxxxx", + and thus the token can probably be skipped. + """ + unique = len(collections.Counter(b64_content.lower()).keys()) + if unique <= 3: + log.debug(f"Considering the hmac {b64_content} a dummy because it has {unique}" + " case-insensitively unique characters") + return False + else: + return True + def setup(bot: Bot) -> None: """Load the TokenRemover cog.""" diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 275350144..56d269105 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -86,6 +86,34 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): result = TokenRemover.is_valid_timestamp(timestamp) self.assertFalse(result) + def test_is_valid_hmac_valid(self): + """Should consider hmac valid if it is a valid hmac with a variety of characters.""" + valid_hmacs = ( + "VXmErH7j511turNpfURmb0rVNm8", + "Ysnu2wacjaKs7qnoo46S8Dm2us8", + "sJf6omBPORBPju3WJEIAcwW9Zds", + "s45jqDV_Iisn-symw0yDRrk_jf4", + ) + + for hmac in valid_hmacs: + with self.subTest(msg=hmac): + result = TokenRemover.is_maybevalid_hmac(hmac) + self.assertTrue(result) + + def test_is_invalid_hmac_invalid(self): + """Should consider hmac invalid if it possesses too little variety.""" + invalid_hmacs = ( + ("xxxxxxxxxxxxxxxxxx", "Single character"), + ("XxXxXxXxXxXxXxXxXx", "Single character alternating case"), + ("ASFasfASFasfASFASsf", "Three characters alternating-case"), + ("asdasdasdasdasdasdasd", "Three characters one case"), + ) + + for hmac, msg in invalid_hmacs: + with self.subTest(msg=msg): + result = TokenRemover.is_maybevalid_hmac(hmac) + self.assertFalse(result) + def test_mod_log_property(self): """The `mod_log` property should ask the bot to return the `ModLog` cog.""" self.bot.get_cog.return_value = 'lemon' @@ -143,11 +171,11 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(return_value) token_re.finditer.assert_called_once_with(self.msg.content) - @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") + @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp", "is_maybevalid_hmac") @autospec("bot.cogs.token_remover", "Token") @autospec("bot.cogs.token_remover", "TOKEN_RE") - def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp): - """The first match with a valid user ID and timestamp should be returned as a `Token`.""" + def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp, is_maybevalid_hmac): + """The first match with a valid user ID. timestamp and hmac should be returned as a `Token`.""" matches = [ mock.create_autospec(Match, spec_set=True, instance=True), mock.create_autospec(Match, spec_set=True, instance=True), @@ -161,21 +189,23 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): token_cls.side_effect = tokens is_valid_id.side_effect = (False, True) # The 1st match will be invalid, 2nd one valid. is_valid_timestamp.return_value = True + is_maybevalid_hmac.return_value = True return_value = TokenRemover.find_token_in_message(self.msg) self.assertEqual(tokens[1], return_value) token_re.finditer.assert_called_once_with(self.msg.content) - @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") + @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp", "is_maybevalid_hmac") @autospec("bot.cogs.token_remover", "Token") @autospec("bot.cogs.token_remover", "TOKEN_RE") - def test_find_token_invalid_matches(self, token_re, token_cls, is_valid_id, is_valid_timestamp): + def test_find_token_invalid_matches(self, token_re, token_cls, is_valid_id, is_valid_timestamp, is_maybevalid_hmac): """None should be returned if no matches have valid user IDs or timestamps.""" token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)] token_cls.return_value = mock.create_autospec(Token, spec_set=True, instance=True) is_valid_id.return_value = False is_valid_timestamp.return_value = False + is_maybevalid_hmac.return_value = False return_value = TokenRemover.find_token_in_message(self.msg) -- cgit v1.2.3 From 681027b61663bcdff5b174aa3e06f34b54f05349 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Mon, 21 Sep 2020 19:21:13 +0530 Subject: refactor code to GET users from site endpoint `bot/users` with pagination Added method to recursively GET users if paginated and another method to parse URL and return endpoint and query parameters. --- bot/cogs/sync/syncers.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index f7ba811bc..156c32a15 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -4,6 +4,7 @@ import logging import typing as t from collections import namedtuple from functools import partial +from urllib.parse import parse_qsl, urlparse import discord from discord import Guild, HTTPException, Member, Message, Reaction, User @@ -287,7 +288,8 @@ class UserSyncer(Syncer): async def _get_diff(self, guild: Guild) -> _Diff: """Return the difference of users between the cache of `guild` and the database.""" log.trace("Getting the diff for users.") - users = await self.bot.api_client.get('bot/users') + + users = await self._get_users() # Pack DB roles and guild roles into one common, hashable format. # They're hashable so that they're easily comparable with sets later. @@ -336,6 +338,32 @@ class UserSyncer(Syncer): return _Diff(users_to_create, users_to_update, None) + async def _get_users(self, endpoint: str = "bot/users", query_params: dict = None) -> t.List[dict]: + """GET all users recursively.""" + users: list = [] + response: dict = await self.bot.api_client.get(endpoint, params=query_params) + users.extend(response["results"]) + + # The `response` is paginated, hence check if next page exists. + if (next_page_url := response["next"]) is not None: + next_endpoint, query_params = self.get_endpoint(next_page_url) + users.extend(await self._get_users(next_endpoint, query_params)) + + return users + + @staticmethod + def get_endpoint(url: str) -> tuple: + """Extract the API endpoint and query params from a URL.""" + url = urlparse(url) + + # Do not include starting `/` for endpoint. + endpoint = url.path[1:] + + # Query params. + params = parse_qsl(url.query) + + return endpoint, params + async def _sync(self, diff: _Diff) -> None: """Synchronise the database with the user cache of `guild`.""" log.trace("Syncing created users...") -- cgit v1.2.3 From ec0db2dd98e55f8bf5ba1c07375e196933129f99 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Tue, 22 Sep 2020 21:55:09 +0530 Subject: Refactor code to make use of bulk create and update API endpoints. instead of creating and updating a single user at a time, a list of dicts will be sent for bulk update and creation. --- bot/exts/backend/sync/_syncers.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index 156c32a15..7d1a8eacc 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -367,9 +367,10 @@ class UserSyncer(Syncer): async def _sync(self, diff: _Diff) -> None: """Synchronise the database with the user cache of `guild`.""" log.trace("Syncing created users...") - for user in diff.created: - await self.bot.api_client.post('bot/users', json=user._asdict()) + if diff.created: + created: list = [user._asdict() for user in diff.created] + await self.bot.api_client.post("bot/users", json=created) - log.trace("Syncing updated users...") - for user in diff.updated: - await self.bot.api_client.put(f'bot/users/{user.id}', json=user._asdict()) + if diff.updated: + updated = [user._asdict() for user in diff.created] + await self.bot.api_client.patch("bot/users/bulk_patch", json=updated) -- cgit v1.2.3 From eca87e32948142863c562664bde262bf9054ca94 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Tue, 22 Sep 2020 22:19:27 +0530 Subject: fix type and add variable type hinting --- bot/exts/backend/sync/_syncers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index 7d1a8eacc..cf75b6407 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -370,7 +370,6 @@ class UserSyncer(Syncer): if diff.created: created: list = [user._asdict() for user in diff.created] await self.bot.api_client.post("bot/users", json=created) - if diff.updated: - updated = [user._asdict() for user in diff.created] + updated: list = [user._asdict() for user in diff.updated] await self.bot.api_client.patch("bot/users/bulk_patch", json=updated) -- cgit v1.2.3 From 73c21c6fda0472cd2eabaa3ffc0b58b0782ecf84 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 22 Sep 2020 12:28:01 -0700 Subject: Sync: refactor conditional for sending message The ternary is a bit confusing. Co-authored-by: Numerlor <25886452+Numerlor@users.noreply.github.com> --- bot/cogs/sync/syncers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index b3819a1e1..e2013dafd 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -50,7 +50,10 @@ class Syncer(abc.ABC): """ log.info(f"Starting {self.name} syncer.") - message = await ctx.send(f"📊 Synchronising {self.name}s.") if ctx else None + if ctx: + message = await ctx.send(f"📊 Synchronising {self.name}s.") + else: + message = None diff = await self._get_diff(guild) try: -- cgit v1.2.3 From 569f2aaf7b6025bddd59abb39d21939c4666ebed Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 22 Sep 2020 14:10:34 -0700 Subject: Silence: use f-string for message Co-authored-by: Numerlor <25886452+Numerlor@users.noreply.github.com> --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index c339fd4d0..8e15b2284 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -19,7 +19,7 @@ log = logging.getLogger(__name__) MSG_SILENCE_FAIL = f"{Emojis.cross_mark} current channel is already silenced." MSG_SILENCE_PERMANENT = f"{Emojis.check_mark} silenced current channel indefinitely." -MSG_SILENCE_SUCCESS = Emojis.check_mark + " silenced current channel for {duration} minute(s)." +MSG_SILENCE_SUCCESS = f"{Emojis.check_mark} silenced current channel for {{duration}} minute(s)." MSG_UNSILENCE_FAIL = f"{Emojis.cross_mark} current channel was not silenced." MSG_UNSILENCE_MANUAL = ( -- cgit v1.2.3 From bcca56c726d30f2c9e0cd762e9e65aebda2521d0 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 23 Sep 2020 17:09:58 +0200 Subject: Verification: reduce request dispatch log level Avoid information duplication in production logs. --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 210c7a1af..6bbe81701 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -286,7 +286,7 @@ class Verification(Cog): Returns the amount of successful requests. Failed requests are logged at info level. """ - log.info(f"Sending {len(members)} requests") + log.trace(f"Sending {len(members)} requests") n_success, bad_statuses = 0, set() for progress, member in enumerate(members, start=1): -- cgit v1.2.3 From 5038aea67d41f579914dec2cf93042468dc2d3cf Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 23 Sep 2020 17:10:09 +0200 Subject: Incidents: bump archive log to INFO level --- bot/exts/moderation/incidents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index e49913552..31be48a43 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -237,7 +237,7 @@ class Incidents(Cog): not all information was relayed, return False. This signals that the original message is not safe to be deleted, as we will lose some information. """ - log.debug(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})") + log.info(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})") embed, attachment_file = await make_embed(incident, outcome, actioned_by) try: -- cgit v1.2.3 From 77205149613e25623ee646de977e5d5d0cd16e11 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 23 Sep 2020 09:35:18 -0700 Subject: Fix use of expanded infraction response for username Fixes BOT-9A --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index de4fb4175..856a4e1a2 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -194,7 +194,7 @@ class ModManagement(commands.Cog): user = self.bot.get_user(user.id) if not user and infraction_list: # Use the user data retrieved from the DB for the username. - user = infraction_list[0] + user = infraction_list[0]["user"] user = escape_markdown(user["name"]) + f"#{user['discriminator']:04}" embed = discord.Embed( -- cgit v1.2.3 From 9a90d6b16e0ec61c023a916ec58d05b6142a6e2d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 23 Sep 2020 13:25:23 -0700 Subject: Sync: remove _asdict comment The comment doesn't contribute anything. --- bot/exts/backend/sync/_syncers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index e2013dafd..a07a93eab 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -65,7 +65,7 @@ class Syncer(abc.ABC): results = f"status {e.status}\n```{e.response_json or 'See log output for details'}```" content = f":x: Synchronisation of {self.name}s failed: {results}" else: - diff_dict = diff._asdict() # Ugly method for transforming the NamedTuple into a dict + diff_dict = diff._asdict() results = (f"{name} `{len(val)}`" for name, val in diff_dict.items() if val is not None) results = ", ".join(results) -- cgit v1.2.3 From 1b38ad4a16d17bacfe20513c9f33a58aa6ee1b56 Mon Sep 17 00:00:00 2001 From: Bast Date: Thu, 24 Sep 2020 10:03:24 -0700 Subject: Implement review-suggested changes userid -> user ID maybevalid -> maybe_valid remove collections import and added a new function that handles the "format user ID log message" and should_ping_everyone feature --- bot/exts/filters/token_remover.py | 71 ++++++++++++----------- tests/bot/exts/filters/test_token_remover.py | 87 +++++++++++++++++----------- 2 files changed, 91 insertions(+), 67 deletions(-) diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py index a31912d5b..54f0bc034 100644 --- a/bot/exts/filters/token_remover.py +++ b/bot/exts/filters/token_remover.py @@ -1,6 +1,5 @@ import base64 import binascii -import collections import logging import re import typing as t @@ -98,14 +97,8 @@ class TokenRemover(Cog): await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) - user_name = None - user_id = self.extract_user_id(found_token.user_id) - user = msg.guild.get_member(user_id) - - if user: - user_name = str(user) - - log_message = self.format_log_message(msg, found_token, user_id, user_name) + log_message = self.format_log_message(msg, found_token) + userid_message, mention_everyone = self.format_userid_log_message(msg, found_token) log.debug(log_message) # Send pretty mod log embed to mod-alerts @@ -113,26 +106,35 @@ class TokenRemover(Cog): icon_url=Icons.token_removed, colour=Colour(Colours.soft_red), title="Token removed!", - text=log_message, + text=log_message + "\n" + userid_message, thumbnail=msg.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, - ping_everyone=user_name is not None, + ping_everyone=mention_everyone, ) self.bot.stats.incr("tokens.removed_tokens") - @staticmethod - def format_log_message( - msg: Message, - token: Token, - user_id: int, - user_name: t.Optional[str] = None, - ) -> str: + @classmethod + def format_userid_log_message(cls, msg: Message, token: Token) -> t.Tuple[str, bool]: """ - Return the log message to send for `token` being censored in `msg`. + Format the potion of the log message that includes details about the detected user ID. - Additonally, mention if the token was decodable into a user id, and if that resolves to a user on the server. + Includes the user ID and, if present on the server, their name and a toggle to + mention everyone. + + Returns a tuple of (log_message, mention_everyone) """ + user_id = cls.extract_user_id(token.user_id) + user = msg.guild.get_member(user_id) + + if user: + return USER_TOKEN_MESSAGE.format(user_id=user_id, user_name=str(user)), True + else: + return DECODED_LOG_MESSAGE.format(user_id=user_id), False + + @staticmethod + def format_log_message(msg: Message, token: Token) -> str: + """Return the generic portion of the log message to send for `token` being censored in `msg`.""" message = LOG_MESSAGE.format( author=msg.author, author_id=msg.author.id, @@ -141,11 +143,8 @@ class TokenRemover(Cog): timestamp=token.timestamp, hmac='x' * len(token.hmac), ) - if user_name: - more = USER_TOKEN_MESSAGE.format(user_id=user_id, user_name=user_name) - else: - more = DECODED_LOG_MESSAGE.format(user_id=user_id) - return message + "\n" + more + + return message @classmethod def find_token_in_message(cls, msg: Message) -> t.Optional[Token]: @@ -154,9 +153,11 @@ class TokenRemover(Cog): # token check (e.g. `message.channel.send` also matches our token pattern) for match in TOKEN_RE.finditer(msg.content): token = Token(*match.groups()) - if cls.is_valid_user_id(token.user_id) \ - and cls.is_valid_timestamp(token.timestamp) \ - and cls.is_maybevalid_hmac(token.hmac): + if ( + cls.is_valid_user_id(token.user_id) + and cls.is_valid_timestamp(token.timestamp) + and cls.is_maybe_valid_hmac(token.hmac) + ): # Short-circuit on first match return token @@ -165,7 +166,7 @@ class TokenRemover(Cog): @staticmethod def extract_user_id(b64_content: str) -> t.Optional[int]: - """Return a userid integer from part of a potential token, or None if it couldn't be decoded.""" + """Return a user ID integer from part of a potential token, or None if it couldn't be decoded.""" b64_content = utils.pad_base64(b64_content) try: @@ -218,17 +219,19 @@ class TokenRemover(Cog): return False @staticmethod - def is_maybevalid_hmac(b64_content: str) -> bool: + def is_maybe_valid_hmac(b64_content: str) -> bool: """ - Determine if a given hmac portion of a token is potentially valid. + Determine if a given HMAC portion of a token is potentially valid. If the HMAC has 3 or less characters, it's probably a dummy value like "xxxxxxxxxx", and thus the token can probably be skipped. """ - unique = len(collections.Counter(b64_content.lower()).keys()) + unique = len(set(b64_content.lower())) if unique <= 3: - log.debug(f"Considering the hmac {b64_content} a dummy because it has {unique}" - " case-insensitively unique characters") + log.debug( + f"Considering the HMAC {b64_content} a dummy because it has {unique}" + " case-insensitively unique characters" + ) return False else: return True diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py index 8742b73c5..92dce201b 100644 --- a/tests/bot/exts/filters/test_token_remover.py +++ b/tests/bot/exts/filters/test_token_remover.py @@ -87,7 +87,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(result) def test_is_valid_hmac_valid(self): - """Should consider hmac valid if it is a valid hmac with a variety of characters.""" + """Should consider an HMAC valid if it has at least 3 unique characters.""" valid_hmacs = ( "VXmErH7j511turNpfURmb0rVNm8", "Ysnu2wacjaKs7qnoo46S8Dm2us8", @@ -97,11 +97,11 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): for hmac in valid_hmacs: with self.subTest(msg=hmac): - result = TokenRemover.is_maybevalid_hmac(hmac) + result = TokenRemover.is_maybe_valid_hmac(hmac) self.assertTrue(result) def test_is_invalid_hmac_invalid(self): - """Should consider hmac invalid if it possesses too little variety.""" + """Should consider an HMAC invalid if has fewer than 3 unique characters.""" invalid_hmacs = ( ("xxxxxxxxxxxxxxxxxx", "Single character"), ("XxXxXxXxXxXxXxXxXx", "Single character alternating case"), @@ -111,7 +111,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): for hmac, msg in invalid_hmacs: with self.subTest(msg=msg): - result = TokenRemover.is_maybevalid_hmac(hmac) + result = TokenRemover.is_maybe_valid_hmac(hmac) self.assertFalse(result) def test_mod_log_property(self): @@ -171,11 +171,11 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(return_value) token_re.finditer.assert_called_once_with(self.msg.content) - @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp", "is_maybevalid_hmac") + @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp", "is_maybe_valid_hmac") @autospec("bot.exts.filters.token_remover", "Token") @autospec("bot.exts.filters.token_remover", "TOKEN_RE") - def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp, is_maybevalid_hmac): - """The first match with a valid user ID. timestamp and hmac should be returned as a `Token`.""" + def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp, is_maybe_valid_hmac): + """The first match with a valid user ID, timestamp, and HMAC should be returned as a `Token`.""" matches = [ mock.create_autospec(Match, spec_set=True, instance=True), mock.create_autospec(Match, spec_set=True, instance=True), @@ -189,23 +189,30 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): token_cls.side_effect = tokens is_valid_id.side_effect = (False, True) # The 1st match will be invalid, 2nd one valid. is_valid_timestamp.return_value = True - is_maybevalid_hmac.return_value = True + is_maybe_valid_hmac.return_value = True return_value = TokenRemover.find_token_in_message(self.msg) self.assertEqual(tokens[1], return_value) token_re.finditer.assert_called_once_with(self.msg.content) - @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp", "is_maybevalid_hmac") + @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp", "is_maybe_valid_hmac") @autospec("bot.exts.filters.token_remover", "Token") @autospec("bot.exts.filters.token_remover", "TOKEN_RE") - def test_find_token_invalid_matches(self, token_re, token_cls, is_valid_id, is_valid_timestamp, is_maybevalid_hmac): + def test_find_token_invalid_matches( + self, + token_re, + token_cls, + is_valid_id, + is_valid_timestamp, + is_maybe_valid_hmac, + ): """None should be returned if no matches have valid user IDs or timestamps.""" token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)] token_cls.return_value = mock.create_autospec(Token, spec_set=True, instance=True) is_valid_id.return_value = False is_valid_timestamp.return_value = False - is_maybevalid_hmac.return_value = False + is_maybe_valid_hmac.return_value = False return_value = TokenRemover.find_token_in_message(self.msg) @@ -261,18 +268,17 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): results = [match[0] for match in results] self.assertCountEqual((token_1, token_2), results) - @autospec("bot.exts.filters.token_remover", "LOG_MESSAGE", "DECODED_LOG_MESSAGE") - def test_format_log_message(self, log_message, decoded_log_message): + @autospec("bot.exts.filters.token_remover", "LOG_MESSAGE") + def test_format_log_message(self, log_message): """Should correctly format the log message with info from the message and token.""" token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") log_message.format.return_value = "Howdy" - decoded_log_message.format.return_value = " Partner" - return_value = TokenRemover.format_log_message(self.msg, token, 472265943062413332, None) + return_value = TokenRemover.format_log_message(self.msg, token) self.assertEqual( return_value, - log_message.format.return_value + "\n" + decoded_log_message.format.return_value, + log_message.format.return_value, ) log_message.format.assert_called_once_with( author=self.msg.author, @@ -283,26 +289,38 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): hmac="x" * len(token.hmac), ) - @autospec("bot.exts.filters.token_remover", "LOG_MESSAGE", "USER_TOKEN_MESSAGE") - def test_format_log_message_user_token(self, log_message, user_token_message): + @autospec("bot.exts.filters.token_remover", "DECODED_LOG_MESSAGE") + def test_format_userid_log_message_bot(self, decoded_log_message): + """ + Should correctly format the user ID portion of the log message when the user ID is + not found in the server. + """ + token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") + decoded_log_message.format.return_value = " Partner" + msg = MockMessage(id=555, content="hello world") + msg.guild.get_member = MagicMock(return_value=None) + + return_value = TokenRemover.format_userid_log_message(msg, token) + + self.assertEqual( + return_value, + (decoded_log_message.format.return_value, False), + ) + decoded_log_message.format.assert_called_once_with( + user_id=472265943062413332, + ) + + @autospec("bot.exts.filters.token_remover", "USER_TOKEN_MESSAGE") + def test_format_log_message_user_token_user(self, user_token_message): """Should correctly format the log message with info from the message and token.""" token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") - log_message.format.return_value = "Howdy" user_token_message.format.return_value = "Partner" - return_value = TokenRemover.format_log_message(self.msg, token, 467223230650777641, "Bob") + return_value = TokenRemover.format_userid_log_message(self.msg, token) self.assertEqual( return_value, - log_message.format.return_value + "\n" + user_token_message.format.return_value, - ) - log_message.format.assert_called_once_with( - author=self.msg.author, - author_id=self.msg.author.id, - channel=self.msg.channel.mention, - user_id=token.user_id, - timestamp=token.timestamp, - hmac="x" * len(token.hmac), + (user_token_message.format.return_value, True), ) user_token_message.format.assert_called_once_with( user_id=467223230650777641, @@ -311,17 +329,19 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) @autospec("bot.exts.filters.token_remover", "log") - @autospec(TokenRemover, "format_log_message") - async def test_take_action(self, format_log_message, logger, mod_log_property): + @autospec(TokenRemover, "format_log_message", "format_userid_log_message") + async def test_take_action(self, format_log_message, format_userid_log_message, logger, mod_log_property): """Should delete the message and send a mod log.""" cog = TokenRemover(self.bot) mod_log = mock.create_autospec(ModLog, spec_set=True, instance=True) token = mock.create_autospec(Token, spec_set=True, instance=True) token.user_id = "no-id" log_msg = "testing123" + userid_log_message = "userid-log-message" mod_log_property.return_value = mod_log format_log_message.return_value = log_msg + format_userid_log_message.return_value = (userid_log_message, True) await cog.take_action(self.msg, token) @@ -330,7 +350,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): token_remover.DELETION_MESSAGE_TEMPLATE.format(mention=self.msg.author.mention) ) - format_log_message.assert_called_once_with(self.msg, token, None, "Bob") + format_log_message.assert_called_once_with(self.msg, token) + format_userid_log_message.assert_called_once_with(self.msg, token) logger.debug.assert_called_with(log_msg) self.bot.stats.incr.assert_called_once_with("tokens.removed_tokens") @@ -339,7 +360,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): icon_url=constants.Icons.token_removed, colour=Colour(constants.Colours.soft_red), title="Token removed!", - text=log_msg, + text=log_msg + "\n" + userid_log_message, thumbnail=self.msg.author.avatar_url_as.return_value, channel_id=constants.Channels.mod_alerts, ping_everyone=True, -- cgit v1.2.3 From b62db241766e20d54093273a7457cc52d34e3f75 Mon Sep 17 00:00:00 2001 From: Bast Date: Thu, 24 Sep 2020 10:26:45 -0700 Subject: Add BOT vs USER token detection, properly handling bot tokens for bots in the current server Also adjust the naming and purposes of the format messages to KNOWN and UNKNOWN token messages. --- bot/exts/filters/token_remover.py | 14 ++++++--- tests/bot/exts/filters/test_token_remover.py | 46 +++++++++++++++++++++++----- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py index 54f0bc034..87d4aa135 100644 --- a/bot/exts/filters/token_remover.py +++ b/bot/exts/filters/token_remover.py @@ -18,10 +18,10 @@ LOG_MESSAGE = ( "Censored a seemingly valid token sent by {author} (`{author_id}`) in {channel}, " "token was `{user_id}.{timestamp}.{hmac}`" ) -DECODED_LOG_MESSAGE = "The token user_id decodes into {user_id}." -USER_TOKEN_MESSAGE = ( +UNKNOWN_USER_LOG_MESSAGE = "The token user_id decodes into {user_id}." +KNOWN_USER_LOG_MESSAGE = ( "The token user_id decodes into {user_id}, " - "which matches `{user_name}` and means this is a valid USER token." + "which matches `{user_name}` and means this is a valid {kind} token." ) DELETION_MESSAGE_TEMPLATE = ( "Hey {mention}! I noticed you posted a seemingly valid Discord API " @@ -128,9 +128,13 @@ class TokenRemover(Cog): user = msg.guild.get_member(user_id) if user: - return USER_TOKEN_MESSAGE.format(user_id=user_id, user_name=str(user)), True + return KNOWN_USER_LOG_MESSAGE.format( + user_id=user_id, + user_name=str(user), + kind="BOT" if user.bot else "USER", + ), not user.bot else: - return DECODED_LOG_MESSAGE.format(user_id=user_id), False + return UNKNOWN_USER_LOG_MESSAGE.format(user_id=user_id), False @staticmethod def format_log_message(msg: Message, token: Token) -> str: diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py index 92dce201b..90d40d1df 100644 --- a/tests/bot/exts/filters/test_token_remover.py +++ b/tests/bot/exts/filters/test_token_remover.py @@ -22,7 +22,12 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.msg = MockMessage(id=555, content="hello world") self.msg.channel.mention = "#lemonade-stand" - self.msg.guild.get_member = MagicMock(return_value="Bob") + self.msg.guild.get_member = MagicMock( + return_value=MagicMock( + bot=False, + __str__=MagicMock(return_value="Woody"), + ), + ) self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) self.msg.author.avatar_url_as.return_value = "picture-lemon.png" @@ -289,14 +294,14 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): hmac="x" * len(token.hmac), ) - @autospec("bot.exts.filters.token_remover", "DECODED_LOG_MESSAGE") - def test_format_userid_log_message_bot(self, decoded_log_message): + @autospec("bot.exts.filters.token_remover", "UNKNOWN_USER_LOG_MESSAGE") + def test_format_userid_log_message_unknown(self, unknown_user_log_message): """ Should correctly format the user ID portion of the log message when the user ID is not found in the server. """ token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") - decoded_log_message.format.return_value = " Partner" + unknown_user_log_message.format.return_value = " Partner" msg = MockMessage(id=555, content="hello world") msg.guild.get_member = MagicMock(return_value=None) @@ -304,13 +309,37 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( return_value, - (decoded_log_message.format.return_value, False), + (unknown_user_log_message.format.return_value, False), + ) + unknown_user_log_message.format.assert_called_once_with( + user_id=472265943062413332, ) - decoded_log_message.format.assert_called_once_with( + + @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE") + def test_format_userid_log_message_bot(self, known_user_log_message): + """ + Should correctly format the user ID portion of the log message when the user ID is + not found in the server. + """ + token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") + known_user_log_message.format.return_value = " Partner" + msg = MockMessage(id=555, content="hello world") + msg.guild.get_member = MagicMock(return_value=MagicMock(__str__=MagicMock(return_value="Sam"), bot=True)) + + return_value = TokenRemover.format_userid_log_message(msg, token) + + self.assertEqual( + return_value, + (known_user_log_message.format.return_value, False), + ) + + known_user_log_message.format.assert_called_once_with( user_id=472265943062413332, + user_name="Sam", + kind="BOT", ) - @autospec("bot.exts.filters.token_remover", "USER_TOKEN_MESSAGE") + @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE") def test_format_log_message_user_token_user(self, user_token_message): """Should correctly format the log message with info from the message and token.""" token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") @@ -324,7 +353,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): ) user_token_message.format.assert_called_once_with( user_id=467223230650777641, - user_name="Bob", + user_name="Woody", + kind="USER", ) @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) -- cgit v1.2.3 From ce80892eb3928c7c312a221c9d0271698f1563f4 Mon Sep 17 00:00:00 2001 From: Bast Date: Thu, 24 Sep 2020 14:16:10 -0700 Subject: Change the mod alert message component for the user token detection Clean up mock usage, docstrings, unnecessarily split-lined function calls --- bot/exts/filters/token_remover.py | 18 +++++----- tests/bot/exts/filters/test_token_remover.py | 51 ++++++++-------------------- 2 files changed, 23 insertions(+), 46 deletions(-) diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py index 87d4aa135..87072e161 100644 --- a/bot/exts/filters/token_remover.py +++ b/bot/exts/filters/token_remover.py @@ -18,10 +18,10 @@ LOG_MESSAGE = ( "Censored a seemingly valid token sent by {author} (`{author_id}`) in {channel}, " "token was `{user_id}.{timestamp}.{hmac}`" ) -UNKNOWN_USER_LOG_MESSAGE = "The token user_id decodes into {user_id}." +UNKNOWN_USER_LOG_MESSAGE = "Decoded user ID: `{user_id}` (Not present in server)." KNOWN_USER_LOG_MESSAGE = ( - "The token user_id decodes into {user_id}, " - "which matches `{user_name}` and means this is a valid {kind} token." + "Decoded user ID: `{user_id}` **(Present in server)**.\n" + "This matches `{user_name}` and means this is likely a valid **{kind}** token." ) DELETION_MESSAGE_TEMPLATE = ( "Hey {mention}! I noticed you posted a seemingly valid Discord API " @@ -117,10 +117,12 @@ class TokenRemover(Cog): @classmethod def format_userid_log_message(cls, msg: Message, token: Token) -> t.Tuple[str, bool]: """ - Format the potion of the log message that includes details about the detected user ID. + Format the portion of the log message that includes details about the detected user ID. - Includes the user ID and, if present on the server, their name and a toggle to - mention everyone. + If the user is resolved to a member, the format includes the user ID, name, and the + kind of user detected. + + If we resolve to a member and it is not a bot, we also return True to ping everyone. Returns a tuple of (log_message, mention_everyone) """ @@ -139,7 +141,7 @@ class TokenRemover(Cog): @staticmethod def format_log_message(msg: Message, token: Token) -> str: """Return the generic portion of the log message to send for `token` being censored in `msg`.""" - message = LOG_MESSAGE.format( + return LOG_MESSAGE.format( author=msg.author, author_id=msg.author.id, channel=msg.channel.mention, @@ -148,8 +150,6 @@ class TokenRemover(Cog): hmac='x' * len(token.hmac), ) - return message - @classmethod def find_token_in_message(cls, msg: Message) -> t.Optional[Token]: """Return a seemingly valid token found in `msg` or `None` if no token is found.""" diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py index 90d40d1df..5f28ab571 100644 --- a/tests/bot/exts/filters/test_token_remover.py +++ b/tests/bot/exts/filters/test_token_remover.py @@ -22,12 +22,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.msg = MockMessage(id=555, content="hello world") self.msg.channel.mention = "#lemonade-stand" - self.msg.guild.get_member = MagicMock( - return_value=MagicMock( - bot=False, - __str__=MagicMock(return_value="Woody"), - ), - ) + self.msg.guild.get_member.return_value.bot = False + self.msg.guild.get_member.return_value.__str__.return_value = "Woody" self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) self.msg.author.avatar_url_as.return_value = "picture-lemon.png" @@ -212,7 +208,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): is_valid_timestamp, is_maybe_valid_hmac, ): - """None should be returned if no matches have valid user IDs or timestamps.""" + """None should be returned if no matches have valid user IDs, HMACs, and timestamps.""" token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)] token_cls.return_value = mock.create_autospec(Token, spec_set=True, instance=True) is_valid_id.return_value = False @@ -281,10 +277,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): return_value = TokenRemover.format_log_message(self.msg, token) - self.assertEqual( - return_value, - log_message.format.return_value, - ) + self.assertEqual(return_value, log_message.format.return_value) log_message.format.assert_called_once_with( author=self.msg.author, author_id=self.msg.author.id, @@ -296,42 +289,29 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): @autospec("bot.exts.filters.token_remover", "UNKNOWN_USER_LOG_MESSAGE") def test_format_userid_log_message_unknown(self, unknown_user_log_message): - """ - Should correctly format the user ID portion of the log message when the user ID is - not found in the server. - """ + """Should correctly format the user ID portion when the actual user it belongs to is unknown.""" token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") unknown_user_log_message.format.return_value = " Partner" msg = MockMessage(id=555, content="hello world") - msg.guild.get_member = MagicMock(return_value=None) + msg.guild.get_member.return_value = None return_value = TokenRemover.format_userid_log_message(msg, token) - self.assertEqual( - return_value, - (unknown_user_log_message.format.return_value, False), - ) - unknown_user_log_message.format.assert_called_once_with( - user_id=472265943062413332, - ) + self.assertEqual(return_value, (unknown_user_log_message.format.return_value, False)) + unknown_user_log_message.format.assert_called_once_with(user_id=472265943062413332) @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE") def test_format_userid_log_message_bot(self, known_user_log_message): - """ - Should correctly format the user ID portion of the log message when the user ID is - not found in the server. - """ + """Should correctly format the user ID portion when the ID belongs to a known bot.""" token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") known_user_log_message.format.return_value = " Partner" msg = MockMessage(id=555, content="hello world") - msg.guild.get_member = MagicMock(return_value=MagicMock(__str__=MagicMock(return_value="Sam"), bot=True)) + msg.guild.get_member.return_value.__str__.return_value = "Sam" + msg.guild.get_member.return_value.bot = True return_value = TokenRemover.format_userid_log_message(msg, token) - self.assertEqual( - return_value, - (known_user_log_message.format.return_value, False), - ) + self.assertEqual(return_value, (known_user_log_message.format.return_value, False)) known_user_log_message.format.assert_called_once_with( user_id=472265943062413332, @@ -341,16 +321,13 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE") def test_format_log_message_user_token_user(self, user_token_message): - """Should correctly format the log message with info from the message and token.""" + """Should correctly format the user ID portion when the ID belongs to a known user.""" token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") user_token_message.format.return_value = "Partner" return_value = TokenRemover.format_userid_log_message(self.msg, token) - self.assertEqual( - return_value, - (user_token_message.format.return_value, True), - ) + self.assertEqual(return_value, (user_token_message.format.return_value, True)) user_token_message.format.assert_called_once_with( user_id=467223230650777641, user_name="Woody", -- cgit v1.2.3 From 3f87c52f484afc1316e87f67f4055d5d615b054a Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Fri, 25 Sep 2020 15:29:14 +0530 Subject: Update users on bot start via HTTP PATCH method and send only user ID and the modified user data. --- bot/exts/backend/sync/_syncers.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index cf75b6407..512efaa3d 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -316,9 +316,18 @@ class UserSyncer(Syncer): for db_user in db_users.values(): guild_user = guild_users.get(db_user.id) + if guild_user is not None: if db_user != guild_user: - users_to_update.add(guild_user) + fields_to_none: dict = {} + + for field in _User._fields: + # Set un-changed values to None except ID to speed up API PATCH method. + if getattr(db_user, field) == getattr(guild_user, field) and field != "id": + fields_to_none[field] = None + + new_api_user = guild_user._replace(**fields_to_none) + users_to_update.add(new_api_user) elif db_user.in_guild: # The user is known in the DB but not the guild, and the @@ -326,7 +335,13 @@ class UserSyncer(Syncer): # This means that the user has left since the last sync. # Update the `in_guild` attribute of the user on the site # to signify that the user left. - new_api_user = db_user._replace(in_guild=False) + + # Set un-changed fields to None except ID as it is required by the API. + fields_to_none: dict = {field: None for field in db_user._fields if field not in ["id", "in_guild"]} + new_api_user = db_user._replace( + in_guild=False, + **fields_to_none + ) users_to_update.add(new_api_user) new_user_ids = set(guild_users.keys()) - set(db_users.keys()) @@ -364,6 +379,15 @@ class UserSyncer(Syncer): return endpoint, params + @staticmethod + def patch_dict(user: _User) -> dict: + """Convert namedtuple to dict by omitting None values.""" + user_dict: dict = {} + for field in user._fields: + if (value := getattr(user, field)) is not None: + user_dict[field] = value + return user_dict + async def _sync(self, diff: _Diff) -> None: """Synchronise the database with the user cache of `guild`.""" log.trace("Syncing created users...") @@ -371,5 +395,5 @@ class UserSyncer(Syncer): created: list = [user._asdict() for user in diff.created] await self.bot.api_client.post("bot/users", json=created) if diff.updated: - updated: list = [user._asdict() for user in diff.updated] + updated: list = [self.patch_dict(user) for user in diff.updated] await self.bot.api_client.patch("bot/users/bulk_patch", json=updated) -- cgit v1.2.3 From 840a3c504138ef601583cdf489908b2b6b30691f Mon Sep 17 00:00:00 2001 From: Bast Date: Fri, 25 Sep 2020 07:50:37 -0700 Subject: Remove redundant is_valid_userid function extract_user_id(id) is not None does the same job and is not worth the extra function --- bot/exts/filters/token_remover.py | 15 +--------- tests/bot/exts/filters/test_token_remover.py | 45 ++++++++++++++++------------ 2 files changed, 27 insertions(+), 33 deletions(-) diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py index 87072e161..3eb68c13c 100644 --- a/bot/exts/filters/token_remover.py +++ b/bot/exts/filters/token_remover.py @@ -158,7 +158,7 @@ class TokenRemover(Cog): for match in TOKEN_RE.finditer(msg.content): token = Token(*match.groups()) if ( - cls.is_valid_user_id(token.user_id) + (cls.extract_user_id(token.user_id) is not None) and cls.is_valid_timestamp(token.timestamp) and cls.is_maybe_valid_hmac(token.hmac) ): @@ -184,19 +184,6 @@ class TokenRemover(Cog): except (binascii.Error, ValueError): return None - @classmethod - def is_valid_user_id(cls, b64_content: str) -> bool: - """ - Check potential token to see if it contains a valid Discord user ID. - - See: https://discordapp.com/developers/docs/reference#snowflakes - """ - decoded_id = cls.extract_user_id(b64_content) - if not decoded_id: - return False - - return True - @staticmethod def is_valid_timestamp(b64_content: str) -> bool: """ diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py index 5f28ab571..f14780b02 100644 --- a/tests/bot/exts/filters/test_token_remover.py +++ b/tests/bot/exts/filters/test_token_remover.py @@ -27,20 +27,20 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) self.msg.author.avatar_url_as.return_value = "picture-lemon.png" - def test_is_valid_user_id_valid(self): - """Should consider user IDs valid if they decode entirely to ASCII digits.""" - ids = ( - "NDcyMjY1OTQzMDYyNDEzMzMy", - "NDc1MDczNjI5Mzk5NTQ3OTA0", - "NDY3MjIzMjMwNjUwNzc3NjQx", + def test_extract_user_id_valid(self): + """Should consider user IDs valid if they decode into an integer ID.""" + id_pairs = ( + ("NDcyMjY1OTQzMDYyNDEzMzMy", 472265943062413332), + ("NDc1MDczNjI5Mzk5NTQ3OTA0", 475073629399547904), + ("NDY3MjIzMjMwNjUwNzc3NjQx", 467223230650777641), ) - for user_id in ids: - with self.subTest(user_id=user_id): - result = TokenRemover.is_valid_user_id(user_id) - self.assertTrue(result) + for token_id, user_id in id_pairs: + with self.subTest(token_id=token_id): + result = TokenRemover.extract_user_id(token_id) + self.assertEqual(result, user_id) - def test_is_valid_user_id_invalid(self): + def test_extract_user_id_invalid(self): """Should consider non-digit and non-ASCII IDs invalid.""" ids = ( ("SGVsbG8gd29ybGQ", "non-digit ASCII"), @@ -54,8 +54,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): for user_id, msg in ids: with self.subTest(msg=msg): - result = TokenRemover.is_valid_user_id(user_id) - self.assertFalse(result) + result = TokenRemover.extract_user_id(user_id) + self.assertIsNone(result) def test_is_valid_timestamp_valid(self): """Should consider timestamps valid if they're greater than the Discord epoch.""" @@ -172,10 +172,17 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(return_value) token_re.finditer.assert_called_once_with(self.msg.content) - @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp", "is_maybe_valid_hmac") + @autospec(TokenRemover, "extract_user_id", "is_valid_timestamp", "is_maybe_valid_hmac") @autospec("bot.exts.filters.token_remover", "Token") @autospec("bot.exts.filters.token_remover", "TOKEN_RE") - def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp, is_maybe_valid_hmac): + def test_find_token_valid_match( + self, + token_re, + token_cls, + extract_user_id, + is_valid_timestamp, + is_maybe_valid_hmac, + ): """The first match with a valid user ID, timestamp, and HMAC should be returned as a `Token`.""" matches = [ mock.create_autospec(Match, spec_set=True, instance=True), @@ -188,7 +195,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): token_re.finditer.return_value = matches token_cls.side_effect = tokens - is_valid_id.side_effect = (False, True) # The 1st match will be invalid, 2nd one valid. + extract_user_id.side_effect = (None, True) # The 1st match will be invalid, 2nd one valid. is_valid_timestamp.return_value = True is_maybe_valid_hmac.return_value = True @@ -197,21 +204,21 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(tokens[1], return_value) token_re.finditer.assert_called_once_with(self.msg.content) - @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp", "is_maybe_valid_hmac") + @autospec(TokenRemover, "extract_user_id", "is_valid_timestamp", "is_maybe_valid_hmac") @autospec("bot.exts.filters.token_remover", "Token") @autospec("bot.exts.filters.token_remover", "TOKEN_RE") def test_find_token_invalid_matches( self, token_re, token_cls, - is_valid_id, + extract_user_id, is_valid_timestamp, is_maybe_valid_hmac, ): """None should be returned if no matches have valid user IDs, HMACs, and timestamps.""" token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)] token_cls.return_value = mock.create_autospec(Token, spec_set=True, instance=True) - is_valid_id.return_value = False + extract_user_id.return_value = None is_valid_timestamp.return_value = False is_maybe_valid_hmac.return_value = False -- cgit v1.2.3 From a96c5434a51a074584e46058591e4e27c91538f7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 25 Sep 2020 14:05:12 -0700 Subject: Add license & copyright for autospec's _decoration_helper --- LICENSE-THIRD-PARTY | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 LICENSE-THIRD-PARTY diff --git a/LICENSE-THIRD-PARTY b/LICENSE-THIRD-PARTY new file mode 100644 index 000000000..a126700a3 --- /dev/null +++ b/LICENSE-THIRD-PARTY @@ -0,0 +1,52 @@ +--------------------------------------------------------------------------------------------------- + PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +Applies to: + - Copyright © 2001-2020 Python Software Foundation. All rights reserved. + - tests/_autospec.py: _decoration_helper +--------------------------------------------------------------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, +2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation; +All Rights Reserved" are retained in Python alone or in any derivative version +prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. -- cgit v1.2.3 From 0739e0bce87d667e602d609eb39008530918cb0e Mon Sep 17 00:00:00 2001 From: Jack92829 <62740006+Jack92829@users.noreply.github.com> Date: Sat, 26 Sep 2020 10:13:27 +1000 Subject: Update guilds.md --- bot/resources/tags/guilds.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/resources/tags/guilds.md b/bot/resources/tags/guilds.md index d328b9e6e..fa02a1751 100644 --- a/bot/resources/tags/guilds.md +++ b/bot/resources/tags/guilds.md @@ -1,5 +1,4 @@ -**Are you on the lookout for new guilds to join?** +**Need help with another language or related field of interest?** -If you're looking for a community dedicated to a certain tool, language or related field of interest, check out this *[awesome list](https://github.com/mhxion/awesome-discord-communities)*. A curated list of Discord communities that are dedicated to a multitude of areas including [Programming languages](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#programming-languages), [Electricals](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#electricals), [Computer science](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#art-of-computer-science), [Operating systems](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#operating-systems) and more! - -Also consider checking out the wonderful communities this server has partnered with, either in the partners channel or the [communities](https://pythondiscord.com/pages/resources/communities/) page of the python discord's website. +This community is dedicated to python, and while we have off-topic channels, it is not always the greatest place to find help regarding other languages or fields. If you need help with another language or particular field of interest, we recommend you check out this [awesome list](https://github.com/mhxion/awesome-discord-communities), a list of communities specialising in a wide range of areas including [Programming languages](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#programming-languages), [Electricals](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#electricals), [Computer science](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#art-of-computer-science) and [Operating systems](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#operating-systems). +Also consider joining the wonderful [communities](https://pythondiscord.com/pages/resources/communities/) we have partnered with. -- cgit v1.2.3 From fd955f61447dc5401629a9312d4a86e3cbe68693 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 26 Sep 2020 15:43:09 +0300 Subject: Async Cache: Create class-based async cache --- bot/exts/info/doc.py | 7 +++++-- bot/exts/utils/utils.py | 5 ++++- bot/utils/cache.py | 49 ++++++++++++++++++++++++++++--------------------- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/bot/exts/info/doc.py b/bot/exts/info/doc.py index 06dd4df63..ba443d817 100644 --- a/bot/exts/info/doc.py +++ b/bot/exts/info/doc.py @@ -22,7 +22,7 @@ from bot.bot import Bot from bot.constants import MODERATION_ROLES, RedirectOutput from bot.converters import ValidPythonIdentifier, ValidURL from bot.pagination import LinePaginator -from bot.utils.cache import async_cache +from bot.utils.cache import AsyncCache from bot.utils.messages import wait_for_deletion @@ -66,6 +66,9 @@ WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)") FAILED_REQUEST_RETRY_AMOUNT = 3 NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay +# Async cache instance for docs cog +async_cache = AsyncCache() + class DocMarkdownConverter(MarkdownConverter): """Subclass markdownify's MarkdownCoverter to provide custom conversion methods.""" @@ -187,7 +190,7 @@ class Doc(commands.Cog): self.base_urls.clear() self.inventories.clear() self.renamed_symbols.clear() - async_cache.cache["get_symbol_embed"] = OrderedDict() + async_cache.clear() # Run all coroutines concurrently - since each of them performs a HTTP # request, this speeds up fetching the inventory data heavily. diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 2a74af172..64d42c93e 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -15,7 +15,7 @@ from bot.constants import Channels, MODERATION_ROLES, STAFF_ROLES from bot.decorators import in_whitelist from bot.pagination import LinePaginator from bot.utils import messages -from bot.utils.cache import async_cache +from bot.utils.cache import AsyncCache log = logging.getLogger(__name__) @@ -43,6 +43,9 @@ Namespaces are one honking great idea -- let's do more of those! ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" +# Async cache instance for PEPs +async_cache = AsyncCache() + class Utils(Cog): """A selection of utilities which don't have a clear category.""" diff --git a/bot/utils/cache.py b/bot/utils/cache.py index 37c2b199c..d8ec64ec8 100644 --- a/bot/utils/cache.py +++ b/bot/utils/cache.py @@ -3,7 +3,7 @@ from collections import OrderedDict from typing import Any, Callable -def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: +class AsyncCache: """ LRU cache implementation for coroutines. @@ -11,23 +11,30 @@ def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key. """ - # Make global cache as dictionary to allow multiple function caches - async_cache.cache = {} - - def decorator(function: Callable) -> Callable: - """Define the async_cache decorator.""" - async_cache.cache[function.__name__] = OrderedDict() - - @functools.wraps(function) - async def wrapper(*args) -> Any: - """Decorator wrapper for the caching logic.""" - key = ':'.join(str(args[arg_offset:])) - - if key not in async_cache.cache: - if len(async_cache.cache[function.__name__]) > max_size: - async_cache.cache[function.__name__].popitem(last=False) - - async_cache.cache[function.__name__][key] = await function(*args) - return async_cache.cache[function.__name__][key] - return wrapper - return decorator + + def __init__(self): + self._cache = OrderedDict() + + def __call__(self, max_size: int = 128, arg_offset: int = 0) -> Callable: + """Decorator for async cache.""" + + def decorator(function: Callable) -> Callable: + """Define the async cache decorator.""" + + @functools.wraps(function) + async def wrapper(*args) -> Any: + """Decorator wrapper for the caching logic.""" + key = ':'.join(str(args[arg_offset:])) + + if key not in self._cache: + if len(self._cache) > max_size: + self._cache.popitem(last=False) + + self._cache[key] = await function(*args) + return self._cache[key] + return wrapper + return decorator + + def clear(self): + """Clear cache instance.""" + self._cache.clear() -- cgit v1.2.3 From 17cf8278ff9768f194bf74980507361b0a13af03 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 26 Sep 2020 15:54:32 +0300 Subject: PEP: Split get_pep_embed to smaller parts --- bot/exts/utils/utils.py | 56 ++++++++++++++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 64d42c93e..cc284ec5a 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -7,7 +7,7 @@ from email.parser import HeaderParser from io import StringIO from typing import Dict, Optional, Tuple, Union -from discord import Colour, Embed, utils +from discord import Colour, Embed, Message, utils from discord.ext.commands import BadArgument, Cog, Context, clean_content, command, has_any_role from bot.bot import Bot @@ -223,6 +223,9 @@ class Utils(Cog): if pep_number == 0: pep_embed = self.get_pep_zero_embed() else: + if not await self.validate_pep_number(ctx, pep_number): + return + pep_embed = await self.get_pep_embed(ctx, pep_number) if pep_embed: @@ -244,9 +247,8 @@ class Utils(Cog): return pep_embed - @async_cache(arg_offset=2) - async def get_pep_embed(self, ctx: Context, pep_nr: int) -> Optional[Embed]: - """Fetch, generate and return PEP embed. When any error occur, use `self.send_pep_error_embed`.""" + async def validate_pep_number(self, ctx: Context, pep_nr: int) -> bool: + """Validate is PEP number valid. When it isn't, send error and return False. Otherwise return True.""" if ( pep_nr not in self.peps and (self.last_refreshed_peps + timedelta(minutes=30)) <= datetime.now() @@ -257,8 +259,34 @@ class Utils(Cog): if pep_nr not in self.peps: log.trace(f"PEP {pep_nr} was not found") not_found = f"PEP {pep_nr} does not exist." - return await self.send_pep_error_embed(ctx, "PEP not found", not_found) + await self.send_pep_error_embed(ctx, "PEP not found", not_found) + return False + + return True + + def generate_pep_embed(self, pep_header: Dict, pep_nr: int) -> Embed: + """Generate PEP embed based on PEP headers data.""" + # Assemble the embed + pep_embed = Embed( + title=f"**PEP {pep_nr} - {pep_header['Title']}**", + description=f"[Link]({self.base_pep_url}{pep_nr:04})", + ) + + pep_embed.set_thumbnail(url=ICON_URL) + # Add the interesting information + fields_to_check = ("Status", "Python-Version", "Created", "Type") + for field in fields_to_check: + # Check for a PEP metadata field that is present but has an empty value + # embed field values can't contain an empty string + if pep_header.get(field, ""): + pep_embed.add_field(name=field, value=pep_header[field]) + + return pep_embed + + @async_cache(arg_offset=2) + async def get_pep_embed(self, ctx: Context, pep_nr: int) -> Optional[Embed]: + """Fetch, generate and return PEP embed. When any error occur, use `self.send_pep_error_embed`.""" response = await self.bot.http_session.get(self.peps[pep_nr]) if response.status == 200: @@ -267,23 +295,7 @@ class Utils(Cog): # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 pep_header = HeaderParser().parse(StringIO(pep_content)) - - # Assemble the embed - pep_embed = Embed( - title=f"**PEP {pep_nr} - {pep_header['Title']}**", - description=f"[Link]({self.base_pep_url}{pep_nr:04})", - ) - - pep_embed.set_thumbnail(url=ICON_URL) - - # Add the interesting information - fields_to_check = ("Status", "Python-Version", "Created", "Type") - for field in fields_to_check: - # Check for a PEP metadata field that is present but has an empty value - # embed field values can't contain an empty string - if pep_header.get(field, ""): - pep_embed.add_field(name=field, value=pep_header[field]) - return pep_embed + return self.generate_pep_embed(pep_header, pep_nr) else: log.trace( f"The user requested PEP {pep_nr}, but the response had an unexpected status code: {response.status}." -- cgit v1.2.3 From 3e66482d026490654af1a5a24e96b44bdc804af2 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 26 Sep 2020 15:57:30 +0300 Subject: Fix linting --- bot/exts/info/doc.py | 1 - bot/exts/utils/utils.py | 2 +- bot/utils/cache.py | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/doc.py b/bot/exts/info/doc.py index ba443d817..1fd0ee266 100644 --- a/bot/exts/info/doc.py +++ b/bot/exts/info/doc.py @@ -3,7 +3,6 @@ import functools import logging import re import textwrap -from collections import OrderedDict from contextlib import suppress from types import SimpleNamespace from typing import Optional, Tuple diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index cc284ec5a..278b6fefb 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -7,7 +7,7 @@ from email.parser import HeaderParser from io import StringIO from typing import Dict, Optional, Tuple, Union -from discord import Colour, Embed, Message, utils +from discord import Colour, Embed, utils from discord.ext.commands import BadArgument, Cog, Context, clean_content, command, has_any_role from bot.bot import Bot diff --git a/bot/utils/cache.py b/bot/utils/cache.py index d8ec64ec8..70925b71d 100644 --- a/bot/utils/cache.py +++ b/bot/utils/cache.py @@ -35,6 +35,6 @@ class AsyncCache: return wrapper return decorator - def clear(self): + def clear(self) -> None: """Clear cache instance.""" self._cache.clear() -- cgit v1.2.3 From f81920ad6427490a06061cfb8533828b26735dcf Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 26 Sep 2020 12:23:20 -0700 Subject: Sync: update sync() docstring --- bot/exts/backend/sync/_syncers.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index a07a93eab..3d4a09df3 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -44,9 +44,7 @@ class Syncer(abc.ABC): """ Synchronise the database with the cache of `guild`. - If the differences between the cache and the database are greater than - `bot.constants.Sync.max_diff`, then a confirmation prompt will be sent to the dev-core - channel. The confirmation can be optionally redirect to `ctx` instead. + If `ctx` is given, send a message with the results. """ log.info(f"Starting {self.name} syncer.") -- cgit v1.2.3 From 107ca75eedb2cdc140df9b9116b53998bfd61cfe Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 27 Sep 2020 14:27:36 +0200 Subject: Add the video to the welcome DM. This rewords the welcome DM, and adds the new Welcome To Python Discord video to it. --- bot/exts/moderation/verification.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 6bbe81701..e9ab2c816 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -21,12 +21,15 @@ log = logging.getLogger(__name__) # Sent via DMs once user joins the guild ON_JOIN_MESSAGE = f""" -Hello! Welcome to Python Discord! +Welcome to Python Discord! -As a new user, you have read-only access to a few select channels to give you a taste of what our server is like. +To show you what kind of community we are, we've created this video: +https://youtu.be/ZH26PuX3re0 -In order to see the rest of the channels and to send messages, you first have to accept our rules. To do so, \ -please visit <#{constants.Channels.verification}>. Thank you! +As a new user, you have read-only access to a few select channels to give you a taste of what our server is like. \ +In order to see the rest of the channels and to send messages, you first have to accept our rules. + +Please visit <#{constants.Channels.verification}> to get started. Thank you! """ # Sent via DMs once user verifies -- cgit v1.2.3 From 2032391d50c16d15ab71fb0b29081c89bf77e751 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 27 Sep 2020 14:41:36 +0200 Subject: Relock Pipfile to update async-redis. This also bumps minor versions of several other packages. I've spun up the bot and played around with it, and run all unit tests. Everything still seems to be in order. --- Pipfile.lock | 132 +++++++++++++++++++++++++++++++---------------------------- 1 file changed, 70 insertions(+), 62 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index f75852081..4c63277de 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -86,12 +86,12 @@ "fakeredis" ], "hashes": [ - "sha256:407aed1aad97bf22f690eca5369806d22eefc8ca104a52c1f1bd47dd6db45fc2", - "sha256:563aaff79ec611a92a0ad78e39ff159e3a4b4cf0bea41e061de5f3701a17d50c" + "sha256:6be8a657d724ccbcfb1946d29a80c3478c5f9ecd2f78a0a26d2f4013a622258f", + "sha256:c25e4fff73f64d20645254783c3224a4c49e083e3fab67c44f17af944c5e26af" ], "index": "pypi", "markers": "python_version ~= '3.7'", - "version": "==0.1.2" + "version": "==0.1.4" }, "async-timeout": { "hashes": [ @@ -119,12 +119,12 @@ }, "beautifulsoup4": { "hashes": [ - "sha256:73cc4d115b96f79c7d77c1c7f7a0a8d4c57860d1041df407dd1aae7f07a77fd7", - "sha256:a6237df3c32ccfaee4fd201c8f5f9d9df619b93121d01353a64a73ce8c6ef9a8", - "sha256:e718f2342e2e099b640a34ab782407b7b676f47ee272d6739e60b8ea23829f2c" + "sha256:1edf5e39f3a5bc6e38b235b369128416c7239b34f692acccececb040233032a1", + "sha256:5dfe44f8fddc89ac5453f02659d3ab1668f2c0d9684839f0785037e8c6d9ac8d", + "sha256:645d833a828722357038299b7f6879940c11dddd95b900fe5387c258b72bb883" ], "index": "pypi", - "version": "==4.9.1" + "version": "==4.9.2" }, "certifi": { "hashes": [ @@ -135,36 +135,44 @@ }, "cffi": { "hashes": [ - "sha256:0da50dcbccd7cb7e6c741ab7912b2eff48e85af217d72b57f80ebc616257125e", - "sha256:12a453e03124069b6896107ee133ae3ab04c624bb10683e1ed1c1663df17c13c", - "sha256:15419020b0e812b40d96ec9d369b2bc8109cc3295eac6e013d3261343580cc7e", - "sha256:15a5f59a4808f82d8ec7364cbace851df591c2d43bc76bcbe5c4543a7ddd1bf1", - "sha256:23e44937d7695c27c66a54d793dd4b45889a81b35c0751ba91040fe825ec59c4", - "sha256:29c4688ace466a365b85a51dcc5e3c853c1d283f293dfcc12f7a77e498f160d2", - "sha256:57214fa5430399dffd54f4be37b56fe22cedb2b98862550d43cc085fb698dc2c", - "sha256:577791f948d34d569acb2d1add5831731c59d5a0c50a6d9f629ae1cefd9ca4a0", - "sha256:6539314d84c4d36f28d73adc1b45e9f4ee2a89cdc7e5d2b0a6dbacba31906798", - "sha256:65867d63f0fd1b500fa343d7798fa64e9e681b594e0a07dc934c13e76ee28fb1", - "sha256:672b539db20fef6b03d6f7a14b5825d57c98e4026401fce838849f8de73fe4d4", - "sha256:6843db0343e12e3f52cc58430ad559d850a53684f5b352540ca3f1bc56df0731", - "sha256:7057613efefd36cacabbdbcef010e0a9c20a88fc07eb3e616019ea1692fa5df4", - "sha256:76ada88d62eb24de7051c5157a1a78fd853cca9b91c0713c2e973e4196271d0c", - "sha256:837398c2ec00228679513802e3744d1e8e3cb1204aa6ad408b6aff081e99a487", - "sha256:8662aabfeab00cea149a3d1c2999b0731e70c6b5bac596d95d13f643e76d3d4e", - "sha256:95e9094162fa712f18b4f60896e34b621df99147c2cee216cfa8f022294e8e9f", - "sha256:99cc66b33c418cd579c0f03b77b94263c305c389cb0c6972dac420f24b3bf123", - "sha256:9b219511d8b64d3fa14261963933be34028ea0e57455baf6781fe399c2c3206c", - "sha256:ae8f34d50af2c2154035984b8b5fc5d9ed63f32fe615646ab435b05b132ca91b", - "sha256:b9aa9d8818c2e917fa2c105ad538e222a5bce59777133840b93134022a7ce650", - "sha256:bf44a9a0141a082e89c90e8d785b212a872db793a0080c20f6ae6e2a0ebf82ad", - "sha256:c0b48b98d79cf795b0916c57bebbc6d16bb43b9fc9b8c9f57f4cf05881904c75", - "sha256:da9d3c506f43e220336433dffe643fbfa40096d408cb9b7f2477892f369d5f82", - "sha256:e4082d832e36e7f9b2278bc774886ca8207346b99f278e54c9de4834f17232f7", - "sha256:e4b9b7af398c32e408c00eb4e0d33ced2f9121fd9fb978e6c1b57edd014a7d15", - "sha256:e613514a82539fc48291d01933951a13ae93b6b444a88782480be32245ed4afa", - "sha256:f5033952def24172e60493b68717792e3aebb387a8d186c43c020d9363ee7281" - ], - "version": "==1.14.2" + "sha256:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d", + "sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b", + "sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4", + "sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f", + "sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3", + "sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579", + "sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537", + "sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e", + "sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05", + "sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171", + "sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca", + "sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522", + "sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c", + "sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc", + "sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d", + "sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808", + "sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828", + "sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869", + "sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d", + "sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9", + "sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0", + "sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc", + "sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15", + "sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c", + "sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a", + "sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3", + "sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1", + "sha256:cb763ceceae04803adcc4e2d80d611ef201c73da32d8f2722e9d0ab0c7f10768", + "sha256:cc75f58cdaf043fe6a7a6c04b3b5a0e694c6a9e24050967747251fb80d7bce0d", + "sha256:d80998ed59176e8cba74028762fbd9b9153b9afc71ea118e63bbf5d4d0f9552b", + "sha256:de31b5164d44ef4943db155b3e8e17929707cac1e5bd2f363e67a56e3af4af6e", + "sha256:e66399cf0fc07de4dce4f588fc25bfe84a6d1285cc544e67987d22663393926d", + "sha256:f0620511387790860b249b9241c2f13c3a80e21a73e0b861a2df24e9d6f56730", + "sha256:f4eae045e6ab2bb54ca279733fe4eb85f1effda392666308250714e01907f394", + "sha256:f92cdecb618e5fa4658aeb97d5eb3d2f47aa94ac6477c6daf0f306c5a3b9e6b1", + "sha256:f92f789e4f9241cd262ad7a555ca2c648a98178a953af117ef7fad46aa1d5591" + ], + "version": "==1.14.3" }, "chardet": { "hashes": [ @@ -575,11 +583,11 @@ }, "sentry-sdk": { "hashes": [ - "sha256:1a086486ff9da15791f294f6e9915eb3747d161ef64dee2d038a4d0b4a369b24", - "sha256:45486deb031cea6bbb25a540d7adb4dd48cd8a1cc31e6a5ce9fb4f792a572e9a" + "sha256:c9c0fa1412bad87104c4eee8dd36c7bbf60b0d92ae917ab519094779b22e6d9a", + "sha256:e159f7c919d19ae86e5a4ff370fccc45149fab461fbeb93fb5a735a0b33a9cb1" ], "index": "pypi", - "version": "==0.17.6" + "version": "==0.17.8" }, "six": { "hashes": [ @@ -608,7 +616,7 @@ "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55", "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232" ], - "markers": "python_version >= '3.5'", + "markers": "python_version >= '3.0'", "version": "==2.0.1" }, "sphinx": { @@ -685,26 +693,26 @@ }, "yarl": { "hashes": [ - "sha256:040b237f58ff7d800e6e0fd89c8439b841f777dd99b4a9cca04d6935564b9409", - "sha256:17668ec6722b1b7a3a05cc0167659f6c95b436d25a36c2d52db0eca7d3f72593", - "sha256:3a584b28086bc93c888a6c2aa5c92ed1ae20932f078c46509a66dce9ea5533f2", - "sha256:4439be27e4eee76c7632c2427ca5e73703151b22cae23e64adb243a9c2f565d8", - "sha256:48e918b05850fffb070a496d2b5f97fc31d15d94ca33d3d08a4f86e26d4e7c5d", - "sha256:9102b59e8337f9874638fcfc9ac3734a0cfadb100e47d55c20d0dc6087fb4692", - "sha256:9b930776c0ae0c691776f4d2891ebc5362af86f152dd0da463a6614074cb1b02", - "sha256:b3b9ad80f8b68519cc3372a6ca85ae02cc5a8807723ac366b53c0f089db19e4a", - "sha256:bc2f976c0e918659f723401c4f834deb8a8e7798a71be4382e024bcc3f7e23a8", - "sha256:c22c75b5f394f3d47105045ea551e08a3e804dc7e01b37800ca35b58f856c3d6", - "sha256:c52ce2883dc193824989a9b97a76ca86ecd1fa7955b14f87bf367a61b6232511", - "sha256:ce584af5de8830d8701b8979b18fcf450cef9a382b1a3c8ef189bedc408faf1e", - "sha256:da456eeec17fa8aa4594d9a9f27c0b1060b6a75f2419fe0c00609587b2695f4a", - "sha256:db6db0f45d2c63ddb1a9d18d1b9b22f308e52c83638c26b422d520a815c4b3fb", - "sha256:df89642981b94e7db5596818499c4b2219028f2a528c9c37cc1de45bf2fd3a3f", - "sha256:f18d68f2be6bf0e89f1521af2b1bb46e66ab0018faafa81d70f358153170a317", - "sha256:f379b7f83f23fe12823085cd6b906edc49df969eb99757f58ff382349a3303c6" + "sha256:04a54f126a0732af75e5edc9addeaa2113e2ca7c6fce8974a63549a70a25e50e", + "sha256:3cc860d72ed989f3b1f3abbd6ecf38e412de722fb38b8f1b1a086315cf0d69c5", + "sha256:5d84cc36981eb5a8533be79d6c43454c8e6a39ee3118ceaadbd3c029ab2ee580", + "sha256:5e447e7f3780f44f890360ea973418025e8c0cdcd7d6a1b221d952600fd945dc", + "sha256:61d3ea3c175fe45f1498af868879c6ffeb989d4143ac542163c45538ba5ec21b", + "sha256:67c5ea0970da882eaf9efcf65b66792557c526f8e55f752194eff8ec722c75c2", + "sha256:6f6898429ec3c4cfbef12907047136fd7b9e81a6ee9f105b45505e633427330a", + "sha256:7ce35944e8e61927a8f4eb78f5bc5d1e6da6d40eadd77e3f79d4e9399e263921", + "sha256:b7c199d2cbaf892ba0f91ed36d12ff41ecd0dde46cbf64ff4bfe997a3ebc925e", + "sha256:c15d71a640fb1f8e98a1423f9c64d7f1f6a3a168f803042eaf3a5b5022fde0c1", + "sha256:c22607421f49c0cb6ff3ed593a49b6a99c6ffdeaaa6c944cdda83c2393c8864d", + "sha256:c604998ab8115db802cc55cb1b91619b2831a6128a62ca7eea577fc8ea4d3131", + "sha256:d088ea9319e49273f25b1c96a3763bf19a882cff774d1792ae6fba34bd40550a", + "sha256:db9eb8307219d7e09b33bcb43287222ef35cbcf1586ba9472b0a4b833666ada1", + "sha256:e31fef4e7b68184545c3d68baec7074532e077bd1906b040ecfba659737df188", + "sha256:e32f0fb443afcfe7f01f95172b66f279938fbc6bdaebe294b0ff6747fb6db020", + "sha256:fcbe419805c9b20db9a51d33b942feddbf6e7fb468cb20686fd7089d4164c12a" ], "markers": "python_version >= '3.5'", - "version": "==1.5.1" + "version": "==1.6.0" } }, "develop": { @@ -857,11 +865,11 @@ }, "identify": { "hashes": [ - "sha256:c770074ae1f19e08aadbda1c886bc6d0cb55ffdc503a8c0fe8699af2fc9664ae", - "sha256:d02d004568c5a01261839a05e91705e3e9f5c57a3551648f9b3fb2b9c62c0f62" + "sha256:7c22c384a2c9b32c5cc891d13f923f6b2653aa83e2d75d8f79be240d6c86c4f4", + "sha256:da683bfb7669fa749fc7731f378229e2dbf29a1d1337cbde04106f02236eb29d" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.5.3" + "version": "==1.5.5" }, "mccabe": { "hashes": [ -- cgit v1.2.3 From c2912658fc3ec6dd8881688fcd489b797a270b0f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 26 Sep 2020 23:23:29 +0200 Subject: Verification: move disabled DM handling into helper Note that we were previously only catching 403. As the docstring explains, we will now catch any Discord exception and only look at the the code, rather than the status. --- bot/exts/moderation/verification.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index e9ab2c816..e10ad3e23 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -109,6 +109,25 @@ def is_verified(member: discord.Member) -> bool: return len(set(member.roles) - unverified_roles) > 0 +async def safe_dm(coro: t.Coroutine) -> None: + """ + Execute `coro` ignoring disabled DM warnings. + + The 50_0007 error code indicates that the target user does not accept DMs. + As it turns out, this error code can appear on both 400 and 403 statuses, + we therefore catch any Discord exception. + + If the request fails on any other error code, the exception propagates, + and must be handled by the caller. + """ + try: + await coro + except discord.HTTPException as discord_exc: + log.trace(f"DM dispatch failed on status {discord_exc.status} with code: {discord_exc.code}") + if discord_exc.code != 50_007: # If any reason other than disabled DMs + raise + + class Verification(Cog): """ User verification and role management. @@ -330,11 +349,9 @@ class Verification(Cog): async def kick_request(member: discord.Member) -> None: """Send `KICKED_MESSAGE` to `member` and kick them from the guild.""" try: - await member.send(KICKED_MESSAGE) - except discord.Forbidden as exc_403: - log.trace(f"DM dispatch failed on 403 error with code: {exc_403.code}") - if exc_403.code != 50_007: # 403 raised for any other reason than disabled DMs - raise StopExecution(reason=exc_403) + await safe_dm(member.send(KICKED_MESSAGE)) # Suppress disabled DMs + except discord.HTTPException as suspicious_exception: + raise StopExecution(reason=suspicious_exception) await member.kick(reason=f"User has not verified in {constants.Verification.kicked_after} days") n_kicked = await self._send_requests(members, kick_request, Limit(batch_size=2, sleep_secs=1)) -- cgit v1.2.3 From 6c069be09c4edee18b5853d990ffe1dff86ef9ce Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 26 Sep 2020 23:37:01 +0200 Subject: Verification: apply 'safe_dm' to all DM dispatches Now, when we send a DM and it fails: * Ignore if due to disabled DMs * Log exception otherwise --- bot/exts/moderation/verification.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index e10ad3e23..206556483 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -520,8 +520,10 @@ class Verification(Cog): return # Only listen for PyDis events log.trace(f"Sending on join message to new member: {member.id}") - with suppress(discord.Forbidden): - await member.send(ON_JOIN_MESSAGE) + try: + await safe_dm(member.send(ON_JOIN_MESSAGE)) + except discord.HTTPException: + log.exception("DM dispatch failed on unexpected error code") @Cog.listener() async def on_message(self, message: discord.Message) -> None: @@ -688,9 +690,9 @@ class Verification(Cog): await ctx.author.remove_roles(discord.Object(constants.Roles.unverified)) try: - await ctx.author.send(VERIFIED_MESSAGE) - except discord.Forbidden: - log.info(f"Sending welcome message failed for {ctx.author}.") + await safe_dm(ctx.author.send(VERIFIED_MESSAGE)) + except discord.HTTPException: + log.exception(f"Sending welcome message failed for {ctx.author}.") finally: log.trace(f"Deleting accept message by {ctx.author}.") with suppress(discord.NotFound): -- cgit v1.2.3 From 921198829a3339caf5e027ac893c0996815650f3 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sun, 27 Sep 2020 17:02:56 +0200 Subject: Allow !eval in #code-help-voice --- bot/constants.py | 1 + bot/exts/utils/snekbox.py | 2 +- config-default.yml | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index c710e2dff..d3794d173 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -391,6 +391,7 @@ class Channels(metaclass=YAMLGetter): big_brother_logs: int bot_commands: int change_log: int + code_help_voice: int cooldown: int defcon: int dev_contrib: int diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index b3baffba2..18b9a5014 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -41,7 +41,7 @@ RAW_CODE_REGEX = re.compile( MAX_PASTE_LEN = 1000 # `!eval` command whitelists -EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric) +EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric, Channels.code_help_voice) EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use) EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) diff --git a/config-default.yml b/config-default.yml index e7669e6db..5112af95b 100644 --- a/config-default.yml +++ b/config-default.yml @@ -190,6 +190,7 @@ guild: admin_announcements: &ADMIN_ANNOUNCEMENTS 749736155569848370 # Voice + code_help_voice: 755154969761677312 admins_voice: &ADMINS_VOICE 500734494840717332 staff_voice: &STAFF_VOICE 412375055910043655 -- cgit v1.2.3 From 27f9e118d4f18cbfd4b64b28e6792b7fe4462523 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sun, 27 Sep 2020 17:08:27 +0200 Subject: Allow !role for any staff role Closes #1173 --- bot/exts/info/information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 156dfec35..f6ed176f1 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -77,7 +77,7 @@ class Information(Cog): channel_type_list = sorted(channel_type_list) return "\n".join(channel_type_list) - @has_any_role(*constants.MODERATION_ROLES) + @has_any_role(*constants.STAFF_ROLES) @command(name="roles") async def roles_info(self, ctx: Context) -> None: """Returns a list of all roles and their corresponding IDs.""" @@ -97,7 +97,7 @@ class Information(Cog): await LinePaginator.paginate(role_list, ctx, embed, empty=False) - @has_any_role(*constants.MODERATION_ROLES) + @has_any_role(*constants.STAFF_ROLES) @command(name="role") async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None: """ -- cgit v1.2.3 From 27b666b65edfdd3294ce9bf58cc2736bf1437eb8 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 27 Sep 2020 18:08:48 +0200 Subject: Incidents: reduce timeout log to info level This shouldn't be a warning, as we cannot do anything about it. Fixes BOT-8X --- bot/exts/moderation/incidents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 31be48a43..0e479d33f 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -319,7 +319,7 @@ class Incidents(Cog): try: await confirmation_task except asyncio.TimeoutError: - log.warning(f"Did not receive incident deletion confirmation within {timeout} seconds!") + log.info(f"Did not receive incident deletion confirmation within {timeout} seconds!") else: log.trace("Deletion was confirmed") -- cgit v1.2.3 From 56089920fb7ece152a97e6dc71968bb875c28c33 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Sun, 27 Sep 2020 22:51:28 +0530 Subject: modify tests to use paginated response. --- tests/bot/exts/backend/sync/test_users.py | 43 ++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py index c0a1da35c..4ebc8b82f 100644 --- a/tests/bot/exts/backend/sync/test_users.py +++ b/tests/bot/exts/backend/sync/test_users.py @@ -41,6 +41,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): return guild async def test_empty_diff_for_no_users(self): + # TODO: need to fix this test. """When no users are given, an empty diff should be returned.""" guild = self.get_guild() @@ -51,7 +52,12 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): async def test_empty_diff_for_identical_users(self): """No differences should be found if the users in the guild and DB are identical.""" - self.bot.api_client.get.return_value = [fake_user()] + self.bot.api_client.get.return_value = { + "count": 3, + "next": None, + "previous": None, + "results": [fake_user()] + } guild = self.get_guild(fake_user()) actual_diff = await self.syncer._get_diff(guild) @@ -63,7 +69,12 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): """Only updated users should be added to the 'updated' set of the diff.""" updated_user = fake_user(id=99, name="new") - self.bot.api_client.get.return_value = [fake_user(id=99, name="old"), fake_user()] + self.bot.api_client.get.return_value = { + "count": 3, + "next": None, + "previous": None, + "results": [fake_user(id=99, name="old"), fake_user()] + } guild = self.get_guild(updated_user, fake_user()) actual_diff = await self.syncer._get_diff(guild) @@ -75,7 +86,12 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): """Only new users should be added to the 'created' set of the diff.""" new_user = fake_user(id=99, name="new") - self.bot.api_client.get.return_value = [fake_user()] + self.bot.api_client.get.return_value = { + "count": 3, + "next": None, + "previous": None, + "results": [fake_user()] + } guild = self.get_guild(fake_user(), new_user) actual_diff = await self.syncer._get_diff(guild) @@ -87,7 +103,12 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" leaving_user = fake_user(id=63, in_guild=False) - self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63)] + self.bot.api_client.get.return_value = { + "count": 3, + "next": None, + "previous": None, + "results": [fake_user(), fake_user(id=63)] + } guild = self.get_guild(fake_user()) actual_diff = await self.syncer._get_diff(guild) @@ -101,7 +122,12 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): updated_user = fake_user(id=55, name="updated") leaving_user = fake_user(id=63, in_guild=False) - self.bot.api_client.get.return_value = [fake_user(), fake_user(id=55), fake_user(id=63)] + self.bot.api_client.get.return_value = { + "count": 3, + "next": None, + "previous": None, + "results": [fake_user(), fake_user(id=55), fake_user(id=63)] + } guild = self.get_guild(fake_user(), new_user, updated_user) actual_diff = await self.syncer._get_diff(guild) @@ -111,7 +137,12 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): async def test_empty_diff_for_db_users_not_in_guild(self): """When the DB knows a user the guild doesn't, no difference is found.""" - self.bot.api_client.get.return_value = [fake_user(), fake_user(id=63, in_guild=False)] + self.bot.api_client.get.return_value = { + "count": 3, + "next": None, + "previous": None, + "results": [fake_user(), fake_user(id=63, in_guild=False)] + } guild = self.get_guild(fake_user()) actual_diff = await self.syncer._get_diff(guild) -- cgit v1.2.3 From f32a665cd0a03d8dbf4802643d32902c99bbd9ee Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Mon, 28 Sep 2020 23:41:48 +0530 Subject: Filter out reddit posts which are meant for users 18 years of older and send the rest. --- bot/exts/info/reddit.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py index 606c26aa7..f2aecc498 100644 --- a/bot/exts/info/reddit.py +++ b/bot/exts/info/reddit.py @@ -140,7 +140,12 @@ class Reddit(Cog): # Got appropriate response - process and return. content = await response.json() posts = content["data"]["children"] - if posts[0]["data"]["over_18"]: + + for post in posts: + if post["data"]["over_18"]: + posts.remove(post) + + if not posts: resp_not_allowed = [ { "error": "Oops ! Looks like this subreddit, doesn't fit in the scope of the server." -- cgit v1.2.3 From 95174717935124956b621046262e7e4242e6e107 Mon Sep 17 00:00:00 2001 From: Jack92829 <62740006+Jack92829@users.noreply.github.com> Date: Tue, 29 Sep 2020 10:40:10 +1000 Subject: Update guilds.md Not to sure of the title to give it but I think the content is a bit more in line with the servers other tags --- bot/resources/tags/guilds.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/resources/tags/guilds.md b/bot/resources/tags/guilds.md index fa02a1751..571abb99b 100644 --- a/bot/resources/tags/guilds.md +++ b/bot/resources/tags/guilds.md @@ -1,4 +1,3 @@ -**Need help with another language or related field of interest?** +**Communities** -This community is dedicated to python, and while we have off-topic channels, it is not always the greatest place to find help regarding other languages or fields. If you need help with another language or particular field of interest, we recommend you check out this [awesome list](https://github.com/mhxion/awesome-discord-communities), a list of communities specialising in a wide range of areas including [Programming languages](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#programming-languages), [Electricals](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#electricals), [Computer science](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#art-of-computer-science) and [Operating systems](https://github.com/mhxion/awesome-discord-communities/blob/main/README.md#operating-systems). -Also consider joining the wonderful [communities](https://pythondiscord.com/pages/resources/communities/) we have partnered with. +The [communities page](https://pythondiscord.com/pages/resources/communities/) on our website contains a number of communities we have partnered with as well as a [curated list](https://github.com/mhxion/awesome-discord-communities) of other communities relating to programming and technology. -- cgit v1.2.3 From c90e50a81b1db63c12ef36af58d4cc04d035db2f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 29 Sep 2020 10:36:48 +0200 Subject: Deps: bump 'discord.py' to 1.5 & re-lock This also removes a duplicate 'discord' entry from the lockfile. --- Pipfile | 2 +- Pipfile.lock | 25 ++++++++----------------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/Pipfile b/Pipfile index e6f84d911..99fc70b46 100644 --- a/Pipfile +++ b/Pipfile @@ -14,7 +14,7 @@ beautifulsoup4 = "~=4.9" colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"} coloredlogs = "~=14.0" deepdiff = "~=4.0" -discord.py = "~=1.4.0" +"discord.py" = "~=1.5.0" feedparser = "~=5.2" fuzzywuzzy = "~=0.17" lxml = "~=4.4" diff --git a/Pipfile.lock b/Pipfile.lock index 4c63277de..becd85c55 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "644012a1c3fa3e3a30f8b8f8e672c468dfaa155d9e43d26e2be8713c8dc5ebb3" + "sha256": "073fd0c51749aafa188fdbe96c5b90dd157cb1d23bdd144801fb0d0a369ffa88" }, "pipfile-spec": 6, "requires": { @@ -18,11 +18,11 @@ "default": { "aio-pika": { "hashes": [ - "sha256:4a20d4d941e1f113a950ea529a90bd9159c8d7aafaa1c71e9c707c8c2b526ea6", - "sha256:7bf3f183df1eb348d007210a0c1a3c5c755f1b3def1a9a395e93f30b91da1daf" + "sha256:9773440a89840941ac3099a7720bf9d51e8764a484066b82ede4d395660ff430", + "sha256:a8065be3c722eb8f9fff8c0e7590729e7782202cdb9363d9830d7d5d47b45c7c" ], "index": "pypi", - "version": "==6.7.0" + "version": "==6.7.1" }, "aiodns": { "hashes": [ @@ -205,22 +205,13 @@ "index": "pypi", "version": "==4.3.2" }, - "discord": { - "hashes": [ - "sha256:9d4debb4a37845543bd4b92cb195bc53a302797333e768e70344222857ff1559", - "sha256:ff6653655e342e7721dfb3f10421345fd852c2a33f2cca912b1c39b3778a9429" - ], - "index": "pypi", - "py": "~=1.4.0", - "version": "==1.0.1" - }, "discord.py": { "hashes": [ - "sha256:98ea3096a3585c9c379209926f530808f5fcf4930928d8cfb579d2562d119570", - "sha256:f9decb3bfa94613d922376288617e6a6f969260923643e2897f4540c34793442" + "sha256:3acb61fde0d862ed346a191d69c46021e6063673f63963bc984ae09a685ab211", + "sha256:e71089886aa157341644bdecad63a72ff56b44406b1a6467b66db31c8e5a5a15" ], - "markers": "python_full_version >= '3.5.3'", - "version": "==1.4.1" + "index": "pypi", + "version": "==1.5.0" }, "docutils": { "hashes": [ -- cgit v1.2.3 From 7e9283260104999973301fe09859c75b87e62514 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Wed, 30 Sep 2020 20:17:22 +0200 Subject: Add intents setup to the bot --- bot/__main__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index a07bc21d6..009f0ff27 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -47,6 +47,13 @@ loop.run_until_complete(redis_session.connect()) # Instantiate the bot. allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES] +intents = discord.Intents().all() +intents.presences = False +intents.dm_typing = False +intents.dm_reactions = False +intents.invites = False +intents.webhooks = False +intents.integrations = False bot = Bot( redis_session=redis_session, loop=loop, @@ -54,7 +61,8 @@ bot = Bot( activity=discord.Game(name="Commands: !help"), case_insensitive=True, max_messages=10_000, - allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles), + intents=intents ) # Load extensions. -- cgit v1.2.3 From 85e31b8d933900dde221d158cc27b08b923d53b3 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Wed, 30 Sep 2020 20:21:10 +0200 Subject: Remove Custom Status and Status from `create_user_embed` --- bot/exts/info/information.py | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index f6ed176f1..c9739dccd 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -211,25 +211,6 @@ class Information(Cog): """Creates an embed containing information on the `user`.""" created = time_since(user.created_at, max_units=3) - # Custom status - custom_status = '' - for activity in user.activities: - if isinstance(activity, CustomActivity): - state = "" - - if activity.name: - state = escape_markdown(activity.name) - - emoji = "" - if activity.emoji: - # If an emoji is unicode use the emoji, else write the emote like :abc: - if not activity.emoji.id: - emoji += activity.emoji.name + " " - else: - emoji += f"`:{activity.emoji.name}:` " - - custom_status = f'Status: {emoji}{state}\n' - name = str(user) if user.nick: name = f"{user.nick} ({name})" @@ -243,10 +224,6 @@ class Information(Cog): joined = time_since(user.joined_at, max_units=3) roles = ", ".join(role.mention for role in user.roles[1:]) - desktop_status = STATUS_EMOTES.get(user.desktop_status, constants.Emojis.status_online) - web_status = STATUS_EMOTES.get(user.web_status, constants.Emojis.status_online) - mobile_status = STATUS_EMOTES.get(user.mobile_status, constants.Emojis.status_online) - fields = [ ( "User information", @@ -254,7 +231,6 @@ class Information(Cog): Created: {created} Profile: {user.mention} ID: {user.id} - {custom_status} """).strip() ), ( @@ -264,14 +240,6 @@ class Information(Cog): Roles: {roles or None} """).strip() ), - ( - "Status", - textwrap.dedent(f""" - {desktop_status} Desktop - {web_status} Web - {mobile_status} Mobile - """).strip() - ) ] # Use getattr to future-proof for commands invoked via DMs. -- cgit v1.2.3 From d2fe88adb94c9dc3d84ff560c5246a023c72d9a8 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Wed, 30 Sep 2020 20:27:39 +0200 Subject: update member status info in `server` command --- bot/exts/info/information.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index c9739dccd..a50433c33 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -6,10 +6,9 @@ from collections import Counter, defaultdict from string import Template from typing import Any, Mapping, Optional, Tuple, Union -from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status, utils +from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils from discord.abc import GuildChannel from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role -from discord.utils import escape_markdown from bot import constants from bot.bot import Bot @@ -153,7 +152,9 @@ class Information(Cog): channel_counts = self.get_channel_type_counts(ctx.guild) # How many of each user status? - statuses = Counter(member.status for member in ctx.guild.members) + py_invite = await self.bot.fetch_invite("python") + online_presences = py_invite.approximate_presence_count + offline_presences = ctx.guild.member_count - online_presences embed = Embed(colour=Colour.blurple()) # How many staff members and staff channels do we have? @@ -181,10 +182,8 @@ class Information(Cog): Roles: {roles} **Member statuses** - {constants.Emojis.status_online} {statuses[Status.online]:,} - {constants.Emojis.status_idle} {statuses[Status.idle]:,} - {constants.Emojis.status_dnd} {statuses[Status.dnd]:,} - {constants.Emojis.status_offline} {statuses[Status.offline]:,} + {constants.Emojis.status_online} {online_presences:,} + {constants.Emojis.status_offline} {offline_presences:,} """) ).substitute({"channel_counts": channel_counts}) embed.set_thumbnail(url=ctx.guild.icon_url) -- cgit v1.2.3 From ae29af7a85a2738d73c1b91689b42b8a22d7da6a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 30 Sep 2020 15:54:28 -0700 Subject: Remove alias cog Last few aliases are an anomaly since #1124 was merged. The remaining aliases are seldom used. The code isn't exactly clean and it has some maintenance costs. Resolves #1159 --- bot/exts/backend/alias.py | 87 ----------------------------------------------- 1 file changed, 87 deletions(-) delete mode 100644 bot/exts/backend/alias.py diff --git a/bot/exts/backend/alias.py b/bot/exts/backend/alias.py deleted file mode 100644 index c6ba8d6f3..000000000 --- a/bot/exts/backend/alias.py +++ /dev/null @@ -1,87 +0,0 @@ -import inspect -import logging - -from discord import Colour, Embed -from discord.ext.commands import ( - Cog, Command, Context, - clean_content, command, group, -) - -from bot.bot import Bot -from bot.converters import TagNameConverter -from bot.pagination import LinePaginator - -log = logging.getLogger(__name__) - - -class Alias (Cog): - """Aliases for commonly used commands.""" - - def __init__(self, bot: Bot): - self.bot = bot - - async def invoke(self, ctx: Context, cmd_name: str, *args, **kwargs) -> None: - """Invokes a command with args and kwargs.""" - log.debug(f"{cmd_name} was invoked through an alias") - cmd = self.bot.get_command(cmd_name) - if not cmd: - return log.info(f'Did not find command "{cmd_name}" to invoke.') - elif not await cmd.can_run(ctx): - return log.info( - f'{str(ctx.author)} tried to run the command "{cmd_name}" but lacks permission.' - ) - - await ctx.invoke(cmd, *args, **kwargs) - - @command(name='aliases') - async def aliases_command(self, ctx: Context) -> None: - """Show configured aliases on the bot.""" - embed = Embed( - title='Configured aliases', - colour=Colour.blue() - ) - await LinePaginator.paginate( - ( - f"• `{ctx.prefix}{value.name}` " - f"=> `{ctx.prefix}{name[:-len('_alias')].replace('_', ' ')}`" - for name, value in inspect.getmembers(self) - if isinstance(value, Command) and name.endswith('_alias') - ), - ctx, embed, empty=False, max_lines=20 - ) - - @command(name="exception", hidden=True) - async def tags_get_traceback_alias(self, ctx: Context) -> None: - """Alias for invoking tags get traceback.""" - await self.invoke(ctx, "tags get", tag_name="traceback") - - @group(name="get", - aliases=("show", "g"), - hidden=True, - invoke_without_command=True) - async def get_group_alias(self, ctx: Context) -> None: - """Group for reverse aliases for commands like `tags get`, allowing for `get tags` or `get docs`.""" - pass - - @get_group_alias.command(name="tags", aliases=("tag", "t"), hidden=True) - async def tags_get_alias( - self, ctx: Context, *, tag_name: TagNameConverter = None - ) -> None: - """ - Alias for invoking tags get [tag_name]. - - tag_name: str - tag to be viewed. - """ - await self.invoke(ctx, "tags get", tag_name=tag_name) - - @get_group_alias.command(name="docs", aliases=("doc", "d"), hidden=True) - async def docs_get_alias( - self, ctx: Context, symbol: clean_content = None - ) -> None: - """Alias for invoking docs get [symbol].""" - await self.invoke(ctx, "docs get", symbol) - - -def setup(bot: Bot) -> None: - """Load the Alias cog.""" - bot.add_cog(Alias(bot)) -- cgit v1.2.3 From fc0da38b15ce01f90219346cf6fc0cfec592c682 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 30 Sep 2020 16:11:00 -0700 Subject: Catch 404 in wait_for_deletion when reacting The message may be deleted before the bot gets a chance to react. Fixes #1181 --- bot/utils/messages.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 9cc0d8a34..d0b2342b3 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -34,7 +34,11 @@ async def wait_for_deletion( if attach_emojis: for emoji in deletion_emojis: - await message.add_reaction(emoji) + try: + await message.add_reaction(emoji) + except discord.NotFound: + log.trace(f"Aborting wait_for_deletion: message {message.id} deleted prematurely.") + return def check(reaction: discord.Reaction, user: discord.Member) -> bool: """Check that the deletion emoji is reacted by the appropriate user.""" -- cgit v1.2.3 From 998ecc6484ab6897310061f9d8b45cb9a534fb0f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 30 Sep 2020 16:19:29 -0700 Subject: Remove null chars before posting deleted messages Our API doesn't allow null characters in the content field. It may be present because of a self bot that is able to send such character. Fixes #1182 Fixes BOT-8E --- bot/exts/moderation/modlog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 41ed46b69..b01de0ee3 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -63,7 +63,7 @@ class ModLog(Cog, name="ModLog"): 'id': message.id, 'author': message.author.id, 'channel_id': message.channel.id, - 'content': message.content, + 'content': message.content.replace("\0", ""), # Null chars cause 400. 'embeds': [embed.to_dict() for embed in message.embeds], 'attachments': attachment, } -- cgit v1.2.3 From 9322e89ba7d043f5525eca31c0dd785260788b44 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 30 Sep 2020 17:06:59 -0700 Subject: Duck pond: ignore reactions in DMs Also handle the channel not being found, which may be due to a cache issue or because it got deleted. Fixes #1183 Fixes BOT-8T --- bot/exts/fun/duck_pond.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 6c2d22b9c..b146545a4 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -145,6 +145,10 @@ class DuckPond(Cog): amount of ducks specified in the config under duck_pond/threshold, it will send the message off to the duck pond. """ + # Ignore DMs. + if payload.guild_id is None: + return + # Was this reaction issued in a blacklisted channel? if payload.channel_id in constants.DuckPond.channel_blacklist: return @@ -154,6 +158,9 @@ class DuckPond(Cog): return channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) + if channel is None: + return + message = await channel.fetch_message(payload.message_id) member = discord.utils.get(message.guild.members, id=payload.user_id) -- cgit v1.2.3 From f791bc32adceeb765638fd8cf2c849e6f642b345 Mon Sep 17 00:00:00 2001 From: Hedy Li Date: Thu, 1 Oct 2020 17:04:09 +0800 Subject: fix spelling typos in bot/ python files --- bot/exts/help_channels.py | 2 +- bot/exts/info/help.py | 2 +- bot/exts/info/information.py | 2 +- bot/exts/utils/bot.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/help_channels.py b/bot/exts/help_channels.py index 9e33a6aba..f5c9a5dd0 100644 --- a/bot/exts/help_channels.py +++ b/bot/exts/help_channels.py @@ -494,7 +494,7 @@ class HelpChannels(commands.Cog): If `options` are provided, the channel will be edited after the move is completed. This is the same order of operations that `discord.TextChannel.edit` uses. For information on available - options, see the documention on `discord.TextChannel.edit`. While possible, position-related + options, see the documentation on `discord.TextChannel.edit`. While possible, position-related options should be avoided, as it may interfere with the category move we perform. """ # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 99d503f5c..599c5d5c0 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -229,7 +229,7 @@ class CustomHelpCommand(HelpCommand): async def send_cog_help(self, cog: Cog) -> None: """Send help for a cog.""" - # sort commands by name, and remove any the user cant run or are hidden. + # sort commands by name, and remove any the user can't run or are hidden. commands_ = await self.filter_commands(cog.get_commands(), sort=True) embed = Embed() diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index f6ed176f1..719f43b14 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -161,7 +161,7 @@ class Information(Cog): staff_channel_count = self.get_staff_channel_count(ctx.guild) # Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the - # f-string. While this is correctly formated by Discord, it makes unit testing difficult. To keep the formatting + # f-string. While this is correctly formatted by Discord, it makes unit testing difficult. To keep the formatting # without joining a tuple of strings we can use a Template string to insert the already-formatted channel_counts # after the dedent is made. embed.description = Template( diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py index 7ed487d47..ba1fd2a5c 100644 --- a/bot/exts/utils/bot.py +++ b/bot/exts/utils/bot.py @@ -130,7 +130,7 @@ class BotCog(Cog, name="Bot"): else: content = "".join(content[1:]) - # Strip it again to remove any leading whitespace. This is neccessary + # Strip it again to remove any leading whitespace. This is necessary # if the first line of the message looked like ```python old = content.strip() -- cgit v1.2.3 From d8fbeedb7ec42b387c7f32d15e45675f987f427b Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Thu, 1 Oct 2020 15:27:50 +0530 Subject: handling empty list error in get_top_posts() method and filter posts using list comprehension. --- bot/exts/info/reddit.py | 32 +++++++++----------------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py index f2aecc498..c6aecaa20 100644 --- a/bot/exts/info/reddit.py +++ b/bot/exts/info/reddit.py @@ -141,31 +141,14 @@ class Reddit(Cog): content = await response.json() posts = content["data"]["children"] - for post in posts: - if post["data"]["over_18"]: - posts.remove(post) - - if not posts: - resp_not_allowed = [ - { - "error": "Oops ! Looks like this subreddit, doesn't fit in the scope of the server." - } - ] - return resp_not_allowed - return posts[:amount] + filtered_posts = [post for post in posts if not post["data"]["over_18"]] + + return filtered_posts[:amount] await asyncio.sleep(3) log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") - resp_failed = [ - { - "error": ( - "Sorry! We couldn't find any posts from that subreddit. " - "If this problem persists, please let us know." - ) - } - ] - return resp_failed # Failed to get appropriate response within allowed number of retries. + return list() async def get_top_posts(self, subreddit: Subreddit, time: str = "all", amount: int = 5) -> Embed: """ @@ -183,10 +166,13 @@ class Reddit(Cog): amount=amount, params={"t": time} ) - if "error" in posts[0]: + if not posts: embed.title = random.choice(ERROR_REPLIES) embed.colour = Colour.red() - embed.description = posts[0]["error"] + embed.description = ( + "Sorry! We couldn't find any SFW posts from that subreddit. " + "If this problem persists, please let us know." + ) return embed -- cgit v1.2.3 From 3554a57cdfd9904e180cbe1689e36fea9df4dfb3 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Thu, 1 Oct 2020 15:30:27 +0530 Subject: re-add comment. --- bot/exts/info/reddit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py index c6aecaa20..0a49e53e7 100644 --- a/bot/exts/info/reddit.py +++ b/bot/exts/info/reddit.py @@ -148,7 +148,7 @@ class Reddit(Cog): await asyncio.sleep(3) log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") - return list() + return list() # Failed to get appropriate response within allowed number of retries. async def get_top_posts(self, subreddit: Subreddit, time: str = "all", amount: int = 5) -> Embed: """ -- cgit v1.2.3 From ba4778d1d618b37ca190c921bcec571319e2914e Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Thu, 1 Oct 2020 15:55:55 +0530 Subject: remove redundant type hints and improve existing function annotations --- bot/exts/backend/sync/_syncers.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index 512efaa3d..ea0f2bcb6 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -353,9 +353,9 @@ class UserSyncer(Syncer): return _Diff(users_to_create, users_to_update, None) - async def _get_users(self, endpoint: str = "bot/users", query_params: dict = None) -> t.List[dict]: + async def _get_users(self, endpoint: str = "bot/users", query_params: list = None) -> t.List[dict]: """GET all users recursively.""" - users: list = [] + users = [] response: dict = await self.bot.api_client.get(endpoint, params=query_params) users.extend(response["results"]) @@ -363,11 +363,10 @@ class UserSyncer(Syncer): if (next_page_url := response["next"]) is not None: next_endpoint, query_params = self.get_endpoint(next_page_url) users.extend(await self._get_users(next_endpoint, query_params)) - return users @staticmethod - def get_endpoint(url: str) -> tuple: + def get_endpoint(url: str) -> t.Tuple[str, t.List[tuple]]: """Extract the API endpoint and query params from a URL.""" url = urlparse(url) @@ -380,9 +379,9 @@ class UserSyncer(Syncer): return endpoint, params @staticmethod - def patch_dict(user: _User) -> dict: + def patch_dict(user: _User) -> t.Dict[str, t.Union[int, str, tuple, bool]]: """Convert namedtuple to dict by omitting None values.""" - user_dict: dict = {} + user_dict = {} for field in user._fields: if (value := getattr(user, field)) is not None: user_dict[field] = value @@ -392,8 +391,9 @@ class UserSyncer(Syncer): """Synchronise the database with the user cache of `guild`.""" log.trace("Syncing created users...") if diff.created: - created: list = [user._asdict() for user in diff.created] + created = [user._asdict() for user in diff.created] await self.bot.api_client.post("bot/users", json=created) + log.trace("Syncing updated users...") if diff.updated: - updated: list = [self.patch_dict(user) for user in diff.updated] + updated = [self.patch_dict(user) for user in diff.updated] await self.bot.api_client.patch("bot/users/bulk_patch", json=updated) -- cgit v1.2.3 From aaeedc97fe7462093b06536f1f4aa7f1fa9c0919 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 1 Oct 2020 09:06:05 -0700 Subject: Duck pond: ignore reaction events from other guilds --- bot/exts/fun/duck_pond.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index b146545a4..82084ea88 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -145,8 +145,8 @@ class DuckPond(Cog): amount of ducks specified in the config under duck_pond/threshold, it will send the message off to the duck pond. """ - # Ignore DMs. - if payload.guild_id is None: + # Ignore other guilds and DMs. + if payload.guild_id != constants.Guild.id: return # Was this reaction issued in a blacklisted channel? @@ -182,7 +182,13 @@ class DuckPond(Cog): @Cog.listener() async def on_raw_reaction_remove(self, payload: RawReactionActionEvent) -> None: """Ensure that people don't remove the green checkmark from duck ponded messages.""" + # Ignore other guilds and DMs. + if payload.guild_id != constants.Guild.id: + return + channel = discord.utils.get(self.bot.get_all_channels(), id=payload.channel_id) + if channel is None: + return # Prevent the green checkmark from being removed if payload.emoji.name == "✅": -- cgit v1.2.3 From cf9d08ffcf65196162f984fecc9341052cc31abd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 1 Oct 2020 09:25:43 -0700 Subject: Remove special handling for the alias cog in the !source command It's obsolete code because the cog has been removed. --- bot/exts/info/source.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/bot/exts/info/source.py b/bot/exts/info/source.py index 205e0ba81..f79be36b0 100644 --- a/bot/exts/info/source.py +++ b/bot/exts/info/source.py @@ -66,14 +66,8 @@ class BotSource(commands.Cog): Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval). """ if isinstance(source_item, commands.Command): - if source_item.cog_name == "Alias": - cmd_name = source_item.callback.__name__.replace("_alias", "") - cmd = self.bot.get_command(cmd_name.replace("_", " ")) - src = cmd.callback.__code__ - filename = src.co_filename - else: - src = source_item.callback.__code__ - filename = src.co_filename + src = source_item.callback.__code__ + filename = src.co_filename elif isinstance(source_item, str): tags_cog = self.bot.get_cog("Tags") filename = tags_cog._cache[source_item]["location"] @@ -113,13 +107,7 @@ class BotSource(commands.Cog): title = "Help Command" description = source_object.__doc__.splitlines()[1] elif isinstance(source_object, commands.Command): - if source_object.cog_name == "Alias": - cmd_name = source_object.callback.__name__.replace("_alias", "") - cmd = self.bot.get_command(cmd_name.replace("_", " ")) - description = cmd.short_doc - else: - description = source_object.short_doc - + description = source_object.short_doc title = f"Command: {source_object.qualified_name}" elif isinstance(source_object, str): title = f"Tag: {source_object}" -- cgit v1.2.3 From cbd972cb26ae8fb23a1a70448b0ae48ed08d894b Mon Sep 17 00:00:00 2001 From: Soumitra Shewale Date: Fri, 2 Oct 2020 00:49:29 +0530 Subject: Escape markdown in faulty source commands Closes #1177 --- bot/exts/info/source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/source.py b/bot/exts/info/source.py index f79be36b0..7746e0c67 100644 --- a/bot/exts/info/source.py +++ b/bot/exts/info/source.py @@ -2,7 +2,7 @@ import inspect from pathlib import Path from typing import Optional, Tuple, Union -from discord import Embed +from discord import Embed, utils from discord.ext import commands from bot.bot import Bot @@ -36,7 +36,7 @@ class SourceConverter(commands.Converter): return argument.lower() raise commands.BadArgument( - f"Unable to convert `{argument}` to valid command{', tag,' if show_tag else ''} or Cog." + f"Unable to convert `{utils.escape_markdown(argument)}` to valid command{', tag,' if show_tag else ''} or Cog." ) -- cgit v1.2.3 From 6267e534fe2fe028ca3fe75844f9f8d8dc2e34ba Mon Sep 17 00:00:00 2001 From: Soumitra Shewale Date: Fri, 2 Oct 2020 01:03:09 +0530 Subject: Linter I had flake8 turned off in my dpy env -_- --- bot/exts/info/source.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/source.py b/bot/exts/info/source.py index 7746e0c67..f2412a8dd 100644 --- a/bot/exts/info/source.py +++ b/bot/exts/info/source.py @@ -36,7 +36,8 @@ class SourceConverter(commands.Converter): return argument.lower() raise commands.BadArgument( - f"Unable to convert `{utils.escape_markdown(argument)}` to valid command{', tag,' if show_tag else ''} or Cog." + f"Unable to convert `{utils.escape_markdown(argument)}` to valid\ + command{', tag,' if show_tag else ''} or Cog." ) -- cgit v1.2.3 From 28f2916f698ffcd1fe2c9d2cda86a180307980ef Mon Sep 17 00:00:00 2001 From: Soumitra Shewale Date: Fri, 2 Oct 2020 01:15:31 +0530 Subject: Move PEP command embed URL to title Closes #1176 --- bot/exts/utils/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 6b6941064..566058435 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -84,7 +84,7 @@ class Utils(Cog): # Assemble the embed pep_embed = Embed( title=f"**PEP {pep_number} - {pep_header['Title']}**", - description=f"[Link]({self.base_pep_url}{pep_number:04})", + url=f"{self.base_pep_url}{pep_number:04}" ) pep_embed.set_thumbnail(url=ICON_URL) -- cgit v1.2.3 From bb423b8105be2b9b5b843ee2661c4ff18be741e0 Mon Sep 17 00:00:00 2001 From: Hedy Li Date: Fri, 2 Oct 2020 06:05:01 +0000 Subject: fix line length in bot/exts/info/information.py --- 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 719f43b14..52239c19e 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -161,9 +161,9 @@ class Information(Cog): staff_channel_count = self.get_staff_channel_count(ctx.guild) # Because channel_counts lacks leading whitespace, it breaks the dedent if it's inserted directly by the - # f-string. While this is correctly formatted by Discord, it makes unit testing difficult. To keep the formatting - # without joining a tuple of strings we can use a Template string to insert the already-formatted channel_counts - # after the dedent is made. + # f-string. While this is correctly formatted by Discord, it makes unit testing difficult. To keep the + # formatting without joining a tuple of strings we can use a Template string to insert the already-formatted + # channel_counts after the dedent is made. embed.description = Template( textwrap.dedent(f""" **Server information** -- cgit v1.2.3 From 4e695bc1c8ea48056e4fe155fca2c518c50277a9 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 2 Oct 2020 08:27:15 +0200 Subject: Remove failing unit tests Testing `information` cog seems redutant as it is not too important part of the bot. --- tests/bot/exts/info/test_information.py | 78 --------------------------------- 1 file changed, 78 deletions(-) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index d3f2995fb..83fc6d188 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -97,79 +97,6 @@ class InformationCogTests(unittest.TestCase): self.assertEqual(admin_embed.title, "Admins info") self.assertEqual(admin_embed.colour, discord.Colour.red()) - @unittest.mock.patch('bot.exts.info.information.time_since') - def test_server_info_command(self, time_since_patch): - time_since_patch.return_value = '2 days ago' - - self.ctx.guild = helpers.MockGuild( - features=('lemons', 'apples'), - region="The Moon", - roles=[self.moderator_role], - channels=[ - discord.TextChannel( - state={}, - guild=self.ctx.guild, - data={'id': 42, 'name': 'lemons-offering', 'position': 22, 'type': 'text'} - ), - discord.CategoryChannel( - state={}, - guild=self.ctx.guild, - data={'id': 5125, 'name': 'the-lemon-collection', 'position': 22, 'type': 'category'} - ), - discord.VoiceChannel( - state={}, - guild=self.ctx.guild, - data={'id': 15290, 'name': 'listen-to-lemon', 'position': 22, 'type': 'voice'} - ) - ], - members=[ - *(helpers.MockMember(status=discord.Status.online) for _ in range(2)), - *(helpers.MockMember(status=discord.Status.idle) for _ in range(1)), - *(helpers.MockMember(status=discord.Status.dnd) for _ in range(4)), - *(helpers.MockMember(status=discord.Status.offline) for _ in range(3)), - ], - member_count=1_234, - icon_url='a-lemon.jpg', - ) - - coroutine = self.cog.server_info.callback(self.cog, self.ctx) - self.assertIsNone(asyncio.run(coroutine)) - - time_since_patch.assert_called_once_with(self.ctx.guild.created_at, precision='days') - _, kwargs = self.ctx.send.call_args - embed = kwargs.pop('embed') - self.assertEqual(embed.colour, discord.Colour.blurple()) - self.assertEqual( - embed.description, - textwrap.dedent( - f""" - **Server information** - Created: {time_since_patch.return_value} - Voice region: {self.ctx.guild.region} - Features: {', '.join(self.ctx.guild.features)} - - **Channel counts** - Category channels: 1 - Text channels: 1 - Voice channels: 1 - Staff channels: 0 - - **Member counts** - Members: {self.ctx.guild.member_count:,} - Staff members: 0 - Roles: {len(self.ctx.guild.roles)} - - **Member statuses** - {constants.Emojis.status_online} 2 - {constants.Emojis.status_idle} 1 - {constants.Emojis.status_dnd} 4 - {constants.Emojis.status_offline} 3 - """ - ) - ) - self.assertEqual(embed.thumbnail.url, 'a-lemon.jpg') - - class UserInfractionHelperMethodTests(unittest.TestCase): """Tests for the helper methods of the `!user` command.""" @@ -465,11 +392,6 @@ class UserEmbedTests(unittest.TestCase): embed.fields[1].value ) - self.assertEqual( - "basic infractions info", - embed.fields[3].value - ) - @unittest.mock.patch( f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) -- cgit v1.2.3 From 93ce90d28e7a8314dbbc34600ab5b1bc89476b4b Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 2 Oct 2020 08:27:34 +0200 Subject: Remove presence stat tracking. --- bot/exts/info/stats.py | 37 ++----------------------------------- 1 file changed, 2 insertions(+), 35 deletions(-) diff --git a/bot/exts/info/stats.py b/bot/exts/info/stats.py index d42f55466..21aa91873 100644 --- a/bot/exts/info/stats.py +++ b/bot/exts/info/stats.py @@ -1,12 +1,11 @@ import string -from datetime import datetime -from discord import Member, Message, Status +from discord import Member, Message from discord.ext.commands import Cog, Context from discord.ext.tasks import loop from bot.bot import Bot -from bot.constants import Categories, Channels, Guild, Stats as StatConf +from bot.constants import Categories, Channels, Guild CHANNEL_NAME_OVERRIDES = { @@ -79,38 +78,6 @@ class Stats(Cog): self.bot.stats.gauge("guild.total_members", len(member.guild.members)) - @Cog.listener() - async def on_member_update(self, _before: Member, after: Member) -> None: - """Update presence estimates on member update.""" - if after.guild.id != Guild.id: - return - - if self.last_presence_update: - if (datetime.now() - self.last_presence_update).seconds < StatConf.presence_update_timeout: - return - - self.last_presence_update = datetime.now() - - online = 0 - idle = 0 - dnd = 0 - offline = 0 - - for member in after.guild.members: - if member.status is Status.online: - online += 1 - elif member.status is Status.dnd: - dnd += 1 - elif member.status is Status.idle: - idle += 1 - elif member.status is Status.offline: - offline += 1 - - self.bot.stats.gauge("guild.status.online", online) - self.bot.stats.gauge("guild.status.idle", idle) - self.bot.stats.gauge("guild.status.do_not_disturb", dnd) - self.bot.stats.gauge("guild.status.offline", offline) - @loop(hours=1) async def update_guild_boost(self) -> None: """Post the server boost level and tier every hour.""" -- cgit v1.2.3 From b87f3163ab05556c82cfe3d826aded68efa5ade4 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 2 Oct 2020 08:35:04 +0200 Subject: Add missing blank line to satisfy the linting gods --- tests/bot/exts/info/test_information.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 83fc6d188..23eeb88cd 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -97,6 +97,7 @@ class InformationCogTests(unittest.TestCase): self.assertEqual(admin_embed.title, "Admins info") self.assertEqual(admin_embed.colour, discord.Colour.red()) + class UserInfractionHelperMethodTests(unittest.TestCase): """Tests for the helper methods of the `!user` command.""" -- cgit v1.2.3 From 2b956b25bedae7cd8fd24109ee73c3996fad8ccb Mon Sep 17 00:00:00 2001 From: Soumitra Shewale Date: Fri, 2 Oct 2020 15:05:15 +0530 Subject: Update !pep 0 command --- bot/exts/utils/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 566058435..3e9230414 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -250,7 +250,7 @@ class Utils(Cog): """Send information about PEP 0.""" pep_embed = Embed( title="**PEP 0 - Index of Python Enhancement Proposals (PEPs)**", - description="[Link](https://www.python.org/dev/peps/)" + url="https://www.python.org/dev/peps/" ) pep_embed.set_thumbnail(url=ICON_URL) pep_embed.add_field(name="Status", value="Active") -- cgit v1.2.3 From 0d3d7822c84d798a639df0bde348a256977db08a Mon Sep 17 00:00:00 2001 From: Soumitra Shewale Date: Fri, 2 Oct 2020 17:52:52 +0530 Subject: Get rid of codeblock in souce commit Double backtick will break if argument contains a double backtick, so getting rid of the codeblock itself makes more sense in my opionion. Also fix the style issue with multiline string by storing the escaped arg in another variable --- bot/exts/info/source.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/source.py b/bot/exts/info/source.py index f2412a8dd..7b41352d4 100644 --- a/bot/exts/info/source.py +++ b/bot/exts/info/source.py @@ -35,9 +35,10 @@ class SourceConverter(commands.Converter): elif argument.lower() in tags_cog._cache: return argument.lower() + escaped_arg = utils.escape_markdown(argument) + raise commands.BadArgument( - f"Unable to convert `{utils.escape_markdown(argument)}` to valid\ - command{', tag,' if show_tag else ''} or Cog." + f"Unable to convert '{escaped_arg}' to valid command{', tag,' if show_tag else ''} or Cog." ) -- cgit v1.2.3 From 10a65fee8b843990a87ab468c924e9f6cd4493d1 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 2 Oct 2020 16:38:26 +0200 Subject: Reminder: no feedback message when no mention --- bot/exts/utils/reminders.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 6806f2889..6fdb0b8ea 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -286,10 +286,11 @@ class Reminders(Cog): now = datetime.utcnow() - timedelta(seconds=1) humanized_delta = humanize_delta(relativedelta(expiration, now)) - mention_string = ( - f"Your reminder will arrive in {humanized_delta} " - f"and will mention {len(mentions)} other(s)!" - ) + mention_string = f"Your reminder will arrive in {humanized_delta}" + + if mentions: + mention_string += f" and will mention {len(mentions)} other(s)" + mention_string += "!" # Confirm to the user that it worked. await self._send_confirmation( -- cgit v1.2.3 From 0820a81057a8945f33cb386e2010ed78102c9c42 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Fri, 2 Oct 2020 21:39:25 +0530 Subject: update UserSyncerDiffTests Tests to use changes made to API calls. --- tests/bot/exts/backend/sync/test_users.py | 38 ++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py index 4ebc8b82f..e60c3a24d 100644 --- a/tests/bot/exts/backend/sync/test_users.py +++ b/tests/bot/exts/backend/sync/test_users.py @@ -16,6 +16,16 @@ def fake_user(**kwargs): return kwargs +def fake_none_user(**kwargs): + kwargs.setdefault("id", None) + kwargs.setdefault("name", None) + kwargs.setdefault("discriminator", None) + kwargs.setdefault("roles", None) + kwargs.setdefault("in_guild", None) + + return kwargs + + class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): """Tests for determining differences between users in the DB and users in the Guild cache.""" @@ -41,8 +51,13 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): return guild async def test_empty_diff_for_no_users(self): - # TODO: need to fix this test. """When no users are given, an empty diff should be returned.""" + self.bot.api_client.get.return_value = { + "count": 3, + "next": None, + "previous": None, + "results": [] + } guild = self.get_guild() actual_diff = await self.syncer._get_diff(guild) @@ -68,6 +83,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): async def test_diff_for_updated_users(self): """Only updated users should be added to the 'updated' set of the diff.""" updated_user = fake_user(id=99, name="new") + updated_user_none = fake_none_user(id=99, name="new") self.bot.api_client.get.return_value = { "count": 3, @@ -78,7 +94,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): guild = self.get_guild(updated_user, fake_user()) actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), {_User(**updated_user)}, None) + expected_diff = (set(), {_User(**updated_user_none)}, None) self.assertEqual(actual_diff, expected_diff) @@ -101,7 +117,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): async def test_diff_sets_in_guild_false_for_leaving_users(self): """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" - leaving_user = fake_user(id=63, in_guild=False) + leaving_user_none = fake_none_user(id=63, in_guild=False) self.bot.api_client.get.return_value = { "count": 3, @@ -112,15 +128,18 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): guild = self.get_guild(fake_user()) actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), {_User(**leaving_user)}, None) + expected_diff = (set(), {_User(**leaving_user_none)}, None) self.assertEqual(actual_diff, expected_diff) async def test_diff_for_new_updated_and_leaving_users(self): """When users are added, updated, and removed, all of them are returned properly.""" new_user = fake_user(id=99, name="new") + updated_user = fake_user(id=55, name="updated") - leaving_user = fake_user(id=63, in_guild=False) + updated_user_none = fake_none_user(id=55, name="updated") + + leaving_user_none = fake_none_user(id=63, in_guild=False) self.bot.api_client.get.return_value = { "count": 3, @@ -131,7 +150,14 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): guild = self.get_guild(fake_user(), new_user, updated_user) actual_diff = await self.syncer._get_diff(guild) - expected_diff = ({_User(**new_user)}, {_User(**updated_user), _User(**leaving_user)}, None) + expected_diff = ( + {_User(**new_user)}, + { + _User(**updated_user_none), + _User(**leaving_user_none) + }, + None + ) self.assertEqual(actual_diff, expected_diff) -- cgit v1.2.3 From 20c85e6fc46ab34fdce23e393a12e275a82a25fa Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Fri, 2 Oct 2020 23:37:15 +0530 Subject: Refactor unit tests UserSyncerSyncTests to use changes made to UserSyncer in _syncers.py --- tests/bot/exts/backend/sync/test_users.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py index e60c3a24d..c3a486743 100644 --- a/tests/bot/exts/backend/sync/test_users.py +++ b/tests/bot/exts/backend/sync/test_users.py @@ -1,5 +1,4 @@ import unittest -from unittest import mock from bot.exts.backend.sync._syncers import UserSyncer, _Diff, _User from tests import helpers @@ -192,9 +191,9 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase): diff = _Diff(user_tuples, set(), None) await self.syncer._sync(diff) - calls = [mock.call("bot/users", json=user) for user in users] - self.bot.api_client.post.assert_has_calls(calls, any_order=True) - self.assertEqual(self.bot.api_client.post.call_count, len(users)) + # Convert namedtuples to dicts as done in self.syncer._sync method. + created = [user._asdict() for user in diff.created] + self.bot.api_client.post.assert_called_once_with("bot/users", json=created) self.bot.api_client.put.assert_not_called() self.bot.api_client.delete.assert_not_called() @@ -207,9 +206,8 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase): diff = _Diff(set(), user_tuples, None) await self.syncer._sync(diff) - calls = [mock.call(f"bot/users/{user['id']}", json=user) for user in users] - self.bot.api_client.put.assert_has_calls(calls, any_order=True) - self.assertEqual(self.bot.api_client.put.call_count, len(users)) + updated = [self.syncer.patch_dict(user) for user in diff.updated] + self.bot.api_client.patch.assert_called_once_with("bot/users/bulk_patch", json=updated) self.bot.api_client.post.assert_not_called() self.bot.api_client.delete.assert_not_called() -- cgit v1.2.3 From 0f2acbc651b400c29ebdabdfab7f6f7e2debe68e Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Fri, 2 Oct 2020 23:48:46 +0530 Subject: remove un-used variable --- bot/exts/backend/sync/_syncers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index 759af96d7..ae7d5d893 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -2,7 +2,6 @@ import abc import logging import typing as t from collections import namedtuple -from functools import partial from urllib.parse import parse_qsl, urlparse from discord import Guild -- cgit v1.2.3 From c035a756bac9f2d4c24dc232bda3a6d46b0c8a0f Mon Sep 17 00:00:00 2001 From: wookie184 Date: Fri, 2 Oct 2020 19:52:23 +0100 Subject: Changed send_attachments so kwargs could be given and would be passed to send() --- bot/utils/messages.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index d0b2342b3..c4ac1e360 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -56,15 +56,22 @@ async def wait_for_deletion( async def send_attachments( message: discord.Message, destination: Union[discord.TextChannel, discord.Webhook], - link_large: bool = True + link_large: bool = True, + **kwargs ) -> List[str]: """ Re-upload the message's attachments to the destination and return a list of their new URLs. Each attachment is sent as a separate message to more easily comply with the request/file size limit. If link_large is True, attachments which are too large are instead grouped into a single - embed which links to them. + embed which links to them. Extra kwargs will be passed to send() when sending the attachment. """ + webhook_send_kwargs = { + 'username': sub_clyde(message.author.display_name), + 'avatar_url': message.author.avatar_url, + } + webhook_send_kwargs.update(kwargs) + large = [] urls = [] for attachment in message.attachments: @@ -82,14 +89,10 @@ async def send_attachments( attachment_file = discord.File(file, filename=attachment.filename) if isinstance(destination, discord.TextChannel): - msg = await destination.send(file=attachment_file) + msg = await destination.send(file=attachment_file, **kwargs) urls.append(msg.attachments[0].url) else: - await destination.send( - file=attachment_file, - username=sub_clyde(message.author.display_name), - avatar_url=message.author.avatar_url - ) + await destination.send(file=attachment_file, **webhook_send_kwargs) elif link_large: large.append(attachment) else: @@ -106,13 +109,9 @@ async def send_attachments( embed.set_footer(text="Attachments exceed upload size limit.") if isinstance(destination, discord.TextChannel): - await destination.send(embed=embed) + await destination.send(embed=embed, **kwargs) else: - await destination.send( - embed=embed, - username=sub_clyde(message.author.display_name), - avatar_url=message.author.avatar_url - ) + await destination.send(embed=embed, **webhook_send_kwargs) return urls -- cgit v1.2.3 From f7015232947198f2a3d05c680df0da0bfaff4a8e Mon Sep 17 00:00:00 2001 From: wookie184 Date: Fri, 2 Oct 2020 19:55:06 +0100 Subject: Add use_cached argument to send_attachments, and change it to default to False --- bot/utils/messages.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index c4ac1e360..9fd571a20 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -57,6 +57,7 @@ async def send_attachments( message: discord.Message, destination: Union[discord.TextChannel, discord.Webhook], link_large: bool = True, + use_cached: bool = False, **kwargs ) -> List[str]: """ @@ -85,7 +86,7 @@ async def send_attachments( # but some may get through hence the try-catch. if attachment.size <= destination.guild.filesize_limit - 512: with BytesIO() as file: - await attachment.save(file, use_cached=True) + await attachment.save(file, use_cached=use_cached) attachment_file = discord.File(file, filename=attachment.filename) if isinstance(destination, discord.TextChannel): -- cgit v1.2.3 From 1481d8feaa4c155e13da2b1c5f9f9544d89e90c4 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Fri, 2 Oct 2020 19:57:07 +0100 Subject: Changed dm_relay to include user id in webhook when sending attachments. --- bot/exts/moderation/dm_relay.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/dm_relay.py b/bot/exts/moderation/dm_relay.py index 14263e004..4d5142b55 100644 --- a/bot/exts/moderation/dm_relay.py +++ b/bot/exts/moderation/dm_relay.py @@ -90,7 +90,11 @@ class DMRelay(Cog): # Handle any attachments if message.attachments: try: - await send_attachments(message, self.webhook) + await send_attachments( + message, + self.webhook, + username=f"{message.author.display_name} ({message.author.id})" + ) except (discord.errors.Forbidden, discord.errors.NotFound): e = discord.Embed( description=":x: **This message contained an attachment, but it could not be retrieved**", -- cgit v1.2.3 From 925219dec3ba199718ac0504cfc7f8b3e6917a1f Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 3 Oct 2020 11:36:19 +0100 Subject: Add a socket stats command --- bot/exts/utils/eval.py | 226 --------------------------------------- bot/exts/utils/internal.py | 258 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 226 deletions(-) delete mode 100644 bot/exts/utils/eval.py create mode 100644 bot/exts/utils/internal.py diff --git a/bot/exts/utils/eval.py b/bot/exts/utils/eval.py deleted file mode 100644 index 6419b320e..000000000 --- a/bot/exts/utils/eval.py +++ /dev/null @@ -1,226 +0,0 @@ -import contextlib -import inspect -import logging -import pprint -import re -import textwrap -import traceback -from io import StringIO -from typing import Any, Optional, Tuple - -import discord -from discord.ext.commands import Cog, Context, group, has_any_role - -from bot.bot import Bot -from bot.constants import Roles -from bot.interpreter import Interpreter -from bot.utils import find_nth_occurrence, send_to_paste_service - -log = logging.getLogger(__name__) - - -class CodeEval(Cog): - """Owner and admin feature that evaluates code and returns the result to the channel.""" - - def __init__(self, bot: Bot): - self.bot = bot - self.env = {} - self.ln = 0 - self.stdout = StringIO() - - self.interpreter = Interpreter(bot) - - def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]: - """Format the eval output into a string & attempt to format it into an Embed.""" - self._ = out - - res = "" - - # Erase temp input we made - if inp.startswith("_ = "): - inp = inp[4:] - - # Get all non-empty lines - lines = [line for line in inp.split("\n") if line.strip()] - if len(lines) != 1: - lines += [""] - - # Create the input dialog - for i, line in enumerate(lines): - if i == 0: - # Start dialog - start = f"In [{self.ln}]: " - - else: - # Indent the 3 dots correctly; - # Normally, it's something like - # In [X]: - # ...: - # - # But if it's - # In [XX]: - # ...: - # - # You can see it doesn't look right. - # This code simply indents the dots - # far enough to align them. - # we first `str()` the line number - # then we get the length - # and use `str.rjust()` - # to indent it. - start = "...: ".rjust(len(str(self.ln)) + 7) - - if i == len(lines) - 2: - if line.startswith("return"): - line = line[6:].strip() - - # Combine everything - res += (start + line + "\n") - - self.stdout.seek(0) - text = self.stdout.read() - self.stdout.close() - self.stdout = StringIO() - - if text: - res += (text + "\n") - - if out is None: - # No output, return the input statement - return (res, None) - - res += f"Out[{self.ln}]: " - - if isinstance(out, discord.Embed): - # We made an embed? Send that as embed - res += "" - res = (res, out) - - else: - if (isinstance(out, str) and out.startswith("Traceback (most recent call last):\n")): - # Leave out the traceback message - out = "\n" + "\n".join(out.split("\n")[1:]) - - if isinstance(out, str): - pretty = out - else: - pretty = pprint.pformat(out, compact=True, width=60) - - if pretty != str(out): - # We're using the pretty version, start on the next line - res += "\n" - - if pretty.count("\n") > 20: - # Text too long, shorten - li = pretty.split("\n") - - pretty = ("\n".join(li[:3]) # First 3 lines - + "\n ...\n" # Ellipsis to indicate removed lines - + "\n".join(li[-3:])) # last 3 lines - - # Add the output - res += pretty - res = (res, None) - - return res # Return (text, embed) - - async def _eval(self, ctx: Context, code: str) -> Optional[discord.Message]: - """Eval the input code string & send an embed to the invoking context.""" - self.ln += 1 - - if code.startswith("exit"): - self.ln = 0 - self.env = {} - return await ctx.send("```Reset history!```") - - env = { - "message": ctx.message, - "author": ctx.message.author, - "channel": ctx.channel, - "guild": ctx.guild, - "ctx": ctx, - "self": self, - "bot": self.bot, - "inspect": inspect, - "discord": discord, - "contextlib": contextlib - } - - self.env.update(env) - - # Ignore this code, it works - code_ = """ -async def func(): # (None,) -> Any - try: - with contextlib.redirect_stdout(self.stdout): -{0} - if '_' in locals(): - if inspect.isawaitable(_): - _ = await _ - return _ - finally: - self.env.update(locals()) -""".format(textwrap.indent(code, ' ')) - - try: - exec(code_, self.env) # noqa: B102,S102 - func = self.env['func'] - res = await func() - - except Exception: - res = traceback.format_exc() - - out, embed = self._format(code, res) - out = out.rstrip("\n") # Strip empty lines from output - - # Truncate output to max 15 lines or 1500 characters - newline_truncate_index = find_nth_occurrence(out, "\n", 15) - - if newline_truncate_index is None or newline_truncate_index > 1500: - truncate_index = 1500 - else: - truncate_index = newline_truncate_index - - if len(out) > truncate_index: - paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py") - if paste_link is not None: - paste_text = f"full contents at {paste_link}" - else: - paste_text = "failed to upload contents to paste service." - - await ctx.send( - f"```py\n{out[:truncate_index]}\n```" - f"... response truncated; {paste_text}", - embed=embed - ) - return - - await ctx.send(f"```py\n{out}```", embed=embed) - - @group(name='internal', aliases=('int',)) - @has_any_role(Roles.owners, Roles.admins) - async def internal_group(self, ctx: Context) -> None: - """Internal commands. Top secret!""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @internal_group.command(name='eval', aliases=('e',)) - @has_any_role(Roles.admins, Roles.owners) - async def eval(self, ctx: Context, *, code: str) -> None: - """Run eval in a REPL-like format.""" - code = code.strip("`") - if re.match('py(thon)?\n', code): - code = "\n".join(code.split("\n")[1:]) - - if not re.search( # Check if it's an expression - r"^(return|import|for|while|def|class|" - r"from|exit|[a-zA-Z0-9]+\s*=)", code, re.M) and len( - code.split("\n")) == 1: - code = "_ = " + code - - await self._eval(ctx, code) - - -def setup(bot: Bot) -> None: - """Load the CodeEval cog.""" - bot.add_cog(CodeEval(bot)) diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py new file mode 100644 index 000000000..d61200575 --- /dev/null +++ b/bot/exts/utils/internal.py @@ -0,0 +1,258 @@ +import contextlib +import inspect +import logging +import pprint +import re +import textwrap +import traceback +from collections import Counter +from datetime import datetime +from io import StringIO +from typing import Any, Optional, Tuple + +import discord +from discord.ext.commands import Cog, Context, group, has_any_role + +from bot.bot import Bot +from bot.constants import Roles +from bot.interpreter import Interpreter +from bot.utils import find_nth_occurrence, send_to_paste_service + +log = logging.getLogger(__name__) + + +class Internal(Cog): + """Administrator and Core Developer commands.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.env = {} + self.ln = 0 + self.stdout = StringIO() + + self.interpreter = Interpreter(bot) + + self.socket_since = datetime.utcnow() + self.socket_event_total = 0 + self.socket_events = Counter() + + @Cog.listener() + async def on_socket_response(self, msg: dict) -> None: + """When a websocket event is received, increase our counters.""" + if event_type := msg.get("t"): + self.socket_event_total += 1 + self.socket_events[event_type] += 1 + + def _format(self, inp: str, out: Any) -> Tuple[str, Optional[discord.Embed]]: + """Format the eval output into a string & attempt to format it into an Embed.""" + self._ = out + + res = "" + + # Erase temp input we made + if inp.startswith("_ = "): + inp = inp[4:] + + # Get all non-empty lines + lines = [line for line in inp.split("\n") if line.strip()] + if len(lines) != 1: + lines += [""] + + # Create the input dialog + for i, line in enumerate(lines): + if i == 0: + # Start dialog + start = f"In [{self.ln}]: " + + else: + # Indent the 3 dots correctly; + # Normally, it's something like + # In [X]: + # ...: + # + # But if it's + # In [XX]: + # ...: + # + # You can see it doesn't look right. + # This code simply indents the dots + # far enough to align them. + # we first `str()` the line number + # then we get the length + # and use `str.rjust()` + # to indent it. + start = "...: ".rjust(len(str(self.ln)) + 7) + + if i == len(lines) - 2: + if line.startswith("return"): + line = line[6:].strip() + + # Combine everything + res += (start + line + "\n") + + self.stdout.seek(0) + text = self.stdout.read() + self.stdout.close() + self.stdout = StringIO() + + if text: + res += (text + "\n") + + if out is None: + # No output, return the input statement + return (res, None) + + res += f"Out[{self.ln}]: " + + if isinstance(out, discord.Embed): + # We made an embed? Send that as embed + res += "" + res = (res, out) + + else: + if (isinstance(out, str) and out.startswith("Traceback (most recent call last):\n")): + # Leave out the traceback message + out = "\n" + "\n".join(out.split("\n")[1:]) + + if isinstance(out, str): + pretty = out + else: + pretty = pprint.pformat(out, compact=True, width=60) + + if pretty != str(out): + # We're using the pretty version, start on the next line + res += "\n" + + if pretty.count("\n") > 20: + # Text too long, shorten + li = pretty.split("\n") + + pretty = ("\n".join(li[:3]) # First 3 lines + + "\n ...\n" # Ellipsis to indicate removed lines + + "\n".join(li[-3:])) # last 3 lines + + # Add the output + res += pretty + res = (res, None) + + return res # Return (text, embed) + + async def _eval(self, ctx: Context, code: str) -> Optional[discord.Message]: + """Eval the input code string & send an embed to the invoking context.""" + self.ln += 1 + + if code.startswith("exit"): + self.ln = 0 + self.env = {} + return await ctx.send("```Reset history!```") + + env = { + "message": ctx.message, + "author": ctx.message.author, + "channel": ctx.channel, + "guild": ctx.guild, + "ctx": ctx, + "self": self, + "bot": self.bot, + "inspect": inspect, + "discord": discord, + "contextlib": contextlib + } + + self.env.update(env) + + # Ignore this code, it works + code_ = """ +async def func(): # (None,) -> Any + try: + with contextlib.redirect_stdout(self.stdout): +{0} + if '_' in locals(): + if inspect.isawaitable(_): + _ = await _ + return _ + finally: + self.env.update(locals()) +""".format(textwrap.indent(code, ' ')) + + try: + exec(code_, self.env) # noqa: B102,S102 + func = self.env['func'] + res = await func() + + except Exception: + res = traceback.format_exc() + + out, embed = self._format(code, res) + out = out.rstrip("\n") # Strip empty lines from output + + # Truncate output to max 15 lines or 1500 characters + newline_truncate_index = find_nth_occurrence(out, "\n", 15) + + if newline_truncate_index is None or newline_truncate_index > 1500: + truncate_index = 1500 + else: + truncate_index = newline_truncate_index + + if len(out) > truncate_index: + paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py") + if paste_link is not None: + paste_text = f"full contents at {paste_link}" + else: + paste_text = "failed to upload contents to paste service." + + await ctx.send( + f"```py\n{out[:truncate_index]}\n```" + f"... response truncated; {paste_text}", + embed=embed + ) + return + + await ctx.send(f"```py\n{out}```", embed=embed) + + @group(name='internal', aliases=('int',)) + @has_any_role(Roles.owners, Roles.admins, Roles.core_developers) + async def internal_group(self, ctx: Context) -> None: + """Internal commands. Top secret!""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @internal_group.command(name='eval', aliases=('e',)) + @has_any_role(Roles.admins, Roles.owners) + async def eval(self, ctx: Context, *, code: str) -> None: + """Run eval in a REPL-like format.""" + code = code.strip("`") + if re.match('py(thon)?\n', code): + code = "\n".join(code.split("\n")[1:]) + + if not re.search( # Check if it's an expression + r"^(return|import|for|while|def|class|" + r"from|exit|[a-zA-Z0-9]+\s*=)", code, re.M) and len( + code.split("\n")) == 1: + code = "_ = " + code + + await self._eval(ctx, code) + + @internal_group.command(name='socketstats', aliases=('socket', 'stats')) + @has_any_role(Roles.admins, Roles.owners, Roles.core_developers) + async def socketstats(self, ctx: Context) -> None: + """Fetch information on the socket events received from Discord.""" + running_s = (datetime.utcnow() - self.socket_since).total_seconds() + + per_s = self.socket_event_total / running_s + + stats_embed = discord.Embed( + title="WebSocket statistics", + description=f"Receiving {per_s:0.2f} event per second.", + color=discord.Color.blurple() + ) + + for event_type, count in self.socket_events.most_common(): + stats_embed.add_field(name=event_type, value=count, inline=False) + + await ctx.send(embed=stats_embed) + + +def setup(bot: Bot) -> None: + """Load the Internal cog.""" + bot.add_cog(Internal(bot)) -- cgit v1.2.3 From 58072451a02a59672dd186358e164ea580e8050f Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 3 Oct 2020 11:48:37 +0100 Subject: Cap most_common to 25 to not go over the embed fields limit --- bot/exts/utils/internal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index d61200575..1b4900f42 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -247,7 +247,7 @@ async def func(): # (None,) -> Any color=discord.Color.blurple() ) - for event_type, count in self.socket_events.most_common(): + for event_type, count in self.socket_events.most_common(25): stats_embed.add_field(name=event_type, value=count, inline=False) await ctx.send(embed=stats_embed) -- cgit v1.2.3 From 764f35fa9c54d651625aad813e2e32a0a6e6d2d6 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Sat, 3 Oct 2020 13:49:16 +0200 Subject: add missing test for `user` command --- tests/bot/exts/info/test_information.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 23eeb88cd..4e391eb57 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -393,6 +393,11 @@ class UserEmbedTests(unittest.TestCase): embed.fields[1].value ) + self.assertEqual( + "basic infractions info", + embed.fields[2].value + ) + @unittest.mock.patch( f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) -- cgit v1.2.3 From 73a0291a4ab4b10eb9d5d4e78bc574ca25fc9c98 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 3 Oct 2020 10:06:43 -0700 Subject: Lock: rename variable to avoid shadowing --- bot/utils/lock.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/utils/lock.py b/bot/utils/lock.py index 5c9dd3725..510f41234 100644 --- a/bot/utils/lock.py +++ b/bot/utils/lock.py @@ -81,11 +81,11 @@ def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = Fa # Get the lock for the ID. Create a lock if one doesn't exist yet. locks = __lock_dicts[namespace] - lock = locks.setdefault(id_, LockGuard()) + lock_guard = locks.setdefault(id_, LockGuard()) - if not lock.locked(): + if not lock_guard.locked(): log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") - with lock: + with lock_guard: return await func(*args, **kwargs) else: log.info(f"{name}: aborted because resource {namespace!r}:{id_!r} is locked") -- cgit v1.2.3 From c1c754a01b10a5c79d35c04431dd43855015ed20 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 3 Oct 2020 10:08:55 -0700 Subject: Lock: make LockGuard.locked a property --- bot/utils/lock.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/utils/lock.py b/bot/utils/lock.py index 510f41234..7aaafbc88 100644 --- a/bot/utils/lock.py +++ b/bot/utils/lock.py @@ -26,6 +26,7 @@ class LockGuard: def __init__(self): self._locked = False + @property def locked(self) -> bool: """Return True if currently locked or False if unlocked.""" return self._locked @@ -83,7 +84,7 @@ def lock(namespace: Hashable, resource_id: ResourceId, *, raise_error: bool = Fa locks = __lock_dicts[namespace] lock_guard = locks.setdefault(id_, LockGuard()) - if not lock_guard.locked(): + if not lock_guard.locked: log.debug(f"{name}: resource {namespace!r}:{id_!r} is free; acquiring it...") with lock_guard: return await func(*args, **kwargs) -- cgit v1.2.3 From a0f42eba424fe3d119f5af2632822b38b78b5bd2 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Sat, 3 Oct 2020 21:28:15 +0200 Subject: Add trailing comma to intents --- bot/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index 133c96302..da042a5ed 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -62,7 +62,7 @@ bot = Bot( case_insensitive=True, max_messages=10_000, allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles), - intents=intents + intents=intents, ) # Load extensions. -- cgit v1.2.3 From 397d29a2f51f2a02558998efe8777a9efa575a43 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Sat, 3 Oct 2020 21:28:57 +0200 Subject: Use invite for tracking offline presences instead of `ctx` --- bot/exts/info/information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index ca9895d61..0f50138e7 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -152,9 +152,9 @@ class Information(Cog): channel_counts = self.get_channel_type_counts(ctx.guild) # How many of each user status? - py_invite = await self.bot.fetch_invite("python") + py_invite = await self.bot.fetch_invite(constants.Guild.invite) online_presences = py_invite.approximate_presence_count - offline_presences = ctx.guild.member_count - online_presences + offline_presences = py_invite.approximate_member_count - online_presences embed = Embed(colour=Colour.blurple()) # How many staff members and staff channels do we have? -- cgit v1.2.3 From 9b63db31fc9b6fe6a726f711383cb38b2c44bd40 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Sat, 3 Oct 2020 20:38:46 -0400 Subject: Replace `map` with a more pythonic list comprehension. --- bot/exts/info/site.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index 2d3a3d9f3..9e7f6b0a5 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -129,7 +129,7 @@ class Site(Cog): ) if invalid_indices: - indices = ', '.join(map(str, invalid_indices)) + indices = ', '.join(str(index) for index in invalid_indices) await ctx.send(f":x: Invalid rule indices: {indices}") return -- cgit v1.2.3 From bfcd4689b8cf8f0d1a26ffc1e1b0b4b9b9e9b59d Mon Sep 17 00:00:00 2001 From: Den4200 Date: Sat, 3 Oct 2020 20:40:37 -0400 Subject: Remove duplicates from given rule indices and sort them in order. --- bot/exts/info/site.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index 9e7f6b0a5..c8ae8dc96 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -122,10 +122,14 @@ class Site(Cog): return full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'}) - invalid_indices = tuple( - pick - for pick in rules - if pick < 1 or pick > len(full_rules) + + # Remove duplicates and sort the invalid rule indices + invalid_indices = sorted( + set( + pick + for pick in rules + if pick < 1 or pick > len(full_rules) + ) ) if invalid_indices: @@ -136,6 +140,9 @@ class Site(Cog): for rule in rules: self.bot.stats.incr(f"rule_uses.{rule}") + # Remove duplicates and sort the rule indices + rules = sorted(set(rules)) + final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules) await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) -- cgit v1.2.3 From 6cc110cf93dd109e371dfae7ad93520920883ca8 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Sat, 3 Oct 2020 20:41:17 -0400 Subject: Use `Greedy` converter instead of the splat operator. --- bot/exts/info/site.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index c8ae8dc96..12c1737a2 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -1,7 +1,7 @@ import logging from discord import Colour, Embed -from discord.ext.commands import Cog, Context, group +from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import URLs @@ -105,7 +105,7 @@ class Site(Cog): await ctx.send(embed=embed) @site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule")) - async def site_rules(self, ctx: Context, *rules: int) -> None: + async def site_rules(self, ctx: Context, rules: Greedy[int]) -> None: """Provides a link to all rules or, if specified, displays specific rule(s).""" rules_embed = Embed(title='Rules', color=Colour.blurple()) rules_embed.url = f"{PAGES_URL}/rules" -- cgit v1.2.3 From dcad46fd4637fadc16f69da6bb92dd3513f68d76 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Sat, 3 Oct 2020 21:10:10 -0400 Subject: Use `url` argument instead of setting it outside. --- bot/exts/info/site.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index 12c1737a2..bf2547895 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -107,8 +107,7 @@ class Site(Cog): @site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule")) async def site_rules(self, ctx: Context, rules: Greedy[int]) -> None: """Provides a link to all rules or, if specified, displays specific rule(s).""" - rules_embed = Embed(title='Rules', color=Colour.blurple()) - rules_embed.url = f"{PAGES_URL}/rules" + rules_embed = Embed(title='Rules', color=Colour.blurple(), url=f'{PAGES_URL}/rules') if not rules: # Rules were not submitted. Return the default description. -- cgit v1.2.3 From 03560d855ec407d1cfb444f392934bdb53ad5d96 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 4 Oct 2020 09:12:12 +0300 Subject: Rename async cache instances --- bot/exts/info/doc.py | 7 +++---- bot/exts/utils/utils.py | 5 ++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/bot/exts/info/doc.py b/bot/exts/info/doc.py index 1fd0ee266..a847f1440 100644 --- a/bot/exts/info/doc.py +++ b/bot/exts/info/doc.py @@ -65,8 +65,7 @@ WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)") FAILED_REQUEST_RETRY_AMOUNT = 3 NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay -# Async cache instance for docs cog -async_cache = AsyncCache() +symbol_cache = AsyncCache() class DocMarkdownConverter(MarkdownConverter): @@ -189,7 +188,7 @@ class Doc(commands.Cog): self.base_urls.clear() self.inventories.clear() self.renamed_symbols.clear() - async_cache.clear() + symbol_cache.clear() # Run all coroutines concurrently - since each of them performs a HTTP # request, this speeds up fetching the inventory data heavily. @@ -254,7 +253,7 @@ class Doc(commands.Cog): return signatures, description.replace('¶', '') - @async_cache(arg_offset=1) + @symbol_cache(arg_offset=1) async def get_symbol_embed(self, symbol: str) -> Optional[discord.Embed]: """ Attempt to scrape and fetch the data for the given `symbol`, and build an embed from its contents. diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 278b6fefb..c006fb87e 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -43,8 +43,7 @@ Namespaces are one honking great idea -- let's do more of those! ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" -# Async cache instance for PEPs -async_cache = AsyncCache() +pep_cache = AsyncCache() class Utils(Cog): @@ -284,7 +283,7 @@ class Utils(Cog): return pep_embed - @async_cache(arg_offset=2) + @pep_cache(arg_offset=2) async def get_pep_embed(self, ctx: Context, pep_nr: int) -> Optional[Embed]: """Fetch, generate and return PEP embed. When any error occur, use `self.send_pep_error_embed`.""" response = await self.bot.http_session.get(self.peps[pep_nr]) -- cgit v1.2.3 From 86f2024b38f2b7d017a7a68300c3a7f4b79aab45 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 4 Oct 2020 09:17:30 +0300 Subject: Move PEP URLs to class constants --- bot/exts/utils/utils.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index c006fb87e..0d16a142e 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -49,13 +49,12 @@ pep_cache = AsyncCache() class Utils(Cog): """A selection of utilities which don't have a clear category.""" + BASE_PEP_URL = "http://www.python.org/dev/peps/pep-" + BASE_GITHUB_PEP_URL = "https://raw.githubusercontent.com/python/peps/master/pep-" + PEPS_LISTING_API_URL = "https://api.github.com/repos/python/peps/contents?ref=master" + def __init__(self, bot: Bot): self.bot = bot - - self.base_pep_url = "http://www.python.org/dev/peps/pep-" - self.base_github_pep_url = "https://raw.githubusercontent.com/python/peps/master/pep-" - self.peps_listing_api_url = "https://api.github.com/repos/python/peps/contents?ref=master" - self.peps: Dict[int, str] = {} self.last_refreshed_peps: Optional[datetime] = None self.bot.loop.create_task(self.refresh_peps_urls()) @@ -198,7 +197,7 @@ class Utils(Cog): await self.bot.wait_until_ready() log.trace("Started refreshing PEP URLs.") - async with self.bot.http_session.get(self.peps_listing_api_url) as resp: + async with self.bot.http_session.get(self.PEPS_LISTING_API_URL) as resp: listing = await resp.json() log.trace("Got PEP URLs listing from GitHub API") @@ -268,7 +267,7 @@ class Utils(Cog): # Assemble the embed pep_embed = Embed( title=f"**PEP {pep_nr} - {pep_header['Title']}**", - description=f"[Link]({self.base_pep_url}{pep_nr:04})", + description=f"[Link]({self.BASE_PEP_URL}{pep_nr:04})", ) pep_embed.set_thumbnail(url=ICON_URL) -- cgit v1.2.3 From c58d68eed338514963525099c233363f01db1e65 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 4 Oct 2020 09:24:08 +0300 Subject: Make AsyncCache key tuple instead string --- bot/utils/cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/cache.py b/bot/utils/cache.py index 70925b71d..8a180b4fa 100644 --- a/bot/utils/cache.py +++ b/bot/utils/cache.py @@ -24,7 +24,7 @@ class AsyncCache: @functools.wraps(function) async def wrapper(*args) -> Any: """Decorator wrapper for the caching logic.""" - key = ':'.join(str(args[arg_offset:])) + key = args[arg_offset:] if key not in self._cache: if len(self._cache) > max_size: -- cgit v1.2.3 From 90103c58697889fdd352cd021faba6be2ad3a7d7 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 4 Oct 2020 09:25:52 +0300 Subject: Move AsyncCache max_size argument to __init__ from decorator --- bot/utils/cache.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/utils/cache.py b/bot/utils/cache.py index 8a180b4fa..68ce15607 100644 --- a/bot/utils/cache.py +++ b/bot/utils/cache.py @@ -12,10 +12,11 @@ class AsyncCache: An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key. """ - def __init__(self): + def __init__(self, max_size: int = 128): self._cache = OrderedDict() + self._max_size = max_size - def __call__(self, max_size: int = 128, arg_offset: int = 0) -> Callable: + def __call__(self, arg_offset: int = 0) -> Callable: """Decorator for async cache.""" def decorator(function: Callable) -> Callable: @@ -27,7 +28,7 @@ class AsyncCache: key = args[arg_offset:] if key not in self._cache: - if len(self._cache) > max_size: + if len(self._cache) > self._max_size: self._cache.popitem(last=False) self._cache[key] = await function(*args) -- cgit v1.2.3 From 20c5a6946a140ef9e79f8a7c4edb60e2d5372298 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 4 Oct 2020 16:59:22 +0300 Subject: Added interleaving text in code blocks option If the message contains both plaintext and code blocks, the text will be ignored. If several code blocks are present, they are concatenated. --- bot/exts/utils/snekbox.py | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index ca6fbf5cb..e1839bdf7 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -31,6 +31,15 @@ FORMATTED_CODE_REGEX = re.compile( r"\s*$", # any trailing whitespace until the end of the string re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive ) +CODE_BLOCK_REGEX = re.compile( + r"```" # code block delimiter: 3 batckticks + r"([a-z]+\n)?" # match optional language (only letters plus newline) + r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code + r"(?P.*?)" # extract all code inside the markup + r"\s*" # any more whitespace before the end of the code markup + r"```", # code block end + re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive +) RAW_CODE_REGEX = re.compile( r"^(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code r"(?P.*?)" # extract all the rest as code @@ -78,7 +87,9 @@ class Snekbox(Cog): def prepare_input(code: str) -> str: """Extract code from the Markdown, format it, and insert it into the code template.""" match = FORMATTED_CODE_REGEX.fullmatch(code) - if match: + + # Despite the wildcard being lazy, this is a fullmatch so we need to check the presence of the delim explicitly. + if match and match.group("delim") not in match.group("code"): code, block, lang, delim = match.group("code", "block", "lang", "delim") code = textwrap.dedent(code) if block: @@ -86,12 +97,20 @@ class Snekbox(Cog): else: info = f"{delim}-enclosed inline code" log.trace(f"Extracted {info} for evaluation:\n{code}") + else: - code = textwrap.dedent(RAW_CODE_REGEX.fullmatch(code).group("code")) - log.trace( - f"Eval message contains unformatted or badly formatted code, " - f"stripping whitespace only:\n{code}" - ) + code_parts = CODE_BLOCK_REGEX.finditer(code) + merge = '\n'.join(map(lambda part: part.group("code"), code_parts)) + if merge: + code = textwrap.dedent(merge) + log.trace(f"Merged one or more code blocks from text combined with code:\n{code}") + + else: + code = textwrap.dedent(RAW_CODE_REGEX.fullmatch(code).group("code")) + log.trace( + f"Eval message contains unformatted or badly formatted code, " + f"stripping whitespace only:\n{code}" + ) return code -- cgit v1.2.3 From 2553a1d35bf52681dc8b28327e15fbd3ec14910e Mon Sep 17 00:00:00 2001 From: Den4200 Date: Sun, 4 Oct 2020 11:20:08 -0400 Subject: Sort rules before determining invalid indices. This is to avoid sorting twice - once for invalid indices and again for send the rules. --- bot/exts/info/site.py | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index bf2547895..fb5b99086 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -122,26 +122,17 @@ class Site(Cog): full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'}) - # Remove duplicates and sort the invalid rule indices - invalid_indices = sorted( - set( - pick - for pick in rules - if pick < 1 or pick > len(full_rules) - ) - ) + # Remove duplicates and sort the rule indices + rules = sorted(set(rules)) + invalid = ', '.join(str(index) for index in rules if index < 1 or index > len(full_rules)) - if invalid_indices: - indices = ', '.join(str(index) for index in invalid_indices) - await ctx.send(f":x: Invalid rule indices: {indices}") + if invalid: + await ctx.send(f":x: Invalid rule indices: {invalid}") return for rule in rules: self.bot.stats.incr(f"rule_uses.{rule}") - # Remove duplicates and sort the rule indices - rules = sorted(set(rules)) - final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules) await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) -- cgit v1.2.3 From 08140e8ceab3ab46a1c956b7a4c90b771064d3c6 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 4 Oct 2020 18:34:50 +0300 Subject: Improved style and fixed comment. --- bot/exts/utils/snekbox.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index e1839bdf7..e782ed745 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -88,7 +88,7 @@ class Snekbox(Cog): """Extract code from the Markdown, format it, and insert it into the code template.""" match = FORMATTED_CODE_REGEX.fullmatch(code) - # Despite the wildcard being lazy, this is a fullmatch so we need to check the presence of the delim explicitly. + # Despite the wildcard being lazy, the pattern is from start to end and will eat any delimiters in the middle. if match and match.group("delim") not in match.group("code"): code, block, lang, delim = match.group("code", "block", "lang", "delim") code = textwrap.dedent(code) @@ -100,7 +100,7 @@ class Snekbox(Cog): else: code_parts = CODE_BLOCK_REGEX.finditer(code) - merge = '\n'.join(map(lambda part: part.group("code"), code_parts)) + merge = '\n'.join(part.group("code") for part in code_parts) if merge: code = textwrap.dedent(merge) log.trace(f"Merged one or more code blocks from text combined with code:\n{code}") -- cgit v1.2.3 From 233f63551bf1945d83f0418e506f9ec9a9381ac6 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 6 Oct 2020 00:15:20 +0100 Subject: Support users with alternate gating methods --- bot/exts/moderation/verification.py | 42 +++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 206556483..1d1dacb37 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -53,6 +53,23 @@ If you'd like to unsubscribe from the announcement notifications, simply send `! <#{constants.Channels.bot_commands}>. """ +ALTERNATE_VERIFIED_MESSAGE = f""" +Thanks for accepting our rules! + +You can find a copy of our rules for reference at . + +Additionally, if you'd like to receive notifications for the announcements \ +we post in <#{constants.Channels.announcements}> +from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \ +to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement. + +If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \ +<#{constants.Channels.bot_commands}>. + +To introduce you to our community, we've made the following video: +https://youtu.be/ZH26PuX3re0 +""" + # Sent via DMs to users kicked for failing to verify KICKED_MESSAGE = f""" Hi! You have been automatically kicked from Python Discord as you have failed to accept our rules \ @@ -156,6 +173,9 @@ class Verification(Cog): # ] task_cache = RedisCache() + # Cache who needs to receive an alternate verified DM. + member_gating_cache = RedisCache() + def __init__(self, bot: Bot) -> None: """Start internal tasks.""" self.bot = bot @@ -519,12 +539,34 @@ class Verification(Cog): if member.guild.id != constants.Guild.id: return # Only listen for PyDis events + raw_member = await self.bot.http.get_member(member.guild.id, member.id) + + # Only send the message to users going through our gating system + if raw_member["is_pending"]: + await self.member_gating_cache.set(raw_member.id, True) + return + log.trace(f"Sending on join message to new member: {member.id}") try: await safe_dm(member.send(ON_JOIN_MESSAGE)) except discord.HTTPException: log.exception("DM dispatch failed on unexpected error code") + @Cog.listener() + async def on_member_update(self, before: discord.Member, after: discord.Member): + """Check if we need to send a verification DM to a gated user.""" + before_roles = [r.id for r in before.roles] + after_roles = [r.id for r in after.roles] + + if constants.Roles.verified not in before_roles and constants.Roles.verified in after_roles: + if await self.member_gating_cache.get(after.id): + try: + await safe_dm(after.send(ALTERNATE_VERIFIED_MESSAGE)) + except discord.HTTPException: + log.exception("DM dispatch failed on unexpected error code") + finally: + self.member_gating_cache.pop(after.id) + @Cog.listener() async def on_message(self, message: discord.Message) -> None: """Check new message event for messages to the checkpoint channel & process.""" -- cgit v1.2.3 From 2f18813a08c544f5d8973ba0f3a7d0e78a3dc6eb Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 6 Oct 2020 00:23:29 +0100 Subject: Add type annotation to on_member_update listener --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 1d1dacb37..b86a67225 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -553,7 +553,7 @@ class Verification(Cog): log.exception("DM dispatch failed on unexpected error code") @Cog.listener() - async def on_member_update(self, before: discord.Member, after: discord.Member): + async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: """Check if we need to send a verification DM to a gated user.""" before_roles = [r.id for r in before.roles] after_roles = [r.id for r in after.roles] -- cgit v1.2.3 From 880b936faf83d8fa3d7489e1f9eaab89b93af1b8 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 6 Oct 2020 00:36:13 +0100 Subject: Merge get and pop into one conditional --- bot/exts/moderation/verification.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index b86a67225..d5eb61f13 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -559,13 +559,11 @@ class Verification(Cog): after_roles = [r.id for r in after.roles] if constants.Roles.verified not in before_roles and constants.Roles.verified in after_roles: - if await self.member_gating_cache.get(after.id): + if await self.member_gating_cache.pop(after.id): try: await safe_dm(after.send(ALTERNATE_VERIFIED_MESSAGE)) except discord.HTTPException: log.exception("DM dispatch failed on unexpected error code") - finally: - self.member_gating_cache.pop(after.id) @Cog.listener() async def on_message(self, message: discord.Message) -> None: -- cgit v1.2.3 From 406da780c06a6797b860d816c4a418def9a3f116 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 6 Oct 2020 00:39:54 +0100 Subject: Use clearer variable names in list comprehensions --- bot/exts/moderation/verification.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index d5eb61f13..8ad42a035 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -555,8 +555,8 @@ class Verification(Cog): @Cog.listener() async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: """Check if we need to send a verification DM to a gated user.""" - before_roles = [r.id for r in before.roles] - after_roles = [r.id for r in after.roles] + before_roles = [role.id for role in before.roles] + after_roles = [role.id for role in after.roles] if constants.Roles.verified not in before_roles and constants.Roles.verified in after_roles: if await self.member_gating_cache.pop(after.id): -- cgit v1.2.3 From ceffe46a0d5136308c8f0684c2c406dd34e758fb Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 6 Oct 2020 00:41:16 +0100 Subject: Reword cache creation comment --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 8ad42a035..c675b8db9 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -173,7 +173,7 @@ class Verification(Cog): # ] task_cache = RedisCache() - # Cache who needs to receive an alternate verified DM. + # Create a cache for storing recipients of the alternate welcome DM. member_gating_cache = RedisCache() def __init__(self, bot: Bot) -> None: -- cgit v1.2.3 From 8d6d3ef56d29c2eff372c858fa0a228eeefdbfb8 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 6 Oct 2020 00:43:50 +0100 Subject: Clear up comment around DM send --- bot/exts/moderation/verification.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index c675b8db9..89e1cdd7e 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -561,6 +561,10 @@ class Verification(Cog): if constants.Roles.verified not in before_roles and constants.Roles.verified in after_roles: if await self.member_gating_cache.pop(after.id): try: + # If the member has not received a DM from our !accept command + # and has gone through the alternate gating system we should send + # our alternate welcome DM which includes info such as our welcome + # video. await safe_dm(after.send(ALTERNATE_VERIFIED_MESSAGE)) except discord.HTTPException: log.exception("DM dispatch failed on unexpected error code") -- cgit v1.2.3 From ea3217effacc02e06444ea0b21985cd7439a13e7 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 6 Oct 2020 00:45:31 +0100 Subject: Reword on_join comment for alternate gate members --- bot/exts/moderation/verification.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 89e1cdd7e..659c7414f 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -541,7 +541,10 @@ class Verification(Cog): raw_member = await self.bot.http.get_member(member.guild.id, member.id) - # Only send the message to users going through our gating system + # If the user has the is_pending flag set, they will be using the alternate + # gate and will not need a welcome DM with verification instructions. + # We will send them an alternate DM once they verify with the welcome + # video. if raw_member["is_pending"]: await self.member_gating_cache.set(raw_member.id, True) return -- cgit v1.2.3 From 082e26342eb3faf104523334cdb87e07eda03db3 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 6 Oct 2020 00:46:09 +0100 Subject: Correct raw_member to member in verification on_join --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 659c7414f..3b5d7e58b 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -546,7 +546,7 @@ class Verification(Cog): # We will send them an alternate DM once they verify with the welcome # video. if raw_member["is_pending"]: - await self.member_gating_cache.set(raw_member.id, True) + await self.member_gating_cache.set(member.id, True) return log.trace(f"Sending on join message to new member: {member.id}") -- cgit v1.2.3 From 42697e85354223fc1c678bfaf7e273274a9c81bc Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 6 Oct 2020 00:50:38 +0100 Subject: Use .get() instead of index for fetching is_pending property --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 3b5d7e58b..c3ad8687e 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -545,7 +545,7 @@ class Verification(Cog): # gate and will not need a welcome DM with verification instructions. # We will send them an alternate DM once they verify with the welcome # video. - if raw_member["is_pending"]: + if raw_member.get("is_pending"): await self.member_gating_cache.set(member.id, True) return -- cgit v1.2.3 From ace40ecf463a17ad228541ac9ed9a97df15c624c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 5 Oct 2020 19:03:59 -0700 Subject: Code block: support the "pycon" language specifier It's used for code copied from the Python REPL. --- bot/cogs/codeblock/parsing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 01c220c61..e67224494 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -12,7 +12,7 @@ from bot.utils import has_lines log = logging.getLogger(__name__) BACKTICK = "`" -PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. +PY_LANG_CODES = ("python", "pycon", "py") # Order is important; "py" is last cause it's a subset. _TICKS = { BACKTICK, "'", -- cgit v1.2.3 From d69804ee73391dc7c95d1d743615ba4b7a1de7d8 Mon Sep 17 00:00:00 2001 From: Boris Muratov Date: Tue, 6 Oct 2020 11:43:10 +0300 Subject: Fix old nick in superstarify reason --- bot/exts/moderation/infraction/superstarify.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index eec63f5b3..adfe42fcd 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -135,7 +135,8 @@ class Superstarify(InfractionScheduler, Cog): return # Post the infraction to the API - reason = reason or f"old nick: {member.display_name}" + old_nick = member.display_name + reason = reason or f"old nick: {old_nick}" infraction = await _utils.post_infraction(ctx, member, "superstar", reason, duration, active=True) id_ = infraction["id"] @@ -148,7 +149,7 @@ class Superstarify(InfractionScheduler, Cog): await member.edit(nick=forced_nick, reason=reason) self.schedule_expiration(infraction) - old_nick = escape_markdown(member.display_name) + old_nick = escape_markdown(old_nick) forced_nick = escape_markdown(forced_nick) # Send a DM to the user to notify them of their new infraction. -- cgit v1.2.3 From 672704841ddfe79d393a621e8c934bdb362f4ef0 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 6 Oct 2020 21:36:34 +0200 Subject: Include rolled over logs in gitignore RotatingFileHandler appends .# to log names when rolling over to a new file. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index fb3156ab1..2074887ad 100644 --- a/.gitignore +++ b/.gitignore @@ -110,6 +110,7 @@ ENV/ # Logfiles log.* +*.log.* # Custom user configuration config.yml -- cgit v1.2.3 From 5381552c18b541121a33171f4763047e03362780 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 6 Oct 2020 12:45:07 -0700 Subject: Silence: move unsilence scheduling to a separate function --- bot/cogs/moderation/silence.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 8e15b2284..08d0328ab 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -110,16 +110,12 @@ class Silence(commands.Cog): await ctx.send(MSG_SILENCE_FAIL) return + await self._schedule_unsilence(ctx, duration) + if duration is None: await ctx.send(MSG_SILENCE_PERMANENT) - await self.unsilence_timestamps.set(ctx.channel.id, -1) - return - - await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) - - self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) - unsilence_time = (datetime.now(tz=timezone.utc) + timedelta(minutes=duration)) - await self.unsilence_timestamps.set(ctx.channel.id, unsilence_time.timestamp()) + else: + await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context) -> None: @@ -170,6 +166,15 @@ class Silence(commands.Cog): log.info(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") return True + async def _schedule_unsilence(self, ctx: Context, duration: Optional[int]) -> None: + """Schedule `ctx.channel` to be unsilenced if `duration` is not None.""" + if duration is None: + await self.unsilence_timestamps.set(ctx.channel.id, -1) + else: + self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) + unsilence_time = (datetime.now(tz=timezone.utc) + timedelta(minutes=duration)) + await self.unsilence_timestamps.set(ctx.channel.id, unsilence_time.timestamp()) + async def _unsilence(self, channel: TextChannel) -> bool: """ Unsilence `channel`. -- cgit v1.2.3 From cf2c03215ef340b9e093828de365563bb6be587a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 6 Oct 2020 13:23:03 -0700 Subject: Silence: refactor _silence * Rename to `_silence_overwrites` * Reduce responsibilities to only setting permission overwrites * Log in `silence` instead * Add to notifier in `silence` instead --- bot/cogs/moderation/silence.py | 27 ++++++++-------------- tests/bot/cogs/moderation/test_silence.py | 38 ++++++++++++++++++------------- 2 files changed, 32 insertions(+), 33 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 08d0328ab..12896022f 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -104,17 +104,23 @@ class Silence(commands.Cog): Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. """ await self._init_task - log.debug(f"{ctx.author} is silencing channel #{ctx.channel}.") - if not await self._silence(ctx.channel, persistent=(duration is None), duration=duration): + channel_info = f"#{ctx.channel} ({ctx.channel.id})" + log.debug(f"{ctx.author} is silencing channel {channel_info}.") + + if not await self._silence_overwrites(ctx.channel): + log.info(f"Tried to silence channel {channel_info} but the channel was already silenced.") await ctx.send(MSG_SILENCE_FAIL) return await self._schedule_unsilence(ctx, duration) if duration is None: + log.info(f"Silenced {channel_info} indefinitely.") await ctx.send(MSG_SILENCE_PERMANENT) else: + self.notifier.add_channel(ctx.channel) + log.info(f"Silenced {channel_info} for {duration} minute(s).") await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) @commands.command(aliases=("unhush",)) @@ -139,31 +145,18 @@ class Silence(commands.Cog): else: await channel.send(MSG_UNSILENCE_SUCCESS) - async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: - """ - Silence `channel` for `self._verified_role`. - - If `persistent` is `True` add `channel` to notifier. - `duration` is only used for logging; if None is passed `persistent` should be True to not log None. - Return `True` if channel permissions were changed, `False` otherwise. - """ + async def _silence_overwrites(self, channel: TextChannel) -> bool: + """Set silence permission overwrites for `channel` and return True if successful.""" overwrite = channel.overwrites_for(self._verified_role) prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) if channel.id in self.scheduler or all(val is False for val in prev_overwrites.values()): - log.info(f"Tried to silence channel #{channel} ({channel.id}) but the channel was already silenced.") return False overwrite.update(send_messages=False, add_reactions=False) await channel.set_permissions(self._verified_role, overwrite=overwrite) await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites)) - if persistent: - log.info(f"Silenced #{channel} ({channel.id}) indefinitely.") - self.notifier.add_channel(channel) - return True - - log.info(f"Silenced #{channel} ({channel.id}) for {duration} minute(s).") return True async def _schedule_unsilence(self, ctx: Context, duration: Optional[int]) -> None: diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index d56a731b6..9dbdfd10a 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -245,8 +245,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): ) for duration, message, was_silenced in test_cases: ctx = MockContext() - with self.subTest(was_silenced=was_silenced, message=message, duration=duration): - with mock.patch.object(self.cog, "_silence", return_value=was_silenced): + with mock.patch.object(self.cog, "_silence_overwrites", return_value=was_silenced): + with self.subTest(was_silenced=was_silenced, message=message, duration=duration): await self.cog.silence.callback(self.cog, ctx, duration) ctx.send.assert_called_once_with(message) @@ -264,12 +264,12 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockTextChannel() channel.overwrites_for.return_value = overwrite - self.assertFalse(await self.cog._silence(channel, True, None)) + self.assertFalse(await self.cog._silence_overwrites(channel)) channel.set_permissions.assert_not_called() async def test_silenced_channel(self): """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" - self.assertTrue(await self.cog._silence(self.channel, False, None)) + self.assertTrue(await self.cog._silence_overwrites(self.channel)) self.assertFalse(self.overwrite.send_messages) self.assertFalse(self.overwrite.add_reactions) self.channel.set_permissions.assert_awaited_once_with( @@ -280,7 +280,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_preserved_other_overwrites(self): """Channel's other unrelated overwrites were not changed.""" prev_overwrite_dict = dict(self.overwrite) - await self.cog._silence(self.channel, False, None) + await self.cog._silence_overwrites(self.channel) new_overwrite_dict = dict(self.overwrite) # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. @@ -291,22 +291,28 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) - async def test_added_removed_notifier(self): - """Channel was added to notifier if `persistent` was `True`, and removed if `False`.""" - with mock.patch.object(self.cog, "notifier", create=True): - with self.subTest(persistent=True): - await self.cog._silence(self.channel, True, None) - self.cog.notifier.add_channel.assert_called_once() + async def test_temp_added_to_notifier(self): + """Channel was added to notifier if a duration was set for the silence.""" + with mock.patch.object(self.cog, "_silence_overwrites", return_value=True): + await self.cog.silence.callback(self.cog, MockContext(), 15) + self.cog.notifier.add_channel.assert_called_once() - with mock.patch.object(self.cog, "notifier", create=True): - with self.subTest(persistent=False): - await self.cog._silence(self.channel, False, None) - self.cog.notifier.add_channel.assert_not_called() + async def test_indefinite_not_added_to_notifier(self): + """Channel was not added to notifier if a duration was not set for the silence.""" + with mock.patch.object(self.cog, "_silence_overwrites", return_value=True): + await self.cog.silence.callback(self.cog, MockContext(), None) + self.cog.notifier.add_channel.assert_not_called() + + async def test_silenced_not_added_to_notifier(self): + """Channel was not added to the notifier if it was already silenced.""" + with mock.patch.object(self.cog, "_silence_overwrites", return_value=False): + await self.cog.silence.callback(self.cog, MockContext(), 15) + self.cog.notifier.add_channel.assert_not_called() async def test_cached_previous_overwrites(self): """Channel's previous overwrites were cached.""" overwrite_json = '{"send_messages": true, "add_reactions": false}' - await self.cog._silence(self.channel, False, None) + await self.cog._silence_overwrites(self.channel) self.cog.previous_overwrites.set.assert_called_once_with(self.channel.id, overwrite_json) @autospec(silence, "datetime") -- cgit v1.2.3 From f218c7b0a505416d44b177b0e863575db626d20c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 6 Oct 2020 13:27:42 -0700 Subject: Silence: rename _init_cog to _async_init --- bot/cogs/moderation/silence.py | 4 ++-- tests/bot/cogs/moderation/test_silence.py | 26 +++++++++++++------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 12896022f..178dee06f 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -82,9 +82,9 @@ class Silence(commands.Cog): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) - self._init_task = self.bot.loop.create_task(self._init_cog()) + self._init_task = self.bot.loop.create_task(self._async_init()) - async def _init_cog(self) -> None: + async def _async_init(self) -> None: """Set instance attributes once the guild is available and reschedule unsilences.""" await self.bot.wait_until_guild_available() diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 9dbdfd10a..5588115ae 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -91,39 +91,39 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.cog = silence.Silence(self.bot) @autospec(silence, "SilenceNotifier", pass_mocks=False) - async def test_init_cog_got_guild(self): + async def test_async_init_got_guild(self): """Bot got guild after it became available.""" - await self.cog._init_cog() + await self.cog._async_init() self.bot.wait_until_guild_available.assert_awaited_once() self.bot.get_guild.assert_called_once_with(Guild.id) @autospec(silence, "SilenceNotifier", pass_mocks=False) - async def test_init_cog_got_role(self): + async def test_async_init_got_role(self): """Got `Roles.verified` role from guild.""" - await self.cog._init_cog() + await self.cog._async_init() guild = self.bot.get_guild() guild.get_role.assert_called_once_with(Roles.verified) @autospec(silence, "SilenceNotifier", pass_mocks=False) - async def test_init_cog_got_channels(self): + async def test_async_init_got_channels(self): """Got channels from bot.""" - await self.cog._init_cog() + await self.cog._async_init() self.bot.get_channel.called_once_with(Channels.mod_alerts) self.bot.get_channel.called_once_with(Channels.mod_log) @autospec(silence, "SilenceNotifier") - async def test_init_cog_got_notifier(self, notifier): + async def test_async_init_got_notifier(self, notifier): """Notifier was started with channel.""" mod_log = MockTextChannel() self.bot.get_channel.side_effect = (None, mod_log) - await self.cog._init_cog() + await self.cog._async_init() notifier.assert_called_once_with(self.cog._mod_log_channel) @autospec(silence, "SilenceNotifier", pass_mocks=False) - async def test_init_cog_rescheduled(self): + async def test_async_init_rescheduled(self): """`_reschedule_` coroutine was awaited.""" self.cog._reschedule = mock.create_autospec(self.cog._reschedule) - await self.cog._init_cog() + await self.cog._async_init() self.cog._reschedule.assert_awaited_once_with() def test_cog_unload_cancelled_tasks(self): @@ -154,7 +154,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): self.cog._unsilence_wrapper = mock.create_autospec(self.cog._unsilence_wrapper) with mock.patch.object(self.cog, "_reschedule", autospec=True): - asyncio.run(self.cog._init_cog()) # Populate instance attributes. + asyncio.run(self.cog._async_init()) # Populate instance attributes. async def test_skipped_missing_channel(self): """Did nothing because the channel couldn't be retrieved.""" @@ -230,7 +230,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog._init_task = asyncio.Future() self.cog._init_task.set_result(None) - asyncio.run(self.cog._init_cog()) # Populate instance attributes. + asyncio.run(self.cog._async_init()) # Populate instance attributes. self.channel = MockTextChannel() self.overwrite = PermissionOverwrite(stream=True, send_messages=True, add_reactions=False) @@ -367,7 +367,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): overwrites_cache = mock.create_autospec(self.cog.previous_overwrites, spec_set=True) self.cog.previous_overwrites = overwrites_cache - asyncio.run(self.cog._init_cog()) # Populate instance attributes. + asyncio.run(self.cog._async_init()) # Populate instance attributes. self.cog.scheduler.__contains__.return_value = True overwrites_cache.get.return_value = '{"send_messages": true, "add_reactions": false}' -- cgit v1.2.3 From 9243dcb47d126cb506baf2e57d18ba2be7a7c2e6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 6 Oct 2020 14:14:11 -0700 Subject: CI: avoid failing whole job if a cache task fails Restoring from cache is non-critical. The CI can recover if cache tasks fail. --- azure-pipelines.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 4500cb6e8..9f58e38c8 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -21,7 +21,6 @@ jobs: BOT_TOKEN: bar REDDIT_CLIENT_ID: spam REDDIT_SECRET: ham - WOLFRAM_API_KEY: baz REDIS_PASSWORD: '' steps: @@ -38,6 +37,7 @@ jobs: key: python | $(Agent.OS) | "$(python.pythonLocation)" | 0 | ./Pipfile | ./Pipfile.lock cacheHitVar: PY_ENV_RESTORED path: $(PYTHONUSERBASE) + continueOnError: true - script: echo '##vso[task.prependpath]$(PYTHONUSERBASE)/bin' displayName: 'Prepend PATH' @@ -65,6 +65,7 @@ jobs: inputs: key: pre-commit | "$(python.pythonLocation)" | 0 | .pre-commit-config.yaml path: $(PRE_COMMIT_HOME) + continueOnError: true # pre-commit's venv doesn't allow user installs - not that they're really needed anyway. - script: export PIP_USER=0; pre-commit run --all-files -- cgit v1.2.3 From 507451b8e67eb0a8425fa1dd2b5d386ead18ce00 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Wed, 7 Oct 2020 01:44:01 +0300 Subject: prepare_input uses one regex less --- bot/exts/utils/snekbox.py | 52 ++++++++++++++++------------------------------- 1 file changed, 17 insertions(+), 35 deletions(-) diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index e782ed745..77830209e 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -21,23 +21,12 @@ log = logging.getLogger(__name__) ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}") FORMATTED_CODE_REGEX = re.compile( - r"^\s*" # any leading whitespace from the beginning of the string r"(?P(?P```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block r"(?(block)(?:(?P[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline) r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code r"(?P.*?)" # extract all code inside the markup r"\s*" # any more whitespace before the end of the code markup - r"(?P=delim)" # match the exact same delimiter from the start again - r"\s*$", # any trailing whitespace until the end of the string - re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive -) -CODE_BLOCK_REGEX = re.compile( - r"```" # code block delimiter: 3 batckticks - r"([a-z]+\n)?" # match optional language (only letters plus newline) - r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code - r"(?P.*?)" # extract all code inside the markup - r"\s*" # any more whitespace before the end of the code markup - r"```", # code block end + r"(?P=delim)", # match the exact same delimiter from the start again re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive ) RAW_CODE_REGEX = re.compile( @@ -86,32 +75,25 @@ class Snekbox(Cog): @staticmethod def prepare_input(code: str) -> str: """Extract code from the Markdown, format it, and insert it into the code template.""" - match = FORMATTED_CODE_REGEX.fullmatch(code) - - # Despite the wildcard being lazy, the pattern is from start to end and will eat any delimiters in the middle. - if match and match.group("delim") not in match.group("code"): - code, block, lang, delim = match.group("code", "block", "lang", "delim") - code = textwrap.dedent(code) - if block: - info = (f"'{lang}' highlighted" if lang else "plain") + " code block" - else: - info = f"{delim}-enclosed inline code" - log.trace(f"Extracted {info} for evaluation:\n{code}") - - else: - code_parts = CODE_BLOCK_REGEX.finditer(code) - merge = '\n'.join(part.group("code") for part in code_parts) - if merge: - code = textwrap.dedent(merge) - log.trace(f"Merged one or more code blocks from text combined with code:\n{code}") + if match := list(FORMATTED_CODE_REGEX.finditer(code)): + blocks = [block for block in match if block.group("block")] + if len(blocks) > 1: + code = '\n'.join(block.group("code") for block in blocks) + info = "several code blocks" else: - code = textwrap.dedent(RAW_CODE_REGEX.fullmatch(code).group("code")) - log.trace( - f"Eval message contains unformatted or badly formatted code, " - f"stripping whitespace only:\n{code}" - ) + match = match[0] if len(blocks) == 0 else blocks[0] + code, block, lang, delim = match.group("code", "block", "lang", "delim") + if block: + info = (f"'{lang}' highlighted" if lang else "plain") + " code block" + else: + info = f"{delim}-enclosed inline code" + else: + code = RAW_CODE_REGEX.fullmatch(code).group("code") + info = "unformatted or badly formatted code" + code = textwrap.dedent(code) + log.trace(f"Extracted {info} for evaluation:\n{code}") return code @staticmethod -- cgit v1.2.3 From 196838d54f8b80f58807eaefe5914467b5581df1 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Wed, 7 Oct 2020 17:15:01 +1000 Subject: Add the ability to purge and ban in one command. --- bot/exts/moderation/infraction/infractions.py | 28 +++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index a8b3feb38..9d6de1a97 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -71,6 +71,23 @@ class Infractions(InfractionScheduler, commands.Cog): """Permanently ban a user for the given reason and stop watching them with Big Brother.""" await self.apply_ban(ctx, user, reason) + @command() + async def purgeban( + self, + ctx: Context, + user: FetchedMember, + purge_days: t.Optional[int] = 1, + *, + reason: t.Optional[str] = None + ) -> None: + """ + Same as ban but removes all their messages for the given number of days, default being 1. + + `purge_days` can only be values between 0 and 7. + Anything outside these bounds are automatically adjusted to their respective limits. + """ + await self.apply_ban(ctx, user, reason, max(min(purge_days, 7), 0)) + # endregion # region: Temporary infractions @@ -246,7 +263,14 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user, action) @respect_role_hierarchy(member_arg=2) - async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: + async def apply_ban( + self, + ctx: Context, + user: UserSnowflake, + reason: t.Optional[str], + purge_days: t.Optional[int] = 0, + **kwargs + ) -> None: """ Apply a ban infraction with kwargs passed to `post_infraction`. @@ -278,7 +302,7 @@ class Infractions(InfractionScheduler, commands.Cog): if reason: reason = textwrap.shorten(reason, width=512, placeholder="...") - action = ctx.guild.ban(user, reason=reason, delete_message_days=0) + action = ctx.guild.ban(user, reason=reason, delete_message_days=purge_days) await self.apply_infraction(ctx, infraction, user, action) if infraction.get('expires_at') is not None: -- cgit v1.2.3 From 7e7a801366e2bf8f1190fae91f93729b33f32895 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Wed, 7 Oct 2020 23:02:39 +0530 Subject: improve code efficiency and use updated API changes to pagination --- bot/exts/backend/sync/_syncers.py | 146 +++++++++++++------------------------- 1 file changed, 48 insertions(+), 98 deletions(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index ae7d5d893..70887a217 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -2,7 +2,6 @@ import abc import logging import typing as t from collections import namedtuple -from urllib.parse import parse_qsl, urlparse from discord import Guild from discord.ext.commands import Context @@ -15,7 +14,6 @@ log = logging.getLogger(__name__) # These objects are declared as namedtuples because tuples are hashable, # something that we make use of when diffing site roles against guild roles. _Role = namedtuple('Role', ('id', 'name', 'colour', 'permissions', 'position')) -_User = namedtuple('User', ('id', 'name', 'discriminator', 'roles', 'in_guild')) _Diff = namedtuple('Diff', ('created', 'updated', 'deleted')) @@ -42,11 +40,7 @@ class Syncer(abc.ABC): raise NotImplementedError # pragma: no cover async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None: - """ - Synchronise the database with the cache of `guild`. - - If `ctx` is given, send a message with the results. - """ + """If `ctx` is given, send a message with the results.""" log.info(f"Starting {self.name} syncer.") if ctx: @@ -136,111 +130,67 @@ class UserSyncer(Syncer): """Return the difference of users between the cache of `guild` and the database.""" log.trace("Getting the diff for users.") - users = await self._get_users() + users_to_create = [] + users_to_update = [] + seen_guild_users = set() - # Pack DB roles and guild roles into one common, hashable format. - # They're hashable so that they're easily comparable with sets later. - db_users = { - user_dict['id']: _User( - roles=tuple(sorted(user_dict.pop('roles'))), - **user_dict - ) - for user_dict in users - } - guild_users = { - member.id: _User( - id=member.id, - name=member.name, - discriminator=int(member.discriminator), - roles=tuple(sorted(role.id for role in member.roles)), - in_guild=True - ) - for member in guild.members - } + async for db_user in self._get_users(): + updated_fields = {} - users_to_create = set() - users_to_update = set() - - for db_user in db_users.values(): - guild_user = guild_users.get(db_user.id) - - if guild_user is not None: - if db_user != guild_user: - fields_to_none: dict = {} - - for field in _User._fields: - # Set un-changed values to None except ID to speed up API PATCH method. - if getattr(db_user, field) == getattr(guild_user, field) and field != "id": - fields_to_none[field] = None - - new_api_user = guild_user._replace(**fields_to_none) - users_to_update.add(new_api_user) - - elif db_user.in_guild: - # The user is known in the DB but not the guild, and the - # DB currently specifies that the user is a member of the guild. - # This means that the user has left since the last sync. - # Update the `in_guild` attribute of the user on the site - # to signify that the user left. - - # Set un-changed fields to None except ID as it is required by the API. - fields_to_none: dict = {field: None for field in db_user._fields if field not in ["id", "in_guild"]} - new_api_user = db_user._replace( - in_guild=False, - **fields_to_none - ) - users_to_update.add(new_api_user) - - new_user_ids = set(guild_users.keys()) - set(db_users.keys()) - for user_id in new_user_ids: - # The user is known on the guild but not on the API. This means - # that the user has joined since the last sync. Create it. - new_user = guild_users[user_id] - users_to_create.add(new_user) + def maybe_update(db_field: str, guild_value: t.Union[str, int]) -> None: + if db_user[db_field] != guild_value: + updated_fields[db_field] = guild_value - return _Diff(users_to_create, users_to_update, None) + if guild_user := guild.get_member(db_user["id"]): + seen_guild_users.add(guild_user.id) + + maybe_update("name", guild_user.name) + maybe_update("discriminator", int(guild_user.discriminator)) + maybe_update("in_guild", True) - async def _get_users(self, endpoint: str = "bot/users", query_params: list = None) -> t.List[dict]: - """GET all users recursively.""" - users = [] - response: dict = await self.bot.api_client.get(endpoint, params=query_params) - users.extend(response["results"]) + guild_roles = [role.id for role in guild_user.roles] + if set(db_user["roles"]) != set(guild_roles): + updated_fields["roles"] = guild_roles - # The `response` is paginated, hence check if next page exists. - if (next_page_url := response["next"]) is not None: - next_endpoint, query_params = self.get_endpoint(next_page_url) - users.extend(await self._get_users(next_endpoint, query_params)) - return users + elif db_user["in_guild"]: + updated_fields["in_guild"] = False - @staticmethod - def get_endpoint(url: str) -> t.Tuple[str, t.List[tuple]]: - """Extract the API endpoint and query params from a URL.""" - url = urlparse(url) + if updated_fields and updated_fields not in users_to_update: + updated_fields["id"] = db_user["id"] + users_to_update.append(updated_fields) - # Do not include starting `/` for endpoint. - endpoint = url.path[1:] + for member in guild.members: + if member.id not in seen_guild_users: + new_user = { + "id": member.id, + "name": member.name, + "discriminator": int(member.discriminator), + "roles": [role.id for role in member.roles], + "in_guild": True + } + if new_user not in users_to_create: + users_to_create.append(new_user) - # Query params. - params = parse_qsl(url.query) + return _Diff(users_to_create, users_to_update, None) - return endpoint, params + async def _get_users(self) -> t.AsyncIterable: + """GET users from database.""" + query_params = { + "page": 1 + } + while query_params["page"]: + res = await self.bot.api_client.get("bot/users", params=query_params) + for user in res["results"]: + yield user - @staticmethod - def patch_dict(user: _User) -> t.Dict[str, t.Union[int, str, tuple, bool]]: - """Convert namedtuple to dict by omitting None values.""" - user_dict = {} - for field in user._fields: - if (value := getattr(user, field)) is not None: - user_dict[field] = value - return user_dict + query_params["page"] = res["next_page_no"] async def _sync(self, diff: _Diff) -> None: """Synchronise the database with the user cache of `guild`.""" log.trace("Syncing created users...") if diff.created: - created = [user._asdict() for user in diff.created] - await self.bot.api_client.post("bot/users", json=created) + await self.bot.api_client.post("bot/users", json=diff.created) + log.trace("Syncing updated users...") if diff.updated: - updated = [self.patch_dict(user) for user in diff.updated] - await self.bot.api_client.patch("bot/users/bulk_patch", json=updated) + await self.bot.api_client.patch("bot/users/bulk_patch", json=diff.updated) -- cgit v1.2.3 From 6ee08368186716804121cb456783e3bc56ced7f3 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Wed, 7 Oct 2020 23:20:13 +0530 Subject: Refactor tests to use updated changes to syncer.py and API. --- tests/bot/exts/backend/sync/test_users.py | 117 +++++++++++++++--------------- 1 file changed, 59 insertions(+), 58 deletions(-) diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py index c3a486743..9f380a15d 100644 --- a/tests/bot/exts/backend/sync/test_users.py +++ b/tests/bot/exts/backend/sync/test_users.py @@ -1,6 +1,6 @@ import unittest -from bot.exts.backend.sync._syncers import UserSyncer, _Diff, _User +from bot.exts.backend.sync._syncers import UserSyncer, _Diff from tests import helpers @@ -9,22 +9,12 @@ def fake_user(**kwargs): kwargs.setdefault("id", 43) kwargs.setdefault("name", "bob the test man") kwargs.setdefault("discriminator", 1337) - kwargs.setdefault("roles", (666,)) + kwargs.setdefault("roles", [666]) kwargs.setdefault("in_guild", True) return kwargs -def fake_none_user(**kwargs): - kwargs.setdefault("id", None) - kwargs.setdefault("name", None) - kwargs.setdefault("discriminator", None) - kwargs.setdefault("roles", None) - kwargs.setdefault("in_guild", None) - - return kwargs - - class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): """Tests for determining differences between users in the DB and users in the Guild cache.""" @@ -49,18 +39,26 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): return guild + @staticmethod + def get_mock_member(member: dict): + member = member.copy() + del member["in_guild"] + mock_member = helpers.MockMember(**member) + mock_member.roles = [helpers.MockRole(id=role_id) for role_id in member["roles"]] + return mock_member + async def test_empty_diff_for_no_users(self): """When no users are given, an empty diff should be returned.""" self.bot.api_client.get.return_value = { "count": 3, - "next": None, - "previous": None, + "next_page_no": None, + "previous_page_no": None, "results": [] } guild = self.get_guild() actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), None) + expected_diff = ([], [], None) self.assertEqual(actual_diff, expected_diff) @@ -68,66 +66,75 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): """No differences should be found if the users in the guild and DB are identical.""" self.bot.api_client.get.return_value = { "count": 3, - "next": None, - "previous": None, + "next_page_no": None, + "previous_page_no": None, "results": [fake_user()] } guild = self.get_guild(fake_user()) + guild.get_member.return_value = self.get_mock_member(fake_user()) actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), None) + expected_diff = ([], [], None) self.assertEqual(actual_diff, expected_diff) async def test_diff_for_updated_users(self): """Only updated users should be added to the 'updated' set of the diff.""" updated_user = fake_user(id=99, name="new") - updated_user_none = fake_none_user(id=99, name="new") self.bot.api_client.get.return_value = { "count": 3, - "next": None, - "previous": None, + "next_page_no": None, + "previous_page_no": None, "results": [fake_user(id=99, name="old"), fake_user()] } guild = self.get_guild(updated_user, fake_user()) + guild.get_member.side_effect = [ + self.get_mock_member(updated_user), + self.get_mock_member(fake_user()) + ] actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), {_User(**updated_user_none)}, None) + expected_diff = ([], [{"id": 99, "name": "new"}], None) self.assertEqual(actual_diff, expected_diff) async def test_diff_for_new_users(self): - """Only new users should be added to the 'created' set of the diff.""" + """Only new users should be added to the 'created' list of the diff.""" new_user = fake_user(id=99, name="new") self.bot.api_client.get.return_value = { "count": 3, - "next": None, - "previous": None, + "next_page_no": None, + "previous_page_no": None, "results": [fake_user()] } guild = self.get_guild(fake_user(), new_user) - + guild.get_member.side_effect = [ + self.get_mock_member(fake_user()), + self.get_mock_member(new_user) + ] actual_diff = await self.syncer._get_diff(guild) - expected_diff = ({_User(**new_user)}, set(), None) + expected_diff = ([new_user], [], None) self.assertEqual(actual_diff, expected_diff) async def test_diff_sets_in_guild_false_for_leaving_users(self): """When a user leaves the guild, the `in_guild` flag is updated to `False`.""" - leaving_user_none = fake_none_user(id=63, in_guild=False) - self.bot.api_client.get.return_value = { "count": 3, - "next": None, - "previous": None, + "next_page_no": None, + "previous_page_no": None, "results": [fake_user(), fake_user(id=63)] } guild = self.get_guild(fake_user()) + guild.get_member.side_effect = [ + self.get_mock_member(fake_user()), + None + ] actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), {_User(**leaving_user_none)}, None) + expected_diff = ([], [{"id": 63, "in_guild": False}], None) self.assertEqual(actual_diff, expected_diff) @@ -136,42 +143,41 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): new_user = fake_user(id=99, name="new") updated_user = fake_user(id=55, name="updated") - updated_user_none = fake_none_user(id=55, name="updated") - - leaving_user_none = fake_none_user(id=63, in_guild=False) self.bot.api_client.get.return_value = { "count": 3, - "next": None, - "previous": None, + "next_page_no": None, + "previous_page_no": None, "results": [fake_user(), fake_user(id=55), fake_user(id=63)] } guild = self.get_guild(fake_user(), new_user, updated_user) + guild.get_member.side_effect = [ + self.get_mock_member(fake_user()), + self.get_mock_member(updated_user), + None + ] actual_diff = await self.syncer._get_diff(guild) - expected_diff = ( - {_User(**new_user)}, - { - _User(**updated_user_none), - _User(**leaving_user_none) - }, - None - ) + expected_diff = ([new_user], [{"id": 55, "name": "updated"}, {"id": 63, "in_guild": False}], None) self.assertEqual(actual_diff, expected_diff) async def test_empty_diff_for_db_users_not_in_guild(self): - """When the DB knows a user the guild doesn't, no difference is found.""" + """When the DB knows a user, but the guild doesn't, no difference is found.""" self.bot.api_client.get.return_value = { "count": 3, - "next": None, - "previous": None, + "next_page_no": None, + "previous_page_no": None, "results": [fake_user(), fake_user(id=63, in_guild=False)] } guild = self.get_guild(fake_user()) + guild.get_member.side_effect = [ + self.get_mock_member(fake_user()), + None + ] actual_diff = await self.syncer._get_diff(guild) - expected_diff = (set(), set(), None) + expected_diff = ([], [], None) self.assertEqual(actual_diff, expected_diff) @@ -187,13 +193,10 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase): """Only POST requests should be made with the correct payload.""" users = [fake_user(id=111), fake_user(id=222)] - user_tuples = {_User(**user) for user in users} - diff = _Diff(user_tuples, set(), None) + diff = _Diff(users, [], None) await self.syncer._sync(diff) - # Convert namedtuples to dicts as done in self.syncer._sync method. - created = [user._asdict() for user in diff.created] - self.bot.api_client.post.assert_called_once_with("bot/users", json=created) + self.bot.api_client.post.assert_called_once_with("bot/users", json=diff.created) self.bot.api_client.put.assert_not_called() self.bot.api_client.delete.assert_not_called() @@ -202,12 +205,10 @@ class UserSyncerSyncTests(unittest.IsolatedAsyncioTestCase): """Only PUT requests should be made with the correct payload.""" users = [fake_user(id=111), fake_user(id=222)] - user_tuples = {_User(**user) for user in users} - diff = _Diff(set(), user_tuples, None) + diff = _Diff([], users, None) await self.syncer._sync(diff) - updated = [self.syncer.patch_dict(user) for user in diff.updated] - self.bot.api_client.patch.assert_called_once_with("bot/users/bulk_patch", json=updated) + self.bot.api_client.patch.assert_called_once_with("bot/users/bulk_patch", json=diff.updated) self.bot.api_client.post.assert_not_called() self.bot.api_client.delete.assert_not_called() -- cgit v1.2.3 From 64b70160d63d28b1b2b2215cf484a825ca516160 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Wed, 7 Oct 2020 20:06:06 +0100 Subject: made sure to use sub_clyde on username passed to send_attachments --- bot/utils/messages.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 9fd571a20..b6c7cab50 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -68,10 +68,11 @@ async def send_attachments( embed which links to them. Extra kwargs will be passed to send() when sending the attachment. """ webhook_send_kwargs = { - 'username': sub_clyde(message.author.display_name), + 'username': message.author.display_name, 'avatar_url': message.author.avatar_url, } webhook_send_kwargs.update(kwargs) + webhook_send_kwargs['username'] = sub_clyde(webhook_send_kwargs['username']) large = [] urls = [] -- cgit v1.2.3 From 46bdcdf9414786f1432b4937590a0448122e6f34 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 7 Oct 2020 15:13:01 -0700 Subject: Silence tests: fix unawaited coro warnings Because the Scheduler is mocked, it doesn't actually do anything with the coroutines passed to the schedule() functions, hence the warnings. --- tests/bot/cogs/moderation/test_silence.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 5588115ae..6a8db72e8 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -68,7 +68,9 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): with self.subTest(current_loop=current_loop): with mock.patch.object(self.notifier, "_current_loop", new=current_loop): await self.notifier._notifier() - self.alert_channel.send.assert_called_once_with(f"<@&{Roles.moderators}> currently silenced channels: ") + self.alert_channel.send.assert_called_once_with( + f"<@&{Roles.moderators}> currently silenced channels: " + ) self.alert_channel.send.reset_mock() async def test_notifier_skips_alert(self): @@ -158,7 +160,7 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): async def test_skipped_missing_channel(self): """Did nothing because the channel couldn't be retrieved.""" - self.cog.unsilence_timestamps.items.return_value = [(123, -1), (123, 1), (123, 100000000000)] + self.cog.unsilence_timestamps.items.return_value = [(123, -1), (123, 1), (123, 10000000000)] self.bot.get_channel.return_value = None await self.cog._reschedule() @@ -230,6 +232,9 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog._init_task = asyncio.Future() self.cog._init_task.set_result(None) + # Avoid unawaited coroutine warnings. + self.cog.scheduler.schedule_later.side_effect = lambda delay, task_id, coro: coro.close() + asyncio.run(self.cog._async_init()) # Populate instance attributes. self.channel = MockTextChannel() -- cgit v1.2.3 From d0635ea328ed5bc659d77820752dedef3c19df0c Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 8 Oct 2020 01:21:19 +0300 Subject: adjusted prepare_input docs and unittests --- bot/exts/utils/snekbox.py | 8 +++++++- tests/bot/exts/utils/test_snekbox.py | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index 77830209e..295c84901 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -74,7 +74,13 @@ class Snekbox(Cog): @staticmethod def prepare_input(code: str) -> str: - """Extract code from the Markdown, format it, and insert it into the code template.""" + """ + Extract code from the Markdown, format it, and insert it into the code template. + + If there is Markdown, ignores surrounding text. + If there are several Markdown parts in the message, concatenates only the code blocks. + If there is inline code but no code blocks, takes the first instance of inline code. + """ if match := list(FORMATTED_CODE_REGEX.finditer(code)): blocks = [block for block in match if block.group("block")] diff --git a/tests/bot/exts/utils/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py index 6601fad2c..9a42d0610 100644 --- a/tests/bot/exts/utils/test_snekbox.py +++ b/tests/bot/exts/utils/test_snekbox.py @@ -52,6 +52,13 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): ('`print("Hello world!")`', 'print("Hello world!")', 'one line code block'), ('```\nprint("Hello world!")```', 'print("Hello world!")', 'multiline code block'), ('```py\nprint("Hello world!")```', 'print("Hello world!")', 'multiline python code block'), + ('text```print("Hello world!")```text', 'print("Hello world!")', 'code block surrounded by text'), + ('```print("Hello world!")```\ntext\n```py\nprint("Hello world!")```', + 'print("Hello world!")\nprint("Hello world!")', 'two code blocks with text in-between'), + ('`print("Hello world!")`\ntext\n```print("How\'s it going?")```', + 'print("How\'s it going?")', 'code block preceded by inline code'), + ('`print("Hello world!")`\ntext\n`print("Hello world!")`', + 'print("Hello world!")', 'one inline code block of two') ) for case, expected, testname in cases: with self.subTest(msg=f'Extract code from {testname}.'): -- cgit v1.2.3 From b55ce89f01ef4d66a8b930dcbdc061cdef3563f3 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Thu, 8 Oct 2020 03:05:02 +0300 Subject: clarify prepare_input doc Co-authored-by: Mark --- bot/exts/utils/snekbox.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index 295c84901..da3e07f42 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -77,9 +77,9 @@ class Snekbox(Cog): """ Extract code from the Markdown, format it, and insert it into the code template. - If there is Markdown, ignores surrounding text. - If there are several Markdown parts in the message, concatenates only the code blocks. - If there is inline code but no code blocks, takes the first instance of inline code. + If there is any code block, ignore text outside the code block. + Use the first code block, but prefer a fenced code block. + If there are several fenced code blocks, concatenate only the fenced code blocks. """ if match := list(FORMATTED_CODE_REGEX.finditer(code)): blocks = [block for block in match if block.group("block")] -- cgit v1.2.3 From 586aeb66e9156259efbdfed43c11b66003185ad2 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Thu, 8 Oct 2020 10:40:11 +0530 Subject: remove redundant if statement --- bot/exts/backend/sync/_syncers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index 70887a217..3a7719559 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -168,8 +168,7 @@ class UserSyncer(Syncer): "roles": [role.id for role in member.roles], "in_guild": True } - if new_user not in users_to_create: - users_to_create.append(new_user) + users_to_create.append(new_user) return _Diff(users_to_create, users_to_update, None) -- cgit v1.2.3 From 72819f275658f0637deb2d7fba9a838d65294203 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Thu, 8 Oct 2020 10:41:52 +0530 Subject: remove redundant if statement --- bot/exts/backend/sync/_syncers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index 3a7719559..c32038f4e 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -155,7 +155,7 @@ class UserSyncer(Syncer): elif db_user["in_guild"]: updated_fields["in_guild"] = False - if updated_fields and updated_fields not in users_to_update: + if updated_fields: updated_fields["id"] = db_user["id"] users_to_update.append(updated_fields) -- cgit v1.2.3 From 9af0883deeb57b08044400335c759a206d5833fb Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Thu, 8 Oct 2020 10:49:06 +0530 Subject: update documentation --- bot/exts/backend/sync/_syncers.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index c32038f4e..38468c2b1 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -40,7 +40,11 @@ class Syncer(abc.ABC): raise NotImplementedError # pragma: no cover async def sync(self, guild: Guild, ctx: t.Optional[Context] = None) -> None: - """If `ctx` is given, send a message with the results.""" + """ + Synchronise the database with the cache of `guild`. + + If `ctx` is given, send a message with the results. + """ log.info(f"Starting {self.name} syncer.") if ctx: @@ -135,9 +139,11 @@ class UserSyncer(Syncer): seen_guild_users = set() async for db_user in self._get_users(): + # Store user fields which are to be updated. updated_fields = {} def maybe_update(db_field: str, guild_value: t.Union[str, int]) -> None: + # Equalize DB user and guild user attributes. if db_user[db_field] != guild_value: updated_fields[db_field] = guild_value @@ -153,6 +159,11 @@ class UserSyncer(Syncer): updated_fields["roles"] = guild_roles elif db_user["in_guild"]: + # The user is known in the DB but not the guild, and the + # DB currently specifies that the user is a member of the guild. + # This means that the user has left since the last sync. + # Update the `in_guild` attribute of the user on the site + # to signify that the user left. updated_fields["in_guild"] = False if updated_fields: @@ -161,6 +172,8 @@ class UserSyncer(Syncer): for member in guild.members: if member.id not in seen_guild_users: + # The user is known on the guild but not on the API. This means + # that the user has joined since the last sync. Create it. new_user = { "id": member.id, "name": member.name, -- cgit v1.2.3 From 47b06305f567f0ef2d8cb98c7357910cdb61fbd1 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Thu, 8 Oct 2020 17:11:56 +1000 Subject: Update bot/exts/moderation/infraction/infractions.py Co-authored-by: Dennis Pham --- bot/exts/moderation/infraction/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 9d6de1a97..7cf7075e6 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -71,7 +71,7 @@ class Infractions(InfractionScheduler, commands.Cog): """Permanently ban a user for the given reason and stop watching them with Big Brother.""" await self.apply_ban(ctx, user, reason) - @command() + @command(aliases=('pban',)) async def purgeban( self, ctx: Context, -- cgit v1.2.3 From 888c427466b370b9ae51e47496e979c2b6faed0c Mon Sep 17 00:00:00 2001 From: Gustav Odinger Date: Thu, 8 Oct 2020 19:35:20 +0200 Subject: Fix millisecond time for command processing time - For the `.ping` command - Fixes a faulty convertion from seconds to milliseconds --- bot/exts/utils/ping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index a9ca3dbeb..572fc934b 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -33,7 +33,7 @@ class Latency(commands.Cog): """ # datetime.datetime objects do not have the "milliseconds" attribute. # It must be converted to seconds before converting to milliseconds. - bot_ping = (datetime.utcnow() - ctx.message.created_at).total_seconds() / 1000 + bot_ping = (datetime.utcnow() - ctx.message.created_at).total_seconds() * 1000 bot_ping = f"{bot_ping:.{ROUND_LATENCY}f} ms" try: -- cgit v1.2.3 From d0c3990e8eb9e68537c05ec58594abdf5c4cee9e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 8 Oct 2020 12:25:41 -0700 Subject: Silence: add to notifier when indefinite rather than temporary Accidentally swapped the logic in a previous commit during a refactor. --- bot/cogs/moderation/silence.py | 2 +- tests/bot/cogs/moderation/test_silence.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 178dee06f..95706392a 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -116,10 +116,10 @@ class Silence(commands.Cog): await self._schedule_unsilence(ctx, duration) if duration is None: + self.notifier.add_channel(ctx.channel) log.info(f"Silenced {channel_info} indefinitely.") await ctx.send(MSG_SILENCE_PERMANENT) else: - self.notifier.add_channel(ctx.channel) log.info(f"Silenced {channel_info} for {duration} minute(s).") await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6a8db72e8..50d8419ac 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -296,17 +296,17 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) - async def test_temp_added_to_notifier(self): - """Channel was added to notifier if a duration was set for the silence.""" + async def test_temp_not_added_to_notifier(self): + """Channel was not added to notifier if a duration was set for the silence.""" with mock.patch.object(self.cog, "_silence_overwrites", return_value=True): await self.cog.silence.callback(self.cog, MockContext(), 15) - self.cog.notifier.add_channel.assert_called_once() + self.cog.notifier.add_channel.assert_not_called() - async def test_indefinite_not_added_to_notifier(self): - """Channel was not added to notifier if a duration was not set for the silence.""" + async def test_indefinite_added_to_notifier(self): + """Channel was added to notifier if a duration was not set for the silence.""" with mock.patch.object(self.cog, "_silence_overwrites", return_value=True): await self.cog.silence.callback(self.cog, MockContext(), None) - self.cog.notifier.add_channel.assert_not_called() + self.cog.notifier.add_channel.assert_called_once() async def test_silenced_not_added_to_notifier(self): """Channel was not added to the notifier if it was already silenced.""" -- cgit v1.2.3 From 5b87a272ff21df9fa4fb59fdf9ec92c6b57193c6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 8 Oct 2020 13:22:54 -0700 Subject: Silence: remove _mod_log_channel attribute It's only used as an argument to `SilenceNotifier`, so it doesn't need to be an instance attribute. --- bot/cogs/moderation/silence.py | 3 +-- tests/bot/cogs/moderation/test_silence.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 95706392a..80c4e6a25 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -91,8 +91,7 @@ class Silence(commands.Cog): guild = self.bot.get_guild(Guild.id) self._verified_role = guild.get_role(Roles.verified) self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) - self._mod_log_channel = self.bot.get_channel(Channels.mod_log) - self.notifier = SilenceNotifier(self._mod_log_channel) + self.notifier = SilenceNotifier(self.bot.get_channel(Channels.mod_log)) await self._reschedule() @commands.command(aliases=("hush",)) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 50d8419ac..6f8f4386b 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -119,7 +119,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): mod_log = MockTextChannel() self.bot.get_channel.side_effect = (None, mod_log) await self.cog._async_init() - notifier.assert_called_once_with(self.cog._mod_log_channel) + notifier.assert_called_once_with(mod_log) @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_async_init_rescheduled(self): -- cgit v1.2.3 From e85a4d254cadd303537a4d2cce6637bbcd3cf2f9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 8 Oct 2020 13:23:35 -0700 Subject: Silence tests: make _async_init attribute tests more robust --- tests/bot/cogs/moderation/test_silence.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 6f8f4386b..3e1b963b0 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -102,24 +102,28 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_async_init_got_role(self): """Got `Roles.verified` role from guild.""" - await self.cog._async_init() guild = self.bot.get_guild() - guild.get_role.assert_called_once_with(Roles.verified) + guild.get_role.side_effect = lambda id_: Mock(id=id_) + + await self.cog._async_init() + self.assertEqual(self.cog._verified_role.id, Roles.verified) @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_async_init_got_channels(self): """Got channels from bot.""" + self.bot.get_channel.side_effect = lambda id_: MockTextChannel(id=id_) + await self.cog._async_init() - self.bot.get_channel.called_once_with(Channels.mod_alerts) - self.bot.get_channel.called_once_with(Channels.mod_log) + self.assertEqual(self.cog._mod_alerts_channel.id, Channels.mod_alerts) @autospec(silence, "SilenceNotifier") async def test_async_init_got_notifier(self, notifier): """Notifier was started with channel.""" - mod_log = MockTextChannel() - self.bot.get_channel.side_effect = (None, mod_log) + self.bot.get_channel.side_effect = lambda id_: MockTextChannel(id=id_) + await self.cog._async_init() - notifier.assert_called_once_with(mod_log) + notifier.assert_called_once_with(MockTextChannel(id=Channels.mod_log)) + self.assertEqual(self.cog.notifier, notifier.return_value) @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_async_init_rescheduled(self): -- cgit v1.2.3 From 59795ad20ff8a48cb1773ad02135a3da2d6e5eb9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 9 Oct 2020 13:39:27 -0700 Subject: Silence: fix scheduled tasks not being cancelled on unload --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 80c4e6a25..c54f9d849 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -234,7 +234,7 @@ class Silence(commands.Cog): # more tasks after cancel_all has finished, despite _init_task.cancel being called first. # This is cause cancel() on its own doesn't block until the task is cancelled. self._init_task.cancel() - self._init_task.add_done_callback(lambda _: self.scheduler.cancel_all) + self._init_task.add_done_callback(lambda _: self.scheduler.cancel_all()) # This cannot be static (must have a __func__ attribute). def cog_check(self, ctx: Context) -> bool: -- cgit v1.2.3 From bfab4928e5b219660f76e2516c4ec0bb67fcba89 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 9 Oct 2020 18:26:03 -0700 Subject: Silence: require only 1 permission to be False for a manual unsilence Previously, both sending messages and adding reactions had to be false in order for the manual unsilence failure message to be sent. Because staff may only set one of these manually, the message should be sent if at least one of the permissions is set. --- bot/exts/moderation/silence.py | 2 +- tests/bot/exts/moderation/test_silence.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index bb8e06924..ee2c0dc7c 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -136,7 +136,7 @@ class Silence(commands.Cog): """Unsilence `channel` and send a success/failure message.""" if not await self._unsilence(channel): overwrite = channel.overwrites_for(self._verified_role) - if overwrite.send_messages is False and overwrite.add_reactions is False: + if overwrite.send_messages is False or overwrite.add_reactions is False: await channel.send(MSG_UNSILENCE_MANUAL) else: await channel.send(MSG_UNSILENCE_FAIL) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 39e32fdb2..6d5ffa7e8 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -411,6 +411,8 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): (True, silence.MSG_UNSILENCE_SUCCESS, unsilenced_overwrite), (False, silence.MSG_UNSILENCE_FAIL, unsilenced_overwrite), (False, silence.MSG_UNSILENCE_MANUAL, self.overwrite), + (False, silence.MSG_UNSILENCE_MANUAL, PermissionOverwrite(send_messages=False)), + (False, silence.MSG_UNSILENCE_MANUAL, PermissionOverwrite(add_reactions=False)), ) for was_unsilenced, message, overwrite in test_cases: ctx = MockContext() -- cgit v1.2.3 From e1b7b48db3a1510dd2defd9879c12b85929f7364 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 9 Oct 2020 18:31:13 -0700 Subject: Silence: amend the manual unsilence message Clarify that this situation could also result from the cache being cleared prematurely. There's no way to distinguish the two scenarios, so a manual unsilence is required for both. --- bot/exts/moderation/silence.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index ee2c0dc7c..cfdefe103 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -22,8 +22,9 @@ MSG_SILENCE_SUCCESS = f"{Emojis.check_mark} silenced current channel for {{durat MSG_UNSILENCE_FAIL = f"{Emojis.cross_mark} current channel was not silenced." MSG_UNSILENCE_MANUAL = ( - f"{Emojis.cross_mark} current channel was not unsilenced because the current " - f"overwrites were set manually. Please edit them manually to unsilence." + f"{Emojis.cross_mark} current channel was not unsilenced because the current overwrites were " + f"set manually or the cache was prematurely cleared. " + f"Please edit the overwrites manually to unsilence." ) MSG_UNSILENCE_SUCCESS = f"{Emojis.check_mark} unsilenced current channel." -- cgit v1.2.3 From 90356113d6bf75a9567af5be22cbe5422f2cab4d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 10 Oct 2020 17:39:51 +0300 Subject: Create base Voice Gate cog --- bot/exts/moderation/voice_gate.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 bot/exts/moderation/voice_gate.py diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py new file mode 100644 index 000000000..198617857 --- /dev/null +++ b/bot/exts/moderation/voice_gate.py @@ -0,0 +1,15 @@ +from discord.ext.commands import Cog + +from bot.bot import Bot + + +class VoiceGate(Cog): + """Voice channels verification management.""" + + def __init__(self, bot: Bot): + self.bot = bot + + +def setup(bot: Bot) -> None: + """Loads the VoiceGate cog.""" + bot.add_cog(VoiceGate(bot)) -- cgit v1.2.3 From 7039702ef29f4dd44db2f08005ac61d6ab83460f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 10 Oct 2020 17:42:06 +0300 Subject: Define Voice Gate channel, role and requirement in constants.py --- bot/constants.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index bb82b976d..ccc3d505d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -423,6 +423,7 @@ class Channels(metaclass=YAMLGetter): user_event_announcements: int user_log: int verification: int + voice_gate: int voice_log: int @@ -458,6 +459,7 @@ class Roles(metaclass=YAMLGetter): team_leaders: int unverified: int verified: int # This is the Developers role on PyDis, here named verified for readability reasons. + voice_verified: int class Guild(metaclass=YAMLGetter): @@ -577,6 +579,14 @@ class Verification(metaclass=YAMLGetter): kick_confirmation_threshold: float +class VoiceGate(metaclass=YAMLGetter): + section = "voice_gate" + + minimum_days_verified: int + minimum_messages: int + bot_message_delete_delay: int + + class Event(Enum): """ Event names. This does not include every event (for example, raw -- cgit v1.2.3 From 80409d40d0f9d39d08b287d5db460fba7c26ea0d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 10 Oct 2020 17:50:27 +0300 Subject: Add voice gate configuration to config-default.yml --- config-default.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config-default.yml b/config-default.yml index 3de83dbb1..2d70c17e4 100644 --- a/config-default.yml +++ b/config-default.yml @@ -481,5 +481,11 @@ verification: kick_confirmation_threshold: 0.01 # 1% +voice_gate: + minimum_days_verified: 3 # Days how much user have to be verified to pass Voice Gate + minimum_messages: 50 # How much messages user must have to pass Voice Gate + bot_message_delete_delay: 10 # Seconds before deleting bot's response in Voice Gate + + config: required_keys: ['bot.token'] -- cgit v1.2.3 From f76bced0f77cd36a2ce25ff11717c2d277c3de60 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 10 Oct 2020 18:38:10 +0200 Subject: Duckpond: Add a list of already ducked messages Previously race conditions caused the messages to be processed again before knowing the white check mark reaction got added, this seems to solve it --- bot/exts/fun/duck_pond.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 82084ea88..48aa2749c 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -22,6 +22,7 @@ class DuckPond(Cog): self.bot = bot self.webhook_id = constants.Webhooks.duck_pond self.webhook = None + self.ducked_messages = [] self.bot.loop.create_task(self.fetch_webhook()) self.relay_lock = None @@ -176,7 +177,8 @@ class DuckPond(Cog): duck_count = await self.count_ducks(message) # If we've got more than the required amount of ducks, send the message to the duck_pond. - if duck_count >= constants.DuckPond.threshold: + if duck_count >= constants.DuckPond.threshold and message.id not in self.ducked_messages: + self.ducked_messages.append(message.id) await self.locked_relay(message) @Cog.listener() -- cgit v1.2.3 From a660a1ef1ed7d93bff6bf4cb1cdff279a1083324 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 08:07:19 +0300 Subject: Add Metricity DB URL to site (docker-compose.yml) --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index cff7d33d6..8be5aac0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,7 @@ services: - postgres environment: DATABASE_URL: postgres://pysite:pysite@postgres:5432/pysite + METRICITY_DB_URL: postgres://pysite:pysite@postgres:5432/metricity SECRET_KEY: suitable-for-development-only STATIC_ROOT: /var/www/static -- cgit v1.2.3 From 9c1f66e43ed35d9fe8ffdc3ae0a4bb7504bb9c93 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 10:10:21 +0300 Subject: Add voice ban icons and show appeal footer for voice ban --- bot/exts/moderation/infraction/_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 1d91964f1..bff5fcf4c 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -18,9 +18,10 @@ INFRACTION_ICONS = { "note": (Icons.user_warn, None), "superstar": (Icons.superstarify, Icons.unsuperstarify), "warning": (Icons.user_warn, None), + "voice_ban": (Icons.voice_state_red, Icons.voice_state_green), } RULES_URL = "https://pythondiscord.com/pages/rules" -APPEALABLE_INFRACTIONS = ("ban", "mute") +APPEALABLE_INFRACTIONS = ("ban", "mute", "voice_ban") # Type aliases UserObject = t.Union[discord.Member, discord.User] -- cgit v1.2.3 From a4d445a61e06d47afd7cbb152ef4a93a73e6042a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 10:10:47 +0300 Subject: Implement voice bans (temporary and permanent) --- bot/exts/moderation/infraction/infractions.py | 85 +++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 7cf7075e6..93ec59809 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -15,6 +15,7 @@ from bot.decorators import respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler from bot.exts.moderation.infraction._utils import UserSnowflake +from bot.utils.checks import has_any_role_check, has_no_roles_check from bot.utils.messages import format_user log = logging.getLogger(__name__) @@ -31,6 +32,7 @@ class Infractions(InfractionScheduler, commands.Cog): self.category = "Moderation" self._muted_role = discord.Object(constants.Roles.muted) + self._voice_verified_role = discord.Object(constants.Roles.voice_verified) @commands.Cog.listener() async def on_member_join(self, member: Member) -> None: @@ -88,6 +90,11 @@ class Infractions(InfractionScheduler, commands.Cog): """ await self.apply_ban(ctx, user, reason, max(min(purge_days, 7), 0)) + @command(aliases=('vban', 'voiceban')) + async def voice_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str]) -> None: + """Permanently ban user from using voice channels.""" + await self.apply_voice_ban(ctx, user, reason) + # endregion # region: Temporary infractions @@ -136,6 +143,32 @@ class Infractions(InfractionScheduler, commands.Cog): """ await self.apply_ban(ctx, user, reason, expires_at=duration) + @command(aliases=("tempvban", "tvban")) + async def tempvoiceban( + self, + ctx: Context, + user: FetchedMember, + duration: Expiry, + *, + reason: t.Optional[str] + ) -> None: + """ + Temporarily voice ban a user for the given reason and duration. + + A unit of time should be appended to the duration. + Units (∗case-sensitive): + \u2003`y` - years + \u2003`m` - months∗ + \u2003`w` - weeks + \u2003`d` - days + \u2003`h` - hours + \u2003`M` - minutes∗ + \u2003`s` - seconds + + Alternatively, an ISO 8601 timestamp can be provided for the duration. + """ + await self.apply_voice_ban(ctx, user, reason, expires_at=duration) + # endregion # region: Permanent shadow infractions @@ -225,6 +258,11 @@ class Infractions(InfractionScheduler, commands.Cog): """Prematurely end the active ban infraction for the user.""" await self.pardon_infraction(ctx, "ban", user) + @command(aliases=("uvban",)) + async def unvoiceban(self, ctx: Context, user: FetchedMember) -> None: + """Prematurely end the active voice ban infraction for the user.""" + await self.pardon_infraction(ctx, "voice_ban", user) + # endregion # region: Base apply functions @@ -319,6 +357,25 @@ class Infractions(InfractionScheduler, commands.Cog): bb_reason = "User has been permanently banned from the server. Automatically removed." await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) + @respect_role_hierarchy(member_arg=2) + async def apply_voice_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: + """Apply a voice ban infraction with kwargs passed to `post_infraction`.""" + if constants.Roles.voice_verified not in [role.id for role in user.roles]: + await ctx.send(":x: Can't apply Voice Ban to user who have not passed the Voice Gate.") + return + + if await _utils.get_active_infraction(ctx, user, "voice_ban"): + return + + infraction = await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs) + if infraction is None: + return + + self.mod_log.ignore(Event.member_update, user.id) + + action = user.remove_roles(self._voice_verified_role, reason=reason) + await self.apply_infraction(ctx, infraction, user, action) + # endregion # region: Base pardon functions @@ -363,6 +420,32 @@ class Infractions(InfractionScheduler, commands.Cog): return log_text + async def pardon_voice_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: + """Add Voice Verified role back to user, DM them a notification, and return a log dict.""" + user = guild.get_member(user_id) + log_text = {} + + if user: + # Add Voice Verified role back to user. + self.mod_log.ignore(Event.member_update, user.id) + await user.add_roles(self._voice_verified_role, reason=reason) + + # DM user about infraction expiration + notified = await _utils.notify_pardon( + user=user, + title="Your Voice Ban have been removed", + content="You can now speak again in voice channels.", + icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] + ) + + log_text["Member"] = format_user(user) + log_text["DM"] = "Sent" if notified else "**Failed**" + else: + log.info(f"Failed to remove Voice Ban from user {user_id}: user not found") + log_text["Failure"] = "User was not found in the guild." + + return log_text + async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: """ Execute deactivation steps specific to the infraction's type and return a log dict. @@ -377,6 +460,8 @@ class Infractions(InfractionScheduler, commands.Cog): return await self.pardon_mute(user_id, guild, reason) elif infraction["type"] == "ban": return await self.pardon_ban(user_id, guild, reason) + elif infraction["type"] == "voice_ban": + return await self.pardon_voice_ban(user_id, guild, reason) # endregion -- cgit v1.2.3 From 247e866868a7f0687ceb02a64beb79ebcbb440e5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 10:11:25 +0300 Subject: Remove not used imports --- bot/exts/moderation/infraction/infractions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 93ec59809..2157c040c 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -15,7 +15,6 @@ from bot.decorators import respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler from bot.exts.moderation.infraction._utils import UserSnowflake -from bot.utils.checks import has_any_role_check, has_no_roles_check from bot.utils.messages import format_user log = logging.getLogger(__name__) -- cgit v1.2.3 From 0147934b7681cd65496f904e0d8ab15b4331d7c4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 11:35:43 +0300 Subject: Implement Voice Verifying command and delete message in voice gate --- bot/exts/moderation/voice_gate.py | 112 +++++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 198617857..dae19d49e 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -1,6 +1,26 @@ -from discord.ext.commands import Cog +import logging +from contextlib import suppress +from datetime import datetime, timedelta +import discord +from dateutil import parser + +from discord.ext.commands import Cog, Context, command + +from bot.api import ResponseCodeError from bot.bot import Bot +from bot.constants import Channels, Roles, VoiceGate as VoiceGateConf, MODERATION_ROLES, Event +from bot.decorators import has_no_roles, in_whitelist +from bot.exts.moderation.modlog import ModLog + +log = logging.getLogger(__name__) + +# Messages for case when user don't meet with requirements +NOT_ENOUGH_MESSAGES = f"haven't sent at least {VoiceGateConf.minimum_messages} messages" +NOT_ENOUGH_DAYS_AFTER_VERIFICATION = f"haven't been verified for at least {VoiceGateConf.minimum_days_verified} days" +VOICE_BANNED = "are voice banned" + +FAILED_MESSAGE = """{user} you don't meet with our current requirements to pass Voice Gate. You {reasons}.""" class VoiceGate(Cog): @@ -9,6 +29,96 @@ class VoiceGate(Cog): def __init__(self, bot: Bot): self.bot = bot + @property + def mod_log(self) -> ModLog: + """Get the currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @command(aliases=('voiceverify', 'vverify', 'voicev', 'vv')) + @has_no_roles(Roles.voice_verified) + @in_whitelist(channels=(Channels.voice_gate,), redirect=None) + async def voice_verify(self, ctx: Context, *_) -> None: + """ + Apply to be able to use voice within the Discord server. + + In order to use voice you must meet all three of the following criteria: + - You must have over a certain number of messages within the Discord server + - You must have accepted our rules over a certain number of days ago + - You must not be actively banned from using our voice channels + """ + try: + data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") + except ResponseCodeError as e: + if e.status == 404: + await ctx.send(f":x: {ctx.author.mention} Unable to find Metricity data about you.") + log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})") + else: + log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} metricity data.") + await ctx.send(f":x: Got unexpected response from site. Please let us know about this.") + return + + # Pre-parse this for better code style + data["verified_at"] = parser.isoparse(data["verified_at"]) + + failed = False + failed_reasons = [] + + if data["verified_at"] > datetime.utcnow() - timedelta(days=VoiceGateConf.minimum_days_verified): + failed_reasons.append(NOT_ENOUGH_DAYS_AFTER_VERIFICATION) + failed = True + self.bot.stats.incr("voice_gate.failed.verified_at") + if data["total_messages"] < VoiceGateConf.minimum_messages: + failed_reasons.append(NOT_ENOUGH_MESSAGES) + failed = True + self.bot.stats.incr("voice_gate.failed.total_messages") + if data["voice_banned"]: + failed_reasons.append(VOICE_BANNED) + failed = True + self.bot.stats.incr("voice_gate.failed.voice_banned") + + if failed: + if len(failed_reasons) > 1: + reasons = f"{', '.join(failed_reasons[:-1])} and {failed_reasons[-1]}" + else: + reasons = failed_reasons[0] + + await ctx.send( + FAILED_MESSAGE.format( + user=ctx.author.mention, + reasons=reasons + ) + ) + return + + self.mod_log.ignore(Event.member_update, ctx.author.id) + await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") + await ctx.author.send( + ":tada: Congratulations! You are now Voice Verified and have access to PyDis Voice Channels." + ) + self.bot.stats.incr("voice_gate.passed") + + @Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Delete all non-staff messages from voice gate channel that don't invoke voice verify command.""" + # Check is channel voice gate + if message.channel.id != Channels.voice_gate: + return + + # When it's bot sent message, delete it after some time + if message.author.bot: + with suppress(discord.NotFound): + await message.delete(delay=VoiceGateConf.bot_message_delete_delay) + return + + # Then check is member moderator+, because we don't want to delete their messages. + if any(role.id in MODERATION_ROLES for role in message.author.roles): + log.trace(f"Excluding moderator message {message.id} from deletion in #{message.channel}.") + return + + self.mod_log.ignore(Event.message_delete, message.id) + with suppress(discord.NotFound): + await message.delete() + def setup(bot: Bot) -> None: """Loads the VoiceGate cog.""" -- cgit v1.2.3 From 22e9c04d63c4a983448efc91a12335a326393e76 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 12:23:02 +0300 Subject: Suppress Voice Gate cog InWhiteListCheckFailure --- bot/exts/moderation/voice_gate.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index dae19d49e..101db90b8 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -12,6 +12,7 @@ from bot.bot import Bot from bot.constants import Channels, Roles, VoiceGate as VoiceGateConf, MODERATION_ROLES, Event from bot.decorators import has_no_roles, in_whitelist from bot.exts.moderation.modlog import ModLog +from bot.utils.checks import InWhitelistCheckFailure log = logging.getLogger(__name__) @@ -119,6 +120,11 @@ class VoiceGate(Cog): with suppress(discord.NotFound): await message.delete() + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Check for & ignore any InWhitelistCheckFailure.""" + if isinstance(error, InWhitelistCheckFailure): + error.handled = True + def setup(bot: Bot) -> None: """Loads the VoiceGate cog.""" -- cgit v1.2.3 From 002c53cb922f826c33c58fe35afccee24d5b2689 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 12:52:12 +0300 Subject: Improve voice gate messages deletion --- bot/exts/moderation/voice_gate.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 101db90b8..bd2afb464 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -105,6 +105,9 @@ class VoiceGate(Cog): if message.channel.id != Channels.voice_gate: return + ctx = await self.bot.get_context(message) + is_verify_command = ctx.command is not None and ctx.command.name == "voice_verify" + # When it's bot sent message, delete it after some time if message.author.bot: with suppress(discord.NotFound): @@ -112,11 +115,14 @@ class VoiceGate(Cog): return # Then check is member moderator+, because we don't want to delete their messages. - if any(role.id in MODERATION_ROLES for role in message.author.roles): + if any(role.id in MODERATION_ROLES for role in message.author.roles) and is_verify_command == False: log.trace(f"Excluding moderator message {message.id} from deletion in #{message.channel}.") return - self.mod_log.ignore(Event.message_delete, message.id) + # Ignore deleted voice verification messages + if ctx.command is not None and ctx.command.name == "voice_verify": + self.mod_log.ignore(Event.message_delete, message.id) + with suppress(discord.NotFound): await message.delete() -- cgit v1.2.3 From 4d967cd27d049bffc2585d2cc8f381f44f59ca61 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 13:04:35 +0300 Subject: Create test for permanent voice ban --- .../bot/exts/moderation/infraction/test_infractions.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index be1b649e1..27f346648 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -53,3 +53,20 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase): self.cog.apply_infraction.assert_awaited_once_with( self.ctx, {"foo": "bar"}, self.target, self.target.kick.return_value ) + + +class VoiceBanTests(unittest.IsolatedAsyncioTestCase): + """Tests for voice ban related functions and commands.""" + + def setUp(self): + self.bot = MockBot() + self.mod = MockMember() + self.user = MockMember() + self.ctx = MockContext(bot=self.bot, author=self.mod) + self.cog = Infractions(self.bot) + + async def test_permanent_voice_ban(self): + """Should call voice ban applying function.""" + self.cog.apply_voice_ban = AsyncMock() + self.assertIsNone(await self.cog.voice_ban(self.cog, self.ctx, self.user, reason="foobar")) + self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar") -- cgit v1.2.3 From b792af63022bf8e435210c9efefccc664c3bbf80 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 13:08:27 +0300 Subject: Create test for temporary voice ban --- tests/bot/exts/moderation/infraction/test_infractions.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 27f346648..814959775 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -66,7 +66,13 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.cog = Infractions(self.bot) async def test_permanent_voice_ban(self): - """Should call voice ban applying function.""" + """Should call voice ban applying function without expiry.""" self.cog.apply_voice_ban = AsyncMock() self.assertIsNone(await self.cog.voice_ban(self.cog, self.ctx, self.user, reason="foobar")) self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar") + + async def test_temporary_voice_ban(self): + """Should call voice ban applying function with expiry.""" + self.cog.apply_voice_ban = AsyncMock() + self.assertIsNone(await self.cog.tempvoiceban(self.cog, self.ctx, self.user, "baz", reason="foobar")) + self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz") -- cgit v1.2.3 From 2b701b05b55d6c62c27497d39b142370693ef88d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 13:13:23 +0300 Subject: Create test for voice unban --- tests/bot/exts/moderation/infraction/test_infractions.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 814959775..02062932e 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -76,3 +76,9 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.cog.apply_voice_ban = AsyncMock() self.assertIsNone(await self.cog.tempvoiceban(self.cog, self.ctx, self.user, "baz", reason="foobar")) self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz") + + async def test_voice_unban(self): + """Should call infraction pardoning function.""" + self.cog.pardon_infraction = AsyncMock() + self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user)) + self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user) -- cgit v1.2.3 From 8faa82f7d7de795b4a8e2fc7a6dc919994258d6c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:00:09 +0300 Subject: Create test for case when trying to voice ban user who haven't passed gate --- tests/bot/exts/moderation/infraction/test_infractions.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 02062932e..b2b617e51 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -60,8 +60,8 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.bot = MockBot() - self.mod = MockMember() - self.user = MockMember() + self.mod = MockMember(top_role=10) + self.user = MockMember(top_role=1) self.ctx = MockContext(bot=self.bot, author=self.mod) self.cog = Infractions(self.bot) @@ -82,3 +82,12 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.cog.pardon_infraction = AsyncMock() self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user)) self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user) + + @patch("bot.exts.moderation.infraction.infractions.constants.Roles.voice_verified", new=123456) + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_not_having_voice_verified_role(self, get_active_infraction_mock): + """Should send message and not apply infraction when user don't have voice verified role.""" + self.user.roles = [MockRole(id=987)] + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + self.ctx.send.assert_awaited_once() + get_active_infraction_mock.assert_not_awaited() -- cgit v1.2.3 From 55a46c937de9c27cd865ff34cfe82c8fb76dc603 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:01:32 +0300 Subject: Simplify post infraction calling and None check --- bot/exts/moderation/infraction/infractions.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 2157c040c..6a6250238 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -366,8 +366,7 @@ class Infractions(InfractionScheduler, commands.Cog): if await _utils.get_active_infraction(ctx, user, "voice_ban"): return - infraction = await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs) - if infraction is None: + if infraction := await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs): return self.mod_log.ignore(Event.member_update, user.id) -- cgit v1.2.3 From b7a072c1c43ad5b0779c1e979a1870c002cfd5c3 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:05:42 +0300 Subject: Create test for case when user already have active Voice Ban --- tests/bot/exts/moderation/infraction/test_infractions.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index b2b617e51..510f31db3 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -55,13 +55,14 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase): ) +@patch("bot.exts.moderation.infraction.infractions.constants.Roles.voice_verified", new=123456) class VoiceBanTests(unittest.IsolatedAsyncioTestCase): """Tests for voice ban related functions and commands.""" def setUp(self): self.bot = MockBot() self.mod = MockMember(top_role=10) - self.user = MockMember(top_role=1) + self.user = MockMember(top_role=1, roles=[MockRole(id=123456)]) self.ctx = MockContext(bot=self.bot, author=self.mod) self.cog = Infractions(self.bot) @@ -83,7 +84,6 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user)) self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user) - @patch("bot.exts.moderation.infraction.infractions.constants.Roles.voice_verified", new=123456) @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") async def test_voice_ban_not_having_voice_verified_role(self, get_active_infraction_mock): """Should send message and not apply infraction when user don't have voice verified role.""" @@ -91,3 +91,12 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) self.ctx.send.assert_awaited_once() get_active_infraction_mock.assert_not_awaited() + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_user_have_active_infraction(self, get_active_infraction, post_infraction_mock): + """Should return early when user already have Voice Ban infraction.""" + get_active_infraction.return_value = {"foo": "bar"} + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + get_active_infraction.assert_awaited_once() + post_infraction_mock.assert_not_awaited() -- cgit v1.2.3 From a1209554614e3f5b63ab400a754f1d893896754b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:11:03 +0300 Subject: Revert recent walrus operator change --- bot/exts/moderation/infraction/infractions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 6a6250238..2157c040c 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -366,7 +366,8 @@ class Infractions(InfractionScheduler, commands.Cog): if await _utils.get_active_infraction(ctx, user, "voice_ban"): return - if infraction := await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs): + infraction = await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs) + if infraction is None: return self.mod_log.ignore(Event.member_update, user.id) -- cgit v1.2.3 From c719169bffcca8898ced04c1fed0264a5b9cd7f6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:11:36 +0300 Subject: Create test for case when posting infraction fails --- tests/bot/exts/moderation/infraction/test_infractions.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 510f31db3..1c3294b39 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -1,6 +1,6 @@ import textwrap import unittest -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, patch, MagicMock from bot.exts.moderation.infraction.infractions import Infractions from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole @@ -100,3 +100,14 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) get_active_infraction.assert_awaited_once() post_infraction_mock.assert_not_awaited() + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_infraction_post_failed(self, get_active_infraction, post_infraction_mock): + """Should return early when posting infraction fails.""" + self.cog.mod_log.ignore = MagicMock() + get_active_infraction.return_value = None + post_infraction_mock.return_value = None + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + post_infraction_mock.assert_awaited_once() + self.cog.mod_log.ignore.assert_not_called() -- cgit v1.2.3 From 2a6f86b87aa7bc19a26df739111a678f8fa03083 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:15:16 +0300 Subject: Create test to check does this pass proper kwargs to infraction posting --- tests/bot/exts/moderation/infraction/test_infractions.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 1c3294b39..ebb39320a 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -111,3 +111,15 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) post_infraction_mock.assert_awaited_once() self.cog.mod_log.ignore.assert_not_called() + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_infraction_post_add_kwargs(self, get_active_infraction, post_infraction_mock): + """Should pass all kwargs passed to apply_voice_ban to post_infraction.""" + get_active_infraction.return_value = None + # We don't want that this continue yet + post_infraction_mock.return_value = None + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar", my_kwarg=23)) + post_infraction_mock.assert_awaited_once_with( + self.ctx, self.user, "voice_ban", "foobar", active=True, my_kwarg=23 + ) -- cgit v1.2.3 From 06343b5b24aa2b5e9d7d34e39ff604ec4577bcd8 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:16:33 +0300 Subject: Check arguments for get_active_infraction in voice ban tests --- tests/bot/exts/moderation/infraction/test_infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index ebb39320a..37848e9e8 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -98,7 +98,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): """Should return early when user already have Voice Ban infraction.""" get_active_infraction.return_value = {"foo": "bar"} self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) - get_active_infraction.assert_awaited_once() + get_active_infraction.assert_awaited_once_with(self.ctx, self.user, "voice_ban") post_infraction_mock.assert_not_awaited() @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") -- cgit v1.2.3 From a4036476bca02cf645c459510c3866c6442020c7 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:22:37 +0300 Subject: Create test for voice ban applying role remove ignore. --- tests/bot/exts/moderation/infraction/test_infractions.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 37848e9e8..d4fb2b119 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -2,6 +2,7 @@ import textwrap import unittest from unittest.mock import AsyncMock, Mock, patch, MagicMock +from bot.constants import Event from bot.exts.moderation.infraction.infractions import Infractions from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole @@ -123,3 +124,17 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): post_infraction_mock.assert_awaited_once_with( self.ctx, self.user, "voice_ban", "foobar", active=True, my_kwarg=23 ) + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_mod_log_ignore(self, get_active_infraction, post_infraction_mock): + """Should ignore Voice Verified role removing.""" + self.cog.mod_log.ignore = MagicMock() + self.cog.apply_infraction = AsyncMock() + self.user.remove_roles = MagicMock(return_value="my_return_value") + + get_active_infraction.return_value = None + post_infraction_mock.return_value = {"foo": "bar"} + + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) -- cgit v1.2.3 From d9d3b1a3615f347958cd8e194323b0c9b13d6a35 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:26:40 +0300 Subject: Add Voice Ban test about calling apply_infraction --- tests/bot/exts/moderation/infraction/test_infractions.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index d4fb2b119..1f4a3e7f0 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -138,3 +138,18 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_apply_infraction(self, get_active_infraction, post_infraction_mock): + """Should ignore Voice Verified role removing.""" + self.cog.mod_log.ignore = MagicMock() + self.cog.apply_infraction = AsyncMock() + self.user.remove_roles = MagicMock(return_value="my_return_value") + + get_active_infraction.return_value = None + post_infraction_mock.return_value = {"foo": "bar"} + + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + self.user.remove_roles.assert_called_once_with(self.cog._voice_verified_role, reason="foobar") + self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") -- cgit v1.2.3 From fda0359abfe8644cc2a9452c19713395dec16dab Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:41:51 +0300 Subject: Shorten voice ban reason and create test for it --- bot/exts/moderation/infraction/infractions.py | 3 +++ .../bot/exts/moderation/infraction/test_infractions.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 2157c040c..0dab3a72e 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -372,6 +372,9 @@ class Infractions(InfractionScheduler, commands.Cog): self.mod_log.ignore(Event.member_update, user.id) + if reason: + reason = textwrap.shorten(reason, width=512, placeholder="...") + action = user.remove_roles(self._voice_verified_role, reason=reason) await self.apply_infraction(ctx, infraction, user, action) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 1f4a3e7f0..a6ebe2162 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -153,3 +153,20 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) self.user.remove_roles.assert_called_once_with(self.cog._voice_verified_role, reason="foobar") self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_truncate_reason(self, get_active_infraction, post_infraction_mock): + """Should truncate reason for voice ban.""" + self.cog.mod_log.ignore = MagicMock() + self.cog.apply_infraction = AsyncMock() + self.user.remove_roles = MagicMock(return_value="my_return_value") + + get_active_infraction.return_value = None + post_infraction_mock.return_value = {"foo": "bar"} + + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar" * 3000)) + self.user.remove_roles.assert_called_once_with( + self.cog._voice_verified_role, reason=textwrap.shorten("foobar" * 3000, 512, placeholder="...") + ) + self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") -- cgit v1.2.3 From b8855bced0913f087d25d571fe9a5ccf7f5e1727 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:53:33 +0300 Subject: Create test for voice ban pardon when user not found --- tests/bot/exts/moderation/infraction/test_infractions.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index a6ebe2162..ae8c1d35e 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -64,6 +64,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.bot = MockBot() self.mod = MockMember(top_role=10) self.user = MockMember(top_role=1, roles=[MockRole(id=123456)]) + self.guild = MockGuild() self.ctx = MockContext(bot=self.bot, author=self.mod) self.cog = Infractions(self.bot) @@ -170,3 +171,9 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.cog._voice_verified_role, reason=textwrap.shorten("foobar" * 3000, 512, placeholder="...") ) self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") + + async def test_voice_unban_user_not_found(self): + """Should include info to return dict when user was not found from guild.""" + self.guild.get_member.return_value = None + result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + self.assertEqual(result, {"Failure": "User was not found in the guild."}) -- cgit v1.2.3 From 6e8e9fd8c3db4ac8a65bed65d2fa1ecbea1c98c5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 15:02:27 +0300 Subject: Create base test for voice unban --- .../bot/exts/moderation/infraction/test_infractions.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index ae8c1d35e..9d4180902 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -177,3 +177,21 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.guild.get_member.return_value = None result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") self.assertEqual(result, {"Failure": "User was not found in the guild."}) + + @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") + @patch("bot.exts.moderation.infraction.infractions.format_user") + async def test_voice_unban_user_found(self, format_user_mock, notify_pardon_mock): + """Should add role back with ignoring, notify user and return log dictionary..""" + self.cog.mod_log.ignore = MagicMock() + self.guild.get_member.return_value = self.user + notify_pardon_mock.return_value = True + format_user_mock.return_value = "my-user" + + result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + self.assertEqual(result, { + "Member": "my-user", + "DM": "Sent" + }) + self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) + self.user.add_roles.assert_awaited_once_with(self.cog._voice_verified_role, reason="foobar") + notify_pardon_mock.assert_awaited_once() -- cgit v1.2.3 From 339769d8c863b192e1b298e211d1ab0261d1b26f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 15:04:35 +0300 Subject: Create test for voice unban fail send DM --- tests/bot/exts/moderation/infraction/test_infractions.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 9d4180902..b60c203a1 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -195,3 +195,18 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) self.user.add_roles.assert_awaited_once_with(self.cog._voice_verified_role, reason="foobar") notify_pardon_mock.assert_awaited_once() + + @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") + @patch("bot.exts.moderation.infraction.infractions.format_user") + async def test_voice_unban_dm_fail(self, format_user_mock, notify_pardon_mock): + """Should add role back with ignoring, notify user and return log dictionary..""" + self.guild.get_member.return_value = self.user + notify_pardon_mock.return_value = False + format_user_mock.return_value = "my-user" + + result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + self.assertEqual(result, { + "Member": "my-user", + "DM": "**Failed**" + }) + notify_pardon_mock.assert_awaited_once() -- cgit v1.2.3 From 7598faddd8f68e9263d1c9748becd49cb1917919 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 15:05:20 +0300 Subject: Add production voice gate role and channel to configuration --- config-default.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config-default.yml b/config-default.yml index afdb8fe95..a536a94db 100644 --- a/config-default.yml +++ b/config-default.yml @@ -169,6 +169,7 @@ guild: bot_commands: &BOT_CMD 267659945086812160 esoteric: 470884583684964352 verification: 352442727016693763 + voice_gate: 764802555427029012 # Staff admins: &ADMINS 365960823622991872 @@ -228,6 +229,7 @@ guild: unverified: 739794855945044069 verified: 352427296948486144 # @Developers on PyDis + voice_verified: 764802720779337729 # Staff admins: &ADMINS_ROLE 267628507062992896 -- cgit v1.2.3 From 0a4bed86d3826e611cd1675d54596a8dcedbe29a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 15:08:21 +0300 Subject: Fix linting for voice gate and voice ban --- bot/exts/moderation/voice_gate.py | 7 +++---- tests/bot/exts/moderation/infraction/test_infractions.py | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index bd2afb464..8f2b51dbb 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -4,12 +4,11 @@ from datetime import datetime, timedelta import discord from dateutil import parser - from discord.ext.commands import Cog, Context, command from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Roles, VoiceGate as VoiceGateConf, MODERATION_ROLES, Event +from bot.constants import Channels, Event, MODERATION_ROLES, Roles, VoiceGate as VoiceGateConf from bot.decorators import has_no_roles, in_whitelist from bot.exts.moderation.modlog import ModLog from bot.utils.checks import InWhitelistCheckFailure @@ -55,7 +54,7 @@ class VoiceGate(Cog): log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})") else: log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} metricity data.") - await ctx.send(f":x: Got unexpected response from site. Please let us know about this.") + await ctx.send(":x: Got unexpected response from site. Please let us know about this.") return # Pre-parse this for better code style @@ -115,7 +114,7 @@ class VoiceGate(Cog): return # Then check is member moderator+, because we don't want to delete their messages. - if any(role.id in MODERATION_ROLES for role in message.author.roles) and is_verify_command == False: + if any(role.id in MODERATION_ROLES for role in message.author.roles) and is_verify_command is False: log.trace(f"Excluding moderator message {message.id} from deletion in #{message.channel}.") return diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index b60c203a1..caa42ba3d 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -1,6 +1,6 @@ import textwrap import unittest -from unittest.mock import AsyncMock, Mock, patch, MagicMock +from unittest.mock import AsyncMock, MagicMock, Mock, patch from bot.constants import Event from bot.exts.moderation.infraction.infractions import Infractions -- cgit v1.2.3 From c835fe8447b239871957817edf325fe1eeadfa12 Mon Sep 17 00:00:00 2001 From: spitfire-hash Date: Tue, 13 Oct 2020 12:27:27 +0400 Subject: Fixed hardcoded prefix in __main__.py --- bot/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index da042a5ed..367be1300 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -58,7 +58,7 @@ bot = Bot( redis_session=redis_session, loop=loop, command_prefix=when_mentioned_or(constants.Bot.prefix), - activity=discord.Game(name="Commands: !help"), + activity=discord.Game(name=f"Commands: {constants.Bot.prefix}help"), case_insensitive=True, max_messages=10_000, allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles), -- cgit v1.2.3 From 7b40cb697bd10f3640c9f5de3a9666d63606f68b Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 13 Oct 2020 14:41:09 +0200 Subject: Verification: implement kick note post helper --- bot/exts/moderation/verification.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index c3ad8687e..cb6dd14fb 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -11,6 +11,7 @@ from discord.ext.commands import Cog, Context, command, group, has_any_role from discord.utils import snowflake_time from bot import constants +from bot.api import ResponseCodeError from bot.bot import Bot from bot.decorators import has_no_roles, in_whitelist from bot.exts.moderation.modlog import ModLog @@ -355,6 +356,28 @@ class Verification(Cog): return n_success + async def _add_kick_note(self, member: discord.Member) -> None: + """ + Post a note regarding `member` being kicked to site. + + Allows keeping track of kicked members for auditing purposes. + """ + payload = { + "active": False, + "actor": self.bot.user.id, # Bot actions this autonomously + "expires_at": None, + "hidden": True, + "reason": f"Kicked for not having verified after {constants.Verification.kicked_after} days", + "type": "note", + "user": member.id, + } + + log.trace(f"Posting kick note: {payload!r}") + try: + await self.bot.api_client.post("bot/infractions", json=payload) + except ResponseCodeError as api_exc: + log.warning("Failed to post kick note", exc_info=api_exc) + async def _kick_members(self, members: t.Collection[discord.Member]) -> int: """ Kick `members` from the PyDis guild. -- cgit v1.2.3 From ba7429a4efb4c16c27cb7cb8c44cce4bfc13351c Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 13 Oct 2020 14:41:28 +0200 Subject: Verification: add notes to kicked users --- bot/exts/moderation/verification.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index cb6dd14fb..e92524331 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -396,6 +396,7 @@ class Verification(Cog): except discord.HTTPException as suspicious_exception: raise StopExecution(reason=suspicious_exception) await member.kick(reason=f"User has not verified in {constants.Verification.kicked_after} days") + await self._add_kick_note(member) n_kicked = await self._send_requests(members, kick_request, Limit(batch_size=2, sleep_secs=1)) self.bot.stats.incr("verification.kicked", count=n_kicked) -- cgit v1.2.3 From 85d4573f548a4a0b45a75b9c78f102dff647bcfc Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 13 Oct 2020 22:26:37 +0100 Subject: Add production debug log for native verification --- bot/exts/moderation/verification.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index c3ad8687e..8a5937c3d 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -547,6 +547,16 @@ class Verification(Cog): # video. if raw_member.get("is_pending"): await self.member_gating_cache.set(member.id, True) + + # TODO: Temporary, remove soon after asking joe. + await self.mod_log.send_log_message( + icon_url=self.bot.user.avatar_url, + colour=discord.Colour.blurple(), + title="New native gated user", + channel_id=Channels.user_log, + text=f"<@{member.id}> ({member.id})", + ) + return log.trace(f"Sending on join message to new member: {member.id}") -- cgit v1.2.3 From 0c552b0b57f87177346fe43022475800debc9e60 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 13 Oct 2020 22:28:05 +0100 Subject: Fix channel constant --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 8a5937c3d..fe7ab5c67 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -553,7 +553,7 @@ class Verification(Cog): icon_url=self.bot.user.avatar_url, colour=discord.Colour.blurple(), title="New native gated user", - channel_id=Channels.user_log, + channel_id=constants.Channels.user_log, text=f"<@{member.id}> ({member.id})", ) -- cgit v1.2.3 From aefd9a31b32dc76c37a51debd2705ce2287ec6b1 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 13 Oct 2020 22:30:18 +0100 Subject: Remove trailing whitespace from verification.py --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index fe7ab5c67..d28114298 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -547,7 +547,7 @@ class Verification(Cog): # video. if raw_member.get("is_pending"): await self.member_gating_cache.set(member.id, True) - + # TODO: Temporary, remove soon after asking joe. await self.mod_log.send_log_message( icon_url=self.bot.user.avatar_url, -- cgit v1.2.3 From 1bbb8a5a9236582232472b90ccc217380fdfef6f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 14 Oct 2020 15:31:12 -0700 Subject: Utils: clarify why has_lines counts by splitting by newlines --- bot/utils/helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py index b5c13ac9e..3501a3933 100644 --- a/bot/utils/helpers.py +++ b/bot/utils/helpers.py @@ -20,6 +20,7 @@ def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]: def has_lines(string: str, count: int) -> bool: """Return True if `string` has at least `count` lines.""" + # Benchmarks show this is significantly faster than using str.count("\n") or a for loop & break. split = string.split("\n", count - 1) # Make sure the last part isn't empty, which would happen if there was a final newline. -- cgit v1.2.3 From d277ac6d3444bed43f921ee95f79255033e367ba Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 14 Oct 2020 18:53:48 -0700 Subject: Code block: fix _fix_indentation failing for line counts of 1 This could be reproduced by editing a tracked message to a single line of invalid Python that lacks any back ticks. The code was assuming there would be multiple lines because that's what the default value for the threshold is, but this threshold is not applied to edited messages. Fixes BOT-A5 --- bot/exts/info/codeblock/_parsing.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/info/codeblock/_parsing.py b/bot/exts/info/codeblock/_parsing.py index e67224494..a98218dfb 100644 --- a/bot/exts/info/codeblock/_parsing.py +++ b/bot/exts/info/codeblock/_parsing.py @@ -208,6 +208,10 @@ def _fix_indentation(content: str) -> str: first_indent = _get_leading_spaces(content) first_line = lines[0][first_indent:] + # Can't assume there'll be multiple lines cause line counts of edited messages aren't checked. + if len(lines) == 1: + return first_line + second_indent = _get_leading_spaces(lines[1]) # If the first line ends with a colon, all successive lines need to be indented one -- cgit v1.2.3 From 5f4552f01506e071646c42600f30a515d77908d4 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 15 Oct 2020 13:36:38 +0200 Subject: Verification: simplify kick note reason This will make it much easier to filter out verification kicks when querying the infraction database. --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index e92524331..c8e5b481f 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -367,7 +367,7 @@ class Verification(Cog): "actor": self.bot.user.id, # Bot actions this autonomously "expires_at": None, "hidden": True, - "reason": f"Kicked for not having verified after {constants.Verification.kicked_after} days", + "reason": "Verification kick", "type": "note", "user": member.id, } -- cgit v1.2.3 From c77e88c564aa83bc5544b681ed86f001d8a3b865 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 15 Oct 2020 13:36:59 -0700 Subject: Snekbox: raise paste character length It doesn't make sense for it to be at 1000 when the code gets truncated to 1000 as well. Fixes #1239 --- bot/exts/utils/snekbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index ca6fbf5cb..59a27a2be 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -38,7 +38,7 @@ RAW_CODE_REGEX = re.compile( re.DOTALL # "." also matches newlines ) -MAX_PASTE_LEN = 1000 +MAX_PASTE_LEN = 10000 # `!eval` command whitelists EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric, Channels.code_help_voice) -- cgit v1.2.3 From 91d6f5275d2ddd005b2479ef6fb66ebc08f45c87 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Fri, 16 Oct 2020 23:54:34 +0300 Subject: display inf id actioned in mod channel --- bot/exts/moderation/infraction/_scheduler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 814b17830..dba3f1513 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -138,7 +138,7 @@ class InfractionScheduler: end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" elif ctx.channel.id not in MODERATION_CHANNELS: log.trace( - f"Infraction #{id_} context is not in a mod channel; omitting infraction count." + f"Infraction #{id_} context is not in a mod channel; omitting infraction count and id." ) else: log.trace(f"Fetching total infraction count for {user}.") @@ -148,7 +148,7 @@ class InfractionScheduler: params={"user__id": str(user.id)} ) total = len(infractions) - end_msg = f" ({total} infraction{ngettext('', 's', total)} total)" + end_msg = f" (#{id_} ; {total} infraction{ngettext('', 's', total)} total)" # Execute the necessary actions to apply the infraction on Discord. if action_coro: -- cgit v1.2.3 From 54fb16322e49dfa60bc496ed696fefe6e69b9b9e Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 17 Oct 2020 00:08:15 +0200 Subject: Verification: avoid logging whole kick note payload Only the `member` is variable, no need to log the rest. Co-authored-by: Numerlor --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index c8e5b481f..f50ceaffd 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -372,7 +372,7 @@ class Verification(Cog): "user": member.id, } - log.trace(f"Posting kick note: {payload!r}") + log.trace(f"Posting kick note for member {member} ({member.id})") try: await self.bot.api_client.post("bot/infractions", json=payload) except ResponseCodeError as api_exc: -- cgit v1.2.3 From 29d370da244801040f128ad2dca9976c0c7ad61a Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sat, 17 Oct 2020 10:30:13 +0200 Subject: Add sprinters role to filter whitelist I've added the sprinters role to the filter whitelist. This will not affect antispam and antimalware just yet, as they currently default to using the STAFF_ROLES constant. I've also kaizened the config-default.yml file by ensuring there are two linebreaks between all sections. Signed-off-by: Sebastiaan Zeeff --- bot/constants.py | 1 + config-default.yml | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 6c8b933af..0a3e48616 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -456,6 +456,7 @@ class Roles(metaclass=YAMLGetter): owners: int partners: int python_community: int + sprinters: int team_leaders: int unverified: int verified: int # This is the Developers role on PyDis, here named verified for readability reasons. diff --git a/config-default.yml b/config-default.yml index 0e7ebf2e3..c93ab9e0c 100644 --- a/config-default.yml +++ b/config-default.yml @@ -119,6 +119,7 @@ style: voice_state_green: "https://cdn.discordapp.com/emojis/656899770094452754.png" voice_state_red: "https://cdn.discordapp.com/emojis/656899769905709076.png" + guild: id: 267624335836053506 invite: "https://discord.gg/python" @@ -225,6 +226,7 @@ guild: muted: &MUTED_ROLE 277914926603829249 partners: 323426753857191936 python_community: &PY_COMMUNITY_ROLE 458226413825294336 + sprinters: &SPRINTERS 758422482289426471 unverified: 739794855945044069 verified: 352427296948486144 # @Developers on PyDis @@ -261,6 +263,7 @@ guild: reddit: 635408384794951680 talent_pool: 569145364800602132 + filter: # What do we filter? filter_zalgo: false @@ -298,6 +301,7 @@ filter: - *OWNERS_ROLE - *HELPERS_ROLE - *PY_COMMUNITY_ROLE + - *SPRINTERS keys: @@ -326,6 +330,7 @@ urls: bot_avatar: "https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle/logo_circle.png" github_bot_repo: "https://github.com/python-discord/bot" + anti_spam: # Clean messages that violate a rule. clean_offending: true @@ -459,10 +464,12 @@ help_channels: notify_roles: - *HELPERS_ROLE + redirect_output: delete_invocation: true delete_delay: 15 + duck_pond: threshold: 4 channel_blacklist: @@ -478,6 +485,7 @@ duck_pond: - *MOD_ANNOUNCEMENTS - *ADMIN_ANNOUNCEMENTS + python_news: mail_lists: - 'python-ideas' -- cgit v1.2.3 From f8e7b3f82244ff33cd8c8a960d7c6e734b87afd6 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sat, 17 Oct 2020 10:33:31 +0200 Subject: Use filter role whitelist for all filter features We were using different whitelists for different filters, making it slightly more difficult to maintain the role whitelists. They now all use the same list, which combines our staff roles with the Python community role and the sprinters role. Signed-off-by: Sebastiaan Zeeff --- bot/exts/filters/antimalware.py | 4 ++-- bot/exts/filters/antispam.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index 7894ec48f..26f00e91f 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/antimalware.py @@ -6,7 +6,7 @@ from discord import Embed, Message, NotFound from discord.ext.commands import Cog from bot.bot import Bot -from bot.constants import Channels, STAFF_ROLES, URLs +from bot.constants import Channels, Filter, URLs log = logging.getLogger(__name__) @@ -61,7 +61,7 @@ class AntiMalware(Cog): # Check if user is staff, if is, return # Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance - if hasattr(message.author, "roles") and any(role.id in STAFF_ROLES for role in message.author.roles): + if hasattr(message.author, "roles") and any(role.id in Filter.role_whitelist for role in message.author.roles): return embed = Embed() diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 4964283f1..af8528a68 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -15,7 +15,6 @@ from bot.constants import ( AntiSpam as AntiSpamConfig, Channels, Colours, DEBUG_MODE, Event, Filter, Guild as GuildConfig, Icons, - STAFF_ROLES, ) from bot.converters import Duration from bot.exts.moderation.modlog import ModLog @@ -149,7 +148,7 @@ class AntiSpam(Cog): or message.guild.id != GuildConfig.id or message.author.bot or (message.channel.id in Filter.channel_whitelist and not DEBUG_MODE) - or (any(role.id in STAFF_ROLES for role in message.author.roles) and not DEBUG_MODE) + or (any(role.id in Filter.role_whitelist for role in message.author.roles) and not DEBUG_MODE) ): return -- cgit v1.2.3 From 7c5c8fa776e351263ecf6aa24f3d69570443b622 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 17 Oct 2020 16:01:28 +0300 Subject: Centralize moderation channel checks --- bot/exts/info/information.py | 9 ++------- bot/exts/moderation/infraction/_scheduler.py | 5 +++-- bot/exts/moderation/infraction/management.py | 10 ++-------- bot/utils/channel.py | 10 +++++++++- config-default.yml | 4 ++-- 5 files changed, 18 insertions(+), 20 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 0f50138e7..2d9cab94b 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -14,6 +14,7 @@ from bot import constants from bot.bot import Bot from bot.decorators import in_whitelist from bot.pagination import LinePaginator +from bot.utils.channel import is_mod_channel from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check from bot.utils.time import time_since @@ -241,14 +242,8 @@ class Information(Cog): ), ] - # Use getattr to future-proof for commands invoked via DMs. - show_verbose = ( - ctx.channel.id in constants.MODERATION_CHANNELS - or getattr(ctx.channel, "category_id", None) == constants.Categories.modmail - ) - # Show more verbose output in moderation channels for infractions and nominations - if show_verbose: + if is_mod_channel(ctx.channel): fields.append(await self.expanded_user_infraction_counts(user)) fields.append(await self.user_nomination_counts(user)) else: diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 814b17830..12d831453 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -12,11 +12,12 @@ from discord.ext.commands import Context from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Colours, MODERATION_CHANNELS +from bot.constants import Colours from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._utils import UserSnowflake from bot.exts.moderation.modlog import ModLog from bot.utils import messages, scheduling, time +from bot.utils.channel import is_mod_channel log = logging.getLogger(__name__) @@ -136,7 +137,7 @@ class InfractionScheduler: ) if reason: end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" - elif ctx.channel.id not in MODERATION_CHANNELS: + elif not is_mod_channel(ctx.channel): log.trace( f"Infraction #{id_} context is not in a mod channel; omitting infraction count." ) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index cdab1a6c7..394f63da3 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -15,7 +15,7 @@ from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator from bot.utils import messages, time -from bot.utils.checks import in_whitelist_check +from bot.utils.channel import is_mod_channel log = logging.getLogger(__name__) @@ -295,13 +295,7 @@ class ModManagement(commands.Cog): """Only allow moderators inside moderator channels to invoke the commands in this cog.""" checks = [ await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx), - in_whitelist_check( - ctx, - channels=constants.MODERATION_CHANNELS, - categories=[constants.Categories.modmail], - redirect=None, - fail_silently=True, - ) + is_mod_channel(ctx.channel) ] return all(checks) diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 851f9e1fe..d55faab57 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -2,7 +2,7 @@ import logging import discord -from bot.constants import Categories +from bot.constants import Categories, MODERATION_CHANNELS log = logging.getLogger(__name__) @@ -15,6 +15,14 @@ def is_help_channel(channel: discord.TextChannel) -> bool: return any(is_in_category(channel, category) for category in categories) +def is_mod_channel(channel: discord.TextChannel) -> bool: + """Return True if `channel` is one of the moderation channels or in one of the moderation categories.""" + log.trace(f"Checking if #{channel} is a mod channel.") + categories = (Categories.modmail, Categories.logs) + + return channel.id in MODERATION_CHANNELS or any(is_in_category(channel, category) for category in categories) + + def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: """Return True if `channel` is within a category with `category_id`.""" return getattr(channel, "category_id", None) == category_id diff --git a/config-default.yml b/config-default.yml index c93ab9e0c..12f6582ec 100644 --- a/config-default.yml +++ b/config-default.yml @@ -129,6 +129,7 @@ guild: help_in_use: 696958401460043776 help_dormant: 691405908919451718 modmail: 714494672835444826 + logs: 468520609152892958 channels: # Public announcement and news channels @@ -179,7 +180,7 @@ guild: incidents: 714214212200562749 incidents_archive: 720668923636351037 mods: &MODS 305126844661760000 - mod_alerts: &MOD_ALERTS 473092532147060736 + mod_alerts: 473092532147060736 mod_spam: &MOD_SPAM 620607373828030464 organisation: &ORGANISATION 551789653284356126 staff_lounge: &STAFF_LOUNGE 464905259261755392 @@ -202,7 +203,6 @@ guild: moderation_channels: - *ADMINS - *ADMIN_SPAM - - *MOD_ALERTS - *MODS - *MOD_SPAM -- cgit v1.2.3 From 1a330209ca81336b964dce6d6f711f6e127b5d73 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 17 Oct 2020 18:02:21 +0300 Subject: Amended to work with current tests --- bot/utils/channel.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/utils/channel.py b/bot/utils/channel.py index d55faab57..615698cab 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -2,7 +2,8 @@ import logging import discord -from bot.constants import Categories, MODERATION_CHANNELS +from bot import constants +from bot.constants import Categories log = logging.getLogger(__name__) @@ -20,7 +21,8 @@ def is_mod_channel(channel: discord.TextChannel) -> bool: log.trace(f"Checking if #{channel} is a mod channel.") categories = (Categories.modmail, Categories.logs) - return channel.id in MODERATION_CHANNELS or any(is_in_category(channel, category) for category in categories) + return channel.id in constants.MODERATION_CHANNELS \ + or any(is_in_category(channel, category) for category in categories) def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: -- cgit v1.2.3 From db771de1122d4f60e4531fd8538cdfb7ffeb849a Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 17 Oct 2020 19:15:30 +0300 Subject: Fixed style and linting --- bot/utils/channel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 615698cab..487794c59 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -21,8 +21,8 @@ def is_mod_channel(channel: discord.TextChannel) -> bool: log.trace(f"Checking if #{channel} is a mod channel.") categories = (Categories.modmail, Categories.logs) - return channel.id in constants.MODERATION_CHANNELS \ - or any(is_in_category(channel, category) for category in categories) + return (channel.id in constants.MODERATION_CHANNELS + or any(is_in_category(channel, category) for category in categories)) def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: -- cgit v1.2.3 From 39ab2d8a2b00793ccf3ba51f21ece771624e24e0 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 17 Oct 2020 19:16:34 +0200 Subject: Allow !eval in #code-help-voice-2 --- bot/constants.py | 1 + bot/exts/utils/snekbox.py | 2 +- config-default.yml | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index 0a3e48616..99584ab6c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -392,6 +392,7 @@ class Channels(metaclass=YAMLGetter): bot_commands: int change_log: int code_help_voice: int + code_help_voice_2: int cooldown: int defcon: int dev_contrib: int diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index 59a27a2be..cad451571 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -41,7 +41,7 @@ RAW_CODE_REGEX = re.compile( MAX_PASTE_LEN = 10000 # `!eval` command whitelists -EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric, Channels.code_help_voice) +EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric, Channels.code_help_voice, Channels.code_help_voice_2) EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use) EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) diff --git a/config-default.yml b/config-default.yml index c93ab9e0c..fd96ff2c6 100644 --- a/config-default.yml +++ b/config-default.yml @@ -192,6 +192,7 @@ guild: # Voice code_help_voice: 755154969761677312 + code_help_voice_2: 766330079135268884 admins_voice: &ADMINS_VOICE 500734494840717332 staff_voice: &STAFF_VOICE 412375055910043655 -- cgit v1.2.3 From e214f6e6cd0770625cd9a102b1d14a3772990534 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 18 Oct 2020 00:10:09 +0300 Subject: Added moderation categories section to config --- bot/constants.py | 4 ++++ bot/utils/channel.py | 7 ++++--- config-default.yml | 8 ++++++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 0a3e48616..2e6c84fc7 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -468,6 +468,7 @@ class Guild(metaclass=YAMLGetter): id: int invite: str # Discord invite, gets embedded in chat moderation_channels: List[int] + moderation_categories: List[int] moderation_roles: List[int] modlog_blacklist: List[int] reminder_whitelist: List[int] @@ -628,6 +629,9 @@ STAFF_ROLES = Guild.staff_roles # Channel combinations MODERATION_CHANNELS = Guild.moderation_channels +# Category combinations +MODERATION_CATEGORIES = Guild.moderation_categories + # Bot replies NEGATIVE_REPLIES = [ "Noooooo!!", diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 487794c59..1e67d1a9b 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -19,10 +19,11 @@ def is_help_channel(channel: discord.TextChannel) -> bool: def is_mod_channel(channel: discord.TextChannel) -> bool: """Return True if `channel` is one of the moderation channels or in one of the moderation categories.""" log.trace(f"Checking if #{channel} is a mod channel.") - categories = (Categories.modmail, Categories.logs) - return (channel.id in constants.MODERATION_CHANNELS - or any(is_in_category(channel, category) for category in categories)) + return ( + channel.id in constants.MODERATION_CHANNELS + or any(is_in_category(channel, category) for category in constants.MODERATION_CATEGORIES) + ) def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: diff --git a/config-default.yml b/config-default.yml index 12f6582ec..baa5c783a 100644 --- a/config-default.yml +++ b/config-default.yml @@ -128,8 +128,8 @@ guild: help_available: 691405807388196926 help_in_use: 696958401460043776 help_dormant: 691405908919451718 - modmail: 714494672835444826 - logs: 468520609152892958 + modmail: &MODMAIL 714494672835444826 + logs: &LOGS 468520609152892958 channels: # Public announcement and news channels @@ -200,6 +200,10 @@ guild: big_brother_logs: &BB_LOGS 468507907357409333 talent_pool: &TALENT_POOL 534321732593647616 + moderation_categories: + - *MODMAIL + - *LOGS + moderation_channels: - *ADMINS - *ADMIN_SPAM -- cgit v1.2.3 From df6f1f39ccd43314218e84a8e242e1f4414c7ea4 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 18 Oct 2020 00:12:38 +0300 Subject: Improved logging in is_mod_channel --- bot/exts/moderation/infraction/_scheduler.py | 6 +----- bot/utils/channel.py | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 12d831453..7f18017ac 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -137,11 +137,7 @@ class InfractionScheduler: ) if reason: end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" - elif not is_mod_channel(ctx.channel): - log.trace( - f"Infraction #{id_} context is not in a mod channel; omitting infraction count." - ) - else: + elif is_mod_channel(ctx.channel): log.trace(f"Fetching total infraction count for {user}.") infractions = await self.bot.api_client.get( diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 1e67d1a9b..6bf70bfde 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -17,13 +17,18 @@ def is_help_channel(channel: discord.TextChannel) -> bool: def is_mod_channel(channel: discord.TextChannel) -> bool: - """Return True if `channel` is one of the moderation channels or in one of the moderation categories.""" - log.trace(f"Checking if #{channel} is a mod channel.") - - return ( - channel.id in constants.MODERATION_CHANNELS - or any(is_in_category(channel, category) for category in constants.MODERATION_CATEGORIES) - ) + """True if `channel` is considered a mod channel.""" + if channel.id in constants.MODERATION_CHANNELS: + log.trace(f"Channel #{channel} is a configured mod channel") + return True + + elif any(is_in_category(channel, category) for category in constants.MODERATION_CATEGORIES): + log.trace(f"Channel #{channel} is in a configured mod category") + return True + + else: + log.trace(f"Channel #{channel} is not a mod channel") + return False def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: -- cgit v1.2.3 From c9ffb11c440482de3cb9c46c746d213e974ea754 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:06:17 +0300 Subject: Refactor PEP error embed sending --- bot/exts/utils/utils.py | 47 ++++++++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 558d0cf72..e134a0994 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -220,16 +220,18 @@ class Utils(Cog): # Handle PEP 0 directly because it's not in .rst or .txt so it can't be accessed like other PEPs. if pep_number == 0: pep_embed = self.get_pep_zero_embed() + success = True else: - if not await self.validate_pep_number(ctx, pep_number): - return + success = False + if not (pep_embed := await self.validate_pep_number(pep_number)): + pep_embed, success = await self.get_pep_embed(pep_number) - pep_embed = await self.get_pep_embed(ctx, pep_number) - - if pep_embed: - await ctx.send(embed=pep_embed) + await ctx.send(embed=pep_embed) + if success: log.trace(f"PEP {pep_number} getting and sending finished successfully. Increasing stat.") self.bot.stats.incr(f"pep_fetches.{pep_number}") + else: + log.trace(f"Getting PEP {pep_number} failed. Error embed sent.") @staticmethod def get_pep_zero_embed() -> Embed: @@ -245,8 +247,8 @@ class Utils(Cog): return pep_embed - async def validate_pep_number(self, ctx: Context, pep_nr: int) -> bool: - """Validate is PEP number valid. When it isn't, send error and return False. Otherwise return True.""" + async def validate_pep_number(self, pep_nr: int) -> Optional[Embed]: + """Validate is PEP number valid. When it isn't, return error embed, otherwise None.""" if ( pep_nr not in self.peps and (self.last_refreshed_peps + timedelta(minutes=30)) <= datetime.now() @@ -256,11 +258,13 @@ class Utils(Cog): if pep_nr not in self.peps: log.trace(f"PEP {pep_nr} was not found") - not_found = f"PEP {pep_nr} does not exist." - await self.send_pep_error_embed(ctx, "PEP not found", not_found) - return False + return Embed( + title="PEP not found", + description=f"PEP {pep_nr} does not exist.", + colour=Colour.red() + ) - return True + return None def generate_pep_embed(self, pep_header: Dict, pep_nr: int) -> Embed: """Generate PEP embed based on PEP headers data.""" @@ -283,8 +287,8 @@ class Utils(Cog): return pep_embed @pep_cache(arg_offset=2) - async def get_pep_embed(self, ctx: Context, pep_nr: int) -> Optional[Embed]: - """Fetch, generate and return PEP embed. When any error occur, use `self.send_pep_error_embed`.""" + async def get_pep_embed(self, pep_nr: int) -> Tuple[Embed, bool]: + """Fetch, generate and return PEP embed. Second item of return tuple show does getting success.""" response = await self.bot.http_session.get(self.peps[pep_nr]) if response.status == 200: @@ -293,19 +297,16 @@ class Utils(Cog): # Taken from https://github.com/python/peps/blob/master/pep0/pep.py#L179 pep_header = HeaderParser().parse(StringIO(pep_content)) - return self.generate_pep_embed(pep_header, pep_nr) + return self.generate_pep_embed(pep_header, pep_nr), True else: log.trace( f"The user requested PEP {pep_nr}, but the response had an unexpected status code: {response.status}." ) - error_message = "Unexpected HTTP error during PEP search. Please let us know." - return await self.send_pep_error_embed(ctx, "Unexpected error", error_message) - - @staticmethod - async def send_pep_error_embed(ctx: Context, title: str, description: str) -> None: - """Send error PEP embed with `ctx.send`.""" - embed = Embed(title=title, description=description, colour=Colour.red()) - await ctx.send(embed=embed) + return Embed( + title="Unexpected error", + description="Unexpected HTTP error during PEP search. Please let us know.", + colour=Colour.red() + ), False # endregion -- cgit v1.2.3 From 456a6fbf76baf7cfdcbd21864c0d410297acc1ff Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:10:54 +0300 Subject: Fix argument offset --- bot/exts/utils/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index e134a0994..6d8d98695 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -286,7 +286,7 @@ class Utils(Cog): return pep_embed - @pep_cache(arg_offset=2) + @pep_cache(arg_offset=1) async def get_pep_embed(self, pep_nr: int) -> Tuple[Embed, bool]: """Fetch, generate and return PEP embed. Second item of return tuple show does getting success.""" response = await self.bot.http_session.get(self.peps[pep_nr]) -- cgit v1.2.3 From bdd4cceccb7e0d8cfbe5ec60937c416ce6f0fb0e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:21:56 +0300 Subject: Remove unnecessary logging about user not found Co-authored-by: Joe Banks --- bot/exts/moderation/infraction/infractions.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 0dab3a72e..93fa16242 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -443,8 +443,7 @@ class Infractions(InfractionScheduler, commands.Cog): log_text["Member"] = format_user(user) log_text["DM"] = "Sent" if notified else "**Failed**" else: - log.info(f"Failed to remove Voice Ban from user {user_id}: user not found") - log_text["Failure"] = "User was not found in the guild." + log_text["Info"] = "User was not found in the guild." return log_text -- cgit v1.2.3 From 3c2ad44a0bb0cd9cf39677da4bf8128bef387379 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:22:23 +0300 Subject: Fix grammar of voice ban pardoning message Co-authored-by: Joe Banks --- bot/exts/moderation/infraction/infractions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 93fa16242..a5eb720ab 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -435,8 +435,8 @@ class Infractions(InfractionScheduler, commands.Cog): # DM user about infraction expiration notified = await _utils.notify_pardon( user=user, - title="Your Voice Ban have been removed", - content="You can now speak again in voice channels.", + title="Voice ban pardoned", + content="You can now verify yourself for voice access again.", icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] ) -- cgit v1.2.3 From bfd740be8cc0368df38a24906df592aa8f27c4e6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:23:44 +0300 Subject: Fix name and aliases of voice ban command Co-authored-by: Joe Banks --- bot/exts/moderation/infraction/infractions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index a5eb720ab..2ccb1ca97 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -89,8 +89,8 @@ class Infractions(InfractionScheduler, commands.Cog): """ await self.apply_ban(ctx, user, reason, max(min(purge_days, 7), 0)) - @command(aliases=('vban', 'voiceban')) - async def voice_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str]) -> None: + @command(aliases=('vban',)) + async def voiceban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str]) -> None: """Permanently ban user from using voice channels.""" await self.apply_voice_ban(ctx, user, reason) -- cgit v1.2.3 From 29e20171a73990314161e6030f7f884e0f61a122 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:24:27 +0300 Subject: Fix grammar of voice verifing message Co-authored-by: Joe Banks --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 8f2b51dbb..f487c41b2 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -93,7 +93,7 @@ class VoiceGate(Cog): self.mod_log.ignore(Event.member_update, ctx.author.id) await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") await ctx.author.send( - ":tada: Congratulations! You are now Voice Verified and have access to PyDis Voice Channels." + ":tada: Congratulations! You have been granted permission to use voice channels in Python Discord." ) self.bot.stats.incr("voice_gate.passed") -- cgit v1.2.3 From a8b3d0c8c20364ca9737520ffe8e6a6ce649ad5a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:27:39 +0300 Subject: Give user free pass when user don't have verified time in metricity --- bot/exts/moderation/voice_gate.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index f487c41b2..bdf7857f0 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -58,7 +58,10 @@ class VoiceGate(Cog): return # Pre-parse this for better code style - data["verified_at"] = parser.isoparse(data["verified_at"]) + if data["verified_at"] is not None: + data["verified_at"] = parser.isoparse(data["verified_at"]) + else: + data["verified_at"] = datetime.now() - timedelta(days=3) failed = False failed_reasons = [] -- cgit v1.2.3 From ea58222a5cfce295392bd5998b5968df89ddfeea Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:29:34 +0300 Subject: Don't add Voice Verified role automatically back --- bot/exts/moderation/infraction/infractions.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 2ccb1ca97..fc01eee9e 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -428,10 +428,6 @@ class Infractions(InfractionScheduler, commands.Cog): log_text = {} if user: - # Add Voice Verified role back to user. - self.mod_log.ignore(Event.member_update, user.id) - await user.add_roles(self._voice_verified_role, reason=reason) - # DM user about infraction expiration notified = await _utils.notify_pardon( user=user, -- cgit v1.2.3 From 5d732c97daccece1fe7945d92b426be76ee02ea0 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:31:36 +0300 Subject: Fix user not found info field test --- tests/bot/exts/moderation/infraction/test_infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index caa42ba3d..b666e1f85 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -176,7 +176,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): """Should include info to return dict when user was not found from guild.""" self.guild.get_member.return_value = None result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") - self.assertEqual(result, {"Failure": "User was not found in the guild."}) + self.assertEqual(result, {"Info": "User was not found in the guild."}) @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") @patch("bot.exts.moderation.infraction.infractions.format_user") -- cgit v1.2.3 From 77effb0bdf167020f4733b8d8e2bf980a4016f52 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:36:53 +0300 Subject: Update tests to not automatically adding back verified after vban expire --- tests/bot/exts/moderation/infraction/test_infractions.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index b666e1f85..f2617cf59 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -182,7 +182,6 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): @patch("bot.exts.moderation.infraction.infractions.format_user") async def test_voice_unban_user_found(self, format_user_mock, notify_pardon_mock): """Should add role back with ignoring, notify user and return log dictionary..""" - self.cog.mod_log.ignore = MagicMock() self.guild.get_member.return_value = self.user notify_pardon_mock.return_value = True format_user_mock.return_value = "my-user" @@ -192,8 +191,6 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): "Member": "my-user", "DM": "Sent" }) - self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) - self.user.add_roles.assert_awaited_once_with(self.cog._voice_verified_role, reason="foobar") notify_pardon_mock.assert_awaited_once() @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") -- cgit v1.2.3 From b72963930a6d8d28c794c5973efbb83def39a281 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 10:04:36 +0300 Subject: Use embeds instead of normal messages and send to DM instead --- bot/exts/moderation/voice_gate.py | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index bdf7857f0..4a7c66278 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta import discord from dateutil import parser +from discord import Colour from discord.ext.commands import Cog, Context, command from bot.api import ResponseCodeError @@ -46,15 +47,28 @@ class VoiceGate(Cog): - You must have accepted our rules over a certain number of days ago - You must not be actively banned from using our voice channels """ + # Send this as first thing in order to return after sending DM + await ctx.send("Check your DMs for result.") + try: data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") except ResponseCodeError as e: if e.status == 404: - await ctx.send(f":x: {ctx.author.mention} Unable to find Metricity data about you.") + embed = discord.Embed( + title="Not found", + description=f"{ctx.author.mention} Unable to find Metricity data about you.", + color=Colour.red() + ) log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})") else: - log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} metricity data.") - await ctx.send(":x: Got unexpected response from site. Please let us know about this.") + embed = discord.Embed( + title="Unexpected response", + description="Got unexpected response from site. Please let us know about this.", + color=Colour.red() + ) + log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} Metricity data.") + + await ctx.author.send(embed=embed) return # Pre-parse this for better code style @@ -85,19 +99,22 @@ class VoiceGate(Cog): else: reasons = failed_reasons[0] - await ctx.send( - FAILED_MESSAGE.format( - user=ctx.author.mention, - reasons=reasons - ) + embed = discord.Embed( + title="Voice Gate not passed", + description=FAILED_MESSAGE.format(user=ctx.author.mention, reasons=reasons), + color=Colour.red() ) + await ctx.author.send(embed=embed) return self.mod_log.ignore(Event.member_update, ctx.author.id) await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") - await ctx.author.send( - ":tada: Congratulations! You have been granted permission to use voice channels in Python Discord." + embed = discord.Embed( + title="Congratulations", + description="You have been granted permission to use voice channels in Python Discord.", + color=Colour.green() ) + await ctx.author.send(embed=embed) self.bot.stats.incr("voice_gate.passed") @Cog.listener() -- cgit v1.2.3 From 61206175591841d7ffed7b202c1bcf81d2b9ba99 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 10:04:49 +0300 Subject: Fix voice ban command name in test --- tests/bot/exts/moderation/infraction/test_infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index f2617cf59..5dbbb8e00 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -71,7 +71,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): async def test_permanent_voice_ban(self): """Should call voice ban applying function without expiry.""" self.cog.apply_voice_ban = AsyncMock() - self.assertIsNone(await self.cog.voice_ban(self.cog, self.ctx, self.user, reason="foobar")) + self.assertIsNone(await self.cog.voiceban(self.cog, self.ctx, self.user, reason="foobar")) self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar") async def test_temporary_voice_ban(self): -- cgit v1.2.3 From 152d105715fcd9843362b09c582773191bf2af9c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 10:14:23 +0300 Subject: Rework how voice gate do checks --- bot/exts/moderation/voice_gate.py | 42 +++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 4a7c66278..c367510ad 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -9,20 +9,21 @@ from discord.ext.commands import Cog, Context, command from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Event, MODERATION_ROLES, Roles, VoiceGate as VoiceGateConf +from bot.constants import Channels, Event, MODERATION_ROLES, Roles, VoiceGate as GateConf from bot.decorators import has_no_roles, in_whitelist from bot.exts.moderation.modlog import ModLog from bot.utils.checks import InWhitelistCheckFailure log = logging.getLogger(__name__) -# Messages for case when user don't meet with requirements -NOT_ENOUGH_MESSAGES = f"haven't sent at least {VoiceGateConf.minimum_messages} messages" -NOT_ENOUGH_DAYS_AFTER_VERIFICATION = f"haven't been verified for at least {VoiceGateConf.minimum_days_verified} days" -VOICE_BANNED = "are voice banned" - FAILED_MESSAGE = """{user} you don't meet with our current requirements to pass Voice Gate. You {reasons}.""" +MESSAGE_FIELD_MAP = { + "verified_at": f"haven't been verified for at least {GateConf.minimum_days_verified} days", + "voice_banned": "are voice banned", + "total_messages": f"haven't sent at least {GateConf.minimum_messages} messages", +} + class VoiceGate(Cog): """Voice channels verification management.""" @@ -75,23 +76,16 @@ class VoiceGate(Cog): if data["verified_at"] is not None: data["verified_at"] = parser.isoparse(data["verified_at"]) else: - data["verified_at"] = datetime.now() - timedelta(days=3) - - failed = False - failed_reasons = [] - - if data["verified_at"] > datetime.utcnow() - timedelta(days=VoiceGateConf.minimum_days_verified): - failed_reasons.append(NOT_ENOUGH_DAYS_AFTER_VERIFICATION) - failed = True - self.bot.stats.incr("voice_gate.failed.verified_at") - if data["total_messages"] < VoiceGateConf.minimum_messages: - failed_reasons.append(NOT_ENOUGH_MESSAGES) - failed = True - self.bot.stats.incr("voice_gate.failed.total_messages") - if data["voice_banned"]: - failed_reasons.append(VOICE_BANNED) - failed = True - self.bot.stats.incr("voice_gate.failed.voice_banned") + data["verified_at"] = datetime.utcnow() - timedelta(days=3) + + checks = { + "verified_at": data["verified_at"] > datetime.utcnow() - timedelta(days=GateConf.minimum_days_verified), + "total_messages": data["total_messages"] < GateConf.minimum_messages, + "voice_banned": data["voice_banned"] + } + failed = any(checks.values()) + failed_reasons = [MESSAGE_FIELD_MAP[key] for key, value in checks.items() if value is True] + [self.bot.stats.incr(f"voice_gate.failed.{key}") for key, value in checks.items() if value is True] if failed: if len(failed_reasons) > 1: @@ -130,7 +124,7 @@ class VoiceGate(Cog): # When it's bot sent message, delete it after some time if message.author.bot: with suppress(discord.NotFound): - await message.delete(delay=VoiceGateConf.bot_message_delete_delay) + await message.delete(delay=GateConf.bot_message_delete_delay) return # Then check is member moderator+, because we don't want to delete their messages. -- cgit v1.2.3 From ee241f5c3b87cfe576351f9baeed54c4f30147db Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 10:16:28 +0300 Subject: Change message that say to user that he get response to DM --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index c367510ad..05a3b31de 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -49,7 +49,7 @@ class VoiceGate(Cog): - You must not be actively banned from using our voice channels """ # Send this as first thing in order to return after sending DM - await ctx.send("Check your DMs for result.") + await ctx.send("You will get response to DM.") try: data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") -- cgit v1.2.3 From edc099882df1cbb792e005ce36ec36d974a938ff Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 14:45:22 +0300 Subject: Fix grammar and wording of Voice Gate + Voice Ban Co-authored-by: Joe Banks --- bot/exts/moderation/infraction/infractions.py | 4 ++-- bot/exts/moderation/voice_gate.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index fc01eee9e..f2ca6a763 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -431,8 +431,8 @@ class Infractions(InfractionScheduler, commands.Cog): # DM user about infraction expiration notified = await _utils.notify_pardon( user=user, - title="Voice ban pardoned", - content="You can now verify yourself for voice access again.", + title="Voice ban ended", + content="You can verify yourself for voice access again.", icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] ) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 05a3b31de..639642068 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -21,7 +21,7 @@ FAILED_MESSAGE = """{user} you don't meet with our current requirements to pass MESSAGE_FIELD_MAP = { "verified_at": f"haven't been verified for at least {GateConf.minimum_days_verified} days", "voice_banned": "are voice banned", - "total_messages": f"haven't sent at least {GateConf.minimum_messages} messages", + "total_messages": f"have sent less than {GateConf.minimum_messages} messages", } @@ -49,7 +49,7 @@ class VoiceGate(Cog): - You must not be actively banned from using our voice channels """ # Send this as first thing in order to return after sending DM - await ctx.send("You will get response to DM.") + await ctx.send(f"{ctx.author.mention}, check your DMs.") try: data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") @@ -57,14 +57,14 @@ class VoiceGate(Cog): if e.status == 404: embed = discord.Embed( title="Not found", - description=f"{ctx.author.mention} Unable to find Metricity data about you.", + description=f"We were unable to find user data for you. Please try again shortly, if this problem persists please contact the server staff through Modmail.", color=Colour.red() ) log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})") else: embed = discord.Embed( title="Unexpected response", - description="Got unexpected response from site. Please let us know about this.", + description="We encountered an error while attempting to find data for your user. Please try again and let us know if the problem persists.", color=Colour.red() ) log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} Metricity data.") @@ -104,7 +104,7 @@ class VoiceGate(Cog): self.mod_log.ignore(Event.member_update, ctx.author.id) await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") embed = discord.Embed( - title="Congratulations", + title="Voice gate passed", description="You have been granted permission to use voice channels in Python Discord.", color=Colour.green() ) -- cgit v1.2.3 From 3c09836736711de1d25e270c643299e0290eb636 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 14:46:11 +0300 Subject: Remove checking does user have voice verified role for voice ban Co-authored-by: Joe Banks --- bot/exts/moderation/infraction/infractions.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index f2ca6a763..d41e6326e 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -359,10 +359,6 @@ class Infractions(InfractionScheduler, commands.Cog): @respect_role_hierarchy(member_arg=2) async def apply_voice_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: """Apply a voice ban infraction with kwargs passed to `post_infraction`.""" - if constants.Roles.voice_verified not in [role.id for role in user.roles]: - await ctx.send(":x: Can't apply Voice Ban to user who have not passed the Voice Gate.") - return - if await _utils.get_active_infraction(ctx, user, "voice_ban"): return -- cgit v1.2.3 From 00b2a7551a0ff7e758d546c18849474ca8ee173c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 15:10:20 +0300 Subject: Remove _ from infraction type when sending back result --- bot/exts/moderation/infraction/_scheduler.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index dba3f1513..bba80afaf 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -125,7 +125,7 @@ class InfractionScheduler: log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})") else: # Accordingly display whether the user was successfully notified via DM. - if await _utils.notify_infraction(user, infr_type, expiry, reason, icon): + if await _utils.notify_infraction(user, " ".join(infr_type.split("_")).title(), expiry, reason, icon): dm_result = ":incoming_envelope: " dm_log_text = "\nDM: Sent" @@ -166,7 +166,7 @@ class InfractionScheduler: log_content = ctx.author.mention log_title = "failed to apply" - log_msg = f"Failed to apply {infr_type} infraction #{id_} to {user}" + log_msg = f"Failed to apply {' '.join(infr_type.split('_'))} infraction #{id_} to {user}" if isinstance(e, discord.Forbidden): log.warning(f"{log_msg}: bot lacks permissions.") else: @@ -183,7 +183,7 @@ class InfractionScheduler: log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.") infr_message = "" else: - infr_message = f" **{infr_type}** to {user.mention}{expiry_msg}{end_msg}" + infr_message = f" **{' '.join(infr_type.split('_'))}** to {user.mention}{expiry_msg}{end_msg}" # Send a confirmation message to the invoking context. log.trace(f"Sending infraction #{id_} confirmation message.") @@ -195,7 +195,7 @@ class InfractionScheduler: await self.mod_log.send_log_message( icon_url=icon, colour=Colours.soft_red, - title=f"Infraction {log_title}: {infr_type}", + title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}", thumbnail=user.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" Member: {messages.format_user(user)} @@ -272,7 +272,7 @@ class InfractionScheduler: if send_msg: log.trace(f"Sending infraction #{id_} pardon confirmation message.") await ctx.send( - f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " + f"{dm_emoji}{confirm_msg} infraction **{' '.join(infr_type.split('_'))}** for {user.mention}. " f"{log_text.get('Failure', '')}" ) @@ -283,7 +283,7 @@ class InfractionScheduler: await self.mod_log.send_log_message( icon_url=_utils.INFRACTION_ICONS[infr_type][1], colour=Colours.soft_green, - title=f"Infraction {log_title}: {infr_type}", + title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}", thumbnail=user.avatar_url_as(static_format="png"), text="\n".join(f"{k}: {v}" for k, v in log_text.items()), footer=footer, -- cgit v1.2.3 From 07187bd53c28f5c837f3a90eb063efea39c0cc09 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 15:11:43 +0300 Subject: Fix grammar of fail messages of Voice Gate --- bot/exts/moderation/voice_gate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 639642068..325331999 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -19,8 +19,8 @@ log = logging.getLogger(__name__) FAILED_MESSAGE = """{user} you don't meet with our current requirements to pass Voice Gate. You {reasons}.""" MESSAGE_FIELD_MAP = { - "verified_at": f"haven't been verified for at least {GateConf.minimum_days_verified} days", - "voice_banned": "are voice banned", + "verified_at": f"have been verified for less {GateConf.minimum_days_verified} days", + "voice_banned": "have an active voice ban infraction", "total_messages": f"have sent less than {GateConf.minimum_messages} messages", } -- cgit v1.2.3 From 1ce453a13b37f8b4b42ab0b87e0cac242f3b9739 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 15:21:26 +0300 Subject: Update formatting of voice gate failing embed --- bot/exts/moderation/voice_gate.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 325331999..5516675d1 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -16,7 +16,9 @@ from bot.utils.checks import InWhitelistCheckFailure log = logging.getLogger(__name__) -FAILED_MESSAGE = """{user} you don't meet with our current requirements to pass Voice Gate. You {reasons}.""" +FAILED_MESSAGE = ( + """You are not currently eligible to use voice inside Python Discord for the following reasons:\n\n{reasons}""" +) MESSAGE_FIELD_MAP = { "verified_at": f"have been verified for less {GateConf.minimum_days_verified} days", @@ -88,14 +90,9 @@ class VoiceGate(Cog): [self.bot.stats.incr(f"voice_gate.failed.{key}") for key, value in checks.items() if value is True] if failed: - if len(failed_reasons) > 1: - reasons = f"{', '.join(failed_reasons[:-1])} and {failed_reasons[-1]}" - else: - reasons = failed_reasons[0] - embed = discord.Embed( title="Voice Gate not passed", - description=FAILED_MESSAGE.format(user=ctx.author.mention, reasons=reasons), + description=FAILED_MESSAGE.format(reasons="\n".join(f'- You {reason}.' for reason in failed_reasons)), color=Colour.red() ) await ctx.author.send(embed=embed) -- cgit v1.2.3 From 082a6c0ee67ef627e987d6f9f17f1886eedb2518 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 15:22:45 +0300 Subject: Use .title() instead of .capitalize() --- bot/exts/moderation/infraction/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index bff5fcf4c..d0dc3f0a1 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -155,7 +155,7 @@ async def notify_infraction( log.trace(f"Sending {user} a DM about their {infr_type} infraction.") text = INFRACTION_DESCRIPTION_TEMPLATE.format( - type=infr_type.capitalize(), + type=infr_type.title(), expires=expires_at or "N/A", reason=reason or "No reason provided." ) -- cgit v1.2.3 From db0251496884add226e66e0522f27521b1be1496 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 15:25:48 +0300 Subject: Fix too long lines for Voice Gate --- bot/exts/moderation/voice_gate.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 5516675d1..37db5dc87 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -59,14 +59,21 @@ class VoiceGate(Cog): if e.status == 404: embed = discord.Embed( title="Not found", - description=f"We were unable to find user data for you. Please try again shortly, if this problem persists please contact the server staff through Modmail.", + description=( + "We were unable to find user data for you. " + "Please try again shortly, " + "if this problem persists please contact the server staff through Modmail.", + ), color=Colour.red() ) log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})") else: embed = discord.Embed( title="Unexpected response", - description="We encountered an error while attempting to find data for your user. Please try again and let us know if the problem persists.", + description=( + "We encountered an error while attempting to find data for your user. " + "Please try again and let us know if the problem persists." + ), color=Colour.red() ) log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} Metricity data.") -- cgit v1.2.3 From e840f0f17d5f9cdfde9c610ef75224ca84fe52a4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 15:31:00 +0300 Subject: Remove test for case when user don't have VV for voice ban --- tests/bot/exts/moderation/infraction/test_infractions.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 5dbbb8e00..bf557a484 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -86,14 +86,6 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user)) self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user) - @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") - async def test_voice_ban_not_having_voice_verified_role(self, get_active_infraction_mock): - """Should send message and not apply infraction when user don't have voice verified role.""" - self.user.roles = [MockRole(id=987)] - self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) - self.ctx.send.assert_awaited_once() - get_active_infraction_mock.assert_not_awaited() - @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") async def test_voice_ban_user_have_active_infraction(self, get_active_infraction, post_infraction_mock): -- cgit v1.2.3 From f195d3d16b9ae4f66a4420f1d8bb7a004a90a7a6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 16:36:16 +0300 Subject: Remove too much aliases for voice verify command Co-authored-by: Joe Banks --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 37db5dc87..7c3c6e1b0 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -38,7 +38,7 @@ class VoiceGate(Cog): """Get the currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - @command(aliases=('voiceverify', 'vverify', 'voicev', 'vv')) + @command(aliases=('voiceverify',)) @has_no_roles(Roles.voice_verified) @in_whitelist(channels=(Channels.voice_gate,), redirect=None) async def voice_verify(self, ctx: Context, *_) -> None: -- cgit v1.2.3 From 905d90b27570831ad64d8e08cb8d0bc4d23c614e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 16:36:55 +0300 Subject: Use bullet points instead of - for voice verify failing reasons Co-authored-by: Joe Banks --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 7c3c6e1b0..70583655e 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -99,7 +99,7 @@ class VoiceGate(Cog): if failed: embed = discord.Embed( title="Voice Gate not passed", - description=FAILED_MESSAGE.format(reasons="\n".join(f'- You {reason}.' for reason in failed_reasons)), + description=FAILED_MESSAGE.format(reasons="\n".join(f'• You {reason}.' for reason in failed_reasons)), color=Colour.red() ) await ctx.author.send(embed=embed) -- cgit v1.2.3 From a04575ce7ba43623d79ad4ae5611a093a7a452c5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 16:37:25 +0300 Subject: Fix grammar of voice verification config comments Co-authored-by: Joe Banks --- config-default.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config-default.yml b/config-default.yml index 5060e48e0..c712d1eb7 100644 --- a/config-default.yml +++ b/config-default.yml @@ -511,8 +511,8 @@ verification: voice_gate: - minimum_days_verified: 3 # Days how much user have to be verified to pass Voice Gate - minimum_messages: 50 # How much messages user must have to pass Voice Gate + minimum_days_verified: 3 # How many days the user must have been verified for + minimum_messages: 50 # How many messages a user must have to be eligible for voice bot_message_delete_delay: 10 # Seconds before deleting bot's response in Voice Gate -- cgit v1.2.3 From 5154b39f8a2dab9531cdb90f27a62dfede49eed6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 16:37:53 +0300 Subject: Fix grammar of voice unban embed description Co-authored-by: Joe Banks --- bot/exts/moderation/infraction/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index d41e6326e..71d873667 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -428,7 +428,7 @@ class Infractions(InfractionScheduler, commands.Cog): notified = await _utils.notify_pardon( user=user, title="Voice ban ended", - content="You can verify yourself for voice access again.", + content="You have been unbanned and can verify yourself again in the server.", icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] ) -- cgit v1.2.3 From 216bdb0947e9fa8b494e03f3be0d85867453f41d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 16:38:21 +0300 Subject: Use "failed" instead "not passed" for feedback embed of voice gate fail Co-authored-by: Joe Banks --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 70583655e..7cadca153 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -98,7 +98,7 @@ class VoiceGate(Cog): if failed: embed = discord.Embed( - title="Voice Gate not passed", + title="Voice Gate failed", description=FAILED_MESSAGE.format(reasons="\n".join(f'• You {reason}.' for reason in failed_reasons)), color=Colour.red() ) -- cgit v1.2.3 From c49eb6597da7eb0e6973177e4e3e40730267cc11 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 19 Oct 2020 00:59:39 +1000 Subject: Send response in verification if DM fails. At the moment, the bot will attempt to DM the verification result for a member which is reliant on privacy settings allowing member DMs. This commit should add a suitable fallback of sending the response in the voice-verification channel instead. --- bot/exts/moderation/voice_gate.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 7cadca153..8b68b8e2d 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -102,7 +102,10 @@ class VoiceGate(Cog): description=FAILED_MESSAGE.format(reasons="\n".join(f'• You {reason}.' for reason in failed_reasons)), color=Colour.red() ) - await ctx.author.send(embed=embed) + try: + await ctx.author.send(embed=embed) + except discord.Forbidden: + await ctx.channel.send(ctx.author.mention, embed=embed) return self.mod_log.ignore(Event.member_update, ctx.author.id) @@ -112,7 +115,10 @@ class VoiceGate(Cog): description="You have been granted permission to use voice channels in Python Discord.", color=Colour.green() ) - await ctx.author.send(embed=embed) + try: + await ctx.author.send(embed=embed) + except discord.Forbidden: + await ctx.channel.send(ctx.author.mention, embed=embed) self.bot.stats.incr("voice_gate.passed") @Cog.listener() -- cgit v1.2.3 From 572679094288734bbbf9bac5dc59bbe1e7dad155 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 19 Oct 2020 01:15:20 +1000 Subject: Disconnect users on voiceban. On voiceban, a users effective permissions will change to not allow speaking, however this permission isn't effective until rejoining. To ensure a voiceban is immediately in effect, the user will be disconnected from any voice channels. --- bot/exts/moderation/infraction/infractions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 71d873667..746d4e154 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -371,6 +371,8 @@ class Infractions(InfractionScheduler, commands.Cog): if reason: reason = textwrap.shorten(reason, width=512, placeholder="...") + await user.move_to(None, reason="Disconnected from voice to apply voiceban.") + action = user.remove_roles(self._voice_verified_role, reason=reason) await self.apply_infraction(ctx, infraction, user, action) -- cgit v1.2.3 From 5cc01bc834e8b89b21546870989afb93b11aa554 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 19 Oct 2020 02:53:36 +1000 Subject: Ensure verified users can see verified message. When verified users get their role, they cannot see the voice-verification channel anymore, so I've added a 3 second delay for granting the role in order to ensure there's some time for them to see the response. I've also moved the DM message to only be sent if the DM message succeeds, and to not mention them in-channel to avoid distracting them from the DM notification unnecessarily, as I'm sure they'll see a near-instant response to their command usage in that channel. --- bot/exts/moderation/voice_gate.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 8b68b8e2d..f158c2906 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -1,3 +1,4 @@ +import asyncio import logging from contextlib import suppress from datetime import datetime, timedelta @@ -50,9 +51,6 @@ class VoiceGate(Cog): - You must have accepted our rules over a certain number of days ago - You must not be actively banned from using our voice channels """ - # Send this as first thing in order to return after sending DM - await ctx.send(f"{ctx.author.mention}, check your DMs.") - try: data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") except ResponseCodeError as e: @@ -104,12 +102,12 @@ class VoiceGate(Cog): ) try: await ctx.author.send(embed=embed) + await ctx.send(f"{ctx.author}, please check your DMs.") except discord.Forbidden: await ctx.channel.send(ctx.author.mention, embed=embed) return self.mod_log.ignore(Event.member_update, ctx.author.id) - await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") embed = discord.Embed( title="Voice gate passed", description="You have been granted permission to use voice channels in Python Discord.", @@ -117,8 +115,14 @@ class VoiceGate(Cog): ) try: await ctx.author.send(embed=embed) + await ctx.send(f"{ctx.author}, please check your DMs.") except discord.Forbidden: await ctx.channel.send(ctx.author.mention, embed=embed) + + # wait a little bit so those who don't get DMs see the response in-channel before losing perms to see it. + await asyncio.sleep(3) + await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") + self.bot.stats.incr("voice_gate.passed") @Cog.listener() -- cgit v1.2.3 From 7b8f85752098e5bfd77e033d8eddad3b8e5f2b40 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 19 Oct 2020 02:59:32 +1000 Subject: Address a grammar error in failed reasons. An overlooked grammatical error occurred in exactly 1 (one) of the possible failure reasons when being verified for the voice gate system. This was unacceptable to the masses, so a swift correction has been added to address it, adding 1 (one) additional word to the listed reason. --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index f158c2906..ee3ac4003 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -22,7 +22,7 @@ FAILED_MESSAGE = ( ) MESSAGE_FIELD_MAP = { - "verified_at": f"have been verified for less {GateConf.minimum_days_verified} days", + "verified_at": f"have been verified for less than {GateConf.minimum_days_verified} days", "voice_banned": "have an active voice ban infraction", "total_messages": f"have sent less than {GateConf.minimum_messages} messages", } -- cgit v1.2.3 From 0bf48fce994e51428d679605281a879d2abc9905 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 19 Oct 2020 03:13:12 +1000 Subject: Instruct to reconnect to voice channel if connected on verification. If a user is already connected to a voice channel at the time of getting verified through voice gate, they won't have their permissions actually apply to their current session. This change adds information on verifying so they know they must reconnect to have the changes apply. --- bot/exts/moderation/voice_gate.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index ee3ac4003..c2743e136 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -113,6 +113,10 @@ class VoiceGate(Cog): description="You have been granted permission to use voice channels in Python Discord.", color=Colour.green() ) + + if ctx.author.voice: + embed.description += "\n\nPlease reconnect to your voice channel to be granted your new permissions." + try: await ctx.author.send(embed=embed) await ctx.send(f"{ctx.author}, please check your DMs.") -- cgit v1.2.3 From 44ffb80ae132472c377a280218f839e4f9b21e47 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 19 Oct 2020 00:36:45 +0200 Subject: Set logging level for async-rediscache to warning --- bot/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/__init__.py b/bot/__init__.py index 3ee70c4e9..ce5b21fbf 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -64,6 +64,7 @@ coloredlogs.install(logger=root_log, stream=sys.stdout) logging.getLogger("discord").setLevel(logging.WARNING) logging.getLogger("websockets").setLevel(logging.WARNING) logging.getLogger("chardet").setLevel(logging.WARNING) +logging.getLogger("async_rediscache").setLevel(logging.WARNING) logging.getLogger(__name__) -- cgit v1.2.3 From dc2797bc7d24d80c08c559bc381c465db1312143 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 18 Oct 2020 18:12:29 -0700 Subject: Silence: rename function to reduce ambiguity --- bot/exts/moderation/silence.py | 4 ++-- tests/bot/exts/moderation/test_silence.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index cfdefe103..3bbf8d21a 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -107,7 +107,7 @@ class Silence(commands.Cog): channel_info = f"#{ctx.channel} ({ctx.channel.id})" log.debug(f"{ctx.author} is silencing channel {channel_info}.") - if not await self._silence_overwrites(ctx.channel): + if not await self._set_silence_overwrites(ctx.channel): log.info(f"Tried to silence channel {channel_info} but the channel was already silenced.") await ctx.send(MSG_SILENCE_FAIL) return @@ -144,7 +144,7 @@ class Silence(commands.Cog): else: await channel.send(MSG_UNSILENCE_SUCCESS) - async def _silence_overwrites(self, channel: TextChannel) -> bool: + async def _set_silence_overwrites(self, channel: TextChannel) -> bool: """Set silence permission overwrites for `channel` and return True if successful.""" overwrite = channel.overwrites_for(self._verified_role) prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 6d5ffa7e8..6b67a21a0 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -274,7 +274,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): ) for duration, message, was_silenced in test_cases: ctx = MockContext() - with mock.patch.object(self.cog, "_silence_overwrites", return_value=was_silenced): + with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=was_silenced): with self.subTest(was_silenced=was_silenced, message=message, duration=duration): await self.cog.silence.callback(self.cog, ctx, duration) ctx.send.assert_called_once_with(message) @@ -293,12 +293,12 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockTextChannel() channel.overwrites_for.return_value = overwrite - self.assertFalse(await self.cog._silence_overwrites(channel)) + self.assertFalse(await self.cog._set_silence_overwrites(channel)) channel.set_permissions.assert_not_called() async def test_silenced_channel(self): """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" - self.assertTrue(await self.cog._silence_overwrites(self.channel)) + self.assertTrue(await self.cog._set_silence_overwrites(self.channel)) self.assertFalse(self.overwrite.send_messages) self.assertFalse(self.overwrite.add_reactions) self.channel.set_permissions.assert_awaited_once_with( @@ -309,7 +309,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_preserved_other_overwrites(self): """Channel's other unrelated overwrites were not changed.""" prev_overwrite_dict = dict(self.overwrite) - await self.cog._silence_overwrites(self.channel) + await self.cog._set_silence_overwrites(self.channel) new_overwrite_dict = dict(self.overwrite) # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. @@ -322,26 +322,26 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_temp_not_added_to_notifier(self): """Channel was not added to notifier if a duration was set for the silence.""" - with mock.patch.object(self.cog, "_silence_overwrites", return_value=True): + with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True): await self.cog.silence.callback(self.cog, MockContext(), 15) self.cog.notifier.add_channel.assert_not_called() async def test_indefinite_added_to_notifier(self): """Channel was added to notifier if a duration was not set for the silence.""" - with mock.patch.object(self.cog, "_silence_overwrites", return_value=True): + with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True): await self.cog.silence.callback(self.cog, MockContext(), None) self.cog.notifier.add_channel.assert_called_once() async def test_silenced_not_added_to_notifier(self): """Channel was not added to the notifier if it was already silenced.""" - with mock.patch.object(self.cog, "_silence_overwrites", return_value=False): + with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=False): await self.cog.silence.callback(self.cog, MockContext(), 15) self.cog.notifier.add_channel.assert_not_called() async def test_cached_previous_overwrites(self): """Channel's previous overwrites were cached.""" overwrite_json = '{"send_messages": true, "add_reactions": false}' - await self.cog._silence_overwrites(self.channel) + await self.cog._set_silence_overwrites(self.channel) self.cog.previous_overwrites.set.assert_called_once_with(self.channel.id, overwrite_json) @autospec(silence, "datetime") -- cgit v1.2.3 From a03fe441e1082c93d268a7e6673ae9f0b3feba34 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 18 Oct 2020 18:31:30 -0700 Subject: Silence: add locks to commands --- bot/exts/moderation/silence.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 3bbf8d21a..e6712b3b6 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -2,6 +2,7 @@ import json import logging from contextlib import suppress from datetime import datetime, timedelta, timezone +from operator import attrgetter from typing import Optional from async_rediscache import RedisCache @@ -12,10 +13,13 @@ from discord.ext.commands import Context from bot.bot import Bot from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles from bot.converters import HushDurationConverter +from bot.utils.lock import LockedResourceError, lock_arg from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) +LOCK_NAMESPACE = "silence" + MSG_SILENCE_FAIL = f"{Emojis.cross_mark} current channel is already silenced." MSG_SILENCE_PERMANENT = f"{Emojis.check_mark} silenced current channel indefinitely." MSG_SILENCE_SUCCESS = f"{Emojis.check_mark} silenced current channel for {{duration}} minute(s)." @@ -95,6 +99,7 @@ class Silence(commands.Cog): await self._reschedule() @commands.command(aliases=("hush",)) + @lock_arg(LOCK_NAMESPACE, "ctx", attrgetter("channel"), raise_error=True) async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: """ Silence the current channel for `duration` minutes or `forever`. @@ -133,6 +138,7 @@ class Silence(commands.Cog): log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") await self._unsilence_wrapper(ctx.channel) + @lock_arg(LOCK_NAMESPACE, "channel", raise_error=True) async def _unsilence_wrapper(self, channel: TextChannel) -> None: """Unsilence `channel` and send a success/failure message.""" if not await self._unsilence(channel): @@ -222,7 +228,9 @@ class Silence(commands.Cog): dt = datetime.fromtimestamp(timestamp, tz=timezone.utc) delta = (dt - datetime.now(tz=timezone.utc)).total_seconds() if delta <= 0: - await self._unsilence_wrapper(channel) + # Suppress the error since it's not being invoked by a user via the command. + with suppress(LockedResourceError): + await self._unsilence_wrapper(channel) else: log.info(f"Rescheduling silence for #{channel} ({channel.id}).") self.scheduler.schedule_later(delta, channel_id, self._unsilence_wrapper(channel)) -- cgit v1.2.3 From 75efbf81cf403d1f03f9d3147a6493f08081f55b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 18 Oct 2020 18:32:32 -0700 Subject: Reminders: rename namespace constant It's better to have a self-documenting name than a comment, which, by the way, was using the old name for the decorator. --- bot/exts/utils/reminders.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index bf4e24661..3113a1149 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -23,7 +23,7 @@ from bot.utils.time import humanize_delta log = logging.getLogger(__name__) -NAMESPACE = "reminder" # Used for the mutually_exclusive decorator; constant to prevent typos +LOCK_NAMESPACE = "reminder" WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 @@ -170,7 +170,7 @@ class Reminders(Cog): log.trace(f"Scheduling new task #{reminder['id']}") self.schedule_reminder(reminder) - @lock_arg(NAMESPACE, "reminder", itemgetter("id"), raise_error=True) + @lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True) async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) @@ -378,7 +378,7 @@ class Reminders(Cog): mention_ids = [mention.id for mention in mentions] await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) - @lock_arg(NAMESPACE, "id_", raise_error=True) + @lock_arg(LOCK_NAMESPACE, "id_", raise_error=True) async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: """Edits a reminder with the given payload, then sends a confirmation message.""" if not await self._can_modify(ctx, id_): @@ -398,7 +398,7 @@ class Reminders(Cog): await self._reschedule_reminder(reminder) @remind_group.command("delete", aliases=("remove", "cancel")) - @lock_arg(NAMESPACE, "id_", raise_error=True) + @lock_arg(LOCK_NAMESPACE, "id_", raise_error=True) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" if not await self._can_modify(ctx, id_): -- cgit v1.2.3 From 4d1c20461d84613d6102b213fcc333ef12f168e7 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 21 Oct 2020 00:35:50 +0200 Subject: Remove unnecessary getLogger call --- bot/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/__init__.py b/bot/__init__.py index ce5b21fbf..4fce04532 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -65,7 +65,6 @@ logging.getLogger("discord").setLevel(logging.WARNING) logging.getLogger("websockets").setLevel(logging.WARNING) logging.getLogger("chardet").setLevel(logging.WARNING) logging.getLogger("async_rediscache").setLevel(logging.WARNING) -logging.getLogger(__name__) # On Windows, the selector event loop is required for aiodns. -- cgit v1.2.3 From 5be3f87751d4bf87c848f278050867ba45c442ec Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Wed, 21 Oct 2020 12:42:08 +0100 Subject: Relay python-dev to mailing lists channel --- config-default.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config-default.yml b/config-default.yml index 71d4419a7..98c5ff42c 100644 --- a/config-default.yml +++ b/config-default.yml @@ -498,6 +498,7 @@ python_news: - 'python-ideas' - 'python-announce-list' - 'pypi-announce' + - 'python-dev' channel: *PYNEWS_CHANNEL webhook: *PYNEWS_WEBHOOK -- cgit v1.2.3 From 50324b3ec0e0285300e4f4cf389fd93c4801f1ec Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 21 Oct 2020 09:46:33 -0700 Subject: Silence tests: update docstrings in notifier tests --- tests/bot/exts/moderation/test_silence.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 6b67a21a0..104293d8e 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -43,7 +43,7 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.notifier.start = self.notifier_start_mock = Mock() def test_add_channel_adds_channel(self): - """Channel in FirstHash with current loop is added to internal set.""" + """Channel is added to `_silenced_channels` with the current loop.""" channel = Mock() with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: self.notifier.add_channel(channel) @@ -61,7 +61,7 @@ class SilenceNotifierTests(unittest.IsolatedAsyncioTestCase): self.notifier_start_mock.assert_not_called() def test_remove_channel_removes_channel(self): - """Channel in FirstHash is removed from `_silenced_channels`.""" + """Channel is removed from `_silenced_channels`.""" channel = Mock() with mock.patch.object(self.notifier, "_silenced_channels") as silenced_channels: self.notifier.remove_channel(channel) -- cgit v1.2.3 From d2ba37a0f5d20aa0e0100b91fae97ef0f0d9714b Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 22 Oct 2020 02:39:55 +0300 Subject: Add handling of off-server users to user command --- bot/exts/info/information.py | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 2d9cab94b..60c88f375 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -6,7 +6,7 @@ from collections import Counter, defaultdict from string import Template from typing import Any, Mapping, Optional, Tuple, Union -from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils +from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, User, utils from discord.abc import GuildChannel from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role @@ -192,7 +192,7 @@ class Information(Cog): await ctx.send(embed=embed) @command(name="user", aliases=["user_info", "member", "member_info"]) - async def user_info(self, ctx: Context, user: Member = None) -> None: + async def user_info(self, ctx: Context, user: Union[Member, User] = None) -> None: """Returns info about a user.""" if user is None: user = ctx.author @@ -207,12 +207,14 @@ class Information(Cog): embed = await self.create_user_embed(ctx, user) await ctx.send(embed=embed) - async def create_user_embed(self, ctx: Context, user: Member) -> Embed: + async def create_user_embed(self, ctx: Context, user: Union[User, Member]) -> Embed: """Creates an embed containing information on the `user`.""" + on_server = bool(ctx.guild.get_member(user.id)) + created = time_since(user.created_at, max_units=3) name = str(user) - if user.nick: + if on_server and user.nick: name = f"{user.nick} ({name})" badges = [] @@ -221,8 +223,16 @@ class Information(Cog): if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)): badges.append(emoji) - joined = time_since(user.joined_at, max_units=3) - roles = ", ".join(role.mention for role in user.roles[1:]) + if on_server: + joined = time_since(user.joined_at, max_units=3) + roles = ", ".join(role.mention for role in user.roles[1:]) + membership = textwrap.dedent(f""" + Joined: {joined} + Roles: {roles or None} + """).strip() + else: + roles = None + membership = "The user is not a member of the server" fields = [ ( @@ -235,10 +245,7 @@ class Information(Cog): ), ( "Member information", - textwrap.dedent(f""" - Joined: {joined} - Roles: {roles or None} - """).strip() + membership ), ] @@ -263,13 +270,13 @@ class Information(Cog): return embed - async def basic_user_infraction_counts(self, member: Member) -> Tuple[str, str]: + async def basic_user_infraction_counts(self, user: Union[User, Member]) -> Tuple[str, str]: """Gets the total and active infraction counts for the given `member`.""" infractions = await self.bot.api_client.get( 'bot/infractions', params={ 'hidden': 'False', - 'user__id': str(member.id) + 'user__id': str(user.id) } ) @@ -280,7 +287,7 @@ class Information(Cog): return "Infractions", infraction_output - async def expanded_user_infraction_counts(self, member: Member) -> Tuple[str, str]: + async def expanded_user_infraction_counts(self, user: Union[User, Member]) -> Tuple[str, str]: """ Gets expanded infraction counts for the given `member`. @@ -290,7 +297,7 @@ class Information(Cog): infractions = await self.bot.api_client.get( 'bot/infractions', params={ - 'user__id': str(member.id) + 'user__id': str(user.id) } ) @@ -321,12 +328,12 @@ class Information(Cog): return "Infractions", "\n".join(infraction_output) - async def user_nomination_counts(self, member: Member) -> Tuple[str, str]: + async def user_nomination_counts(self, user: Union[User, Member]) -> Tuple[str, str]: """Gets the active and historical nomination counts for the given `member`.""" nominations = await self.bot.api_client.get( 'bot/nominations', params={ - 'user__id': str(member.id) + 'user__id': str(user.id) } ) -- cgit v1.2.3 From fe7a13c07844636c4182d6b32b0fda2f9e03c1a0 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 22 Oct 2020 03:52:54 +0300 Subject: Use FetchedMember for user annotation --- bot/exts/info/information.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 60c88f375..5aaf85e5a 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -6,12 +6,13 @@ from collections import Counter, defaultdict from string import Template from typing import Any, Mapping, Optional, Tuple, Union -from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, User, utils +from discord import ChannelType, Colour, Embed, Guild, Message, Role, Status, utils from discord.abc import GuildChannel from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role from bot import constants from bot.bot import Bot +from bot.converters import FetchedMember from bot.decorators import in_whitelist from bot.pagination import LinePaginator from bot.utils.channel import is_mod_channel @@ -192,7 +193,7 @@ class Information(Cog): await ctx.send(embed=embed) @command(name="user", aliases=["user_info", "member", "member_info"]) - async def user_info(self, ctx: Context, user: Union[Member, User] = None) -> None: + async def user_info(self, ctx: Context, user: FetchedMember = None) -> None: """Returns info about a user.""" if user is None: user = ctx.author @@ -207,7 +208,7 @@ class Information(Cog): embed = await self.create_user_embed(ctx, user) await ctx.send(embed=embed) - async def create_user_embed(self, ctx: Context, user: Union[User, Member]) -> Embed: + async def create_user_embed(self, ctx: Context, user: FetchedMember) -> Embed: """Creates an embed containing information on the `user`.""" on_server = bool(ctx.guild.get_member(user.id)) @@ -270,7 +271,7 @@ class Information(Cog): return embed - async def basic_user_infraction_counts(self, user: Union[User, Member]) -> Tuple[str, str]: + async def basic_user_infraction_counts(self, user: FetchedMember) -> Tuple[str, str]: """Gets the total and active infraction counts for the given `member`.""" infractions = await self.bot.api_client.get( 'bot/infractions', @@ -287,7 +288,7 @@ class Information(Cog): return "Infractions", infraction_output - async def expanded_user_infraction_counts(self, user: Union[User, Member]) -> Tuple[str, str]: + async def expanded_user_infraction_counts(self, user: FetchedMember) -> Tuple[str, str]: """ Gets expanded infraction counts for the given `member`. @@ -328,7 +329,7 @@ class Information(Cog): return "Infractions", "\n".join(infraction_output) - async def user_nomination_counts(self, user: Union[User, Member]) -> Tuple[str, str]: + async def user_nomination_counts(self, user: FetchedMember) -> Tuple[str, str]: """Gets the active and historical nomination counts for the given `member`.""" nominations = await self.bot.api_client.get( 'bot/nominations', -- cgit v1.2.3 From 8f575bd268675bfb7979ebd0aa6480ee195c18ab Mon Sep 17 00:00:00 2001 From: Dennis Pham Date: Thu, 22 Oct 2020 10:51:15 -0400 Subject: Update Python Discord badge to 100k members. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cae7c3454..b37ece296 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Python Utility Bot -[![Discord](https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E60k%20members&color=%237289DA&logoColor=white)](https://discord.gg/2B963hn) +[![Discord](https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E100k%20members&color=%237289DA&logoColor=white)](https://discord.gg/2B963hn) [![Build Status](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master)](https://dev.azure.com/python-discord/Python%20Discord/_build/latest?definitionId=1&branchName=master) [![Tests](https://img.shields.io/azure-devops/tests/python-discord/Python%20Discord/1?compact_message)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master) [![Coverage](https://img.shields.io/azure-devops/coverage/python-discord/Python%20Discord/1/master)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master) -- cgit v1.2.3 From aa06b099fc66ad87cbdb75d8f096ca189fd2a57f Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Thu, 22 Oct 2020 09:53:07 -0500 Subject: Added voice_chat to eval list Signed-off-by: Daniel Brown --- bot/constants.py | 1 + bot/exts/utils/snekbox.py | 8 +++++++- config-default.yml | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index b615dcd19..1bd6ef5e0 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -425,6 +425,7 @@ class Channels(metaclass=YAMLGetter): user_event_announcements: int user_log: int verification: int + voice_chat: int voice_gate: int voice_log: int diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index cad451571..8befb6fde 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -41,7 +41,13 @@ RAW_CODE_REGEX = re.compile( MAX_PASTE_LEN = 10000 # `!eval` command whitelists -EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric, Channels.code_help_voice, Channels.code_help_voice_2) +EVAL_CHANNELS = ( + Channels.bot_commands, + Channels.esoteric, + Channels.code_help_voice, + Channels.code_help_voice_2, + Channels.voice_chat +) EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use) EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) diff --git a/config-default.yml b/config-default.yml index 98c5ff42c..3eac3d171 100644 --- a/config-default.yml +++ b/config-default.yml @@ -195,6 +195,7 @@ guild: # Voice code_help_voice: 755154969761677312 code_help_voice_2: 766330079135268884 + voice-chat: 412357430186344448 admins_voice: &ADMINS_VOICE 500734494840717332 staff_voice: &STAFF_VOICE 412375055910043655 -- cgit v1.2.3 From 0a02ba8d61fa4cee2e38baf3c34d8cb76d7530b0 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Thu, 22 Oct 2020 10:14:47 -0500 Subject: Update config-default.yml Changing a hyphen to an underscore in the config Co-authored-by: kwzrd <44734341+kwzrd@users.noreply.github.com> --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 3eac3d171..3f4352153 100644 --- a/config-default.yml +++ b/config-default.yml @@ -195,7 +195,7 @@ guild: # Voice code_help_voice: 755154969761677312 code_help_voice_2: 766330079135268884 - voice-chat: 412357430186344448 + voice_chat: 412357430186344448 admins_voice: &ADMINS_VOICE 500734494840717332 staff_voice: &STAFF_VOICE 412375055910043655 -- cgit v1.2.3 From eb9777bb0563e02d46d1b6597a39e6e3a9131334 Mon Sep 17 00:00:00 2001 From: Daniel Brown Date: Thu, 22 Oct 2020 10:18:36 -0500 Subject: Swapped individual channel ids for category id. Signed-off-by: Daniel Brown --- bot/constants.py | 1 + bot/exts/utils/snekbox.py | 10 ++-------- config-default.yml | 1 + 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 1bd6ef5e0..23d5b4304 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -377,6 +377,7 @@ class Categories(metaclass=YAMLGetter): help_in_use: int help_dormant: int modmail: int + voice: int class Channels(metaclass=YAMLGetter): diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index 8befb6fde..213d57365 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -41,14 +41,8 @@ RAW_CODE_REGEX = re.compile( MAX_PASTE_LEN = 10000 # `!eval` command whitelists -EVAL_CHANNELS = ( - Channels.bot_commands, - Channels.esoteric, - Channels.code_help_voice, - Channels.code_help_voice_2, - Channels.voice_chat -) -EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use) +EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric) +EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use, Categories.voice) EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) SIGKILL = 9 diff --git a/config-default.yml b/config-default.yml index 3f4352153..071f6e1ec 100644 --- a/config-default.yml +++ b/config-default.yml @@ -130,6 +130,7 @@ guild: help_dormant: 691405908919451718 modmail: &MODMAIL 714494672835444826 logs: &LOGS 468520609152892958 + voice: 356013253765234688 channels: # Public announcement and news channels -- cgit v1.2.3 From 4610483e28100b4f3943612981000a9e8707e802 Mon Sep 17 00:00:00 2001 From: Steele Date: Mon, 26 Oct 2020 10:02:46 -0400 Subject: Made the message significantly shorter --- bot/resources/tags/codeblock.md | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/bot/resources/tags/codeblock.md b/bot/resources/tags/codeblock.md index a28ae397b..2982984a3 100644 --- a/bot/resources/tags/codeblock.md +++ b/bot/resources/tags/codeblock.md @@ -1,17 +1,7 @@ -Discord has support for Markdown, which allows you to post code with full syntax highlighting. Please use these whenever you paste code, as this helps improve the legibility and makes it easier for us to help you. +Here's how to format Python code on Discord: -To do this, use the following method: - -\```python +\```py print('Hello world!') \``` -Note: -• **These are backticks, not quotes.** Backticks can usually be found on the tilde key. -• You can also use py as the language instead of python -• The language must be on the first line next to the backticks with **no** space between them - -This will result in the following: -```py -print('Hello world!') -``` +**These are backticks, not quotes.** Backticks can usually be found to the left of the `1` key. -- cgit v1.2.3 From 8acd7e1544d89e4d71ad078824787f0f193e882b Mon Sep 17 00:00:00 2001 From: Steele Farnsworth <32915757+swfarnsworth@users.noreply.github.com> Date: Mon, 26 Oct 2020 10:37:08 -0400 Subject: link to a page about finding the backtick key on different layouts. --- bot/resources/tags/codeblock.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/codeblock.md b/bot/resources/tags/codeblock.md index 2982984a3..8b5b3047c 100644 --- a/bot/resources/tags/codeblock.md +++ b/bot/resources/tags/codeblock.md @@ -4,4 +4,4 @@ Here's how to format Python code on Discord: print('Hello world!') \``` -**These are backticks, not quotes.** Backticks can usually be found to the left of the `1` key. +**These are backticks, not quotes.** See [here](https://superuser.com/questions/254076/how-do-i-type-the-tick-and-backtick-characters-on-windows/254077#254077) if you can't find the backtick key. -- cgit v1.2.3 From 20275fe06a1b72329eca03b3a6ba1b559f47dc6d Mon Sep 17 00:00:00 2001 From: Steele Farnsworth <32915757+swfarnsworth@users.noreply.github.com> Date: Tue, 27 Oct 2020 10:23:11 -0400 Subject: "see here" -> "check this out" --- bot/resources/tags/codeblock.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/codeblock.md b/bot/resources/tags/codeblock.md index 8b5b3047c..8d48bdf06 100644 --- a/bot/resources/tags/codeblock.md +++ b/bot/resources/tags/codeblock.md @@ -4,4 +4,4 @@ Here's how to format Python code on Discord: print('Hello world!') \``` -**These are backticks, not quotes.** See [here](https://superuser.com/questions/254076/how-do-i-type-the-tick-and-backtick-characters-on-windows/254077#254077) if you can't find the backtick key. +**These are backticks, not quotes.** Check [this](https://superuser.com/questions/254076/how-do-i-type-the-tick-and-backtick-characters-on-windows/254077#254077) out if you can't find the backtick key. -- cgit v1.2.3 From c8c58b7283d201a3efb0524b11591b6ed7e4f3c0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 27 Oct 2020 11:37:31 -0700 Subject: Fix incorrect argument for _send_log when filtering evals Fixes BOT-AN --- bot/exts/filters/filtering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 92cdfb8f5..208fc9e1f 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -246,7 +246,7 @@ class Filtering(Cog): filter_triggered = True stats = self._add_stats(filter_name, match, result) - await self._send_log(filter_name, _filter["type"], msg, stats, is_eval=True) + await self._send_log(filter_name, _filter, msg, stats, is_eval=True) break # We don't want multiple filters to trigger -- cgit v1.2.3 From 765961e784ca5c1d949bd4b02251d3d3132760a8 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 31 Oct 2020 16:06:42 +0000 Subject: Add new activity block constant --- bot/constants.py | 1 + config-default.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 23d5b4304..4d41f4eb2 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -600,6 +600,7 @@ class VoiceGate(metaclass=YAMLGetter): minimum_days_verified: int minimum_messages: int bot_message_delete_delay: int + minimum_activity_blocks: int class Event(Enum): diff --git a/config-default.yml b/config-default.yml index 071f6e1ec..a2cabf5fc 100644 --- a/config-default.yml +++ b/config-default.yml @@ -521,6 +521,7 @@ voice_gate: minimum_days_verified: 3 # How many days the user must have been verified for minimum_messages: 50 # How many messages a user must have to be eligible for voice bot_message_delete_delay: 10 # Seconds before deleting bot's response in Voice Gate + minimum_activity_blocks: 3 # Number of 10 minute blocks during which a user must have been active config: -- cgit v1.2.3 From 732d526807021f0e273840d31b4dff39eb8fe0bb Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 31 Oct 2020 16:12:44 +0000 Subject: Add activity blocks threshold to voice gate --- bot/exts/moderation/voice_gate.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index c2743e136..b9ddc1093 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -25,6 +25,7 @@ MESSAGE_FIELD_MAP = { "verified_at": f"have been verified for less than {GateConf.minimum_days_verified} days", "voice_banned": "have an active voice ban infraction", "total_messages": f"have sent less than {GateConf.minimum_messages} messages", + "activity_blocks": f"have been active for less than {GateConf.minimum_activity_blocks} ten-minute blocks" } @@ -50,6 +51,7 @@ class VoiceGate(Cog): - You must have over a certain number of messages within the Discord server - You must have accepted our rules over a certain number of days ago - You must not be actively banned from using our voice channels + - You must have been active for over a certain number of 10-minute blocks. """ try: data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") @@ -88,7 +90,8 @@ class VoiceGate(Cog): checks = { "verified_at": data["verified_at"] > datetime.utcnow() - timedelta(days=GateConf.minimum_days_verified), "total_messages": data["total_messages"] < GateConf.minimum_messages, - "voice_banned": data["voice_banned"] + "voice_banned": data["voice_banned"], + "activity_blocks": data["activity_blocks"] < GateConf.activity_blocks } failed = any(checks.values()) failed_reasons = [MESSAGE_FIELD_MAP[key] for key, value in checks.items() if value is True] -- cgit v1.2.3 From 54fd8c03aaf2cf7509867a223c5b54366bd8f1e0 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 1 Nov 2020 02:27:07 +0000 Subject: Remove full stop --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index b9ddc1093..cf64c4e52 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -51,7 +51,7 @@ class VoiceGate(Cog): - You must have over a certain number of messages within the Discord server - You must have accepted our rules over a certain number of days ago - You must not be actively banned from using our voice channels - - You must have been active for over a certain number of 10-minute blocks. + - You must have been active for over a certain number of 10-minute blocks """ try: data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") -- cgit v1.2.3 From e0fba54b56d9fc7a7acfc7d7651f9b34b9e0712f Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 1 Nov 2020 02:28:17 +0000 Subject: Change wording of failure message and re-add trailing comma --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index cf64c4e52..78fc1e619 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -25,7 +25,7 @@ MESSAGE_FIELD_MAP = { "verified_at": f"have been verified for less than {GateConf.minimum_days_verified} days", "voice_banned": "have an active voice ban infraction", "total_messages": f"have sent less than {GateConf.minimum_messages} messages", - "activity_blocks": f"have been active for less than {GateConf.minimum_activity_blocks} ten-minute blocks" + "activity_blocks": f"have been active for fewer than {GateConf.minimum_activity_blocks} ten-minute blocks", } -- cgit v1.2.3 From f076231f9919c1f9320c48e5e4af7a1ad2a0401f Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 1 Nov 2020 02:28:43 +0000 Subject: Indent inline comment by two spaces in config-default.yml --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index a2cabf5fc..2afdcd594 100644 --- a/config-default.yml +++ b/config-default.yml @@ -521,7 +521,7 @@ voice_gate: minimum_days_verified: 3 # How many days the user must have been verified for minimum_messages: 50 # How many messages a user must have to be eligible for voice bot_message_delete_delay: 10 # Seconds before deleting bot's response in Voice Gate - minimum_activity_blocks: 3 # Number of 10 minute blocks during which a user must have been active + minimum_activity_blocks: 3 # Number of 10 minute blocks during which a user must have been active config: -- cgit v1.2.3 From 52c9d0706da2ab8253894679df1b113f1e4c51ae Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 1 Nov 2020 02:29:30 +0000 Subject: Correct activity block config name in voice gate extension --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 78fc1e619..529dca53d 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -91,7 +91,7 @@ class VoiceGate(Cog): "verified_at": data["verified_at"] > datetime.utcnow() - timedelta(days=GateConf.minimum_days_verified), "total_messages": data["total_messages"] < GateConf.minimum_messages, "voice_banned": data["voice_banned"], - "activity_blocks": data["activity_blocks"] < GateConf.activity_blocks + "activity_blocks": data["activity_blocks"] < GateConf.minimum_activity_blocks } failed = any(checks.values()) failed_reasons = [MESSAGE_FIELD_MAP[key] for key, value in checks.items() if value is True] -- cgit v1.2.3 From 34dfd440aedc57f2431a0c7c3124f4518c44b5ff Mon Sep 17 00:00:00 2001 From: Zach Gates Date: Sun, 1 Nov 2020 01:07:34 -0600 Subject: Updated langs to include python-repl --- bot/exts/info/codeblock/_parsing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/codeblock/_parsing.py b/bot/exts/info/codeblock/_parsing.py index a98218dfb..3655fb7ea 100644 --- a/bot/exts/info/codeblock/_parsing.py +++ b/bot/exts/info/codeblock/_parsing.py @@ -12,7 +12,7 @@ from bot.utils import has_lines log = logging.getLogger(__name__) BACKTICK = "`" -PY_LANG_CODES = ("python", "pycon", "py") # Order is important; "py" is last cause it's a subset. +PY_LANG_CODES = ("python", "python-repl", "pycon", "py") # Order is important; "py" is last cause it's a subset. _TICKS = { BACKTICK, "'", -- cgit v1.2.3 From fc1805cc6d35103e6a0cbfd4828cd0def31fab6a Mon Sep 17 00:00:00 2001 From: Zach Gates Date: Sun, 1 Nov 2020 01:34:02 -0600 Subject: Reinsert python-repl in PY_LANG_CODES --- bot/exts/info/codeblock/_parsing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/codeblock/_parsing.py b/bot/exts/info/codeblock/_parsing.py index 3655fb7ea..65a2272c8 100644 --- a/bot/exts/info/codeblock/_parsing.py +++ b/bot/exts/info/codeblock/_parsing.py @@ -12,7 +12,7 @@ from bot.utils import has_lines log = logging.getLogger(__name__) BACKTICK = "`" -PY_LANG_CODES = ("python", "python-repl", "pycon", "py") # Order is important; "py" is last cause it's a subset. +PY_LANG_CODES = ("python-repl", "python", "pycon", "py") # Order is important; "py" is last cause it's a subset. _TICKS = { BACKTICK, "'", -- cgit v1.2.3 From 399f286a157b21e860f606b4f4fed11bb29490f2 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 1 Nov 2020 13:53:07 +0000 Subject: Correct 404 error message in voice gate command --- bot/exts/moderation/voice_gate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 529dca53d..9fd553441 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -60,8 +60,8 @@ class VoiceGate(Cog): embed = discord.Embed( title="Not found", description=( - "We were unable to find user data for you. " - "Please try again shortly, " + "We were unable to find user data for you. ", + "Please try again shortly, ", "if this problem persists please contact the server staff through Modmail.", ), color=Colour.red() -- cgit v1.2.3 From 176d22c8b3cd9fe226778327f9eb9ff080101a04 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 1 Nov 2020 15:36:16 +0000 Subject: Actually fix the issue @kwzrd pointed out --- bot/exts/moderation/voice_gate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 9fd553441..93d96693c 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -60,9 +60,9 @@ class VoiceGate(Cog): embed = discord.Embed( title="Not found", description=( - "We were unable to find user data for you. ", - "Please try again shortly, ", - "if this problem persists please contact the server staff through Modmail.", + "We were unable to find user data for you. " + "Please try again shortly, " + "if this problem persists please contact the server staff through Modmail." ), color=Colour.red() ) -- cgit v1.2.3 From 8e7086dd432c5bd416472101c3ce93447681d4d2 Mon Sep 17 00:00:00 2001 From: Amin Boukari Date: Thu, 5 Nov 2020 01:38:48 -0500 Subject: Changed ```python to ```py --- bot/exts/info/codeblock/_instructions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/codeblock/_instructions.py b/bot/exts/info/codeblock/_instructions.py index 508f157fb..1c1881154 100644 --- a/bot/exts/info/codeblock/_instructions.py +++ b/bot/exts/info/codeblock/_instructions.py @@ -71,7 +71,7 @@ def _get_no_ticks_message(content: str) -> Optional[str]: log.trace("Creating instructions for a missing code block.") if _parsing.is_python_code(content): - example_blocks = _get_example("python") + example_blocks = _get_example("py") return ( "It looks like you're trying to paste code into this channel.\n\n" "Discord has support for Markdown, which allows you to post code with full " -- cgit v1.2.3 From ccea2e68824fb51b75238f2ef95104d5dfaa4f35 Mon Sep 17 00:00:00 2001 From: Amin Boukari Date: Thu, 5 Nov 2020 10:00:08 -0500 Subject: Modified instructions for code block without lang --- bot/exts/info/codeblock/_instructions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/codeblock/_instructions.py b/bot/exts/info/codeblock/_instructions.py index 1c1881154..dadb5e1ef 100644 --- a/bot/exts/info/codeblock/_instructions.py +++ b/bot/exts/info/codeblock/_instructions.py @@ -133,7 +133,7 @@ def _get_no_lang_message(content: str) -> Optional[str]: log.trace("Creating instructions for a missing language.") if _parsing.is_python_code(content): - example_blocks = _get_example("python") + example_blocks = _get_example("py") # Note that _get_bad_ticks_message expects the first line to have two newlines. return ( -- cgit v1.2.3 From c99dc2e9faaa691d758e21d9edc4b9bb3c586ca3 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Fri, 6 Nov 2020 23:33:15 +0100 Subject: Detect codeblock language with special characters The regex we use to detect codeblocks did not recognize language specifiers that use a dash, a plus, or a dot in their name. As there are valid language specifiers, such as python-repl and c++, that use those characters, I've changed the regex to reflect that. The character set used now reflects the characters used in language specifiers in highlight.js. Signed-off-by: Sebastiaan Zeeff --- bot/exts/info/codeblock/_parsing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/codeblock/_parsing.py b/bot/exts/info/codeblock/_parsing.py index 65a2272c8..e35fbca22 100644 --- a/bot/exts/info/codeblock/_parsing.py +++ b/bot/exts/info/codeblock/_parsing.py @@ -36,7 +36,7 @@ _RE_CODE_BLOCK = re.compile( (?P[{''.join(_TICKS)}]) # Put all ticks into a character class within a group. \2{{2}} # Match previous group 2 more times to ensure the same char. ) - (?P[^\W_]+\n)? # Optionally match a language specifier followed by a newline. + (?P[A-Za-z0-9\+\-\.]+\n)? # Optionally match a language specifier followed by a newline. (?P.+?) # Match the actual code within the block. \1 # Match the same 3 ticks used at the start of the block. """, -- cgit v1.2.3