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 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 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 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 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 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 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 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 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 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 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 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