From d303557fc601c8b617d34dded401bf85038500ce Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 15 Nov 2020 15:28:11 +0300 Subject: Implements Channel Converter Adds a converter that can decipher more forms of channel mentions, to lay foundation for voice channel muting. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/converters.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/bot/converters.py b/bot/converters.py index 2e118d476..b9db37fce 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -536,6 +536,46 @@ class FetchedUser(UserConverter): raise BadArgument(f"User `{arg}` does not exist") +class AnyChannelConverter(UserConverter): + """ + Converts to a `discord.Channel` or, raises an error. + + Unlike the default Channel Converter, this converter can handle channels given + in string, id, or mention formatting for both `TextChannel`s and `VoiceChannel`s. + Always returns 1 or fewer channels, errors if more than one match exists. + + It is able to handle the following formats (caveats noted below:) + 1. Convert from ID - Example: 267631170882240512 + 2. Convert from Explicit Mention - Example: #welcome + 3. Convert from ID Mention - Example: <#267631170882240512> + 4. Convert from Unmentioned Name: - Example: welcome + + All the previous conversions are valid for both text and voice channels, but explicit + raw names (#4) do not work for non-unique channels, instead opting for an error. + Explicit mentions (#2) do not work for non-unique voice channels either. + """ + + async def convert(self, ctx: Context, arg: str) -> t.Union[discord.TextChannel, discord.VoiceChannel]: + """Convert the `arg` to a `TextChannel` or `VoiceChannel`.""" + stripped = arg.strip().lstrip("<").lstrip("#").rstrip(">") + + # Filter channels by name and ID + channels = [channel for channel in ctx.guild.channels if stripped in (channel.name, str(channel.id))] + + if len(channels) == 0: + # Couldn't find a matching channel + log.debug(f"Could not convert `{arg}` to channel, no matches found.") + raise BadArgument("The provided argument returned no matches.") + + elif len(channels) > 1: + # Couldn't discern the desired channel + log.debug(f"Could not convert `{arg}` to channel, {len(channels)} matches found.") + raise BadArgument(f"The provided argument returned too many matches ({len(channels)}).") + + else: + return channels[0] + + def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int: """ Extract the snowflake from `arg` using a regex `pattern` and return it as an int. -- cgit v1.2.3 From 13866a11f764dec27bc5a600781caa17986e9957 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Mon, 16 Nov 2020 01:48:38 +0300 Subject: Adds VoiceChannel Mute Adds an optional channel parameter to silence and unsilence commands, and adds ability to silence voice channels. TODO: New Tests Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/constants.py | 2 + bot/exts/moderation/silence.py | 165 ++++++++++++++++++++++-------- tests/bot/exts/moderation/test_silence.py | 12 +-- 3 files changed, 132 insertions(+), 47 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 731f06fed..e41be5927 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -395,6 +395,8 @@ class Channels(metaclass=YAMLGetter): change_log: int code_help_voice: int code_help_voice_2: int + admins_voice: int + staff_voice: int cooldown: int defcon: int dev_contrib: int diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index e6712b3b6..266669eed 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -1,18 +1,19 @@ import json import logging +import typing from contextlib import suppress from datetime import datetime, timedelta, timezone from operator import attrgetter from typing import Optional from async_rediscache import RedisCache -from discord import TextChannel +from discord import TextChannel, VoiceChannel from discord.ext import commands, tasks from discord.ext.commands import Context from bot.bot import Bot from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles -from bot.converters import HushDurationConverter +from bot.converters import HushDurationConverter, AnyChannelConverter from bot.utils.lock import LockedResourceError, lock_arg from bot.utils.scheduling import Scheduler @@ -41,7 +42,7 @@ class SilenceNotifier(tasks.Loop): self._silenced_channels = {} self._alert_channel = alert_channel - def add_channel(self, channel: TextChannel) -> None: + def add_channel(self, channel: typing.Union[TextChannel, VoiceChannel]) -> None: """Add channel to `_silenced_channels` and start loop if not launched.""" if not self._silenced_channels: self.start() @@ -93,14 +94,54 @@ class Silence(commands.Cog): await self.bot.wait_until_guild_available() guild = self.bot.get_guild(Guild.id) - self._verified_role = guild.get_role(Roles.verified) + self._verified_msg_role = guild.get_role(Roles.verified) + self._verified_voice_role = guild.get_role(Roles.voice_verified) self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) self.notifier = SilenceNotifier(self.bot.get_channel(Channels.mod_log)) await self._reschedule() + async def send_message(self, message: str, source_channel: TextChannel, + target_channel: typing.Union[TextChannel, VoiceChannel], + alert_target: bool = False, duration: HushDurationConverter = 0) -> None: + """Helper function to send message confirmation to `source_channel`, and notification to `target_channel`""" + if isinstance(source_channel, TextChannel): + await source_channel.send( + message.replace("current", target_channel.mention if source_channel != target_channel else "current") + .replace("{duration}", str(duration)) + ) + + voice_chat = None + if isinstance(target_channel, VoiceChannel): + # Send to relevant channel + # TODO: Figure out a non-hardcoded way of doing this + if "offtopic" in target_channel.name.lower(): + voice_chat = self.bot.get_channel(Channels.voice_chat) + elif "code-help" in target_channel.name.lower(): + if "1" in target_channel.name.lower(): + voice_chat = self.bot.get_channel(Channels.code_help_voice) + else: + voice_chat = self.bot.get_channel(Channels.code_help_voice_2) + elif "admin" in target_channel.name.lower(): + voice_chat = self.bot.get_channel(Channels.admins_voice) + elif "staff" in target_channel.name.lower(): + voice_chat = self.bot.get_channel(Channels.staff_voice) + + if alert_target and source_channel != target_channel: + if isinstance(target_channel, VoiceChannel): + if voice_chat is None or voice_chat == source_channel: + return + + await voice_chat.send( + message.replace("{duration}", str(duration)).replace("current", voice_chat.mention) + ) + + else: + await target_channel.send(message.replace("{duration}", str(duration))) + @commands.command(aliases=("hush",)) @lock_arg(LOCK_NAMESPACE, "ctx", attrgetter("channel"), raise_error=True) - async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: + async def silence(self, ctx: Context, duration: HushDurationConverter = 10, + channel: AnyChannelConverter = None) -> None: """ Silence the current channel for `duration` minutes or `forever`. @@ -108,72 +149,100 @@ class Silence(commands.Cog): Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. """ await self._init_task - - channel_info = f"#{ctx.channel} ({ctx.channel.id})" + if channel is None: + channel = ctx.channel + channel_info = f"#{channel} ({channel.id})" log.debug(f"{ctx.author} is silencing channel {channel_info}.") - if not await self._set_silence_overwrites(ctx.channel): + if not await self._set_silence_overwrites(channel): log.info(f"Tried to silence channel {channel_info} but the channel was already silenced.") - await ctx.send(MSG_SILENCE_FAIL) + await self.send_message(MSG_SILENCE_FAIL, ctx.channel, channel) return - await self._schedule_unsilence(ctx, duration) + await self._schedule_unsilence(ctx, channel, duration) if duration is None: - self.notifier.add_channel(ctx.channel) + self.notifier.add_channel(channel) log.info(f"Silenced {channel_info} indefinitely.") - await ctx.send(MSG_SILENCE_PERMANENT) + await self.send_message(MSG_SILENCE_PERMANENT, ctx.channel, channel, True) + else: log.info(f"Silenced {channel_info} for {duration} minute(s).") - await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) + await self.send_message(MSG_SILENCE_SUCCESS, ctx.channel, channel, True, duration) @commands.command(aliases=("unhush",)) - async def unsilence(self, ctx: Context) -> None: + async def unsilence(self, ctx: Context, channel: AnyChannelConverter = None) -> None: """ - Unsilence the current channel. + Unsilence the given channel if given, else the current one. If the channel was silenced indefinitely, notifications for the channel will stop. """ await self._init_task - log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") - await self._unsilence_wrapper(ctx.channel) + if channel is None: + channel = ctx.channel + log.debug(f"Unsilencing channel #{channel} from {ctx.author}'s command.") + await self._unsilence_wrapper(channel, ctx) @lock_arg(LOCK_NAMESPACE, "channel", raise_error=True) - async def _unsilence_wrapper(self, channel: TextChannel) -> None: + async def _unsilence_wrapper(self, channel: typing.Union[TextChannel, VoiceChannel], + ctx: typing.Optional[Context] = None) -> None: """Unsilence `channel` and send a success/failure message.""" + msg_channel = channel + if ctx is not None: + msg_channel = ctx.channel + if not await self._unsilence(channel): - overwrite = channel.overwrites_for(self._verified_role) - if overwrite.send_messages is False or overwrite.add_reactions is False: - await channel.send(MSG_UNSILENCE_MANUAL) + if isinstance(channel, VoiceChannel): + overwrite = channel.overwrites_for(self._verified_voice_role) + manual = overwrite.speak is False + else: + overwrite = channel.overwrites_for(self._verified_msg_role) + manual = overwrite.send_messages is False or overwrite.add_reactions is False + + # Send fail message to muted channel or voice chat channel, and invocation channel + if manual: + await self.send_message(MSG_UNSILENCE_MANUAL, msg_channel, channel) else: - await channel.send(MSG_UNSILENCE_FAIL) + await self.send_message(MSG_UNSILENCE_FAIL, msg_channel, channel) + else: - await channel.send(MSG_UNSILENCE_SUCCESS) + # Send success message to muted channel or voice chat channel, and invocation channel + await self.send_message(MSG_UNSILENCE_SUCCESS, msg_channel, channel, True) - async def _set_silence_overwrites(self, channel: TextChannel) -> bool: + async def _set_silence_overwrites(self, channel: typing.Union[TextChannel, VoiceChannel]) -> bool: """Set silence permission overwrites for `channel` and return True if successful.""" - overwrite = channel.overwrites_for(self._verified_role) - prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) + if isinstance(channel, TextChannel): + overwrite = channel.overwrites_for(self._verified_msg_role) + prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) + else: + overwrite = channel.overwrites_for(self._verified_voice_role) + prev_overwrites = dict(speak=overwrite.speak) if channel.id in self.scheduler or all(val is False for val in prev_overwrites.values()): return False - overwrite.update(send_messages=False, add_reactions=False) - await channel.set_permissions(self._verified_role, overwrite=overwrite) + if isinstance(channel, TextChannel): + overwrite.update(send_messages=False, add_reactions=False) + await channel.set_permissions(self._verified_msg_role, overwrite=overwrite) + else: + overwrite.update(speak=False) + await channel.set_permissions(self._verified_voice_role, overwrite=overwrite) + await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites)) return True - async def _schedule_unsilence(self, ctx: Context, duration: Optional[int]) -> None: + async def _schedule_unsilence(self, ctx: Context, channel: typing.Union[TextChannel, VoiceChannel], + duration: Optional[int]) -> None: """Schedule `ctx.channel` to be unsilenced if `duration` is not None.""" if duration is None: - await self.unsilence_timestamps.set(ctx.channel.id, -1) + await self.unsilence_timestamps.set(channel.id, -1) else: - self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) + self.scheduler.schedule_later(duration * 60, channel.id, ctx.invoke(self.unsilence, channel=channel)) unsilence_time = datetime.now(tz=timezone.utc) + timedelta(minutes=duration) - await self.unsilence_timestamps.set(ctx.channel.id, unsilence_time.timestamp()) + await self.unsilence_timestamps.set(channel.id, unsilence_time.timestamp()) - async def _unsilence(self, channel: TextChannel) -> bool: + async def _unsilence(self, channel: typing.Union[TextChannel, VoiceChannel]) -> bool: """ Unsilence `channel`. @@ -188,14 +257,21 @@ class Silence(commands.Cog): log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") return False - overwrite = channel.overwrites_for(self._verified_role) + if isinstance(channel, TextChannel): + overwrite = channel.overwrites_for(self._verified_msg_role) + else: + overwrite = channel.overwrites_for(self._verified_voice_role) + if prev_overwrites is None: log.info(f"Missing previous overwrites for #{channel} ({channel.id}); defaulting to None.") - overwrite.update(send_messages=None, add_reactions=None) + overwrite.update(send_messages=None, add_reactions=None, speak=None) else: overwrite.update(**json.loads(prev_overwrites)) - await channel.set_permissions(self._verified_role, overwrite=overwrite) + if isinstance(channel, TextChannel): + await channel.set_permissions(self._verified_msg_role, overwrite=overwrite) + else: + await channel.set_permissions(self._verified_voice_role, overwrite=overwrite) log.info(f"Unsilenced channel #{channel} ({channel.id}).") self.scheduler.cancel(channel.id) @@ -204,11 +280,18 @@ class Silence(commands.Cog): await self.unsilence_timestamps.delete(channel.id) if prev_overwrites is None: - await self._mod_alerts_channel.send( - f"<@&{Roles.admins}> Restored overwrites with default values after unsilencing " - f"{channel.mention}. Please check that the `Send Messages` and `Add Reactions` " - f"overwrites for {self._verified_role.mention} are at their desired values." - ) + if isinstance(channel, TextChannel): + await self._mod_alerts_channel.send( + f"<@&{Roles.admins}> Restored overwrites with default values after unsilencing " + f"{channel.mention}. Please check that the `Send Messages` and `Add Reactions` " + f"overwrites for {self._verified_msg_role.mention} are at their desired values." + ) + else: + await self._mod_alerts_channel.send( + f"<@&{Roles.admins}> Restored overwrites with default values after unsilencing " + f"{channel.mention}. Please check that the `Speak` " + f"overwrites for {self._verified_voice_role.mention} are at their desired values." + ) return True diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 104293d8e..bac933115 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -123,7 +123,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): guild.get_role.side_effect = lambda id_: Mock(id=id_) await self.cog._async_init() - self.assertEqual(self.cog._verified_role.id, Roles.verified) + self.assertEqual(self.cog._verified_msg_role.id, Roles.verified) @autospec(silence, "SilenceNotifier", pass_mocks=False) async def test_async_init_got_channels(self): @@ -277,7 +277,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=was_silenced): with self.subTest(was_silenced=was_silenced, message=message, duration=duration): await self.cog.silence.callback(self.cog, ctx, duration) - ctx.send.assert_called_once_with(message) + ctx.channel.send.assert_called_once_with(message) async def test_skipped_already_silenced(self): """Permissions were not set and `False` was returned for an already silenced channel.""" @@ -302,7 +302,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(self.overwrite.send_messages) self.assertFalse(self.overwrite.add_reactions) self.channel.set_permissions.assert_awaited_once_with( - self.cog._verified_role, + self.cog._verified_msg_role, overwrite=self.overwrite ) @@ -372,7 +372,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): args = (300, ctx.channel.id, ctx.invoke.return_value) self.cog.scheduler.schedule_later.assert_called_once_with(*args) - ctx.invoke.assert_called_once_with(self.cog.unsilence) + ctx.invoke.assert_called_once_with(self.cog.unsilence, channel=ctx.channel) async def test_permanent_not_scheduled(self): """A task was not scheduled for a permanent silence.""" @@ -435,7 +435,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): """Channel's `send_message` and `add_reactions` overwrites were restored.""" await self.cog._unsilence(self.channel) self.channel.set_permissions.assert_awaited_once_with( - self.cog._verified_role, + self.cog._verified_msg_role, overwrite=self.overwrite, ) @@ -449,7 +449,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._unsilence(self.channel) self.channel.set_permissions.assert_awaited_once_with( - self.cog._verified_role, + self.cog._verified_msg_role, overwrite=self.overwrite, ) -- cgit v1.2.3 From 51daa10a98299c5bcf4455b34e10c35850474521 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 22 Nov 2020 12:50:58 +0300 Subject: Improves Channel Converter Usability - Allows spaces in channel name - Allows channel name to have any capitalization - Fixed inherited class to general Converter class Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/converters.py | 8 ++++---- bot/exts/moderation/silence.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index b9db37fce..613be73eb 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -536,7 +536,7 @@ class FetchedUser(UserConverter): raise BadArgument(f"User `{arg}` does not exist") -class AnyChannelConverter(UserConverter): +class AnyChannelConverter(Converter): """ Converts to a `discord.Channel` or, raises an error. @@ -557,15 +557,15 @@ class AnyChannelConverter(UserConverter): async def convert(self, ctx: Context, arg: str) -> t.Union[discord.TextChannel, discord.VoiceChannel]: """Convert the `arg` to a `TextChannel` or `VoiceChannel`.""" - stripped = arg.strip().lstrip("<").lstrip("#").rstrip(">") + stripped = arg.strip().lstrip("<").lstrip("#").rstrip(">").lower() # Filter channels by name and ID - channels = [channel for channel in ctx.guild.channels if stripped in (channel.name, str(channel.id))] + channels = [channel for channel in ctx.guild.channels if stripped in (channel.name.lower(), str(channel.id))] if len(channels) == 0: # Couldn't find a matching channel log.debug(f"Could not convert `{arg}` to channel, no matches found.") - raise BadArgument("The provided argument returned no matches.") + raise BadArgument(f"{arg} returned no matches.") elif len(channels) > 1: # Couldn't discern the desired channel diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 266669eed..c903bfe9e 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -141,7 +141,7 @@ class Silence(commands.Cog): @commands.command(aliases=("hush",)) @lock_arg(LOCK_NAMESPACE, "ctx", attrgetter("channel"), raise_error=True) async def silence(self, ctx: Context, duration: HushDurationConverter = 10, - channel: AnyChannelConverter = None) -> None: + *, channel: AnyChannelConverter = None) -> None: """ Silence the current channel for `duration` minutes or `forever`. @@ -171,7 +171,7 @@ class Silence(commands.Cog): await self.send_message(MSG_SILENCE_SUCCESS, ctx.channel, channel, True, duration) @commands.command(aliases=("unhush",)) - async def unsilence(self, ctx: Context, channel: AnyChannelConverter = None) -> None: + async def unsilence(self, ctx: Context, *, channel: AnyChannelConverter = None) -> None: """ Unsilence the given channel if given, else the current one. -- cgit v1.2.3 From cf56c22b048567b7782aeff123f3df0eee621cea Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 22 Nov 2020 12:53:29 +0300 Subject: Refactor Silence Class Imports Refactors imports of silence class to be more inline with the original import structure. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index c903bfe9e..314aa946e 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -1,10 +1,9 @@ import json import logging -import typing from contextlib import suppress from datetime import datetime, timedelta, timezone from operator import attrgetter -from typing import Optional +from typing import Optional, Union from async_rediscache import RedisCache from discord import TextChannel, VoiceChannel @@ -13,7 +12,7 @@ from discord.ext.commands import Context from bot.bot import Bot from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles -from bot.converters import HushDurationConverter, AnyChannelConverter +from bot.converters import AnyChannelConverter, HushDurationConverter from bot.utils.lock import LockedResourceError, lock_arg from bot.utils.scheduling import Scheduler @@ -42,7 +41,7 @@ class SilenceNotifier(tasks.Loop): self._silenced_channels = {} self._alert_channel = alert_channel - def add_channel(self, channel: typing.Union[TextChannel, VoiceChannel]) -> None: + def add_channel(self, channel: Union[TextChannel, VoiceChannel]) -> None: """Add channel to `_silenced_channels` and start loop if not launched.""" if not self._silenced_channels: self.start() @@ -101,9 +100,9 @@ class Silence(commands.Cog): await self._reschedule() async def send_message(self, message: str, source_channel: TextChannel, - target_channel: typing.Union[TextChannel, VoiceChannel], + target_channel: Union[TextChannel, VoiceChannel], alert_target: bool = False, duration: HushDurationConverter = 0) -> None: - """Helper function to send message confirmation to `source_channel`, and notification to `target_channel`""" + """Helper function to send message confirmation to `source_channel`, and notification to `target_channel`.""" if isinstance(source_channel, TextChannel): await source_channel.send( message.replace("current", target_channel.mention if source_channel != target_channel else "current") @@ -184,8 +183,8 @@ class Silence(commands.Cog): await self._unsilence_wrapper(channel, ctx) @lock_arg(LOCK_NAMESPACE, "channel", raise_error=True) - async def _unsilence_wrapper(self, channel: typing.Union[TextChannel, VoiceChannel], - ctx: typing.Optional[Context] = None) -> None: + async def _unsilence_wrapper(self, channel: Union[TextChannel, VoiceChannel], + ctx: Optional[Context] = None) -> None: """Unsilence `channel` and send a success/failure message.""" msg_channel = channel if ctx is not None: @@ -209,7 +208,7 @@ class Silence(commands.Cog): # Send success message to muted channel or voice chat channel, and invocation channel await self.send_message(MSG_UNSILENCE_SUCCESS, msg_channel, channel, True) - async def _set_silence_overwrites(self, channel: typing.Union[TextChannel, VoiceChannel]) -> bool: + async def _set_silence_overwrites(self, channel: Union[TextChannel, VoiceChannel]) -> bool: """Set silence permission overwrites for `channel` and return True if successful.""" if isinstance(channel, TextChannel): overwrite = channel.overwrites_for(self._verified_msg_role) @@ -242,7 +241,7 @@ class Silence(commands.Cog): unsilence_time = datetime.now(tz=timezone.utc) + timedelta(minutes=duration) await self.unsilence_timestamps.set(channel.id, unsilence_time.timestamp()) - async def _unsilence(self, channel: typing.Union[TextChannel, VoiceChannel]) -> bool: + async def _unsilence(self, channel: Union[TextChannel, VoiceChannel]) -> bool: """ Unsilence `channel`. -- cgit v1.2.3 From 06f9c48e136644a15044b84a0b25f1b6feba0e09 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 22 Nov 2020 13:07:46 +0300 Subject: Add VC Mute Functionality Adds and calls a function to force a voice channel member to sync permissions. See #1160 for why this is necessary. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 66 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 314aa946e..415ab19a6 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -6,7 +6,7 @@ from operator import attrgetter from typing import Optional, Union from async_rediscache import RedisCache -from discord import TextChannel, VoiceChannel +from discord import HTTPException, Member, PermissionOverwrite, TextChannel, VoiceChannel from discord.ext import commands, tasks from discord.ext.commands import Context @@ -93,9 +93,13 @@ class Silence(commands.Cog): await self.bot.wait_until_guild_available() guild = self.bot.get_guild(Guild.id) + self._verified_msg_role = guild.get_role(Roles.verified) self._verified_voice_role = guild.get_role(Roles.voice_verified) + self._helper_role = guild.get_role(Roles.helpers) + self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) + self.notifier = SilenceNotifier(self.bot.get_channel(Channels.mod_log)) await self._reschedule() @@ -226,12 +230,70 @@ class Silence(commands.Cog): else: overwrite.update(speak=False) await channel.set_permissions(self._verified_voice_role, overwrite=overwrite) + try: + await self._force_voice_silence(channel) + except HTTPException: + # TODO: Relay partial failure to invocation channel + pass await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites)) return True - async def _schedule_unsilence(self, ctx: Context, channel: typing.Union[TextChannel, VoiceChannel], + async def _force_voice_silence(self, channel: VoiceChannel, member: Optional[Member] = None) -> None: + """ + Move all non-staff members from `channel` to a temporary channel and back to force toggle role mute. + + If `member` is passed, the mute only occurs to that member. + Permission modification has to happen before this function. + + Raises `discord.HTTPException` if the task fails. + """ + # Obtain temporary channel + afk_channel = channel.guild.afk_channel + if afk_channel is None: + try: + overwrites = { + channel.guild.default_role: PermissionOverwrite(speak=False, connect=False, view_channel=False) + } + afk_channel = await channel.guild.create_voice_channel("mute-temp", overwrites=overwrites) + log.info(f"Failed to get afk-channel, created temporary channel #{afk_channel} ({afk_channel.id})") + + # Schedule channel deletion in case function errors out + self.scheduler.schedule_later(30, afk_channel.id, + afk_channel.delete(reason="Deleting temp mute channel.")) + + except HTTPException as e: + log.warning("Failed to create temporary mute channel.", exc_info=e) + raise e + + # Handle member picking logic + if member is not None: + members = [member] + else: + members = channel.members + + # Move all members to temporary channel and back + for member in members: + # Skip staff + if self._helper_role in member.roles: + continue + + try: + await member.move_to(afk_channel, reason="Muting member.") + log.debug(f"Moved {member.name} to afk channel.") + + await member.move_to(channel, reason="Muting member.") + log.debug(f"Moved {member.name} to original voice channel.") + + except HTTPException as e: + log.warning(f"Failed to move {member.name} while muting, falling back to kick.", exc_info=e) + try: + await member.move_to(None, reason="Forcing member mute.") + except HTTPException: + pass + + async def _schedule_unsilence(self, ctx: Context, channel: Union[TextChannel, VoiceChannel], duration: Optional[int]) -> None: """Schedule `ctx.channel` to be unsilenced if `duration` is not None.""" if duration is None: -- cgit v1.2.3 From b2367b20e9f73d269224a6dbee20e23e6b6de6b7 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Sun, 22 Nov 2020 12:31:14 +0100 Subject: rework clean to fully use `delete_messages` instead of `purge` --- bot/exts/utils/clean.py | 86 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 60 insertions(+), 26 deletions(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index bf25cb4c2..d6dd2401f 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -1,9 +1,10 @@ import logging import random import re -from typing import Iterable, Optional +import time +from typing import Dict, Iterable, List, Optional -from discord import Colour, Embed, Message, TextChannel, User +from discord import Colour, Embed, Message, NotFound, TextChannel, User from discord.ext import commands from discord.ext.commands import Cog, Context, group, has_any_role @@ -36,6 +37,14 @@ class Clean(Cog): """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") + async def _delete_messages_individually(self, messages: List[Message]) -> None: + for message in messages: + try: + await message.delete() + except NotFound: + # message doesn't exist or was already deleted + continue + async def _clean_messages( self, amount: int, @@ -107,7 +116,7 @@ class Clean(Cog): elif regex: predicate = predicate_regex # Delete messages that match regex else: - predicate = None # Delete all messages + predicate = lambda m: True # Delete all messages # noqa: E731 # Default to using the invoking context's channel if not channels: @@ -117,19 +126,28 @@ class Clean(Cog): self.mod_log.ignore(Event.message_delete, ctx.message.id) await ctx.message.delete() - messages = [] + # we need Channel to Message mapping for easier deletion via TextChannel.delete_messages + message_mappings: Dict[TextChannel, List[Message]] = {} message_ids = [] self.cleaning = True # Find the IDs of the messages to delete. IDs are needed in order to ignore mod log events. for channel in channels: + + messages = [] + async for message in channel.history(limit=amount): # If at any point the cancel command is invoked, we should stop. if not self.cleaning: return - # If we are looking for specific message. + # If the message passes predicate, let's save it. + if predicate(message): + messages.append(message) + message_ids.append(message) + + # if we are looking for specific message if until_message: # we could use ID's here however in case if the message we are looking for gets deleted, @@ -138,33 +156,49 @@ class Clean(Cog): # means we have found the message until which we were supposed to be deleting. break - # Since we will be using `delete_messages` method of a TextChannel and we need message objects to - # use it as well as to send logs we will start appending messages here instead adding them from - # purge. - messages.append(message) - - # If the message passes predicate, let's save it. - if predicate is None or predicate(message): - message_ids.append(message.id) + if len(messages) > 0: + # we don't want to create mappings of TextChannel to empty list + message_mappings[channel] = messages self.cleaning = False # Now let's delete the actual messages with purge. self.mod_log.ignore(Event.message_delete, *message_ids) - for channel in channels: - if until_message: - for i in range(0, len(messages), 100): - # while purge automatically handles the amount of messages - # delete_messages only allows for up to 100 messages at once - # thus we need to paginate the amount to always be <= 100 - await channel.delete_messages(messages[i:i + 100]) - else: - messages += await channel.purge(limit=amount, check=predicate) - # Reverse the list to restore chronological order - if messages: - messages = reversed(messages) - log_url = await self.mod_log.upload_log(messages, ctx.author.id) + # Creates ID like int object that would represent an object that is exactly 14 days old + minimum_time = int((time.time() - 14 * 24 * 60 * 60) * 1000.0 - 1420070400000) << 22 + + for channel, messages in message_mappings.items(): + + to_delete = [] + + for current_index, message in enumerate(messages): + + if message.id < minimum_time: + # further messages are too old to be deleted in bulk + await self._delete_messages_individually(messages[current_index:]) + break + + to_delete.append(message) + + if len(to_delete) == 100: + # we can only delete up to 100 messages in a bulk + await channel.delete_messages(to_delete) + to_delete.clear() + + if len(to_delete) > 0: + # deleting any leftover messages if there are any + await channel.delete_messages(to_delete) + + log_messages = [] + + for messages in message_mappings.values(): + log_messages.extend(messages) + + if log_messages: + # Reverse the list to restore chronological order + log_messages = reversed(log_messages) + log_url = await self.mod_log.upload_log(log_messages, ctx.author.id) else: # Can't build an embed, nothing to clean! embed = Embed( -- cgit v1.2.3 From 7bcc74fb600c8e64fa136d8273fea6fbb3834339 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Sun, 22 Nov 2020 12:32:08 +0100 Subject: rename command `messages` to `until` new name should be more selfexplanatory --- bot/exts/utils/clean.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index d6dd2401f..7ee0287fd 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -277,9 +277,9 @@ class Clean(Cog): """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" await self._clean_messages(amount, ctx, regex=regex, channels=channels) - @clean_group.command(name="message", aliases=["messages"]) + @clean_group.command(name="until") @has_any_role(*MODERATION_ROLES) - async def clean_message(self, ctx: Context, message: Message) -> None: + async def clean_until(self, ctx: Context, message: Message) -> None: """Delete all messages until certain message, stop cleaning after hitting the `message`.""" await self._clean_messages( CleanMessages.message_limit, -- cgit v1.2.3 From 5cfc0aca6cbe263259b6b25d6828f73f744d0661 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Mon, 23 Nov 2020 00:19:09 +0300 Subject: Add VC Mute Failure Notification Notifies invocation channel that the silence command failed to silence the channel because it could not move members, but roles were updated. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 45 ++++++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 415ab19a6..9020634f4 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -23,10 +23,11 @@ LOCK_NAMESPACE = "silence" MSG_SILENCE_FAIL = f"{Emojis.cross_mark} current channel is already silenced." MSG_SILENCE_PERMANENT = f"{Emojis.check_mark} silenced current channel indefinitely." MSG_SILENCE_SUCCESS = f"{Emojis.check_mark} silenced current channel for {{duration}} minute(s)." +MSG_SILENCE_PERM_FAIL = f"{Emojis.cross_mark} failed to force-mute members, permissions updated." MSG_UNSILENCE_FAIL = f"{Emojis.cross_mark} current channel was not silenced." MSG_UNSILENCE_MANUAL = ( - f"{Emojis.cross_mark} current channel was not unsilenced because the current overwrites were " + f"{Emojis.cross_mark} current channel was not unsilenced because the channel overwrites were " f"set manually or the cache was prematurely cleared. " f"Please edit the overwrites manually to unsilence." ) @@ -116,18 +117,18 @@ class Silence(commands.Cog): voice_chat = None if isinstance(target_channel, VoiceChannel): # Send to relevant channel - # TODO: Figure out a non-hardcoded way of doing this - if "offtopic" in target_channel.name.lower(): - voice_chat = self.bot.get_channel(Channels.voice_chat) - elif "code-help" in target_channel.name.lower(): - if "1" in target_channel.name.lower(): - voice_chat = self.bot.get_channel(Channels.code_help_voice) - else: - voice_chat = self.bot.get_channel(Channels.code_help_voice_2) - elif "admin" in target_channel.name.lower(): - voice_chat = self.bot.get_channel(Channels.admins_voice) - elif "staff" in target_channel.name.lower(): - voice_chat = self.bot.get_channel(Channels.staff_voice) + # TODO: Figure out a dynamic way of doing this + channels = { + "offtopic": Channels.voice_chat, + "code/help 1": Channels.code_help_voice, + "code/help 2": Channels.code_help_voice, + "admin": Channels.admins_voice, + "staff": Channels.staff_voice + } + for name in channels.keys(): + if name in target_channel.name.lower(): + voice_chat = self.bot.get_channel(channels[name]) + break if alert_target and source_channel != target_channel: if isinstance(target_channel, VoiceChannel): @@ -157,9 +158,15 @@ class Silence(commands.Cog): channel_info = f"#{channel} ({channel.id})" log.debug(f"{ctx.author} is silencing channel {channel_info}.") - if not await self._set_silence_overwrites(channel): - log.info(f"Tried to silence channel {channel_info} but the channel was already silenced.") - await self.send_message(MSG_SILENCE_FAIL, ctx.channel, channel) + try: + if not await self._set_silence_overwrites(channel): + log.info(f"Tried to silence channel {channel_info} but the channel was already silenced.") + await self.send_message(MSG_SILENCE_FAIL, ctx.channel, channel) + return + + except HTTPException: + log.info(f"Could not force mute {channel_info} members. Permissions updated.") + await self.send_message(MSG_SILENCE_PERM_FAIL, ctx.channel, channel) return await self._schedule_unsilence(ctx, channel, duration) @@ -230,11 +237,7 @@ class Silence(commands.Cog): else: overwrite.update(speak=False) await channel.set_permissions(self._verified_voice_role, overwrite=overwrite) - try: - await self._force_voice_silence(channel) - except HTTPException: - # TODO: Relay partial failure to invocation channel - pass + await self._force_voice_silence(channel) await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites)) -- cgit v1.2.3 From 2f57f40d3bfdc3b5c6cdb9e37fffd101cf195b12 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Mon, 23 Nov 2020 01:24:15 +0300 Subject: Write AnyChannelConverter Tests Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- tests/bot/test_converters.py | 48 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index c42111f3f..5039d45ea 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -7,6 +7,7 @@ from dateutil.relativedelta import relativedelta from discord.ext.commands import BadArgument from bot.converters import ( + AnyChannelConverter, Duration, HushDurationConverter, ISODateTime, @@ -24,6 +25,18 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase): cls.context = MagicMock cls.context.author = 'bob' + cls.context.guild = MagicMock + cls.context.guild.channels = [] + + for i in range(10): + channel = MagicMock() + channel.name = f"test-{i}" + channel.id = str(i) * 18 + + cls.context.guild.channels.append(channel) + if i > 7: + cls.context.guild.channels.append(channel) + cls.fixed_utc_now = datetime.datetime.fromisoformat('2019-01-01T00:00:00') async def test_tag_content_converter_for_valid(self): @@ -312,3 +325,38 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase): with self.subTest(invalid_minutes_string=invalid_minutes_string, exception_message=exception_message): with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): await converter.convert(self.context, invalid_minutes_string) + + async def test_any_channel_converter_for_valid(self): + """AnyChannelConverter returns correct channel from input.""" + test_values = ( + ("test-0", self.context.guild.channels[0]), + ("#test-0", self.context.guild.channels[0]), + (" #tEsT-0 ", self.context.guild.channels[0]), + (f"<#{'0' * 18}>", self.context.guild.channels[0]), + (f"{'0' * 18}", self.context.guild.channels[0]), + ("test-5", self.context.guild.channels[5]), + ("#test-5", self.context.guild.channels[5]), + (f"<#{'5' * 18}>", self.context.guild.channels[5]), + (f"{'5' * 18}", self.context.guild.channels[5]), + ) + + converter = AnyChannelConverter() + for input_string, expected_channel in test_values: + with self.subTest(input_string=input_string, expected_channel=expected_channel): + converted = await converter.convert(self.context, input_string) + self.assertEqual(expected_channel, converted) + + async def test_any_channel_converter_for_invalid(self): + """AnyChannelConverter raises BadArgument for invalid channels.""" + test_values = ( + ("#test-8", "The provided argument returned too many matches (2)."), + ("#test-9", "The provided argument returned too many matches (2)."), + ("#random-name", "#random-name returned no matches."), + ("test-10", "test-10 returned no matches.") + ) + + converter = AnyChannelConverter() + for invalid_input, exception_message in test_values: + with self.subTest(invalid_input=invalid_input, exception_message=exception_message): + with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): + await converter.convert(self.context, invalid_input) \ No newline at end of file -- cgit v1.2.3 From 81f923ed784cf09e3b7e90e57a9ad0111099619c Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Mon, 23 Nov 2020 01:24:15 +0300 Subject: Write AnyChannelConverter Tests Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- tests/bot/test_converters.py | 48 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index c42111f3f..6bea71977 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -7,6 +7,7 @@ from dateutil.relativedelta import relativedelta from discord.ext.commands import BadArgument from bot.converters import ( + AnyChannelConverter, Duration, HushDurationConverter, ISODateTime, @@ -24,6 +25,18 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase): cls.context = MagicMock cls.context.author = 'bob' + cls.context.guild = MagicMock + cls.context.guild.channels = [] + + for i in range(10): + channel = MagicMock() + channel.name = f"test-{i}" + channel.id = str(i) * 18 + + cls.context.guild.channels.append(channel) + if i > 7: + cls.context.guild.channels.append(channel) + cls.fixed_utc_now = datetime.datetime.fromisoformat('2019-01-01T00:00:00') async def test_tag_content_converter_for_valid(self): @@ -312,3 +325,38 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase): with self.subTest(invalid_minutes_string=invalid_minutes_string, exception_message=exception_message): with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): await converter.convert(self.context, invalid_minutes_string) + + async def test_any_channel_converter_for_valid(self): + """AnyChannelConverter returns correct channel from input.""" + test_values = ( + ("test-0", self.context.guild.channels[0]), + ("#test-0", self.context.guild.channels[0]), + (" #tEsT-0 ", self.context.guild.channels[0]), + (f"<#{'0' * 18}>", self.context.guild.channels[0]), + (f"{'0' * 18}", self.context.guild.channels[0]), + ("test-5", self.context.guild.channels[5]), + ("#test-5", self.context.guild.channels[5]), + (f"<#{'5' * 18}>", self.context.guild.channels[5]), + (f"{'5' * 18}", self.context.guild.channels[5]), + ) + + converter = AnyChannelConverter() + for input_string, expected_channel in test_values: + with self.subTest(input_string=input_string, expected_channel=expected_channel): + converted = await converter.convert(self.context, input_string) + self.assertEqual(expected_channel, converted) + + async def test_any_channel_converter_for_invalid(self): + """AnyChannelConverter raises BadArgument for invalid channels.""" + test_values = ( + ("#test-8", "The provided argument returned too many matches (2)."), + ("#test-9", "The provided argument returned too many matches (2)."), + ("#random-name", "#random-name returned no matches."), + ("test-10", "test-10 returned no matches.") + ) + + converter = AnyChannelConverter() + for invalid_input, exception_message in test_values: + with self.subTest(invalid_input=invalid_input, exception_message=exception_message): + with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): + await converter.convert(self.context, invalid_input) -- cgit v1.2.3 From 985b681b0a5679fb9816e961ffb2abd69ef305bf Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Mon, 23 Nov 2020 09:20:41 +0300 Subject: Make Voice Channel Kick Optional Adds an optional parameter to the silence command to enable moderators to choose if they only update permissions, or kick members too. As an accompanying feature, the unsilence command now syncs voice channel permissions too. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 50 ++++++++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 9020634f4..93a0dad98 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -144,13 +144,16 @@ class Silence(commands.Cog): @commands.command(aliases=("hush",)) @lock_arg(LOCK_NAMESPACE, "ctx", attrgetter("channel"), raise_error=True) - async def silence(self, ctx: Context, duration: HushDurationConverter = 10, - *, channel: AnyChannelConverter = None) -> None: + async def silence(self, ctx: Context, duration: HushDurationConverter = 10, kick: bool = False, + *, channel: Optional[AnyChannelConverter] = None) -> None: """ Silence the current channel for `duration` minutes or `forever`. Duration is capped at 15 minutes, passing forever makes the silence indefinite. Indefinitely silenced channels get added to a notifier which posts notices every 15 minutes from the start. + + Passing a voice channel will attempt to move members out of the channel and back to force sync permissions. + If `kick` is True, members will not be added back to the voice channel, and members will be unable to rejoin. """ await self._init_task if channel is None: @@ -159,7 +162,7 @@ class Silence(commands.Cog): log.debug(f"{ctx.author} is silencing channel {channel_info}.") try: - if not await self._set_silence_overwrites(channel): + if not await self._set_silence_overwrites(channel, kick): log.info(f"Tried to silence channel {channel_info} but the channel was already silenced.") await self.send_message(MSG_SILENCE_FAIL, ctx.channel, channel) return @@ -217,9 +220,10 @@ class Silence(commands.Cog): else: # Send success message to muted channel or voice chat channel, and invocation channel + await self._force_voice_sync(channel) await self.send_message(MSG_UNSILENCE_SUCCESS, msg_channel, channel, True) - async def _set_silence_overwrites(self, channel: Union[TextChannel, VoiceChannel]) -> bool: + async def _set_silence_overwrites(self, channel: Union[TextChannel, VoiceChannel], kick: bool) -> bool: """Set silence permission overwrites for `channel` and return True if successful.""" if isinstance(channel, TextChannel): overwrite = channel.overwrites_for(self._verified_msg_role) @@ -227,6 +231,8 @@ class Silence(commands.Cog): else: overwrite = channel.overwrites_for(self._verified_voice_role) prev_overwrites = dict(speak=overwrite.speak) + if kick: + prev_overwrites.update(connect=overwrite.connect) if channel.id in self.scheduler or all(val is False for val in prev_overwrites.values()): return False @@ -236,22 +242,43 @@ class Silence(commands.Cog): await channel.set_permissions(self._verified_msg_role, overwrite=overwrite) else: overwrite.update(speak=False) + if kick: + overwrite.update(connect=False) + await channel.set_permissions(self._verified_voice_role, overwrite=overwrite) - await self._force_voice_silence(channel) + + await self._force_voice_sync(channel, kick=kick) await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites)) return True - async def _force_voice_silence(self, channel: VoiceChannel, member: Optional[Member] = None) -> None: + async def _force_voice_sync(self, channel: VoiceChannel, member: Optional[Member] = None, + kick: bool = False) -> None: """ Move all non-staff members from `channel` to a temporary channel and back to force toggle role mute. If `member` is passed, the mute only occurs to that member. Permission modification has to happen before this function. + If `kick_all` is True, members will not be added back to the voice channel + Raises `discord.HTTPException` if the task fails. """ + # Handle member picking logic + if member is not None: + members = [member] + else: + members = channel.members + + # Handle kick logic + if kick: + for member in members: + await member.move_to(None, reason="Kicking voice channel member.") + + log.debug(f"Kicked all members from #{channel.name} ({channel.id}).") + return + # Obtain temporary channel afk_channel = channel.guild.afk_channel if afk_channel is None: @@ -270,12 +297,6 @@ class Silence(commands.Cog): log.warning("Failed to create temporary mute channel.", exc_info=e) raise e - # Handle member picking logic - if member is not None: - members = [member] - else: - members = channel.members - # Move all members to temporary channel and back for member in members: # Skip staff @@ -291,10 +312,7 @@ class Silence(commands.Cog): except HTTPException as e: log.warning(f"Failed to move {member.name} while muting, falling back to kick.", exc_info=e) - try: - await member.move_to(None, reason="Forcing member mute.") - except HTTPException: - pass + await member.move_to(None, reason="Forcing member mute.") async def _schedule_unsilence(self, ctx: Context, channel: Union[TextChannel, VoiceChannel], duration: Optional[int]) -> None: -- cgit v1.2.3 From 02be861e90c176ae7b577829f4fc636215cdcc3c Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Mon, 23 Nov 2020 09:26:01 +0300 Subject: Fix Failing Functions Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 93a0dad98..87327e72a 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -223,7 +223,7 @@ class Silence(commands.Cog): await self._force_voice_sync(channel) await self.send_message(MSG_UNSILENCE_SUCCESS, msg_channel, channel, True) - async def _set_silence_overwrites(self, channel: Union[TextChannel, VoiceChannel], kick: bool) -> bool: + async def _set_silence_overwrites(self, channel: Union[TextChannel, VoiceChannel], kick: bool = False) -> bool: """Set silence permission overwrites for `channel` and return True if successful.""" if isinstance(channel, TextChannel): overwrite = channel.overwrites_for(self._verified_msg_role) -- cgit v1.2.3 From 7569fefee98e17922857bc3aa4bdcb806c76874b Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Mon, 23 Nov 2020 14:00:18 +0300 Subject: Fixes Voice Silence Reporting Fixes the channel reported as muted to voice channel chat channels when silencing voice channels. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 87327e72a..2d928182a 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -108,11 +108,10 @@ class Silence(commands.Cog): target_channel: Union[TextChannel, VoiceChannel], alert_target: bool = False, duration: HushDurationConverter = 0) -> None: """Helper function to send message confirmation to `source_channel`, and notification to `target_channel`.""" - if isinstance(source_channel, TextChannel): - await source_channel.send( - message.replace("current", target_channel.mention if source_channel != target_channel else "current") - .replace("{duration}", str(duration)) - ) + await source_channel.send( + message.replace("current", target_channel.mention if source_channel != target_channel else "current") + .replace("{duration}", str(duration)) + ) voice_chat = None if isinstance(target_channel, VoiceChannel): @@ -136,7 +135,7 @@ class Silence(commands.Cog): return await voice_chat.send( - message.replace("{duration}", str(duration)).replace("current", voice_chat.mention) + message.replace("{duration}", str(duration)).replace("current", target_channel.mention) ) else: -- cgit v1.2.3 From 483114bc29da713870346c0e4b88d008f6281185 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Mon, 23 Nov 2020 14:24:27 +0300 Subject: General Silence Class Tests Adds tests for helper functions in the silence cog. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 27 +++++---- tests/bot/exts/moderation/test_silence.py | 91 ++++++++++++++++++++++++++++++- tests/helpers.py | 25 ++++++++- 3 files changed, 126 insertions(+), 17 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 2d928182a..2aebee9d7 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -104,6 +104,20 @@ class Silence(commands.Cog): self.notifier = SilenceNotifier(self.bot.get_channel(Channels.mod_log)) await self._reschedule() + async def _get_related_text_channel(self, channel: VoiceChannel) -> Optional[TextChannel]: + """Returns the text channel related to a voice channel.""" + # TODO: Figure out a dynamic way of doing this + channels = { + "off-topic": Channels.voice_chat, + "code/help 1": Channels.code_help_voice, + "code/help 2": Channels.code_help_voice, + "admin": Channels.admins_voice, + "staff": Channels.staff_voice + } + for name in channels.keys(): + if name in channel.name.lower(): + return self.bot.get_channel(channels[name]) + async def send_message(self, message: str, source_channel: TextChannel, target_channel: Union[TextChannel, VoiceChannel], alert_target: bool = False, duration: HushDurationConverter = 0) -> None: @@ -116,18 +130,7 @@ class Silence(commands.Cog): voice_chat = None if isinstance(target_channel, VoiceChannel): # Send to relevant channel - # TODO: Figure out a dynamic way of doing this - channels = { - "offtopic": Channels.voice_chat, - "code/help 1": Channels.code_help_voice, - "code/help 2": Channels.code_help_voice, - "admin": Channels.admins_voice, - "staff": Channels.staff_voice - } - for name in channels.keys(): - if name in target_channel.name.lower(): - voice_chat = self.bot.get_channel(channels[name]) - break + voice_chat = await self._get_related_text_channel(target_channel) if alert_target and source_channel != target_channel: if isinstance(target_channel, VoiceChannel): diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index bac933115..577725071 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -7,9 +7,9 @@ from unittest.mock import Mock from async_rediscache import RedisSession from discord import PermissionOverwrite -from bot.constants import Channels, Guild, Roles +from bot.constants import Channels, Emojis, Guild, Roles from bot.exts.moderation import silence -from tests.helpers import MockBot, MockContext, MockTextChannel, autospec +from tests.helpers import MockBot, MockContext, MockTextChannel, MockVoiceChannel, autospec redis_session = None redis_loop = asyncio.get_event_loop() @@ -168,6 +168,93 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): role_check.assert_called_once_with(*(1, 2, 3)) role_check.return_value.predicate.assert_awaited_once_with(ctx) + @mock.patch.object(silence.Silence, "_get_related_text_channel") + async def test_send_message(self, mock_get_related_text_channel): + """Test the send function reports to the correct channels.""" + text_channel_1 = MockTextChannel() + text_channel_2 = MockTextChannel() + + voice_channel = MockVoiceChannel() + voice_channel.name = "General/Offtopic" + voice_channel.mention = f"#{voice_channel.name}" + + mock_get_related_text_channel.return_value = text_channel_2 + + def reset(): + text_channel_1.reset_mock() + text_channel_2.reset_mock() + voice_channel.reset_mock() + + with self.subTest("Basic One Channel Test"): + await self.cog.send_message("Text basic message.", text_channel_1, text_channel_2, False) + text_channel_1.send.assert_called_once_with("Text basic message.") + text_channel_2.send.assert_not_called() + + reset() + with self.subTest("Basic Two Channel Test"): + await self.cog.send_message("Text basic message.", text_channel_1, text_channel_2, True) + text_channel_1.send.assert_called_once_with("Text basic message.") + text_channel_2.send.assert_called_once_with("Text basic message.") + + reset() + with self.subTest("Replacement One Channel Test"): + await self.cog.send_message("The following should be replaced: current", + text_channel_1, text_channel_2, False) + text_channel_1.send.assert_called_once_with(f"The following should be replaced: {text_channel_1.mention}") + text_channel_2.send.assert_not_called() + + reset() + with self.subTest("Replacement Two Channel Test"): + await self.cog.send_message("The following should be replaced: current", + text_channel_1, text_channel_2, True) + text_channel_1.send.assert_called_once_with(f"The following should be replaced: {text_channel_1.mention}") + text_channel_2.send.assert_called_once_with("The following should be replaced: current") + + reset() + with self.subTest("Replace Duration"): + await self.cog.send_message(f"{Emojis.check_mark} The following should be replaced: {{duration}}", + text_channel_1, text_channel_2, False) + text_channel_1.send.assert_called_once_with(f"{Emojis.check_mark} The following should be replaced: 0") + text_channel_2.send.assert_not_called() + + reset() + with self.subTest("Text and Voice"): + await self.cog.send_message("This should show up just here", + text_channel_1, voice_channel, False) + text_channel_1.send.assert_called_once_with("This should show up just here") + + reset() + with self.subTest("Text and Voice"): + await self.cog.send_message("This should show up as current", + text_channel_1, voice_channel, True) + text_channel_1.send.assert_called_once_with(f"This should show up as {voice_channel.mention}") + text_channel_2.send.assert_called_once_with(f"This should show up as {voice_channel.mention}") + + reset() + with self.subTest("Text and Voice Same Invocation"): + await self.cog.send_message("This should show up as current", + text_channel_2, voice_channel, True) + text_channel_2.send.assert_called_once_with(f"This should show up as {voice_channel.mention}") + + async def test_get_related_text_channel(self): + voice_channel = MockVoiceChannel() + + tests = ( + ("Off-Topic/General", Channels.voice_chat), + ("code/help 1", Channels.code_help_voice), + ("Staff", Channels.staff_voice), + ("ADMIN", Channels.admins_voice), + ("not in the channel list", None) + ) + + with mock.patch.object(self.cog.bot, "get_channel", lambda x: x): + for (name, channel_id) in tests: + voice_channel.name = name + voice_channel.id = channel_id + + result_id = await self.cog._get_related_text_channel(voice_channel) + self.assertEqual(result_id, channel_id) + @autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) class RescheduleTests(unittest.IsolatedAsyncioTestCase): diff --git a/tests/helpers.py b/tests/helpers.py index 870f66197..5628ca31f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -16,7 +16,6 @@ from bot.async_stats import AsyncStatsClient from bot.bot import Bot from tests._autospec import autospec # noqa: F401 other modules import it via this module - for logger in logging.Logger.manager.loggerDict.values(): # Set all loggers to CRITICAL by default to prevent screen clutter during testing @@ -320,7 +319,10 @@ channel_data = { } state = unittest.mock.MagicMock() guild = unittest.mock.MagicMock() -channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data) +text_channel_instance = discord.TextChannel(state=state, guild=guild, data=channel_data) + +channel_data["type"] = "VoiceChannel" +voice_channel_instance = discord.VoiceChannel(state=state, guild=guild, data=channel_data) class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): @@ -330,7 +332,24 @@ class MockTextChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): Instances of this class will follow the specifications of `discord.TextChannel` instances. For more information, see the `MockGuild` docstring. """ - spec_set = channel_instance + spec_set = text_channel_instance + + def __init__(self, **kwargs) -> None: + default_kwargs = {'id': next(self.discord_id), 'name': 'channel', 'guild': MockGuild()} + super().__init__(**collections.ChainMap(kwargs, default_kwargs)) + + if 'mention' not in kwargs: + self.mention = f"#{self.name}" + + +class MockVoiceChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): + """ + A MagicMock subclass to mock VoiceChannel objects. + + Instances of this class will follow the specifications of `discord.VoiceChannel` instances. For + more information, see the `MockGuild` docstring. + """ + spec_set = voice_channel_instance def __init__(self, **kwargs) -> None: default_kwargs = {'id': next(self.discord_id), 'name': 'channel', 'guild': MockGuild()} -- cgit v1.2.3 From 198dceda779df1372491057d3ef640f390e849e4 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Mon, 23 Nov 2020 18:24:24 +0300 Subject: Removes Redundant Exception Handling Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 58 +++++++++++++++--------------------------- 1 file changed, 20 insertions(+), 38 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 2aebee9d7..c60ca9327 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -6,7 +6,7 @@ from operator import attrgetter from typing import Optional, Union from async_rediscache import RedisCache -from discord import HTTPException, Member, PermissionOverwrite, TextChannel, VoiceChannel +from discord import Member, PermissionOverwrite, TextChannel, VoiceChannel from discord.ext import commands, tasks from discord.ext.commands import Context @@ -23,7 +23,6 @@ LOCK_NAMESPACE = "silence" MSG_SILENCE_FAIL = f"{Emojis.cross_mark} current channel is already silenced." MSG_SILENCE_PERMANENT = f"{Emojis.check_mark} silenced current channel indefinitely." MSG_SILENCE_SUCCESS = f"{Emojis.check_mark} silenced current channel for {{duration}} minute(s)." -MSG_SILENCE_PERM_FAIL = f"{Emojis.cross_mark} failed to force-mute members, permissions updated." MSG_UNSILENCE_FAIL = f"{Emojis.cross_mark} current channel was not silenced." MSG_UNSILENCE_MANUAL = ( @@ -163,15 +162,9 @@ class Silence(commands.Cog): channel_info = f"#{channel} ({channel.id})" log.debug(f"{ctx.author} is silencing channel {channel_info}.") - try: - if not await self._set_silence_overwrites(channel, kick): - log.info(f"Tried to silence channel {channel_info} but the channel was already silenced.") - await self.send_message(MSG_SILENCE_FAIL, ctx.channel, channel) - return - - except HTTPException: - log.info(f"Could not force mute {channel_info} members. Permissions updated.") - await self.send_message(MSG_SILENCE_PERM_FAIL, ctx.channel, channel) + if not await self._set_silence_overwrites(channel, kick): + log.info(f"Tried to silence channel {channel_info} but the channel was already silenced.") + await self.send_message(MSG_SILENCE_FAIL, ctx.channel, channel) return await self._schedule_unsilence(ctx, channel, duration) @@ -222,7 +215,9 @@ class Silence(commands.Cog): else: # Send success message to muted channel or voice chat channel, and invocation channel - await self._force_voice_sync(channel) + if isinstance(channel, VoiceChannel): + await self._force_voice_sync(channel) + await self.send_message(MSG_UNSILENCE_SUCCESS, msg_channel, channel, True) async def _set_silence_overwrites(self, channel: Union[TextChannel, VoiceChannel], kick: bool = False) -> bool: @@ -248,7 +243,6 @@ class Silence(commands.Cog): overwrite.update(connect=False) await channel.set_permissions(self._verified_voice_role, overwrite=overwrite) - await self._force_voice_sync(channel, kick=kick) await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites)) @@ -263,9 +257,7 @@ class Silence(commands.Cog): If `member` is passed, the mute only occurs to that member. Permission modification has to happen before this function. - If `kick_all` is True, members will not be added back to the voice channel - - Raises `discord.HTTPException` if the task fails. + If `kick_all` is True, members will not be added back to the voice channel. """ # Handle member picking logic if member is not None: @@ -284,20 +276,15 @@ class Silence(commands.Cog): # Obtain temporary channel afk_channel = channel.guild.afk_channel if afk_channel is None: - try: - overwrites = { - channel.guild.default_role: PermissionOverwrite(speak=False, connect=False, view_channel=False) - } - afk_channel = await channel.guild.create_voice_channel("mute-temp", overwrites=overwrites) - log.info(f"Failed to get afk-channel, created temporary channel #{afk_channel} ({afk_channel.id})") - - # Schedule channel deletion in case function errors out - self.scheduler.schedule_later(30, afk_channel.id, - afk_channel.delete(reason="Deleting temp mute channel.")) + overwrites = { + channel.guild.default_role: PermissionOverwrite(speak=False, connect=False, view_channel=False) + } + afk_channel = await channel.guild.create_voice_channel("mute-temp", overwrites=overwrites) + log.info(f"Failed to get afk-channel, created temporary channel #{afk_channel} ({afk_channel.id})") - except HTTPException as e: - log.warning("Failed to create temporary mute channel.", exc_info=e) - raise e + # Schedule channel deletion in case function errors out + self.scheduler.schedule_later(30, afk_channel.id, + afk_channel.delete(reason="Deleting temp mute channel.")) # Move all members to temporary channel and back for member in members: @@ -305,16 +292,11 @@ class Silence(commands.Cog): if self._helper_role in member.roles: continue - try: - await member.move_to(afk_channel, reason="Muting member.") - log.debug(f"Moved {member.name} to afk channel.") - - await member.move_to(channel, reason="Muting member.") - log.debug(f"Moved {member.name} to original voice channel.") + await member.move_to(afk_channel, reason="Muting member.") + log.debug(f"Moved {member.name} to afk channel.") - except HTTPException as e: - log.warning(f"Failed to move {member.name} while muting, falling back to kick.", exc_info=e) - await member.move_to(None, reason="Forcing member mute.") + await member.move_to(channel, reason="Muting member.") + log.debug(f"Moved {member.name} to original voice channel.") async def _schedule_unsilence(self, ctx: Context, channel: Union[TextChannel, VoiceChannel], duration: Optional[int]) -> None: -- cgit v1.2.3 From 00a26b95bc248e3e7b13263df8c6c0d46ebbb2ff Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Mon, 23 Nov 2020 23:09:13 +0300 Subject: Adds Silence & Unsilence UnitTests Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- tests/bot/exts/moderation/test_silence.py | 212 +++++++++++++++++++++++++++++- 1 file changed, 211 insertions(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 577725071..1d05ee357 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -9,7 +9,7 @@ from discord import PermissionOverwrite from bot.constants import Channels, Emojis, Guild, Roles from bot.exts.moderation import silence -from tests.helpers import MockBot, MockContext, MockTextChannel, MockVoiceChannel, autospec +from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockTextChannel, MockVoiceChannel, autospec redis_session = None redis_loop = asyncio.get_event_loop() @@ -237,6 +237,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): text_channel_2.send.assert_called_once_with(f"This should show up as {voice_channel.mention}") async def test_get_related_text_channel(self): + """Tests the helper function that connects voice to text channels.""" voice_channel = MockVoiceChannel() tests = ( @@ -255,6 +256,72 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): result_id = await self.cog._get_related_text_channel(voice_channel) self.assertEqual(result_id, channel_id) + async def test_force_voice_sync(self): + """Tests the _force_voice_sync helper function.""" + await self.cog._async_init() + + afk_channel = MockVoiceChannel() + channel = MockVoiceChannel(guild=MockGuild(afk_channel=afk_channel)) + + members = [] + for _ in range(10): + members.append(MockMember()) + + channel.members = members + test_cases = ( + (members[0], False, "Muting member."), + (members[0], True, "Kicking voice channel member."), + (None, False, "Muting member."), + (None, True, "Kicking voice channel member."), + ) + + for member, kick, reason in test_cases: + with self.subTest(members=member, kick=kick, reason=reason): + await self.cog._force_voice_sync(channel, member, kick) + + for single_member in channel.members if member is None else [member]: + if kick: + single_member.move_to.assert_called_once_with(None, reason=reason) + else: + self.assertEqual(single_member.move_to.call_count, 2) + single_member.move_to.assert_has_calls([ + mock.call(afk_channel, reason=reason), + mock.call(channel, reason=reason) + ], any_order=False) + + single_member.reset_mock() + + async def test_force_voice_sync_staff(self): + """Tests to ensure _force_voice_sync does not kick staff members.""" + await self.cog._async_init() + member = MockMember(roles=[self.cog._helper_role]) + + await self.cog._force_voice_sync(MockVoiceChannel(), member) + member.move_to.assert_not_called() + + async def test_force_voice_sync_no_channel(self): + """Test to ensure _force_voice_sync can create its own voice channel if one is not available.""" + await self.cog._async_init() + + member = MockMember() + channel = MockVoiceChannel(guild=MockGuild(afk_channel=None)) + + new_channel = MockVoiceChannel(delete=Mock()) + channel.guild.create_voice_channel.return_value = new_channel + + with mock.patch.object(self.cog.scheduler, "schedule_later") as scheduler: + await self.cog._force_voice_sync(channel, member) + + # Check channel creation + overwrites = { + channel.guild.default_role: PermissionOverwrite(speak=False, connect=False, view_channel=False) + } + channel.guild.create_voice_channel.assert_called_once_with("mute-temp", overwrites=overwrites) + + # Check bot queued deletion + new_channel.delete.assert_called_once_with(reason="Deleting temp mute channel.") + scheduler.assert_called_once_with(30, new_channel.id, new_channel.delete()) + @autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) class RescheduleTests(unittest.IsolatedAsyncioTestCase): @@ -366,6 +433,31 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog.silence.callback(self.cog, ctx, duration) ctx.channel.send.assert_called_once_with(message) + async def test_sent_to_correct_channel(self): + """Test function sends messages to the correct channels.""" + text_channel = MockTextChannel() + ctx = MockContext() + + test_cases = ( + (None, silence.MSG_SILENCE_SUCCESS.format(duration=10)), + (text_channel, silence.MSG_SILENCE_SUCCESS.format(duration=10)), + (ctx.channel, silence.MSG_SILENCE_SUCCESS.format(duration=10)), + ) + + for target, message in test_cases: + with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True): + with self.subTest(target_channel=target, message=message): + await self.cog.silence.callback(self.cog, ctx, 10, False, channel=target) + if ctx.channel == target or target is None: + ctx.channel.send.assert_called_once_with(message) + else: + ctx.channel.send.assert_called_once_with(message.replace("current", text_channel.mention)) + target.send.assert_called_once_with(message) + + ctx.channel.send.reset_mock() + if target is not None: + target.send.reset_mock() + async def test_skipped_already_silenced(self): """Permissions were not set and `False` was returned for an already silenced channel.""" subtests = ( @@ -509,6 +601,40 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog.unsilence.callback(self.cog, ctx) ctx.channel.send.assert_called_once_with(message) + async def test_sent_to_correct_channel(self): + """Test function sends messages to the correct channels.""" + unsilenced_overwrite = PermissionOverwrite(send_messages=True, add_reactions=True) + text_channel = MockTextChannel() + ctx = MockContext() + + test_cases = ( + (None, silence.MSG_UNSILENCE_SUCCESS.format(duration=10)), + (text_channel, silence.MSG_UNSILENCE_SUCCESS.format(duration=10)), + (ctx.channel, silence.MSG_UNSILENCE_SUCCESS.format(duration=10)), + ) + + for target, message in test_cases: + with self.subTest(target_channel=target, message=message): + with mock.patch.object(self.cog, "_unsilence", return_value=True): + # Assign Return + if ctx.channel == target or target is None: + ctx.channel.overwrites_for.return_value = unsilenced_overwrite + else: + target.overwrites_for.return_value = unsilenced_overwrite + + await self.cog.unsilence.callback(self.cog, ctx, channel=target) + + # Check Messages + if ctx.channel == target or target is None: + ctx.channel.send.assert_called_once_with(message) + else: + ctx.channel.send.assert_called_once_with(message.replace("current", text_channel.mention)) + target.send.assert_called_once_with(message) + + ctx.channel.send.reset_mock() + if target is not None: + target.send.reset_mock() + async def test_skipped_already_unsilenced(self): """Permissions were not set and `False` was returned for an already unsilenced channel.""" self.cog.scheduler.__contains__.return_value = False @@ -549,6 +675,10 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._unsilence(self.channel) self.cog._mod_alerts_channel.send.assert_awaited_once() + self.cog._mod_alerts_channel.send.reset_mock() + + await self.cog._unsilence(MockVoiceChannel()) + self.cog._mod_alerts_channel.send.assert_awaited_once() async def test_removed_notifier(self): """Channel was removed from `notifier`.""" @@ -587,3 +717,83 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): del new_overwrite_dict['add_reactions'] self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) + + async def test_unsilence_helper_fail(self): + """Tests unsilence_wrapper when `_unsilence` fails.""" + ctx = MockContext() + + text_channel = MockTextChannel() + text_role = self.cog.bot.get_guild(Guild.id).get_role(Roles.verified) + + voice_channel = MockVoiceChannel() + voice_role = self.cog.bot.get_guild(Guild.id).get_role(Roles.voice_verified) + + test_cases = ( + (ctx, text_channel, text_role, True, silence.MSG_UNSILENCE_FAIL), + (ctx, text_channel, text_role, False, silence.MSG_UNSILENCE_MANUAL), + (ctx, voice_channel, voice_role, True, silence.MSG_UNSILENCE_FAIL), + (ctx, voice_channel, voice_role, False, silence.MSG_UNSILENCE_MANUAL), + ) + + class PermClass: + """Class to Mock return permissions""" + def __init__(self, value: bool): + self.val = value + + def __getattr__(self, item): + return self.val + + for context, channel, role, permission, message in test_cases: + with self.subTest(channel=channel, message=message): + with mock.patch.object(channel, "overwrites_for", return_value=PermClass(permission)) as overwrites: + with mock.patch.object(self.cog, "send_message") as send_message: + with mock.patch.object(self.cog, "_unsilence", return_value=False): + await self.cog._unsilence_wrapper(channel, context) + + overwrites.assert_called_once_with(role) + send_message.assert_called_once_with(message, ctx.channel, channel) + + async def test_correct_overwrites(self): + """Tests the overwrites returned by the _unsilence_wrapper are correct for voice and text channels.""" + ctx = MockContext() + + text_channel = MockTextChannel() + text_role = self.cog.bot.get_guild(Guild.id).get_role(Roles.verified) + + voice_channel = MockVoiceChannel() + voice_role = self.cog.bot.get_guild(Guild.id).get_role(Roles.voice_verified) + + async def reset(): + await text_channel.set_permissions(text_role, PermissionOverwrite(send_messages=False, add_reactions=False)) + await voice_channel.set_permissions(voice_role, PermissionOverwrite(speak=False, connect=False)) + + text_channel.reset_mock() + voice_channel.reset_mock() + await reset() + + default_text_overwrites = text_channel.overwrites_for(text_role) + default_voice_overwrites = voice_channel.overwrites_for(voice_role) + + test_cases = ( + (ctx, text_channel, text_role, default_text_overwrites, silence.MSG_UNSILENCE_SUCCESS), + (ctx, voice_channel, voice_role, default_voice_overwrites, silence.MSG_UNSILENCE_SUCCESS), + (ctx, ctx.channel, text_role, ctx.channel.overwrites_for(text_role), silence.MSG_UNSILENCE_SUCCESS), + (None, text_channel, text_role, default_text_overwrites, silence.MSG_UNSILENCE_SUCCESS), + ) + + for context, channel, role, overwrites, message in test_cases: + with self.subTest(ctx=context, channel=channel): + with mock.patch.object(self.cog, "send_message") as send_message: + with mock.patch.object(self.cog, "_force_voice_sync"): + await self.cog._unsilence_wrapper(channel, context) + + if context is None: + send_message.assert_called_once_with(message, channel, channel, True) + else: + send_message.assert_called_once_with(message, context.channel, channel, True) + + channel.set_permissions.assert_called_once_with(role, overwrite=overwrites) + if channel != ctx.channel: + ctx.channel.assert_not_called() + + await reset() -- cgit v1.2.3 From dc202302fa9bfa7ab54d80cc486acdd5b278ad6a Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Mon, 23 Nov 2020 23:50:23 +0300 Subject: Finalizes Silence & Unsilence UnitTests Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- tests/bot/exts/moderation/test_silence.py | 49 ++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 1d05ee357..2392e6e59 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -415,9 +415,13 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): asyncio.run(self.cog._async_init()) # Populate instance attributes. - self.channel = MockTextChannel() - self.overwrite = PermissionOverwrite(stream=True, send_messages=True, add_reactions=False) - self.channel.overwrites_for.return_value = self.overwrite + self.text_channel = MockTextChannel() + self.text_overwrite = PermissionOverwrite(stream=True, send_messages=True, add_reactions=False) + self.text_channel.overwrites_for.return_value = self.text_overwrite + + self.voice_channel = MockVoiceChannel() + self.voice_overwrite = PermissionOverwrite(speak=True) + self.voice_channel.overwrites_for.return_value = self.voice_overwrite async def test_sent_correct_message(self): """Appropriate failure/success message was sent by the command.""" @@ -477,19 +481,19 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_silenced_channel(self): """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" - self.assertTrue(await self.cog._set_silence_overwrites(self.channel)) - self.assertFalse(self.overwrite.send_messages) - self.assertFalse(self.overwrite.add_reactions) - self.channel.set_permissions.assert_awaited_once_with( + self.assertTrue(await self.cog._set_silence_overwrites(self.text_channel)) + self.assertFalse(self.text_overwrite.send_messages) + self.assertFalse(self.text_overwrite.add_reactions) + self.text_channel.set_permissions.assert_awaited_once_with( self.cog._verified_msg_role, - overwrite=self.overwrite + overwrite=self.text_overwrite ) async def test_preserved_other_overwrites(self): """Channel's other unrelated overwrites were not changed.""" - prev_overwrite_dict = dict(self.overwrite) - await self.cog._set_silence_overwrites(self.channel) - new_overwrite_dict = dict(self.overwrite) + prev_overwrite_dict = dict(self.text_overwrite) + await self.cog._set_silence_overwrites(self.text_channel) + new_overwrite_dict = dict(self.text_overwrite) # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. del prev_overwrite_dict['send_messages'] @@ -520,8 +524,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_cached_previous_overwrites(self): """Channel's previous overwrites were cached.""" overwrite_json = '{"send_messages": true, "add_reactions": false}' - await self.cog._set_silence_overwrites(self.channel) - self.cog.previous_overwrites.set.assert_called_once_with(self.channel.id, overwrite_json) + await self.cog._set_silence_overwrites(self.text_channel) + self.cog.previous_overwrites.set.assert_called_once_with(self.text_channel.id, overwrite_json) @autospec(silence, "datetime") async def test_cached_unsilence_time(self, datetime_mock): @@ -531,7 +535,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): timestamp = now_timestamp + duration * 60 datetime_mock.now.return_value = datetime.fromtimestamp(now_timestamp, tz=timezone.utc) - ctx = MockContext(channel=self.channel) + ctx = MockContext(channel=self.text_channel) await self.cog.silence.callback(self.cog, ctx, duration) self.cog.unsilence_timestamps.set.assert_awaited_once_with(ctx.channel.id, timestamp) @@ -539,13 +543,13 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_cached_indefinite_time(self): """A value of -1 was cached for a permanent silence.""" - ctx = MockContext(channel=self.channel) + ctx = MockContext(channel=self.text_channel) await self.cog.silence.callback(self.cog, ctx, None) self.cog.unsilence_timestamps.set.assert_awaited_once_with(ctx.channel.id, -1) async def test_scheduled_task(self): """An unsilence task was scheduled.""" - ctx = MockContext(channel=self.channel, invoke=mock.MagicMock()) + ctx = MockContext(channel=self.text_channel, invoke=mock.MagicMock()) await self.cog.silence.callback(self.cog, ctx, 5) @@ -555,10 +559,21 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_permanent_not_scheduled(self): """A task was not scheduled for a permanent silence.""" - ctx = MockContext(channel=self.channel) + ctx = MockContext(channel=self.text_channel) await self.cog.silence.callback(self.cog, ctx, None) self.cog.scheduler.schedule_later.assert_not_called() + async def test_correct_permission_updates(self): + """Tests if _set_silence_overwrites can correctly get and update permissions.""" + self.assertTrue(await self.cog._set_silence_overwrites(self.text_channel)) + self.assertFalse(self.text_overwrite.send_messages or self.text_overwrite.add_reactions) + + self.assertTrue(await self.cog._set_silence_overwrites(self.voice_channel)) + self.assertFalse(self.voice_overwrite.speak) + + self.assertTrue(await self.cog._set_silence_overwrites(self.voice_channel, True)) + self.assertFalse(self.voice_overwrite.speak or self.voice_overwrite.connect) + @autospec(silence.Silence, "unsilence_timestamps", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From 1cfd1a95a3d6351555b1520249b423ac7a9f2e06 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 24 Nov 2020 11:52:45 +0300 Subject: Refractors For Style Guidelines Refractors method signatures and calls to follow python-discord style guide. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index c60ca9327..4d62588a1 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -117,9 +117,10 @@ class Silence(commands.Cog): if name in channel.name.lower(): return self.bot.get_channel(channels[name]) - async def send_message(self, message: str, source_channel: TextChannel, - target_channel: Union[TextChannel, VoiceChannel], - alert_target: bool = False, duration: HushDurationConverter = 0) -> None: + async def send_message( + self, message: str, source_channel: TextChannel, target_channel: Union[TextChannel, VoiceChannel], + alert_target: bool = False, duration: int = 0 + ) -> None: """Helper function to send message confirmation to `source_channel`, and notification to `target_channel`.""" await source_channel.send( message.replace("current", target_channel.mention if source_channel != target_channel else "current") @@ -145,8 +146,10 @@ class Silence(commands.Cog): @commands.command(aliases=("hush",)) @lock_arg(LOCK_NAMESPACE, "ctx", attrgetter("channel"), raise_error=True) - async def silence(self, ctx: Context, duration: HushDurationConverter = 10, kick: bool = False, - *, channel: Optional[AnyChannelConverter] = None) -> None: + async def silence( + self, ctx: Context, duration: HushDurationConverter = 10, kick: bool = False, + *, channel: Optional[AnyChannelConverter] = None + ) -> None: """ Silence the current channel for `duration` minutes or `forever`. @@ -192,8 +195,9 @@ class Silence(commands.Cog): await self._unsilence_wrapper(channel, ctx) @lock_arg(LOCK_NAMESPACE, "channel", raise_error=True) - async def _unsilence_wrapper(self, channel: Union[TextChannel, VoiceChannel], - ctx: Optional[Context] = None) -> None: + async def _unsilence_wrapper( + self, channel: Union[TextChannel, VoiceChannel], ctx: Optional[Context] = None + ) -> None: """Unsilence `channel` and send a success/failure message.""" msg_channel = channel if ctx is not None: @@ -249,8 +253,9 @@ class Silence(commands.Cog): return True - async def _force_voice_sync(self, channel: VoiceChannel, member: Optional[Member] = None, - kick: bool = False) -> None: + async def _force_voice_sync( + self, channel: VoiceChannel, member: Optional[Member] = None, kick: bool = False + ) -> None: """ Move all non-staff members from `channel` to a temporary channel and back to force toggle role mute. @@ -283,8 +288,9 @@ class Silence(commands.Cog): log.info(f"Failed to get afk-channel, created temporary channel #{afk_channel} ({afk_channel.id})") # Schedule channel deletion in case function errors out - self.scheduler.schedule_later(30, afk_channel.id, - afk_channel.delete(reason="Deleting temp mute channel.")) + self.scheduler.schedule_later( + 30, afk_channel.id, afk_channel.delete(reason="Deleting temp mute channel.") + ) # Move all members to temporary channel and back for member in members: @@ -298,8 +304,9 @@ class Silence(commands.Cog): await member.move_to(channel, reason="Muting member.") log.debug(f"Moved {member.name} to original voice channel.") - async def _schedule_unsilence(self, ctx: Context, channel: Union[TextChannel, VoiceChannel], - duration: Optional[int]) -> None: + async def _schedule_unsilence( + self, ctx: Context, channel: Union[TextChannel, VoiceChannel], duration: Optional[int] + ) -> None: """Schedule `ctx.channel` to be unsilenced if `duration` is not None.""" if duration is None: await self.unsilence_timestamps.set(channel.id, -1) -- cgit v1.2.3 From b4e65a7b2578fe34296ed302c1ddba39df11bbd4 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 24 Nov 2020 11:55:30 +0300 Subject: Fixes Voice Channel Access A typo caused the function to return the text channel for `code/help 1`, when it is meant to access `code/help 2`. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 4d62588a1..62f3ede73 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -109,7 +109,7 @@ class Silence(commands.Cog): channels = { "off-topic": Channels.voice_chat, "code/help 1": Channels.code_help_voice, - "code/help 2": Channels.code_help_voice, + "code/help 2": Channels.code_help_voice_2, "admin": Channels.admins_voice, "staff": Channels.staff_voice } -- cgit v1.2.3 From b2d88f860d5ca9d08c72be676a7bfdfd5943b417 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 24 Nov 2020 12:20:01 +0300 Subject: Removes AnyChannel Converter Removes the AnyChannel converter in favor of a combination of Text and Voice converters. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/converters.py | 40 ----------------------------------- bot/exts/moderation/silence.py | 6 +++--- tests/bot/test_converters.py | 48 ------------------------------------------ 3 files changed, 3 insertions(+), 91 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 613be73eb..2e118d476 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -536,46 +536,6 @@ class FetchedUser(UserConverter): raise BadArgument(f"User `{arg}` does not exist") -class AnyChannelConverter(Converter): - """ - Converts to a `discord.Channel` or, raises an error. - - Unlike the default Channel Converter, this converter can handle channels given - in string, id, or mention formatting for both `TextChannel`s and `VoiceChannel`s. - Always returns 1 or fewer channels, errors if more than one match exists. - - It is able to handle the following formats (caveats noted below:) - 1. Convert from ID - Example: 267631170882240512 - 2. Convert from Explicit Mention - Example: #welcome - 3. Convert from ID Mention - Example: <#267631170882240512> - 4. Convert from Unmentioned Name: - Example: welcome - - All the previous conversions are valid for both text and voice channels, but explicit - raw names (#4) do not work for non-unique channels, instead opting for an error. - Explicit mentions (#2) do not work for non-unique voice channels either. - """ - - async def convert(self, ctx: Context, arg: str) -> t.Union[discord.TextChannel, discord.VoiceChannel]: - """Convert the `arg` to a `TextChannel` or `VoiceChannel`.""" - stripped = arg.strip().lstrip("<").lstrip("#").rstrip(">").lower() - - # Filter channels by name and ID - channels = [channel for channel in ctx.guild.channels if stripped in (channel.name.lower(), str(channel.id))] - - if len(channels) == 0: - # Couldn't find a matching channel - log.debug(f"Could not convert `{arg}` to channel, no matches found.") - raise BadArgument(f"{arg} returned no matches.") - - elif len(channels) > 1: - # Couldn't discern the desired channel - log.debug(f"Could not convert `{arg}` to channel, {len(channels)} matches found.") - raise BadArgument(f"The provided argument returned too many matches ({len(channels)}).") - - else: - return channels[0] - - def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int: """ Extract the snowflake from `arg` using a regex `pattern` and return it as an int. diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 62f3ede73..8ad30f0d9 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -12,7 +12,7 @@ from discord.ext.commands import Context from bot.bot import Bot from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles -from bot.converters import AnyChannelConverter, HushDurationConverter +from bot.converters import HushDurationConverter from bot.utils.lock import LockedResourceError, lock_arg from bot.utils.scheduling import Scheduler @@ -148,7 +148,7 @@ class Silence(commands.Cog): @lock_arg(LOCK_NAMESPACE, "ctx", attrgetter("channel"), raise_error=True) async def silence( self, ctx: Context, duration: HushDurationConverter = 10, kick: bool = False, - *, channel: Optional[AnyChannelConverter] = None + *, channel: Union[TextChannel, VoiceChannel] = None ) -> None: """ Silence the current channel for `duration` minutes or `forever`. @@ -182,7 +182,7 @@ class Silence(commands.Cog): await self.send_message(MSG_SILENCE_SUCCESS, ctx.channel, channel, True, duration) @commands.command(aliases=("unhush",)) - async def unsilence(self, ctx: Context, *, channel: AnyChannelConverter = None) -> None: + async def unsilence(self, ctx: Context, *, channel: Union[TextChannel, VoiceChannel] = None) -> None: """ Unsilence the given channel if given, else the current one. diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index 6bea71977..c42111f3f 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -7,7 +7,6 @@ from dateutil.relativedelta import relativedelta from discord.ext.commands import BadArgument from bot.converters import ( - AnyChannelConverter, Duration, HushDurationConverter, ISODateTime, @@ -25,18 +24,6 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase): cls.context = MagicMock cls.context.author = 'bob' - cls.context.guild = MagicMock - cls.context.guild.channels = [] - - for i in range(10): - channel = MagicMock() - channel.name = f"test-{i}" - channel.id = str(i) * 18 - - cls.context.guild.channels.append(channel) - if i > 7: - cls.context.guild.channels.append(channel) - cls.fixed_utc_now = datetime.datetime.fromisoformat('2019-01-01T00:00:00') async def test_tag_content_converter_for_valid(self): @@ -325,38 +312,3 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase): with self.subTest(invalid_minutes_string=invalid_minutes_string, exception_message=exception_message): with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): await converter.convert(self.context, invalid_minutes_string) - - async def test_any_channel_converter_for_valid(self): - """AnyChannelConverter returns correct channel from input.""" - test_values = ( - ("test-0", self.context.guild.channels[0]), - ("#test-0", self.context.guild.channels[0]), - (" #tEsT-0 ", self.context.guild.channels[0]), - (f"<#{'0' * 18}>", self.context.guild.channels[0]), - (f"{'0' * 18}", self.context.guild.channels[0]), - ("test-5", self.context.guild.channels[5]), - ("#test-5", self.context.guild.channels[5]), - (f"<#{'5' * 18}>", self.context.guild.channels[5]), - (f"{'5' * 18}", self.context.guild.channels[5]), - ) - - converter = AnyChannelConverter() - for input_string, expected_channel in test_values: - with self.subTest(input_string=input_string, expected_channel=expected_channel): - converted = await converter.convert(self.context, input_string) - self.assertEqual(expected_channel, converted) - - async def test_any_channel_converter_for_invalid(self): - """AnyChannelConverter raises BadArgument for invalid channels.""" - test_values = ( - ("#test-8", "The provided argument returned too many matches (2)."), - ("#test-9", "The provided argument returned too many matches (2)."), - ("#random-name", "#random-name returned no matches."), - ("test-10", "test-10 returned no matches.") - ) - - converter = AnyChannelConverter() - for invalid_input, exception_message in test_values: - with self.subTest(invalid_input=invalid_input, exception_message=exception_message): - with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): - await converter.convert(self.context, invalid_input) -- cgit v1.2.3 From c37e9842602f6e8e7bb2cc0897a6c8e0988f7cff Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 24 Nov 2020 12:29:51 +0300 Subject: Fixes Typo in Doc Co-authored-by: Mark --- tests/bot/exts/moderation/test_silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 2392e6e59..5c6e2d0f1 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -564,7 +564,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.cog.scheduler.schedule_later.assert_not_called() async def test_correct_permission_updates(self): - """Tests if _set_silence_overwrites can correctly get and update permissions.""" + """Tests if _set_silence_overwrites can correctly get and update permissions.""" self.assertTrue(await self.cog._set_silence_overwrites(self.text_channel)) self.assertFalse(self.text_overwrite.send_messages or self.text_overwrite.add_reactions) -- cgit v1.2.3 From 7cb3024e71cd81e9ef29f5f10cb2bc5fe62ad846 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 24 Nov 2020 13:19:51 +0300 Subject: Refractors SendMessage Function Refractors the send message function in silence to make it more understandable and flexible. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 31 ++++++++---------- tests/bot/exts/moderation/test_silence.py | 54 ++++++++++++++++++------------- 2 files changed, 46 insertions(+), 39 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 8ad30f0d9..7bc51ee93 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -26,7 +26,7 @@ MSG_SILENCE_SUCCESS = f"{Emojis.check_mark} silenced current channel for {{durat MSG_UNSILENCE_FAIL = f"{Emojis.cross_mark} current channel was not silenced." MSG_UNSILENCE_MANUAL = ( - f"{Emojis.cross_mark} current channel was not unsilenced because the channel overwrites were " + f"{Emojis.cross_mark} current channel was not unsilenced because the current overwrites were " f"set manually or the cache was prematurely cleared. " f"Please edit the overwrites manually to unsilence." ) @@ -119,30 +119,27 @@ class Silence(commands.Cog): async def send_message( self, message: str, source_channel: TextChannel, target_channel: Union[TextChannel, VoiceChannel], - alert_target: bool = False, duration: int = 0 + alert_target: bool = False ) -> None: """Helper function to send message confirmation to `source_channel`, and notification to `target_channel`.""" - await source_channel.send( - message.replace("current", target_channel.mention if source_channel != target_channel else "current") - .replace("{duration}", str(duration)) - ) - + # Get TextChannel connected to VoiceChannel if channel is of type voice voice_chat = None if isinstance(target_channel, VoiceChannel): - # Send to relevant channel voice_chat = await self._get_related_text_channel(target_channel) - if alert_target and source_channel != target_channel: - if isinstance(target_channel, VoiceChannel): - if voice_chat is None or voice_chat == source_channel: - return + # Reply to invocation channel + source_reply = message + if source_channel != target_channel: + source_reply = source_reply.replace("current channel", target_channel.mention) + await source_channel.send(source_reply) - await voice_chat.send( - message.replace("{duration}", str(duration)).replace("current", target_channel.mention) - ) + # Reply to target channel + if alert_target and source_channel != target_channel and source_channel != voice_chat: + if isinstance(target_channel, VoiceChannel) and (voice_chat is not None or voice_chat != source_channel): + await voice_chat.send(message.replace("current channel", target_channel.mention)) else: - await target_channel.send(message.replace("{duration}", str(duration))) + await target_channel.send(message) @commands.command(aliases=("hush",)) @lock_arg(LOCK_NAMESPACE, "ctx", attrgetter("channel"), raise_error=True) @@ -179,7 +176,7 @@ class Silence(commands.Cog): else: log.info(f"Silenced {channel_info} for {duration} minute(s).") - await self.send_message(MSG_SILENCE_SUCCESS, ctx.channel, channel, True, duration) + await self.send_message(MSG_SILENCE_SUCCESS.format(duration=duration), ctx.channel, channel, True) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context, *, channel: Union[TextChannel, VoiceChannel] = None) -> None: diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 5c6e2d0f1..70678d207 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -7,7 +7,7 @@ from unittest.mock import Mock from async_rediscache import RedisSession from discord import PermissionOverwrite -from bot.constants import Channels, Emojis, Guild, Roles +from bot.constants import Channels, Guild, Roles from bot.exts.moderation import silence from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockTextChannel, MockVoiceChannel, autospec @@ -198,42 +198,48 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): reset() with self.subTest("Replacement One Channel Test"): - await self.cog.send_message("The following should be replaced: current", - text_channel_1, text_channel_2, False) - text_channel_1.send.assert_called_once_with(f"The following should be replaced: {text_channel_1.mention}") + await self.cog.send_message( + "Current. The following should be replaced: current channel.", text_channel_1, text_channel_2, False + ) + + text_channel_1.send.assert_called_once_with( + f"Current. The following should be replaced: {text_channel_1.mention}." + ) + text_channel_2.send.assert_not_called() reset() with self.subTest("Replacement Two Channel Test"): - await self.cog.send_message("The following should be replaced: current", - text_channel_1, text_channel_2, True) - text_channel_1.send.assert_called_once_with(f"The following should be replaced: {text_channel_1.mention}") - text_channel_2.send.assert_called_once_with("The following should be replaced: current") + await self.cog.send_message( + "Current. The following should be replaced: current channel.", text_channel_1, text_channel_2, True + ) - reset() - with self.subTest("Replace Duration"): - await self.cog.send_message(f"{Emojis.check_mark} The following should be replaced: {{duration}}", - text_channel_1, text_channel_2, False) - text_channel_1.send.assert_called_once_with(f"{Emojis.check_mark} The following should be replaced: 0") - text_channel_2.send.assert_not_called() + text_channel_1.send.assert_called_once_with( + f"Current. The following should be replaced: {text_channel_1.mention}." + ) + + text_channel_2.send.assert_called_once_with("Current. The following should be replaced: current channel.") reset() with self.subTest("Text and Voice"): - await self.cog.send_message("This should show up just here", - text_channel_1, voice_channel, False) + await self.cog.send_message( + "This should show up just here", text_channel_1, voice_channel, False + ) text_channel_1.send.assert_called_once_with("This should show up just here") reset() with self.subTest("Text and Voice"): - await self.cog.send_message("This should show up as current", - text_channel_1, voice_channel, True) + await self.cog.send_message( + "This should show up as current channel", text_channel_1, voice_channel, True + ) text_channel_1.send.assert_called_once_with(f"This should show up as {voice_channel.mention}") text_channel_2.send.assert_called_once_with(f"This should show up as {voice_channel.mention}") reset() with self.subTest("Text and Voice Same Invocation"): - await self.cog.send_message("This should show up as current", - text_channel_2, voice_channel, True) + await self.cog.send_message( + "This should show up as current channel", text_channel_2, voice_channel, True + ) text_channel_2.send.assert_called_once_with(f"This should show up as {voice_channel.mention}") async def test_get_related_text_channel(self): @@ -455,7 +461,9 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): if ctx.channel == target or target is None: ctx.channel.send.assert_called_once_with(message) else: - ctx.channel.send.assert_called_once_with(message.replace("current", text_channel.mention)) + ctx.channel.send.assert_called_once_with( + message.replace("current channel", text_channel.mention) + ) target.send.assert_called_once_with(message) ctx.channel.send.reset_mock() @@ -643,7 +651,9 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): if ctx.channel == target or target is None: ctx.channel.send.assert_called_once_with(message) else: - ctx.channel.send.assert_called_once_with(message.replace("current", text_channel.mention)) + ctx.channel.send.assert_called_once_with( + message.replace("current channel", text_channel.mention) + ) target.send.assert_called_once_with(message) ctx.channel.send.reset_mock() -- cgit v1.2.3 From d2d7db69c4e54120d36146e2334f702f4259ed6b Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 24 Nov 2020 20:36:48 +0300 Subject: Moves VoiceChat Sync Out of Overwrites Function VoiceChat sync only needs to be called when the command is invoked, instead of while updating permissions. Moved call to command function to reflect that, and fixed failing tests. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 11 +++++++---- tests/bot/exts/moderation/test_silence.py | 12 +++++++----- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 7bc51ee93..64ffaa347 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -134,9 +134,10 @@ class Silence(commands.Cog): await source_channel.send(source_reply) # Reply to target channel - if alert_target and source_channel != target_channel and source_channel != voice_chat: - if isinstance(target_channel, VoiceChannel) and (voice_chat is not None or voice_chat != source_channel): - await voice_chat.send(message.replace("current channel", target_channel.mention)) + if alert_target and source_channel not in [target_channel, voice_chat]: + if isinstance(target_channel, VoiceChannel): + if voice_chat is not None: + await voice_chat.send(message.replace("current channel", target_channel.mention)) else: await target_channel.send(message) @@ -167,6 +168,9 @@ class Silence(commands.Cog): await self.send_message(MSG_SILENCE_FAIL, ctx.channel, channel) return + if isinstance(channel, VoiceChannel): + await self._force_voice_sync(channel, kick=kick) + await self._schedule_unsilence(ctx, channel, duration) if duration is None: @@ -244,7 +248,6 @@ class Silence(commands.Cog): overwrite.update(connect=False) await channel.set_permissions(self._verified_voice_role, overwrite=overwrite) - await self._force_voice_sync(channel, kick=kick) await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites)) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 70678d207..aab607392 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -446,11 +446,13 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_sent_to_correct_channel(self): """Test function sends messages to the correct channels.""" text_channel = MockTextChannel() + voice_channel = MockVoiceChannel() ctx = MockContext() test_cases = ( (None, silence.MSG_SILENCE_SUCCESS.format(duration=10)), (text_channel, silence.MSG_SILENCE_SUCCESS.format(duration=10)), + (voice_channel, silence.MSG_SILENCE_SUCCESS.format(duration=10)), (ctx.channel, silence.MSG_SILENCE_SUCCESS.format(duration=10)), ) @@ -460,14 +462,14 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog.silence.callback(self.cog, ctx, 10, False, channel=target) if ctx.channel == target or target is None: ctx.channel.send.assert_called_once_with(message) + else: - ctx.channel.send.assert_called_once_with( - message.replace("current channel", text_channel.mention) - ) - target.send.assert_called_once_with(message) + ctx.channel.send.assert_called_once_with(message.replace("current channel", target.mention)) + if isinstance(target, MockTextChannel): + target.send.assert_called_once_with(message) ctx.channel.send.reset_mock() - if target is not None: + if target is not None and isinstance(target, MockTextChannel): target.send.reset_mock() async def test_skipped_already_silenced(self): -- cgit v1.2.3 From e50bc5f507fe62afc534d286c5fc380d72c75b36 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 24 Nov 2020 20:58:58 +0300 Subject: Move VoiceChat Sync To _unsilence Moves the call to voice chat sync from _unsilence_wrapper to _unsilence. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 64ffaa347..fd051a0ff 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -219,10 +219,6 @@ class Silence(commands.Cog): await self.send_message(MSG_UNSILENCE_FAIL, msg_channel, channel) else: - # Send success message to muted channel or voice chat channel, and invocation channel - if isinstance(channel, VoiceChannel): - await self._force_voice_sync(channel) - await self.send_message(MSG_UNSILENCE_SUCCESS, msg_channel, channel, True) async def _set_silence_overwrites(self, channel: Union[TextChannel, VoiceChannel], kick: bool = False) -> bool: @@ -345,6 +341,8 @@ class Silence(commands.Cog): await channel.set_permissions(self._verified_msg_role, overwrite=overwrite) else: await channel.set_permissions(self._verified_voice_role, overwrite=overwrite) + await self._force_voice_sync(channel) + log.info(f"Unsilenced channel #{channel} ({channel.id}).") self.scheduler.cancel(channel.id) -- cgit v1.2.3 From 675d7504977acafdb73d6a51e91228180a7c02a2 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 24 Nov 2020 20:59:43 +0300 Subject: Fixes Typo in Silence Tests Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- tests/bot/exts/moderation/test_silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index aab607392..8f3c1cb8b 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -821,6 +821,6 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): channel.set_permissions.assert_called_once_with(role, overwrite=overwrites) if channel != ctx.channel: - ctx.channel.assert_not_called() + ctx.channel.send.assert_not_called() await reset() -- cgit v1.2.3 From 7f44647e4cff507ffed200a4f29edb18775d4388 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 24 Nov 2020 21:15:09 +0300 Subject: Adds Connect to Reset Permissions During unsilencing, if the previous channel overwrites are None, the channel should default to None for all relevant permissions. Adds the connect permission as it was missing. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index fd051a0ff..e13911d9e 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -333,7 +333,7 @@ class Silence(commands.Cog): if prev_overwrites is None: log.info(f"Missing previous overwrites for #{channel} ({channel.id}); defaulting to None.") - overwrite.update(send_messages=None, add_reactions=None, speak=None) + overwrite.update(send_messages=None, add_reactions=None, speak=None, connect=None) else: overwrite.update(**json.loads(prev_overwrites)) @@ -360,7 +360,7 @@ class Silence(commands.Cog): else: await self._mod_alerts_channel.send( f"<@&{Roles.admins}> Restored overwrites with default values after unsilencing " - f"{channel.mention}. Please check that the `Speak` " + f"{channel.mention}. Please check that the `Speak` and `Connect`" f"overwrites for {self._verified_voice_role.mention} are at their desired values." ) -- cgit v1.2.3 From 7456f17485481ac02538a7bc67d78845f56a0dd9 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 24 Nov 2020 21:23:32 +0300 Subject: Reduces Redundancy in Unmute Replaces a repeated hardcoded message with a dynamically built one. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index e13911d9e..4196a2ac4 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -352,17 +352,17 @@ class Silence(commands.Cog): if prev_overwrites is None: if isinstance(channel, TextChannel): - await self._mod_alerts_channel.send( - f"<@&{Roles.admins}> Restored overwrites with default values after unsilencing " - f"{channel.mention}. Please check that the `Send Messages` and `Add Reactions` " - f"overwrites for {self._verified_msg_role.mention} are at their desired values." - ) + permissions = "`Send Messages` and `Add Reactions`" + role = self._verified_msg_role else: - await self._mod_alerts_channel.send( - f"<@&{Roles.admins}> Restored overwrites with default values after unsilencing " - f"{channel.mention}. Please check that the `Speak` and `Connect`" - f"overwrites for {self._verified_voice_role.mention} are at their desired values." - ) + permissions = "`Speak` and `Connect`" + role = self._verified_voice_role + + await self._mod_alerts_channel.send( + f"<@&{Roles.admins}> Restored overwrites with default values after unsilencing " + f"{channel.mention}. Please check that the {permissions} " + f"overwrites for {role.mention} are at their desired values." + ) return True -- cgit v1.2.3 From e4483ec353824167e573c4d86be5529ea329e97e Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 24 Nov 2020 21:32:25 +0300 Subject: Reduces IsInstance Calls Where Possible Reduces redundant calls to isinstance by saving the result where applicable. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 4196a2ac4..69ad1f45e 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -124,7 +124,9 @@ class Silence(commands.Cog): """Helper function to send message confirmation to `source_channel`, and notification to `target_channel`.""" # Get TextChannel connected to VoiceChannel if channel is of type voice voice_chat = None - if isinstance(target_channel, VoiceChannel): + target_is_voice_channel = isinstance(target_channel, VoiceChannel) + + if target_is_voice_channel: voice_chat = await self._get_related_text_channel(target_channel) # Reply to invocation channel @@ -135,7 +137,7 @@ class Silence(commands.Cog): # Reply to target channel if alert_target and source_channel not in [target_channel, voice_chat]: - if isinstance(target_channel, VoiceChannel): + if target_is_voice_channel: if voice_chat is not None: await voice_chat.send(message.replace("current channel", target_channel.mention)) @@ -223,7 +225,9 @@ class Silence(commands.Cog): async def _set_silence_overwrites(self, channel: Union[TextChannel, VoiceChannel], kick: bool = False) -> bool: """Set silence permission overwrites for `channel` and return True if successful.""" - if isinstance(channel, TextChannel): + is_text_channel = isinstance(channel, TextChannel) + + if is_text_channel: overwrite = channel.overwrites_for(self._verified_msg_role) prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) else: @@ -235,7 +239,7 @@ class Silence(commands.Cog): if channel.id in self.scheduler or all(val is False for val in prev_overwrites.values()): return False - if isinstance(channel, TextChannel): + if is_text_channel: overwrite.update(send_messages=False, add_reactions=False) await channel.set_permissions(self._verified_msg_role, overwrite=overwrite) else: @@ -326,7 +330,9 @@ class Silence(commands.Cog): log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") return False - if isinstance(channel, TextChannel): + is_text_channel = isinstance(channel, TextChannel) + + if is_text_channel: overwrite = channel.overwrites_for(self._verified_msg_role) else: overwrite = channel.overwrites_for(self._verified_voice_role) @@ -337,7 +343,7 @@ class Silence(commands.Cog): else: overwrite.update(**json.loads(prev_overwrites)) - if isinstance(channel, TextChannel): + if is_text_channel: await channel.set_permissions(self._verified_msg_role, overwrite=overwrite) else: await channel.set_permissions(self._verified_voice_role, overwrite=overwrite) -- cgit v1.2.3 From 7c87400c05276534bbeb155a569d9c88ae83f0c6 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Wed, 25 Nov 2020 09:05:29 +0300 Subject: Refactors Send Message Function Refactors the send message utility function to make it more legible and reduce unnecessary calls. Co-authored-by: Mark --- bot/exts/moderation/silence.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 69ad1f45e..eaaf7e69b 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -123,12 +123,6 @@ class Silence(commands.Cog): ) -> None: """Helper function to send message confirmation to `source_channel`, and notification to `target_channel`.""" # Get TextChannel connected to VoiceChannel if channel is of type voice - voice_chat = None - target_is_voice_channel = isinstance(target_channel, VoiceChannel) - - if target_is_voice_channel: - voice_chat = await self._get_related_text_channel(target_channel) - # Reply to invocation channel source_reply = message if source_channel != target_channel: @@ -136,12 +130,12 @@ class Silence(commands.Cog): await source_channel.send(source_reply) # Reply to target channel - if alert_target and source_channel not in [target_channel, voice_chat]: - if target_is_voice_channel: - if voice_chat is not None: + if alert_target: + if isinstance(target_channel, VoiceChannel): + voice_chat = await self._get_related_text_channel(target_channel) + if voice_chat and source_channel != voice_chat: await voice_chat.send(message.replace("current channel", target_channel.mention)) - - else: + elif source_channel != target_channel: await target_channel.send(message) @commands.command(aliases=("hush",)) -- cgit v1.2.3 From 03cd956165a806acd3f9e9b204129488142f12fd Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Wed, 25 Nov 2020 09:13:55 +0300 Subject: Reduces Redundant Code in Silence Cog Restructures some code to make it more understandable and reduce duplication. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 57 ++++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index eaaf7e69b..f35214cf4 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -122,7 +122,6 @@ class Silence(commands.Cog): alert_target: bool = False ) -> None: """Helper function to send message confirmation to `source_channel`, and notification to `target_channel`.""" - # Get TextChannel connected to VoiceChannel if channel is of type voice # Reply to invocation channel source_reply = message if source_channel != target_channel: @@ -219,30 +218,26 @@ class Silence(commands.Cog): async def _set_silence_overwrites(self, channel: Union[TextChannel, VoiceChannel], kick: bool = False) -> bool: """Set silence permission overwrites for `channel` and return True if successful.""" - is_text_channel = isinstance(channel, TextChannel) - - if is_text_channel: - overwrite = channel.overwrites_for(self._verified_msg_role) + # Get the original channel overwrites + if isinstance(channel, TextChannel): + role = self._verified_msg_role + overwrite = channel.overwrites_for(role) prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) + else: - overwrite = channel.overwrites_for(self._verified_voice_role) + role = self._verified_voice_role + overwrite = channel.overwrites_for(role) prev_overwrites = dict(speak=overwrite.speak) if kick: prev_overwrites.update(connect=overwrite.connect) + # Stop if channel was already silenced if channel.id in self.scheduler or all(val is False for val in prev_overwrites.values()): return False - if is_text_channel: - overwrite.update(send_messages=False, add_reactions=False) - await channel.set_permissions(self._verified_msg_role, overwrite=overwrite) - else: - overwrite.update(speak=False) - if kick: - overwrite.update(connect=False) - - await channel.set_permissions(self._verified_voice_role, overwrite=overwrite) - + # Set new permissions, store + overwrite.update(**dict.fromkeys(prev_overwrites, False)) + await channel.set_permissions(role, overwrite=overwrite) await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites)) return True @@ -319,28 +314,32 @@ class Silence(commands.Cog): Return `True` if channel permissions were changed, `False` otherwise. """ + # Get stored overwrites, and return if channel is unsilenced prev_overwrites = await self.previous_overwrites.get(channel.id) if channel.id not in self.scheduler and prev_overwrites is None: log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") return False - is_text_channel = isinstance(channel, TextChannel) - - if is_text_channel: - overwrite = channel.overwrites_for(self._verified_msg_role) + # Select the role based on channel type, and get current overwrites + if isinstance(channel, TextChannel): + role = self._verified_msg_role + overwrite = channel.overwrites_for(role) + permissions = "`Send Messages` and `Add Reactions`" else: - overwrite = channel.overwrites_for(self._verified_voice_role) + role = self._verified_voice_role + overwrite = channel.overwrites_for(role) + permissions = "`Speak` and `Connect`" + # Check if old overwrites were not stored if prev_overwrites is None: log.info(f"Missing previous overwrites for #{channel} ({channel.id}); defaulting to None.") overwrite.update(send_messages=None, add_reactions=None, speak=None, connect=None) else: overwrite.update(**json.loads(prev_overwrites)) - if is_text_channel: - await channel.set_permissions(self._verified_msg_role, overwrite=overwrite) - else: - await channel.set_permissions(self._verified_voice_role, overwrite=overwrite) + # Update Permissions + await channel.set_permissions(role, overwrite=overwrite) + if isinstance(channel, VoiceChannel): await self._force_voice_sync(channel) log.info(f"Unsilenced channel #{channel} ({channel.id}).") @@ -350,14 +349,8 @@ class Silence(commands.Cog): await self.previous_overwrites.delete(channel.id) await self.unsilence_timestamps.delete(channel.id) + # Alert Admin team if old overwrites were not available if prev_overwrites is None: - if isinstance(channel, TextChannel): - permissions = "`Send Messages` and `Add Reactions`" - role = self._verified_msg_role - else: - permissions = "`Speak` and `Connect`" - role = self._verified_voice_role - await self._mod_alerts_channel.send( f"<@&{Roles.admins}> Restored overwrites with default values after unsilencing " f"{channel.mention}. Please check that the {permissions} " -- cgit v1.2.3 From e9ed70c02ea20871c41abe55649147a42b185c69 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 26 Nov 2020 22:17:09 +0300 Subject: Updates Silence Lock Modifies the lock on the silence command, in order to choose between ctx and channel arg based on input. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index f35214cf4..8c71d422d 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -2,7 +2,6 @@ import json import logging from contextlib import suppress from datetime import datetime, timedelta, timezone -from operator import attrgetter from typing import Optional, Union from async_rediscache import RedisCache @@ -13,7 +12,7 @@ from discord.ext.commands import Context from bot.bot import Bot from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles from bot.converters import HushDurationConverter -from bot.utils.lock import LockedResourceError, lock_arg +from bot.utils.lock import LockedResourceError, lock, lock_arg from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) @@ -137,8 +136,17 @@ class Silence(commands.Cog): elif source_channel != target_channel: await target_channel.send(message) + async def _select_lock_channel(*args) -> Union[TextChannel, VoiceChannel]: + """Passes the channel to be silenced to the resource lock.""" + channel = args[0].get("channel") + if channel is not None: + return channel + + else: + return args[0].get("ctx").channel + @commands.command(aliases=("hush",)) - @lock_arg(LOCK_NAMESPACE, "ctx", attrgetter("channel"), raise_error=True) + @lock(LOCK_NAMESPACE, _select_lock_channel, raise_error=True) async def silence( self, ctx: Context, duration: HushDurationConverter = 10, kick: bool = False, *, channel: Union[TextChannel, VoiceChannel] = None -- cgit v1.2.3 From 3ea66f3a61720258e0dda44fc59e547692375280 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sat, 28 Nov 2020 20:13:54 +0300 Subject: Clarifies Constants Use in Silence Changes all usages of bot.constant to use dotted path to remove confusion and namespace collision. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 46 ++++++++++++++++--------------- tests/bot/exts/moderation/test_silence.py | 2 +- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 8c71d422d..d1db0da9b 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -5,12 +5,12 @@ from datetime import datetime, timedelta, timezone from typing import Optional, Union from async_rediscache import RedisCache -from discord import Member, PermissionOverwrite, TextChannel, VoiceChannel +from discord import Guild, PermissionOverwrite, TextChannel, VoiceChannel from discord.ext import commands, tasks from discord.ext.commands import Context +from bot import constants from bot.bot import Bot -from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles from bot.converters import HushDurationConverter from bot.utils.lock import LockedResourceError, lock, lock_arg from bot.utils.scheduling import Scheduler @@ -19,17 +19,17 @@ log = logging.getLogger(__name__) LOCK_NAMESPACE = "silence" -MSG_SILENCE_FAIL = f"{Emojis.cross_mark} current channel is already silenced." -MSG_SILENCE_PERMANENT = f"{Emojis.check_mark} silenced current channel indefinitely." -MSG_SILENCE_SUCCESS = f"{Emojis.check_mark} silenced current channel for {{duration}} minute(s)." +MSG_SILENCE_FAIL = f"{constants.Emojis.cross_mark} current channel is already silenced." +MSG_SILENCE_PERMANENT = f"{constants.Emojis.check_mark} silenced current channel indefinitely." +MSG_SILENCE_SUCCESS = f"{constants.Emojis.check_mark} silenced current channel for {{duration}} minute(s)." -MSG_UNSILENCE_FAIL = f"{Emojis.cross_mark} current channel was not silenced." +MSG_UNSILENCE_FAIL = f"{constants.Emojis.cross_mark} current channel was not silenced." MSG_UNSILENCE_MANUAL = ( - f"{Emojis.cross_mark} current channel was not unsilenced because the current overwrites were " + f"{constants.Emojis.cross_mark} current channel was not unsilenced because the current overwrites were " f"set manually or the cache was prematurely cleared. " f"Please edit the overwrites manually to unsilence." ) -MSG_UNSILENCE_SUCCESS = f"{Emojis.check_mark} unsilenced current channel." +MSG_UNSILENCE_SUCCESS = f"{constants.Emojis.check_mark} unsilenced current channel." class SilenceNotifier(tasks.Loop): @@ -67,7 +67,9 @@ class SilenceNotifier(tasks.Loop): f"{channel.mention} for {(self._current_loop-start)//60} min" for channel, start in self._silenced_channels.items() ) - await self._alert_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}") + await self._alert_channel.send( + f"<@&{constants.Roles.moderators}> currently silenced channels: {channels_text}" + ) class Silence(commands.Cog): @@ -91,26 +93,26 @@ class Silence(commands.Cog): """Set instance attributes once the guild is available and reschedule unsilences.""" await self.bot.wait_until_guild_available() - guild = self.bot.get_guild(Guild.id) + guild = self.bot.get_guild(constants.Guild.id) - self._verified_msg_role = guild.get_role(Roles.verified) - self._verified_voice_role = guild.get_role(Roles.voice_verified) - self._helper_role = guild.get_role(Roles.helpers) + self._verified_msg_role = guild.get_role(constants.Roles.verified) + self._verified_voice_role = guild.get_role(constants.Roles.voice_verified) + self._helper_role = guild.get_role(constants.Roles.helpers) - self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) + self._mod_alerts_channel = self.bot.get_channel(constants.Channels.mod_alerts) - self.notifier = SilenceNotifier(self.bot.get_channel(Channels.mod_log)) + self.notifier = SilenceNotifier(self.bot.get_channel(constants.Channels.mod_log)) await self._reschedule() async def _get_related_text_channel(self, channel: VoiceChannel) -> Optional[TextChannel]: """Returns the text channel related to a voice channel.""" # TODO: Figure out a dynamic way of doing this channels = { - "off-topic": Channels.voice_chat, - "code/help 1": Channels.code_help_voice, - "code/help 2": Channels.code_help_voice_2, - "admin": Channels.admins_voice, - "staff": Channels.staff_voice + "off-topic": constants.Channels.voice_chat, + "code/help 1": constants.Channels.code_help_voice, + "code/help 2": constants.Channels.code_help_voice_2, + "admin": constants.Channels.admins_voice, + "staff": constants.Channels.staff_voice } for name in channels.keys(): if name in channel.name.lower(): @@ -360,7 +362,7 @@ class Silence(commands.Cog): # Alert Admin team if old overwrites were not available if prev_overwrites is None: await self._mod_alerts_channel.send( - f"<@&{Roles.admins}> Restored overwrites with default values after unsilencing " + f"<@&{constants.Roles.admins}> Restored overwrites with default values after unsilencing " f"{channel.mention}. Please check that the {permissions} " f"overwrites for {role.mention} are at their desired values." ) @@ -402,7 +404,7 @@ class Silence(commands.Cog): # This cannot be static (must have a __func__ attribute). async def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" - return await commands.has_any_role(*MODERATION_ROLES).predicate(ctx) + return await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx) def setup(bot: Bot) -> None: diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 8f3c1cb8b..bff2888b9 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -158,7 +158,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(self.cog._init_task.cancelled()) @autospec("discord.ext.commands", "has_any_role") - @mock.patch.object(silence, "MODERATION_ROLES", new=(1, 2, 3)) + @mock.patch.object(silence.constants, "MODERATION_ROLES", new=(1, 2, 3)) async def test_cog_check(self, role_check): """Role check was called with `MODERATION_ROLES`""" ctx = MockContext() -- cgit v1.2.3 From a6c8a9aca63f7d40d6c5701a08626da198c1d54a Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sat, 28 Nov 2020 20:14:48 +0300 Subject: Refractors Voice Sync Helper Refractors the voice sync helper function into two different functions, one for each purpose. Moves the afk_channel get/creation code to its own function. Updates tests. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 88 +++++++++++---------- tests/bot/exts/moderation/test_silence.py | 124 ++++++++++++++++++------------ 2 files changed, 121 insertions(+), 91 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index d1db0da9b..9b3725326 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -174,7 +174,10 @@ class Silence(commands.Cog): return if isinstance(channel, VoiceChannel): - await self._force_voice_sync(channel, kick=kick) + if kick: + await self._kick_voice_members(channel) + else: + await self._force_voice_sync(channel) await self._schedule_unsilence(ctx, channel, duration) @@ -252,56 +255,57 @@ class Silence(commands.Cog): return True - async def _force_voice_sync( - self, channel: VoiceChannel, member: Optional[Member] = None, kick: bool = False - ) -> None: - """ - Move all non-staff members from `channel` to a temporary channel and back to force toggle role mute. - - If `member` is passed, the mute only occurs to that member. - Permission modification has to happen before this function. - - If `kick_all` is True, members will not be added back to the voice channel. - """ - # Handle member picking logic - if member is not None: - members = [member] - else: - members = channel.members - - # Handle kick logic - if kick: - for member in members: - await member.move_to(None, reason="Kicking voice channel member.") + @staticmethod + async def _get_afk_channel(guild: Guild) -> VoiceChannel: + """Get a guild's AFK channel, or create one if it does not exist.""" + afk_channel = guild.afk_channel - log.debug(f"Kicked all members from #{channel.name} ({channel.id}).") - return - - # Obtain temporary channel - afk_channel = channel.guild.afk_channel if afk_channel is None: overwrites = { - channel.guild.default_role: PermissionOverwrite(speak=False, connect=False, view_channel=False) + guild.default_role: PermissionOverwrite(speak=False, connect=False, view_channel=False) } - afk_channel = await channel.guild.create_voice_channel("mute-temp", overwrites=overwrites) + afk_channel = await guild.create_voice_channel("mute-temp", overwrites=overwrites) log.info(f"Failed to get afk-channel, created temporary channel #{afk_channel} ({afk_channel.id})") - # Schedule channel deletion in case function errors out - self.scheduler.schedule_later( - 30, afk_channel.id, afk_channel.delete(reason="Deleting temp mute channel.") - ) + return afk_channel - # Move all members to temporary channel and back - for member in members: - # Skip staff - if self._helper_role in member.roles: - continue + async def _kick_voice_members(self, channel: VoiceChannel) -> None: + """Remove all non-staff members from a voice channel.""" + log.debug(f"Removing all non staff members from #{channel.name} ({channel.id}).") + + for member in channel.members: + if self._helper_role not in member.roles: + await member.move_to(None, reason="Kicking member from voice channel.") - await member.move_to(afk_channel, reason="Muting member.") - log.debug(f"Moved {member.name} to afk channel.") + log.debug("Removed all members.") - await member.move_to(channel, reason="Muting member.") - log.debug(f"Moved {member.name} to original voice channel.") + async def _force_voice_sync(self, channel: VoiceChannel) -> None: + """ + Move all non-staff members from `channel` to a temporary channel and back to force toggle role mute. + + Permission modification has to happen before this function. + """ + # Obtain temporary channel + delete_channel = channel.guild.afk_channel is None + afk_channel = await self._get_afk_channel(channel.guild) + + try: + # Move all members to temporary channel and back + for member in channel.members: + # Skip staff + if self._helper_role in member.roles: + continue + + await member.move_to(afk_channel, reason="Muting VC member.") + log.debug(f"Moved {member.name} to afk channel.") + + await member.move_to(channel, reason="Muting VC member.") + log.debug(f"Moved {member.name} to original voice channel.") + + finally: + # Delete VC channel if it was created. + if delete_channel: + await afk_channel.delete(reason="Deleting temp mute channel.") async def _schedule_unsilence( self, ctx: Context, channel: Union[TextChannel, VoiceChannel], duration: Optional[int] diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index bff2888b9..9fb3e404a 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -2,7 +2,7 @@ import asyncio import unittest from datetime import datetime, timezone from unittest import mock -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock from async_rediscache import RedisSession from discord import PermissionOverwrite @@ -266,67 +266,69 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): """Tests the _force_voice_sync helper function.""" await self.cog._async_init() - afk_channel = MockVoiceChannel() - channel = MockVoiceChannel(guild=MockGuild(afk_channel=afk_channel)) - members = [] for _ in range(10): members.append(MockMember()) - channel.members = members - test_cases = ( - (members[0], False, "Muting member."), - (members[0], True, "Kicking voice channel member."), - (None, False, "Muting member."), - (None, True, "Kicking voice channel member."), - ) - - for member, kick, reason in test_cases: - with self.subTest(members=member, kick=kick, reason=reason): - await self.cog._force_voice_sync(channel, member, kick) - - for single_member in channel.members if member is None else [member]: - if kick: - single_member.move_to.assert_called_once_with(None, reason=reason) - else: - self.assertEqual(single_member.move_to.call_count, 2) - single_member.move_to.assert_has_calls([ - mock.call(afk_channel, reason=reason), - mock.call(channel, reason=reason) - ], any_order=False) + afk_channel = MockVoiceChannel() + channel = MockVoiceChannel(guild=MockGuild(afk_channel=afk_channel), members=members) - single_member.reset_mock() + await self.cog._force_voice_sync(channel) + for member in members: + self.assertEqual(member.move_to.call_count, 2) + member.move_to.assert_has_calls([ + mock.call(afk_channel, reason="Muting VC member."), + mock.call(channel, reason="Muting VC member.") + ], any_order=False) async def test_force_voice_sync_staff(self): """Tests to ensure _force_voice_sync does not kick staff members.""" await self.cog._async_init() member = MockMember(roles=[self.cog._helper_role]) - await self.cog._force_voice_sync(MockVoiceChannel(), member) + await self.cog._force_voice_sync(MockVoiceChannel(members=[member])) member.move_to.assert_not_called() async def test_force_voice_sync_no_channel(self): """Test to ensure _force_voice_sync can create its own voice channel if one is not available.""" await self.cog._async_init() - member = MockMember() channel = MockVoiceChannel(guild=MockGuild(afk_channel=None)) - - new_channel = MockVoiceChannel(delete=Mock()) + new_channel = MockVoiceChannel(delete=AsyncMock()) channel.guild.create_voice_channel.return_value = new_channel - with mock.patch.object(self.cog.scheduler, "schedule_later") as scheduler: - await self.cog._force_voice_sync(channel, member) + await self.cog._force_voice_sync(channel) + + # Check channel creation + overwrites = { + channel.guild.default_role: PermissionOverwrite(speak=False, connect=False, view_channel=False) + } + channel.guild.create_voice_channel.assert_called_once_with("mute-temp", overwrites=overwrites) + + # Check bot deleted channel + new_channel.delete.assert_called_once_with(reason="Deleting temp mute channel.") + + async def test_voice_kick(self): + """Test to ensure kick function can remove all members from a voice channel.""" + await self.cog._async_init() + + members = [] + for _ in range(10): + members.append(MockMember()) + + channel = MockVoiceChannel(members=members) + await self.cog._kick_voice_members(channel) + + for member in members: + member.move_to.assert_called_once_with(None, reason="Kicking member from voice channel.") - # Check channel creation - overwrites = { - channel.guild.default_role: PermissionOverwrite(speak=False, connect=False, view_channel=False) - } - channel.guild.create_voice_channel.assert_called_once_with("mute-temp", overwrites=overwrites) + async def test_voice_kick_staff(self): + """Test to ensure voice kick skips staff members.""" + await self.cog._async_init() + member = MockMember(roles=[self.cog._helper_role]) - # Check bot queued deletion - new_channel.delete.assert_called_once_with(reason="Deleting temp mute channel.") - scheduler.assert_called_once_with(30, new_channel.id, new_channel.delete()) + await self.cog._kick_voice_members(MockVoiceChannel(members=[member])) + member.move_to.assert_not_called() @autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) @@ -457,21 +459,45 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): ) for target, message in test_cases: - with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True): - with self.subTest(target_channel=target, message=message): - await self.cog.silence.callback(self.cog, ctx, 10, False, channel=target) - if ctx.channel == target or target is None: - ctx.channel.send.assert_called_once_with(message) + with mock.patch.object(self.cog, "_force_voice_sync") as voice_sync: + with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True): + with self.subTest(target_channel=target, message=message): + await self.cog.silence.callback(self.cog, ctx, 10, False, channel=target) + if ctx.channel == target or target is None: + ctx.channel.send.assert_called_once_with(message) - else: - ctx.channel.send.assert_called_once_with(message.replace("current channel", target.mention)) - if isinstance(target, MockTextChannel): - target.send.assert_called_once_with(message) + else: + ctx.channel.send.assert_called_once_with(message.replace("current channel", target.mention)) + if isinstance(target, MockTextChannel): + target.send.assert_called_once_with(message) + else: + voice_sync.assert_called_once_with(target) ctx.channel.send.reset_mock() if target is not None and isinstance(target, MockTextChannel): target.send.reset_mock() + @mock.patch.object(silence.Silence, "_kick_voice_members") + @mock.patch.object(silence.Silence, "_force_voice_sync") + async def test_sync_or_kick_called(self, sync, kick): + """Tests if silence command calls kick or sync on voice channels when appropriate.""" + channel = MockVoiceChannel() + ctx = MockContext() + + with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True): + with self.subTest("Test calls kick"): + await self.cog.silence.callback(self.cog, ctx, 10, kick=True, channel=channel) + kick.assert_called_once_with(channel) + sync.assert_not_called() + + kick.reset_mock() + sync.reset_mock() + + with self.subTest("Test calls sync"): + await self.cog.silence.callback(self.cog, ctx, 10, kick=False, channel=channel) + sync.assert_called_once_with(channel) + kick.assert_not_called() + async def test_skipped_already_silenced(self): """Permissions were not set and `False` was returned for an already silenced channel.""" subtests = ( -- cgit v1.2.3 From a8a8c104823fa1a23a9b33cd52c7c4e574d84330 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sat, 28 Nov 2020 21:10:24 +0300 Subject: Refractors According To Style Guide Updates changes made in the PR to be more inline with style guide. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 32 ++++---- tests/bot/exts/moderation/test_silence.py | 120 ++++++++++++++---------------- 2 files changed, 74 insertions(+), 78 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 9b3725326..45c3f5b92 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -31,6 +31,8 @@ MSG_UNSILENCE_MANUAL = ( ) MSG_UNSILENCE_SUCCESS = f"{constants.Emojis.check_mark} unsilenced current channel." +TextOrVoiceChannel = Union[TextChannel, VoiceChannel] + class SilenceNotifier(tasks.Loop): """Loop notifier for posting notices to `alert_channel` containing added channels.""" @@ -40,7 +42,7 @@ class SilenceNotifier(tasks.Loop): self._silenced_channels = {} self._alert_channel = alert_channel - def add_channel(self, channel: Union[TextChannel, VoiceChannel]) -> None: + def add_channel(self, channel: TextOrVoiceChannel) -> None: """Add channel to `_silenced_channels` and start loop if not launched.""" if not self._silenced_channels: self.start() @@ -119,7 +121,10 @@ class Silence(commands.Cog): return self.bot.get_channel(channels[name]) async def send_message( - self, message: str, source_channel: TextChannel, target_channel: Union[TextChannel, VoiceChannel], + self, + message: str, + source_channel: TextChannel, + target_channel: TextOrVoiceChannel, alert_target: bool = False ) -> None: """Helper function to send message confirmation to `source_channel`, and notification to `target_channel`.""" @@ -138,7 +143,7 @@ class Silence(commands.Cog): elif source_channel != target_channel: await target_channel.send(message) - async def _select_lock_channel(*args) -> Union[TextChannel, VoiceChannel]: + async def _select_lock_channel(*args) -> TextOrVoiceChannel: """Passes the channel to be silenced to the resource lock.""" channel = args[0].get("channel") if channel is not None: @@ -150,8 +155,11 @@ class Silence(commands.Cog): @commands.command(aliases=("hush",)) @lock(LOCK_NAMESPACE, _select_lock_channel, raise_error=True) async def silence( - self, ctx: Context, duration: HushDurationConverter = 10, kick: bool = False, - *, channel: Union[TextChannel, VoiceChannel] = None + self, + ctx: Context, + duration: HushDurationConverter = 10, + kick: bool = False, + *, channel: TextOrVoiceChannel = None ) -> None: """ Silence the current channel for `duration` minutes or `forever`. @@ -191,7 +199,7 @@ class Silence(commands.Cog): await self.send_message(MSG_SILENCE_SUCCESS.format(duration=duration), ctx.channel, channel, True) @commands.command(aliases=("unhush",)) - async def unsilence(self, ctx: Context, *, channel: Union[TextChannel, VoiceChannel] = None) -> None: + async def unsilence(self, ctx: Context, *, channel: TextOrVoiceChannel = None) -> None: """ Unsilence the given channel if given, else the current one. @@ -204,9 +212,7 @@ class Silence(commands.Cog): await self._unsilence_wrapper(channel, ctx) @lock_arg(LOCK_NAMESPACE, "channel", raise_error=True) - async def _unsilence_wrapper( - self, channel: Union[TextChannel, VoiceChannel], ctx: Optional[Context] = None - ) -> None: + async def _unsilence_wrapper(self, channel: TextOrVoiceChannel, ctx: Optional[Context] = None) -> None: """Unsilence `channel` and send a success/failure message.""" msg_channel = channel if ctx is not None: @@ -229,7 +235,7 @@ class Silence(commands.Cog): else: await self.send_message(MSG_UNSILENCE_SUCCESS, msg_channel, channel, True) - async def _set_silence_overwrites(self, channel: Union[TextChannel, VoiceChannel], kick: bool = False) -> bool: + async def _set_silence_overwrites(self, channel: TextOrVoiceChannel, kick: bool = False) -> bool: """Set silence permission overwrites for `channel` and return True if successful.""" # Get the original channel overwrites if isinstance(channel, TextChannel): @@ -307,9 +313,7 @@ class Silence(commands.Cog): if delete_channel: await afk_channel.delete(reason="Deleting temp mute channel.") - async def _schedule_unsilence( - self, ctx: Context, channel: Union[TextChannel, VoiceChannel], duration: Optional[int] - ) -> None: + async def _schedule_unsilence(self, ctx: Context, channel: TextOrVoiceChannel, duration: Optional[int]) -> None: """Schedule `ctx.channel` to be unsilenced if `duration` is not None.""" if duration is None: await self.unsilence_timestamps.set(channel.id, -1) @@ -318,7 +322,7 @@ class Silence(commands.Cog): unsilence_time = datetime.now(tz=timezone.utc) + timedelta(minutes=duration) await self.unsilence_timestamps.set(channel.id, unsilence_time.timestamp()) - async def _unsilence(self, channel: Union[TextChannel, VoiceChannel]) -> bool: + async def _unsilence(self, channel: TextOrVoiceChannel) -> bool: """ Unsilence `channel`. diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 9fb3e404a..635e017e3 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -198,49 +198,37 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): reset() with self.subTest("Replacement One Channel Test"): - await self.cog.send_message( - "Current. The following should be replaced: current channel.", text_channel_1, text_channel_2, False - ) - - text_channel_1.send.assert_called_once_with( - f"Current. The following should be replaced: {text_channel_1.mention}." - ) + message = "Current. The following should be replaced: current channel." + await self.cog.send_message(message, text_channel_1, text_channel_2, False) + text_channel_1.send.assert_called_once_with(message.replace("current channel", text_channel_1.mention)) text_channel_2.send.assert_not_called() reset() with self.subTest("Replacement Two Channel Test"): - await self.cog.send_message( - "Current. The following should be replaced: current channel.", text_channel_1, text_channel_2, True - ) - - text_channel_1.send.assert_called_once_with( - f"Current. The following should be replaced: {text_channel_1.mention}." - ) + message = "Current. The following should be replaced: current channel." + await self.cog.send_message(message, text_channel_1, text_channel_2, True) - text_channel_2.send.assert_called_once_with("Current. The following should be replaced: current channel.") + text_channel_1.send.assert_called_once_with(message.replace("current channel", text_channel_1.mention)) + text_channel_2.send.assert_called_once_with(message) reset() with self.subTest("Text and Voice"): - await self.cog.send_message( - "This should show up just here", text_channel_1, voice_channel, False - ) + await self.cog.send_message("This should show up just here", text_channel_1, voice_channel, False) text_channel_1.send.assert_called_once_with("This should show up just here") reset() with self.subTest("Text and Voice"): - await self.cog.send_message( - "This should show up as current channel", text_channel_1, voice_channel, True - ) - text_channel_1.send.assert_called_once_with(f"This should show up as {voice_channel.mention}") - text_channel_2.send.assert_called_once_with(f"This should show up as {voice_channel.mention}") + message = "This should show up as current channel" + await self.cog.send_message(message, text_channel_1, voice_channel, True) + text_channel_1.send.assert_called_once_with(message.replace("current channel", voice_channel.mention)) + text_channel_2.send.assert_called_once_with(message.replace("current channel", voice_channel.mention)) reset() with self.subTest("Text and Voice Same Invocation"): - await self.cog.send_message( - "This should show up as current channel", text_channel_2, voice_channel, True - ) - text_channel_2.send.assert_called_once_with(f"This should show up as {voice_channel.mention}") + message = "This should show up as current channel" + await self.cog.send_message(message, text_channel_2, voice_channel, True) + text_channel_2.send.assert_called_once_with(message.replace("current channel", voice_channel.mention)) async def test_get_related_text_channel(self): """Tests the helper function that connects voice to text channels.""" @@ -276,10 +264,11 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): await self.cog._force_voice_sync(channel) for member in members: self.assertEqual(member.move_to.call_count, 2) - member.move_to.assert_has_calls([ + calls = [ mock.call(afk_channel, reason="Muting VC member."), mock.call(channel, reason="Muting VC member.") - ], any_order=False) + ] + member.move_to.assert_has_calls(calls, any_order=False) async def test_force_voice_sync_staff(self): """Tests to ensure _force_voice_sync does not kick staff members.""" @@ -445,7 +434,9 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog.silence.callback(self.cog, ctx, duration) ctx.channel.send.assert_called_once_with(message) - async def test_sent_to_correct_channel(self): + @mock.patch.object(silence.Silence, "_set_silence_overwrites", return_value=True) + @mock.patch.object(silence.Silence, "_force_voice_sync") + async def test_sent_to_correct_channel(self, voice_sync, _): """Test function sends messages to the correct channels.""" text_channel = MockTextChannel() voice_channel = MockVoiceChannel() @@ -459,19 +450,17 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): ) for target, message in test_cases: - with mock.patch.object(self.cog, "_force_voice_sync") as voice_sync: - with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True): - with self.subTest(target_channel=target, message=message): - await self.cog.silence.callback(self.cog, ctx, 10, False, channel=target) - if ctx.channel == target or target is None: - ctx.channel.send.assert_called_once_with(message) - - else: - ctx.channel.send.assert_called_once_with(message.replace("current channel", target.mention)) - if isinstance(target, MockTextChannel): - target.send.assert_called_once_with(message) - else: - voice_sync.assert_called_once_with(target) + with self.subTest(target_channel=target, message=message): + await self.cog.silence.callback(self.cog, ctx, 10, False, channel=target) + if ctx.channel == target or target is None: + ctx.channel.send.assert_called_once_with(message) + + else: + ctx.channel.send.assert_called_once_with(message.replace("current channel", target.mention)) + if isinstance(target, MockTextChannel): + target.send.assert_called_once_with(message) + else: + voice_sync.assert_called_once_with(target) ctx.channel.send.reset_mock() if target is not None and isinstance(target, MockTextChannel): @@ -771,7 +760,9 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) - async def test_unsilence_helper_fail(self): + @mock.patch.object(silence.Silence, "_unsilence", return_value=False) + @mock.patch.object(silence.Silence, "send_message") + async def test_unsilence_helper_fail(self, send_message, _): """Tests unsilence_wrapper when `_unsilence` fails.""" ctx = MockContext() @@ -797,16 +788,18 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): return self.val for context, channel, role, permission, message in test_cases: - with self.subTest(channel=channel, message=message): - with mock.patch.object(channel, "overwrites_for", return_value=PermClass(permission)) as overwrites: - with mock.patch.object(self.cog, "send_message") as send_message: - with mock.patch.object(self.cog, "_unsilence", return_value=False): - await self.cog._unsilence_wrapper(channel, context) + with mock.patch.object(channel, "overwrites_for", return_value=PermClass(permission)) as overwrites: + with self.subTest(channel=channel, message=message): + await self.cog._unsilence_wrapper(channel, context) + + overwrites.assert_called_once_with(role) + send_message.assert_called_once_with(message, ctx.channel, channel) - overwrites.assert_called_once_with(role) - send_message.assert_called_once_with(message, ctx.channel, channel) + send_message.reset_mock() - async def test_correct_overwrites(self): + @mock.patch.object(silence.Silence, "_force_voice_sync") + @mock.patch.object(silence.Silence, "send_message") + async def test_correct_overwrites(self, send_message, _): """Tests the overwrites returned by the _unsilence_wrapper are correct for voice and text channels.""" ctx = MockContext() @@ -822,6 +815,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): text_channel.reset_mock() voice_channel.reset_mock() + send_message.reset_mock() await reset() default_text_overwrites = text_channel.overwrites_for(text_role) @@ -836,17 +830,15 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): for context, channel, role, overwrites, message in test_cases: with self.subTest(ctx=context, channel=channel): - with mock.patch.object(self.cog, "send_message") as send_message: - with mock.patch.object(self.cog, "_force_voice_sync"): - await self.cog._unsilence_wrapper(channel, context) - - if context is None: - send_message.assert_called_once_with(message, channel, channel, True) - else: - send_message.assert_called_once_with(message, context.channel, channel, True) - - channel.set_permissions.assert_called_once_with(role, overwrite=overwrites) - if channel != ctx.channel: - ctx.channel.send.assert_not_called() + await self.cog._unsilence_wrapper(channel, context) + + if context is None: + send_message.assert_called_once_with(message, channel, channel, True) + else: + send_message.assert_called_once_with(message, context.channel, channel, True) + + channel.set_permissions.assert_called_once_with(role, overwrite=overwrites) + if channel != ctx.channel: + ctx.channel.send.assert_not_called() await reset() -- cgit v1.2.3 From 301978174cac156282b89fe6e749edc611824b8d Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 1 Dec 2020 10:17:55 +0300 Subject: Improves Voice Chat Matching Changes the way voice channels are matched with chat channels, to make it less hardcoded. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 45c3f5b92..4cc89827f 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -33,6 +33,13 @@ MSG_UNSILENCE_SUCCESS = f"{constants.Emojis.check_mark} unsilenced current chann TextOrVoiceChannel = Union[TextChannel, VoiceChannel] +VOICE_CHANNELS = { + constants.Channels.code_help_voice_1: constants.Channels.code_help_chat_1, + constants.Channels.code_help_voice_2: constants.Channels.code_help_chat_2, + constants.Channels.general_voice: constants.Channels.voice_chat, + constants.Channels.staff_voice: constants.Channels.staff_voice_chat, +} + class SilenceNotifier(tasks.Loop): """Loop notifier for posting notices to `alert_channel` containing added channels.""" @@ -106,20 +113,6 @@ class Silence(commands.Cog): self.notifier = SilenceNotifier(self.bot.get_channel(constants.Channels.mod_log)) await self._reschedule() - async def _get_related_text_channel(self, channel: VoiceChannel) -> Optional[TextChannel]: - """Returns the text channel related to a voice channel.""" - # TODO: Figure out a dynamic way of doing this - channels = { - "off-topic": constants.Channels.voice_chat, - "code/help 1": constants.Channels.code_help_voice, - "code/help 2": constants.Channels.code_help_voice_2, - "admin": constants.Channels.admins_voice, - "staff": constants.Channels.staff_voice - } - for name in channels.keys(): - if name in channel.name.lower(): - return self.bot.get_channel(channels[name]) - async def send_message( self, message: str, @@ -137,9 +130,10 @@ class Silence(commands.Cog): # Reply to target channel if alert_target: if isinstance(target_channel, VoiceChannel): - voice_chat = await self._get_related_text_channel(target_channel) + voice_chat = self.bot.get_channel(VOICE_CHANNELS.get(target_channel.id)) if voice_chat and source_channel != voice_chat: await voice_chat.send(message.replace("current channel", target_channel.mention)) + elif source_channel != target_channel: await target_channel.send(message) -- cgit v1.2.3 From ca266629c8083c261208ddba86ffe9e2a8b65bf3 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 1 Dec 2020 10:19:22 +0300 Subject: Fixes Voice Silence Tests Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- tests/bot/exts/moderation/test_silence.py | 66 +++++++++++++++---------------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 635e017e3..90ddf6ad7 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -168,8 +168,8 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): role_check.assert_called_once_with(*(1, 2, 3)) role_check.return_value.predicate.assert_awaited_once_with(ctx) - @mock.patch.object(silence.Silence, "_get_related_text_channel") - async def test_send_message(self, mock_get_related_text_channel): + @mock.patch.object(silence, "VOICE_CHANNELS") + async def test_send_message(self, mock_voice_channels): """Test the send function reports to the correct channels.""" text_channel_1 = MockTextChannel() text_channel_2 = MockTextChannel() @@ -178,12 +178,13 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): voice_channel.name = "General/Offtopic" voice_channel.mention = f"#{voice_channel.name}" - mock_get_related_text_channel.return_value = text_channel_2 + mock_voice_channels.get.return_value = text_channel_2.id def reset(): text_channel_1.reset_mock() text_channel_2.reset_mock() voice_channel.reset_mock() + mock_voice_channels.reset_mock() with self.subTest("Basic One Channel Test"): await self.cog.send_message("Text basic message.", text_channel_1, text_channel_2, False) @@ -217,38 +218,29 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): await self.cog.send_message("This should show up just here", text_channel_1, voice_channel, False) text_channel_1.send.assert_called_once_with("This should show up just here") - reset() - with self.subTest("Text and Voice"): - message = "This should show up as current channel" - await self.cog.send_message(message, text_channel_1, voice_channel, True) - text_channel_1.send.assert_called_once_with(message.replace("current channel", voice_channel.mention)) - text_channel_2.send.assert_called_once_with(message.replace("current channel", voice_channel.mention)) + with mock.patch.object(self.cog, "bot") as bot_mock: + bot_mock.get_channel.return_value = text_channel_2 - reset() - with self.subTest("Text and Voice Same Invocation"): - message = "This should show up as current channel" - await self.cog.send_message(message, text_channel_2, voice_channel, True) - text_channel_2.send.assert_called_once_with(message.replace("current channel", voice_channel.mention)) - - async def test_get_related_text_channel(self): - """Tests the helper function that connects voice to text channels.""" - voice_channel = MockVoiceChannel() + reset() + with self.subTest("Text and Voice"): + message = "This should show up as current channel" + await self.cog.send_message(message, text_channel_1, voice_channel, True) + text_channel_1.send.assert_called_once_with(message.replace("current channel", voice_channel.mention)) + text_channel_2.send.assert_called_once_with(message.replace("current channel", voice_channel.mention)) - tests = ( - ("Off-Topic/General", Channels.voice_chat), - ("code/help 1", Channels.code_help_voice), - ("Staff", Channels.staff_voice), - ("ADMIN", Channels.admins_voice), - ("not in the channel list", None) - ) + mock_voice_channels.get.assert_called_once_with(voice_channel.id) + bot_mock.get_channel.assert_called_once_with(text_channel_2.id) + bot_mock.reset_mock() - with mock.patch.object(self.cog.bot, "get_channel", lambda x: x): - for (name, channel_id) in tests: - voice_channel.name = name - voice_channel.id = channel_id + reset() + with self.subTest("Text and Voice Same Invocation"): + message = "This should show up as current channel" + await self.cog.send_message(message, text_channel_2, voice_channel, True) + text_channel_2.send.assert_called_once_with(message.replace("current channel", voice_channel.mention)) - result_id = await self.cog._get_related_text_channel(voice_channel) - self.assertEqual(result_id, channel_id) + mock_voice_channels.get.assert_called_once_with(voice_channel.id) + bot_mock.get_channel.assert_called_once_with(text_channel_2.id) + bot_mock.reset_mock() async def test_force_voice_sync(self): """Tests the _force_voice_sync helper function.""" @@ -451,7 +443,10 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): for target, message in test_cases: with self.subTest(target_channel=target, message=message): - await self.cog.silence.callback(self.cog, ctx, 10, False, channel=target) + with mock.patch.object(self.cog, "bot") as bot_mock: + bot_mock.get_channel.return_value = AsyncMock() + await self.cog.silence.callback(self.cog, ctx, 10, False, channel=target) + if ctx.channel == target or target is None: ctx.channel.send.assert_called_once_with(message) @@ -466,14 +461,17 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): if target is not None and isinstance(target, MockTextChannel): target.send.reset_mock() + @mock.patch.object(silence.Silence, "_set_silence_overwrites", return_value=True) @mock.patch.object(silence.Silence, "_kick_voice_members") @mock.patch.object(silence.Silence, "_force_voice_sync") - async def test_sync_or_kick_called(self, sync, kick): + async def test_sync_or_kick_called(self, sync, kick, _): """Tests if silence command calls kick or sync on voice channels when appropriate.""" channel = MockVoiceChannel() ctx = MockContext() - with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True): + with mock.patch.object(self.cog, "bot") as bot_mock: + bot_mock.get_channel.return_value = AsyncMock() + with self.subTest("Test calls kick"): await self.cog.silence.callback(self.cog, ctx, 10, kick=True, channel=channel) kick.assert_called_once_with(channel) -- cgit v1.2.3 From 68c2545e794fa284a957809a0cb1022740966118 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 29 Dec 2020 19:11:51 +0300 Subject: Refractors Helper Method Signatures Changes the signatures of a few helper methods to make them more concise and understandable. --- bot/exts/moderation/silence.py | 34 ++++++++++++++++--------------- tests/bot/exts/moderation/test_silence.py | 23 +++++++++++---------- 2 files changed, 30 insertions(+), 27 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index befcb2cc4..31103bc3e 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -2,7 +2,7 @@ import json import logging from contextlib import suppress from datetime import datetime, timedelta, timezone -from typing import Optional, Union +from typing import Optional, OrderedDict, Union from async_rediscache import RedisCache from discord import Guild, PermissionOverwrite, TextChannel, VoiceChannel @@ -81,6 +81,16 @@ class SilenceNotifier(tasks.Loop): ) +async def _select_lock_channel(args: OrderedDict[str, any]) -> TextOrVoiceChannel: + """Passes the channel to be silenced to the resource lock.""" + channel = args["channel"] + if channel is not None: + return channel + + else: + return args["ctx"].channel + + class Silence(commands.Cog): """Commands for stopping channel messages for `verified` role in a channel.""" @@ -118,7 +128,7 @@ class Silence(commands.Cog): message: str, source_channel: TextChannel, target_channel: TextOrVoiceChannel, - alert_target: bool = False + *, alert_target: bool = False ) -> None: """Helper function to send message confirmation to `source_channel`, and notification to `target_channel`.""" # Reply to invocation channel @@ -137,23 +147,14 @@ class Silence(commands.Cog): elif source_channel != target_channel: await target_channel.send(message) - async def _select_lock_channel(*args) -> TextOrVoiceChannel: - """Passes the channel to be silenced to the resource lock.""" - channel = args[0].get("channel") - if channel is not None: - return channel - - else: - return args[0].get("ctx").channel - @commands.command(aliases=("hush",)) @lock(LOCK_NAMESPACE, _select_lock_channel, raise_error=True) async def silence( self, ctx: Context, duration: HushDurationConverter = 10, - kick: bool = False, - *, channel: TextOrVoiceChannel = None + channel: TextOrVoiceChannel = None, + *, kick: bool = False ) -> None: """ Silence the current channel for `duration` minutes or `forever`. @@ -186,11 +187,12 @@ class Silence(commands.Cog): if duration is None: self.notifier.add_channel(channel) log.info(f"Silenced {channel_info} indefinitely.") - await self.send_message(MSG_SILENCE_PERMANENT, ctx.channel, channel, True) + await self.send_message(MSG_SILENCE_PERMANENT, ctx.channel, channel, alert_target=True) else: log.info(f"Silenced {channel_info} for {duration} minute(s).") - await self.send_message(MSG_SILENCE_SUCCESS.format(duration=duration), ctx.channel, channel, True) + formatted_message = MSG_SILENCE_SUCCESS.format(duration=duration) + await self.send_message(formatted_message, ctx.channel, channel, alert_target=True) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context, *, channel: TextOrVoiceChannel = None) -> None: @@ -227,7 +229,7 @@ class Silence(commands.Cog): await self.send_message(MSG_UNSILENCE_FAIL, msg_channel, channel) else: - await self.send_message(MSG_UNSILENCE_SUCCESS, msg_channel, channel, True) + await self.send_message(MSG_UNSILENCE_SUCCESS, msg_channel, channel, alert_target=True) async def _set_silence_overwrites(self, channel: TextOrVoiceChannel, kick: bool = False) -> bool: """Set silence permission overwrites for `channel` and return True if successful.""" diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 31894761c..038e0a1a4 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -178,20 +178,20 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): mock_voice_channels.reset_mock() with self.subTest("Basic One Channel Test"): - await self.cog.send_message("Text basic message.", text_channel_1, text_channel_2, False) + await self.cog.send_message("Text basic message.", text_channel_1, text_channel_2, alert_target=False) text_channel_1.send.assert_called_once_with("Text basic message.") text_channel_2.send.assert_not_called() reset() with self.subTest("Basic Two Channel Test"): - await self.cog.send_message("Text basic message.", text_channel_1, text_channel_2, True) + await self.cog.send_message("Text basic message.", text_channel_1, text_channel_2, alert_target=True) text_channel_1.send.assert_called_once_with("Text basic message.") text_channel_2.send.assert_called_once_with("Text basic message.") reset() with self.subTest("Replacement One Channel Test"): message = "Current. The following should be replaced: current channel." - await self.cog.send_message(message, text_channel_1, text_channel_2, False) + await self.cog.send_message(message, text_channel_1, text_channel_2, alert_target=False) text_channel_1.send.assert_called_once_with(message.replace("current channel", text_channel_1.mention)) text_channel_2.send.assert_not_called() @@ -199,15 +199,16 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): reset() with self.subTest("Replacement Two Channel Test"): message = "Current. The following should be replaced: current channel." - await self.cog.send_message(message, text_channel_1, text_channel_2, True) + await self.cog.send_message(message, text_channel_1, text_channel_2, alert_target=True) text_channel_1.send.assert_called_once_with(message.replace("current channel", text_channel_1.mention)) text_channel_2.send.assert_called_once_with(message) reset() with self.subTest("Text and Voice"): - await self.cog.send_message("This should show up just here", text_channel_1, voice_channel, False) - text_channel_1.send.assert_called_once_with("This should show up just here") + message = "This should show up just here" + await self.cog.send_message(message, text_channel_1, voice_channel, alert_target=False) + text_channel_1.send.assert_called_once_with(message) with mock.patch.object(self.cog, "bot") as bot_mock: bot_mock.get_channel.return_value = text_channel_2 @@ -215,7 +216,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): reset() with self.subTest("Text and Voice"): message = "This should show up as current channel" - await self.cog.send_message(message, text_channel_1, voice_channel, True) + await self.cog.send_message(message, text_channel_1, voice_channel, alert_target=True) text_channel_1.send.assert_called_once_with(message.replace("current channel", voice_channel.mention)) text_channel_2.send.assert_called_once_with(message.replace("current channel", voice_channel.mention)) @@ -226,7 +227,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): reset() with self.subTest("Text and Voice Same Invocation"): message = "This should show up as current channel" - await self.cog.send_message(message, text_channel_2, voice_channel, True) + await self.cog.send_message(message, text_channel_2, voice_channel, alert_target=True) text_channel_2.send.assert_called_once_with(message.replace("current channel", voice_channel.mention)) mock_voice_channels.get.assert_called_once_with(voice_channel.id) @@ -436,7 +437,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): with self.subTest(target_channel=target, message=message): with mock.patch.object(self.cog, "bot") as bot_mock: bot_mock.get_channel.return_value = AsyncMock() - await self.cog.silence.callback(self.cog, ctx, 10, False, channel=target) + await self.cog.silence.callback(self.cog, ctx, 10, target, kick=False) if ctx.channel == target or target is None: ctx.channel.send.assert_called_once_with(message) @@ -822,9 +823,9 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._unsilence_wrapper(channel, context) if context is None: - send_message.assert_called_once_with(message, channel, channel, True) + send_message.assert_called_once_with(message, channel, channel, alert_target=True) else: - send_message.assert_called_once_with(message, context.channel, channel, True) + send_message.assert_called_once_with(message, context.channel, channel, alert_target=True) channel.set_permissions.assert_called_once_with(role, overwrite=overwrites) if channel != ctx.channel: -- cgit v1.2.3 From f8afee54c3ffb1bfee3fca443738f4e91b3d1565 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 29 Dec 2020 19:12:53 +0300 Subject: Adds Error Handling For Voice Channel Muting Adds error handlers to allow voice channel muting to handle as many members as possible. --- bot/exts/moderation/silence.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 31103bc3e..157c150fd 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -277,7 +277,12 @@ class Silence(commands.Cog): for member in channel.members: if self._helper_role not in member.roles: - await member.move_to(None, reason="Kicking member from voice channel.") + try: + await member.move_to(None, reason="Kicking member from voice channel.") + log.debug(f"Kicked {member.name} from voice channel.") + except Exception as e: + log.debug(f"Failed to move {member.name}. Reason: {e}") + continue log.debug("Removed all members.") @@ -298,11 +303,15 @@ class Silence(commands.Cog): if self._helper_role in member.roles: continue - await member.move_to(afk_channel, reason="Muting VC member.") - log.debug(f"Moved {member.name} to afk channel.") + try: + await member.move_to(afk_channel, reason="Muting VC member.") + log.debug(f"Moved {member.name} to afk channel.") - await member.move_to(channel, reason="Muting VC member.") - log.debug(f"Moved {member.name} to original voice channel.") + await member.move_to(channel, reason="Muting VC member.") + log.debug(f"Moved {member.name} to original voice channel.") + except Exception as e: + log.debug(f"Failed to move {member.name}. Reason: {e}") + continue finally: # Delete VC channel if it was created. -- cgit v1.2.3 From 1b747fccd16d2667c6f4129a222cd9ea3eda5602 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 29 Dec 2020 19:18:03 +0300 Subject: Makes Kick Keyword Only Parameter --- bot/exts/moderation/silence.py | 4 ++-- tests/bot/exts/moderation/test_silence.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 157c150fd..e91e558ec 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -171,7 +171,7 @@ class Silence(commands.Cog): channel_info = f"#{channel} ({channel.id})" log.debug(f"{ctx.author} is silencing channel {channel_info}.") - if not await self._set_silence_overwrites(channel, kick): + if not await self._set_silence_overwrites(channel, kick=kick): log.info(f"Tried to silence channel {channel_info} but the channel was already silenced.") await self.send_message(MSG_SILENCE_FAIL, ctx.channel, channel) return @@ -231,7 +231,7 @@ class Silence(commands.Cog): else: await self.send_message(MSG_UNSILENCE_SUCCESS, msg_channel, channel, alert_target=True) - async def _set_silence_overwrites(self, channel: TextOrVoiceChannel, kick: bool = False) -> bool: + async def _set_silence_overwrites(self, channel: TextOrVoiceChannel, *, kick: bool = False) -> bool: """Set silence permission overwrites for `channel` and return True if successful.""" # Get the original channel overwrites if isinstance(channel, TextChannel): diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 038e0a1a4..44c3620ac 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -586,7 +586,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(await self.cog._set_silence_overwrites(self.voice_channel)) self.assertFalse(self.voice_overwrite.speak) - self.assertTrue(await self.cog._set_silence_overwrites(self.voice_channel, True)) + self.assertTrue(await self.cog._set_silence_overwrites(self.voice_channel, kick=True)) self.assertFalse(self.voice_overwrite.speak or self.voice_overwrite.connect) -- cgit v1.2.3 From 9d3fba1c3143a529e32bd660696922c3ff902d16 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Tue, 29 Dec 2020 20:06:51 +0300 Subject: Breaks Out Send Message Tests Moves the tests for the helper method `send_message` to simplify tests, and avoid repeated code. --- tests/bot/exts/moderation/test_silence.py | 156 ++++++++++++++++-------------- 1 file changed, 81 insertions(+), 75 deletions(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 44c3620ac..8f4574d13 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -159,81 +159,6 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): role_check.assert_called_once_with(*(1, 2, 3)) role_check.return_value.predicate.assert_awaited_once_with(ctx) - @mock.patch.object(silence, "VOICE_CHANNELS") - async def test_send_message(self, mock_voice_channels): - """Test the send function reports to the correct channels.""" - text_channel_1 = MockTextChannel() - text_channel_2 = MockTextChannel() - - voice_channel = MockVoiceChannel() - voice_channel.name = "General/Offtopic" - voice_channel.mention = f"#{voice_channel.name}" - - mock_voice_channels.get.return_value = text_channel_2.id - - def reset(): - text_channel_1.reset_mock() - text_channel_2.reset_mock() - voice_channel.reset_mock() - mock_voice_channels.reset_mock() - - with self.subTest("Basic One Channel Test"): - await self.cog.send_message("Text basic message.", text_channel_1, text_channel_2, alert_target=False) - text_channel_1.send.assert_called_once_with("Text basic message.") - text_channel_2.send.assert_not_called() - - reset() - with self.subTest("Basic Two Channel Test"): - await self.cog.send_message("Text basic message.", text_channel_1, text_channel_2, alert_target=True) - text_channel_1.send.assert_called_once_with("Text basic message.") - text_channel_2.send.assert_called_once_with("Text basic message.") - - reset() - with self.subTest("Replacement One Channel Test"): - message = "Current. The following should be replaced: current channel." - await self.cog.send_message(message, text_channel_1, text_channel_2, alert_target=False) - - text_channel_1.send.assert_called_once_with(message.replace("current channel", text_channel_1.mention)) - text_channel_2.send.assert_not_called() - - reset() - with self.subTest("Replacement Two Channel Test"): - message = "Current. The following should be replaced: current channel." - await self.cog.send_message(message, text_channel_1, text_channel_2, alert_target=True) - - text_channel_1.send.assert_called_once_with(message.replace("current channel", text_channel_1.mention)) - text_channel_2.send.assert_called_once_with(message) - - reset() - with self.subTest("Text and Voice"): - message = "This should show up just here" - await self.cog.send_message(message, text_channel_1, voice_channel, alert_target=False) - text_channel_1.send.assert_called_once_with(message) - - with mock.patch.object(self.cog, "bot") as bot_mock: - bot_mock.get_channel.return_value = text_channel_2 - - reset() - with self.subTest("Text and Voice"): - message = "This should show up as current channel" - await self.cog.send_message(message, text_channel_1, voice_channel, alert_target=True) - text_channel_1.send.assert_called_once_with(message.replace("current channel", voice_channel.mention)) - text_channel_2.send.assert_called_once_with(message.replace("current channel", voice_channel.mention)) - - mock_voice_channels.get.assert_called_once_with(voice_channel.id) - bot_mock.get_channel.assert_called_once_with(text_channel_2.id) - bot_mock.reset_mock() - - reset() - with self.subTest("Text and Voice Same Invocation"): - message = "This should show up as current channel" - await self.cog.send_message(message, text_channel_2, voice_channel, alert_target=True) - text_channel_2.send.assert_called_once_with(message.replace("current channel", voice_channel.mention)) - - mock_voice_channels.get.assert_called_once_with(voice_channel.id) - bot_mock.get_channel.assert_called_once_with(text_channel_2.id) - bot_mock.reset_mock() - async def test_force_voice_sync(self): """Tests the _force_voice_sync helper function.""" await self.cog._async_init() @@ -832,3 +757,84 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): ctx.channel.send.assert_not_called() await reset() + + +class SendMessageTests(unittest.IsolatedAsyncioTestCase): + """Unittests for the send message helper function.""" + + def setUp(self) -> None: + self.bot = MockBot() + self.cog = silence.Silence(self.bot) + + self.text_channels = [MockTextChannel() for _ in range(2)] + self.bot.get_channel.return_value = self.text_channels[1] + + self.voice_channel = MockVoiceChannel(name="General/Offtopic") + + async def test_send_to_channel(self): + """Tests a basic case for the send function.""" + message = "Test basic message." + await self.cog.send_message(message, *self.text_channels, alert_target=False) + + self.text_channels[0].send.assert_called_once_with(message) + self.text_channels[1].send.assert_not_called() + + async def test_send_to_multiple_channels(self): + """Tests sending messages to two channels.""" + message = "Test basic message." + await self.cog.send_message(message, *self.text_channels, alert_target=True) + + self.text_channels[0].send.assert_called_once_with(message) + self.text_channels[1].send.assert_called_once_with(message) + + async def test_duration_replacement(self): + """Tests that the channel name was set correctly for one target channel.""" + message = "Current. The following should be replaced: current channel." + await self.cog.send_message(message, *self.text_channels, alert_target=False) + + updated_message = message.replace("current channel", self.text_channels[0].mention) + self.text_channels[0].send.assert_called_once_with(updated_message) + self.text_channels[1].send.assert_not_called() + + async def test_name_replacement_multiple_channels(self): + """Tests that the channel name was set correctly for two channels.""" + message = "Current. The following should be replaced: current channel." + await self.cog.send_message(message, *self.text_channels, alert_target=True) + + updated_message = message.replace("current channel", self.text_channels[0].mention) + self.text_channels[0].send.assert_called_once_with(updated_message) + self.text_channels[1].send.assert_called_once_with(message) + + async def test_silence_voice(self): + """Tests that the correct message was sent when a voice channel is muted without alerting.""" + message = "This should show up just here." + await self.cog.send_message(message, self.text_channels[0], self.voice_channel, alert_target=False) + self.text_channels[0].send.assert_called_once_with(message) + + async def test_silence_voice_alert(self): + """Tests that the correct message was sent when a voice channel is muted with alerts.""" + with unittest.mock.patch.object(silence, "VOICE_CHANNELS") as mock_voice_channels: + mock_voice_channels.get.return_value = self.text_channels[1].id + + message = "This should show up as current channel." + await self.cog.send_message(message, self.text_channels[0], self.voice_channel, alert_target=True) + + updated_message = message.replace("current channel", self.voice_channel.mention) + self.text_channels[0].send.assert_called_once_with(updated_message) + self.text_channels[1].send.assert_called_once_with(updated_message) + + mock_voice_channels.get.assert_called_once_with(self.voice_channel.id) + + async def test_silence_voice_sibling_channel(self): + """Tests silencing a voice channel from the related text channel.""" + with unittest.mock.patch.object(silence, "VOICE_CHANNELS") as mock_voice_channels: + mock_voice_channels.get.return_value = self.text_channels[1].id + + message = "This should show up as current channel." + await self.cog.send_message(message, self.text_channels[1], self.voice_channel, alert_target=True) + + updated_message = message.replace("current channel", self.voice_channel.mention) + self.text_channels[1].send.assert_called_once_with(updated_message) + + mock_voice_channels.get.assert_called_once_with(self.voice_channel.id) + self.bot.get_channel.assert_called_once_with(self.text_channels[1].id) -- cgit v1.2.3 From 43c407cc925ea27faab93d86e54bfce5cca8a8b1 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 10 Jan 2021 16:16:34 +0300 Subject: Improves Unsilence Wrapper Docstring Modifies the unsilence wrapper docstring to make the arguments and behavior more clear. --- bot/exts/moderation/silence.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index e91e558ec..26aa77b61 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -209,7 +209,12 @@ class Silence(commands.Cog): @lock_arg(LOCK_NAMESPACE, "channel", raise_error=True) async def _unsilence_wrapper(self, channel: TextOrVoiceChannel, ctx: Optional[Context] = None) -> None: - """Unsilence `channel` and send a success/failure message.""" + """ + Unsilence `channel` and send a success/failure message to ctx.channel. + + If ctx is None or not passed, `channel` is used in its place. + If `channel` and ctx.channel are the same, only one message is sent. + """ msg_channel = channel if ctx is not None: msg_channel = ctx.channel -- cgit v1.2.3 From 70fcd2b68706ae5e8e35407beaa04e2895f3dae8 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 10 Jan 2021 16:22:15 +0300 Subject: Cleans Up & Simplifies Tests Cleans up the silence tests by removing unneeded or repeated mocks. Simplifies tests where possible by joining similar tests. --- bot/exts/moderation/silence.py | 4 +- tests/bot/exts/moderation/test_silence.py | 111 +++++++++--------------------- 2 files changed, 35 insertions(+), 80 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 26aa77b61..1a3c48394 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -229,9 +229,9 @@ class Silence(commands.Cog): # Send fail message to muted channel or voice chat channel, and invocation channel if manual: - await self.send_message(MSG_UNSILENCE_MANUAL, msg_channel, channel) + await self.send_message(MSG_UNSILENCE_MANUAL, msg_channel, channel, alert_target=False) else: - await self.send_message(MSG_UNSILENCE_FAIL, msg_channel, channel) + await self.send_message(MSG_UNSILENCE_FAIL, msg_channel, channel, alert_target=False) else: await self.send_message(MSG_UNSILENCE_SUCCESS, msg_channel, channel, alert_target=True) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 8f4574d13..5505d7a53 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -163,29 +163,22 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): """Tests the _force_voice_sync helper function.""" await self.cog._async_init() - members = [] - for _ in range(10): - members.append(MockMember()) + members = [MockMember() for _ in range(10)] + members.extend([MockMember(roles=[self.cog._helper_role]) for _ in range(3)]) - afk_channel = MockVoiceChannel() - channel = MockVoiceChannel(guild=MockGuild(afk_channel=afk_channel), members=members) + channel = MockVoiceChannel(members=members) await self.cog._force_voice_sync(channel) for member in members: - self.assertEqual(member.move_to.call_count, 2) - calls = [ - mock.call(afk_channel, reason="Muting VC member."), - mock.call(channel, reason="Muting VC member.") - ] - member.move_to.assert_has_calls(calls, any_order=False) - - async def test_force_voice_sync_staff(self): - """Tests to ensure _force_voice_sync does not kick staff members.""" - await self.cog._async_init() - member = MockMember(roles=[self.cog._helper_role]) + if self.cog._helper_role in member.roles: + member.move_to.assert_not_called() + else: + self.assertEqual(member.move_to.call_count, 2) + calls = member.move_to.call_args_list - await self.cog._force_voice_sync(MockVoiceChannel(members=[member])) - member.move_to.assert_not_called() + # Tests that the member was moved to the afk channel, and back. + self.assertEqual((channel.guild.afk_channel,), calls[0].args) + self.assertEqual((channel,), calls[1].args) async def test_force_voice_sync_no_channel(self): """Test to ensure _force_voice_sync can create its own voice channel if one is not available.""" @@ -204,29 +197,23 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): channel.guild.create_voice_channel.assert_called_once_with("mute-temp", overwrites=overwrites) # Check bot deleted channel - new_channel.delete.assert_called_once_with(reason="Deleting temp mute channel.") + new_channel.delete.assert_called_once() async def test_voice_kick(self): """Test to ensure kick function can remove all members from a voice channel.""" await self.cog._async_init() - members = [] - for _ in range(10): - members.append(MockMember()) + members = [MockMember() for _ in range(10)] + members.extend([MockMember(roles=[self.cog._helper_role]) for _ in range(3)]) channel = MockVoiceChannel(members=members) await self.cog._kick_voice_members(channel) for member in members: - member.move_to.assert_called_once_with(None, reason="Kicking member from voice channel.") - - async def test_voice_kick_staff(self): - """Test to ensure voice kick skips staff members.""" - await self.cog._async_init() - member = MockMember(roles=[self.cog._helper_role]) - - await self.cog._kick_voice_members(MockVoiceChannel(members=[member])) - member.move_to.assert_not_called() + if self.cog._helper_role in member.roles: + member.move_to.assert_not_called() + else: + self.assertEqual((None,), member.move_to.call_args_list[0].args) @autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) @@ -311,7 +298,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): @autospec(silence.Silence, "_reschedule", pass_mocks=False) @autospec(silence, "Scheduler", "SilenceNotifier", pass_mocks=False) def setUp(self) -> None: - self.bot = MockBot() + self.bot = MockBot(get_channel=lambda _: MockTextChannel()) self.cog = silence.Silence(self.bot) self.cog._init_task = asyncio.Future() self.cog._init_task.set_result(None) @@ -360,9 +347,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): for target, message in test_cases: with self.subTest(target_channel=target, message=message): - with mock.patch.object(self.cog, "bot") as bot_mock: - bot_mock.get_channel.return_value = AsyncMock() - await self.cog.silence.callback(self.cog, ctx, 10, target, kick=False) + await self.cog.silence.callback(self.cog, ctx, 10, target, kick=False) if ctx.channel == target or target is None: ctx.channel.send.assert_called_once_with(message) @@ -505,9 +490,6 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_correct_permission_updates(self): """Tests if _set_silence_overwrites can correctly get and update permissions.""" - self.assertTrue(await self.cog._set_silence_overwrites(self.text_channel)) - self.assertFalse(self.text_overwrite.send_messages or self.text_overwrite.add_reactions) - self.assertTrue(await self.cog._set_silence_overwrites(self.voice_channel)) self.assertFalse(self.voice_overwrite.speak) @@ -548,49 +530,22 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): (False, silence.MSG_UNSILENCE_MANUAL, PermissionOverwrite(send_messages=False)), (False, silence.MSG_UNSILENCE_MANUAL, PermissionOverwrite(add_reactions=False)), ) + for was_unsilenced, message, overwrite in test_cases: ctx = MockContext() - with self.subTest(was_unsilenced=was_unsilenced, message=message, overwrite=overwrite): - with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced): - ctx.channel.overwrites_for.return_value = overwrite - await self.cog.unsilence.callback(self.cog, ctx) - ctx.channel.send.assert_called_once_with(message) - - async def test_sent_to_correct_channel(self): - """Test function sends messages to the correct channels.""" - unsilenced_overwrite = PermissionOverwrite(send_messages=True, add_reactions=True) - text_channel = MockTextChannel() - ctx = MockContext() - - test_cases = ( - (None, silence.MSG_UNSILENCE_SUCCESS.format(duration=10)), - (text_channel, silence.MSG_UNSILENCE_SUCCESS.format(duration=10)), - (ctx.channel, silence.MSG_UNSILENCE_SUCCESS.format(duration=10)), - ) - for target, message in test_cases: - with self.subTest(target_channel=target, message=message): - with mock.patch.object(self.cog, "_unsilence", return_value=True): - # Assign Return - if ctx.channel == target or target is None: - ctx.channel.overwrites_for.return_value = unsilenced_overwrite - else: - target.overwrites_for.return_value = unsilenced_overwrite + for target in [None, MockTextChannel()]: + ctx.channel.overwrites_for.return_value = overwrite + if target: + target.overwrites_for.return_value = overwrite - await self.cog.unsilence.callback(self.cog, ctx, channel=target) + with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced): + with mock.patch.object(self.cog, "send_message") as send_message: + with self.subTest(was_unsilenced=was_unsilenced, overwrite=overwrite, target=target): + await self.cog.unsilence.callback(self.cog, ctx, channel=target) - # Check Messages - if ctx.channel == target or target is None: - ctx.channel.send.assert_called_once_with(message) - else: - ctx.channel.send.assert_called_once_with( - message.replace("current channel", text_channel.mention) - ) - target.send.assert_called_once_with(message) - - ctx.channel.send.reset_mock() - if target is not None: - target.send.reset_mock() + call_args = (message, ctx.channel, target or ctx.channel) + send_message.assert_called_once_with(*call_args, alert_target=was_unsilenced) async def test_skipped_already_unsilenced(self): """Permissions were not set and `False` was returned for an already unsilenced channel.""" @@ -708,7 +663,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._unsilence_wrapper(channel, context) overwrites.assert_called_once_with(role) - send_message.assert_called_once_with(message, ctx.channel, channel) + send_message.assert_called_once_with(message, ctx.channel, channel, alert_target=False) send_message.reset_mock() @@ -769,7 +724,7 @@ class SendMessageTests(unittest.IsolatedAsyncioTestCase): self.text_channels = [MockTextChannel() for _ in range(2)] self.bot.get_channel.return_value = self.text_channels[1] - self.voice_channel = MockVoiceChannel(name="General/Offtopic") + self.voice_channel = MockVoiceChannel() async def test_send_to_channel(self): """Tests a basic case for the send function.""" -- cgit v1.2.3 From 52c69c5d51d9938cd7a56bf5cbc2c26a371883d9 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Sun, 10 Jan 2021 16:23:32 +0300 Subject: Cleans Up Voice Sync Tests Cleans up the tests related to the voice sync/kick functions by adding a helper method to simplify mocking. --- tests/bot/exts/moderation/test_silence.py | 59 +++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 5505d7a53..a365b2aae 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -291,6 +291,17 @@ class RescheduleTests(unittest.IsolatedAsyncioTestCase): self.cog.notifier.add_channel.assert_not_called() +def voice_sync_helper(function): + """Helper wrapper to test the sync and kick functions for voice channels.""" + @autospec(silence.Silence, "_force_voice_sync", "_kick_voice_members", "_set_silence_overwrites") + async def inner(self, sync, kick, overwrites): + overwrites.return_value = True + await function(self, MockContext(), + sync, kick) + + return inner + + @autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) class SilenceTests(unittest.IsolatedAsyncioTestCase): """Tests for the silence command and its related helper methods.""" @@ -363,29 +374,41 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): if target is not None and isinstance(target, MockTextChannel): target.send.reset_mock() - @mock.patch.object(silence.Silence, "_set_silence_overwrites", return_value=True) - @mock.patch.object(silence.Silence, "_kick_voice_members") - @mock.patch.object(silence.Silence, "_force_voice_sync") - async def test_sync_or_kick_called(self, sync, kick, _): - """Tests if silence command calls kick or sync on voice channels when appropriate.""" + @voice_sync_helper + async def test_sync_called(self, ctx, sync, kick): + """Tests if silence command calls sync on a voice channel.""" channel = MockVoiceChannel() - ctx = MockContext() + await self.cog.silence.callback(self.cog, ctx, 10, channel, kick=False) - with mock.patch.object(self.cog, "bot") as bot_mock: - bot_mock.get_channel.return_value = AsyncMock() + sync.assert_called_once_with(self.cog, channel) + kick.assert_not_called() - with self.subTest("Test calls kick"): - await self.cog.silence.callback(self.cog, ctx, 10, kick=True, channel=channel) - kick.assert_called_once_with(channel) - sync.assert_not_called() + @voice_sync_helper + async def test_kick_called(self, ctx, sync, kick): + """Tests if silence command calls kick on a voice channel.""" + channel = MockVoiceChannel() + await self.cog.silence.callback(self.cog, ctx, 10, channel, kick=True) + + kick.assert_called_once_with(self.cog, channel) + sync.assert_not_called() + + @voice_sync_helper + async def test_sync_not_called(self, ctx, sync, kick): + """Tests that silence command does not call sync on a text channel.""" + channel = MockTextChannel() + await self.cog.silence.callback(self.cog, ctx, 10, channel, kick=False) + + sync.assert_not_called() + kick.assert_not_called() - kick.reset_mock() - sync.reset_mock() + @voice_sync_helper + async def test_kick_not_called(self, ctx, sync, kick): + """Tests that silence command does not call kick on a text channel.""" + channel = MockTextChannel() + await self.cog.silence.callback(self.cog, ctx, 10, channel, kick=True) - with self.subTest("Test calls sync"): - await self.cog.silence.callback(self.cog, ctx, 10, kick=False, channel=channel) - sync.assert_called_once_with(channel) - kick.assert_not_called() + sync.assert_not_called() + kick.assert_not_called() async def test_skipped_already_silenced(self): """Permissions were not set and `False` was returned for an already silenced channel.""" -- cgit v1.2.3 From 1c08e5a152a3ac0f571bdbd6f6c632f9299dda03 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 4 Feb 2021 11:17:52 +0300 Subject: Updates Voice Channel Config Adds the new voice and text channels to the default config, and renames all affected channels in the config and constants to match the new names. --- bot/constants.py | 10 ++++++---- bot/exts/moderation/silence.py | 5 +++-- config-default.yml | 14 ++++++++------ 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 95e22513f..e0ff80e93 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -441,15 +441,17 @@ class Channels(metaclass=YAMLGetter): staff_announcements: int admins_voice: int + code_help_voice_0: int code_help_voice_1: int - code_help_voice_2: int - general_voice: int + general_voice_0: int + general_voice_1: int staff_voice: int + code_help_chat_0: int code_help_chat_1: int - code_help_chat_2: int staff_voice_chat: int - voice_chat: int + voice_chat_0: int + voice_chat_1: int big_brother_logs: int talent_pool: int diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index cf4d16067..c15cbccaa 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -34,9 +34,10 @@ MSG_UNSILENCE_SUCCESS = f"{constants.Emojis.check_mark} unsilenced current chann TextOrVoiceChannel = Union[TextChannel, VoiceChannel] VOICE_CHANNELS = { + constants.Channels.code_help_voice_0: constants.Channels.code_help_chat_0, constants.Channels.code_help_voice_1: constants.Channels.code_help_chat_1, - constants.Channels.code_help_voice_2: constants.Channels.code_help_chat_2, - constants.Channels.general_voice: constants.Channels.voice_chat, + constants.Channels.general_voice_0: constants.Channels.voice_chat_0, + constants.Channels.general_voice_1: constants.Channels.voice_chat_1, constants.Channels.staff_voice: constants.Channels.staff_voice_chat, } diff --git a/config-default.yml b/config-default.yml index d3b267159..8e8d1f43e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -204,16 +204,18 @@ guild: # Voice Channels admins_voice: &ADMINS_VOICE 500734494840717332 - code_help_voice_1: 751592231726481530 - code_help_voice_2: 764232549840846858 - general_voice: 751591688538947646 + code_help_voice_0: 751592231726481530 + code_help_voice_1: 764232549840846858 + general_voice_0: 751591688538947646 + general_voice_1: 799641437645701151 staff_voice: &STAFF_VOICE 412375055910043655 # Voice Chat - code_help_chat_1: 755154969761677312 - code_help_chat_2: 766330079135268884 + code_help_chat_0: 755154969761677312 + code_help_chat_1: 766330079135268884 staff_voice_chat: 541638762007101470 - voice_chat: 412357430186344448 + voice_chat_0: 412357430186344448 + voice_chat_1: 799647045886541885 # Watch big_brother_logs: &BB_LOGS 468507907357409333 -- cgit v1.2.3 From b10e9b0e3f7de0daf52234f336e43154aa4c1e9a Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 4 Feb 2021 11:54:35 +0300 Subject: Restricts Voice Silence Skip To Mod Roles Raises the permission required to not be muted during a voice silence to moderation roles. --- bot/exts/moderation/silence.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index c15cbccaa..4253cd4f3 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -282,13 +282,16 @@ class Silence(commands.Cog): log.debug(f"Removing all non staff members from #{channel.name} ({channel.id}).") for member in channel.members: - if self._helper_role not in member.roles: - try: - await member.move_to(None, reason="Kicking member from voice channel.") - log.debug(f"Kicked {member.name} from voice channel.") - except Exception as e: - log.debug(f"Failed to move {member.name}. Reason: {e}") - continue + # Skip staff + if any(role.id in constants.MODERATION_ROLES for role in member.roles): + continue + + try: + await member.move_to(None, reason="Kicking member from voice channel.") + log.debug(f"Kicked {member.name} from voice channel.") + except Exception as e: + log.debug(f"Failed to move {member.name}. Reason: {e}") + continue log.debug("Removed all members.") @@ -306,7 +309,7 @@ class Silence(commands.Cog): # Move all members to temporary channel and back for member in channel.members: # Skip staff - if self._helper_role in member.roles: + if any(role.id in constants.MODERATION_ROLES for role in member.roles): continue try: -- cgit v1.2.3 From a9034e649722730ed2551c2660e8d03f798f25a1 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 4 Feb 2021 11:58:37 +0300 Subject: Modifies Channel Creation Reason Co-authored-by: Matteo Bertucci --- bot/exts/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 4253cd4f3..d0115b0cd 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -325,7 +325,7 @@ class Silence(commands.Cog): finally: # Delete VC channel if it was created. if delete_channel: - await afk_channel.delete(reason="Deleting temp mute channel.") + await afk_channel.delete(reason="Deleting temporary mute channel.") async def _schedule_unsilence(self, ctx: Context, channel: TextOrVoiceChannel, duration: Optional[int]) -> None: """Schedule `ctx.channel` to be unsilenced if `duration` is not None.""" -- cgit v1.2.3 From 1a1a283d617592fbf3995d7cd5e1d88be75f92ea Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 4 Feb 2021 12:29:59 +0300 Subject: Updates Voice Kick Restriction Tests --- bot/exts/moderation/silence.py | 4 ++-- tests/bot/exts/moderation/test_silence.py | 26 +++++++++++++++++--------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 4253cd4f3..ea531c37a 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -117,7 +117,6 @@ class Silence(commands.Cog): self._everyone_role = guild.default_role self._verified_voice_role = guild.get_role(constants.Roles.voice_verified) - self._helper_role = guild.get_role(constants.Roles.helpers) self._mod_alerts_channel = self.bot.get_channel(constants.Channels.mod_alerts) @@ -277,7 +276,8 @@ class Silence(commands.Cog): return afk_channel - async def _kick_voice_members(self, channel: VoiceChannel) -> None: + @staticmethod + async def _kick_voice_members(channel: VoiceChannel) -> None: """Remove all non-staff members from a voice channel.""" log.debug(f"Removing all non staff members from #{channel.name} ({channel.id}).") diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index a365b2aae..2d85af7e0 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -7,9 +7,18 @@ from unittest.mock import AsyncMock, Mock from async_rediscache import RedisSession from discord import PermissionOverwrite -from bot.constants import Channels, Guild, Roles +from bot.constants import Channels, Guild, MODERATION_ROLES, Roles from bot.exts.moderation import silence -from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockTextChannel, MockVoiceChannel, autospec +from tests.helpers import ( + MockBot, + MockContext, + MockGuild, + MockMember, + MockRole, + MockTextChannel, + MockVoiceChannel, + autospec +) redis_session = None redis_loop = asyncio.get_event_loop() @@ -164,13 +173,13 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): await self.cog._async_init() members = [MockMember() for _ in range(10)] - members.extend([MockMember(roles=[self.cog._helper_role]) for _ in range(3)]) + members.extend([MockMember(roles=[MockRole(id=role)]) for role in MODERATION_ROLES]) channel = MockVoiceChannel(members=members) await self.cog._force_voice_sync(channel) for member in members: - if self.cog._helper_role in member.roles: + if any(role.id in MODERATION_ROLES for role in member.roles): member.move_to.assert_not_called() else: self.assertEqual(member.move_to.call_count, 2) @@ -204,13 +213,13 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): await self.cog._async_init() members = [MockMember() for _ in range(10)] - members.extend([MockMember(roles=[self.cog._helper_role]) for _ in range(3)]) + members.extend([MockMember(roles=[MockRole(id=role)]) for role in MODERATION_ROLES]) channel = MockVoiceChannel(members=members) await self.cog._kick_voice_members(channel) for member in members: - if self.cog._helper_role in member.roles: + if any(role.id in MODERATION_ROLES for role in member.roles): member.move_to.assert_not_called() else: self.assertEqual((None,), member.move_to.call_args_list[0].args) @@ -296,8 +305,7 @@ def voice_sync_helper(function): @autospec(silence.Silence, "_force_voice_sync", "_kick_voice_members", "_set_silence_overwrites") async def inner(self, sync, kick, overwrites): overwrites.return_value = True - await function(self, MockContext(), - sync, kick) + await function(self, MockContext(), sync, kick) return inner @@ -389,7 +397,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockVoiceChannel() await self.cog.silence.callback(self.cog, ctx, 10, channel, kick=True) - kick.assert_called_once_with(self.cog, channel) + kick.assert_called_once_with(channel) sync.assert_not_called() @voice_sync_helper -- cgit v1.2.3 From baa907f5ef056ec3001759cc6f9a9523953afc39 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 4 Feb 2021 13:07:16 +0300 Subject: Adds Move To Failure Tests Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- tests/bot/exts/moderation/test_silence.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 2d85af7e0..70fe756fd 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -224,6 +224,32 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): else: self.assertEqual((None,), member.move_to.call_args_list[0].args) + async def test_voice_move_to_error(self): + """Test to ensure move_to get called on all members, even if some fail.""" + await self.cog._async_init() + + def failing_move_to(*_): + raise Exception() + failing_members = [MockMember(move_to=Mock(failing_move_to)) for _ in range(5)] + + members = [] + for i in range(5): + members.append(MockMember()) + members.append(failing_members[i]) + + channel = MockVoiceChannel(members=members) + + with self.subTest("Kick"): + await self.cog._kick_voice_members(channel) + for member in members: + member.move_to.assert_called_once() + member.reset_mock() + + with self.subTest("Sync"): + await self.cog._force_voice_sync(channel) + for member in members: + self.assertEqual(member.move_to.call_count, 1 if member in failing_members else 2) + @autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) class RescheduleTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From 09886c234c9840dc2c2eca0f0a26e72ae6cee527 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Thu, 4 Feb 2021 19:58:50 +0300 Subject: Separates Voice Overwrite Tests Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- tests/bot/exts/moderation/test_silence.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 70fe756fd..6b48792cb 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -461,7 +461,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(await self.cog._set_silence_overwrites(channel)) channel.set_permissions.assert_not_called() - async def test_silenced_channel(self): + async def test_silenced_text_channel(self): """Channel had `send_message` and `add_reactions` permissions revoked for verified role.""" self.assertTrue(await self.cog._set_silence_overwrites(self.text_channel)) self.assertFalse(self.text_overwrite.send_messages) @@ -471,6 +471,24 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): overwrite=self.text_overwrite ) + async def test_silenced_voice_channel_speak(self): + """Channel had `speak` permissions revoked for verified role.""" + self.assertTrue(await self.cog._set_silence_overwrites(self.voice_channel)) + self.assertFalse(self.voice_overwrite.speak) + self.voice_channel.set_permissions.assert_awaited_once_with( + self.cog._verified_voice_role, + overwrite=self.voice_overwrite + ) + + async def test_silenced_voice_channel_full(self): + """Channel had `speak` and `connect` permissions revoked for verified role.""" + self.assertTrue(await self.cog._set_silence_overwrites(self.voice_channel, kick=True)) + self.assertFalse(self.voice_overwrite.speak or self.voice_overwrite.connect) + self.voice_channel.set_permissions.assert_awaited_once_with( + self.cog._verified_voice_role, + overwrite=self.voice_overwrite + ) + async def test_preserved_other_overwrites(self): """Channel's other unrelated overwrites were not changed.""" prev_overwrite_dict = dict(self.text_overwrite) @@ -545,14 +563,6 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog.silence.callback(self.cog, ctx, None) self.cog.scheduler.schedule_later.assert_not_called() - async def test_correct_permission_updates(self): - """Tests if _set_silence_overwrites can correctly get and update permissions.""" - self.assertTrue(await self.cog._set_silence_overwrites(self.voice_channel)) - self.assertFalse(self.voice_overwrite.speak) - - self.assertTrue(await self.cog._set_silence_overwrites(self.voice_channel, kick=True)) - self.assertFalse(self.voice_overwrite.speak or self.voice_overwrite.connect) - @autospec(silence.Silence, "unsilence_timestamps", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): -- cgit v1.2.3 From 4fea67cb8a535228cb37a83c4a2d44b5112fb707 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Fri, 5 Feb 2021 02:23:10 +0300 Subject: Modifies Silence Tests Adds a missing test assertion, and seperates the voice and text components of a test. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- tests/bot/exts/moderation/test_silence.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 6b48792cb..2fcf4de43 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -648,14 +648,15 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(self.overwrite.send_messages) self.assertIsNone(self.overwrite.add_reactions) - async def test_cache_miss_sent_mod_alert(self): - """A message was sent to the mod alerts channel.""" + async def test_cache_miss_sent_mod_alert_text(self): + """A message was sent to the mod alerts channel upon muting a text channel.""" self.cog.previous_overwrites.get.return_value = None - await self.cog._unsilence(self.channel) self.cog._mod_alerts_channel.send.assert_awaited_once() - self.cog._mod_alerts_channel.send.reset_mock() + async def test_cache_miss_sent_mod_alert_voice(self): + """A message was sent to the mod alerts channel upon muting a voice channel.""" + self.cog.previous_overwrites.get.return_value = None await self.cog._unsilence(MockVoiceChannel()) self.cog._mod_alerts_channel.send.assert_awaited_once() @@ -832,6 +833,7 @@ class SendMessageTests(unittest.IsolatedAsyncioTestCase): message = "This should show up just here." await self.cog.send_message(message, self.text_channels[0], self.voice_channel, alert_target=False) self.text_channels[0].send.assert_called_once_with(message) + self.text_channels[1].send.assert_not_called() async def test_silence_voice_alert(self): """Tests that the correct message was sent when a voice channel is muted with alerts.""" -- cgit v1.2.3 From 1343a1c22bcd8a21c2d3fc38293033f546c86036 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Fri, 5 Feb 2021 02:49:06 +0300 Subject: Modifies Silence Tests Adds a missing test assertion, and seperates the voice and text components of a test. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- tests/bot/exts/moderation/test_silence.py | 43 ++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 2fcf4de43..a52f2447d 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -1,6 +1,7 @@ import asyncio import unittest from datetime import datetime, timezone +from typing import List, Tuple from unittest import mock from unittest.mock import AsyncMock, Mock @@ -224,31 +225,43 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): else: self.assertEqual((None,), member.move_to.call_args_list[0].args) - async def test_voice_move_to_error(self): - """Test to ensure move_to get called on all members, even if some fail.""" - await self.cog._async_init() + @staticmethod + def create_erroneous_members() -> Tuple[List[MockMember], List[MockMember]]: + """ + Helper method to generate a list of members that error out on move_to call. + Returns the list of erroneous members, + as well as a list of regular and erroneous members combined, in that order. + """ def failing_move_to(*_): raise Exception() - failing_members = [MockMember(move_to=Mock(failing_move_to)) for _ in range(5)] + erroneous_members = [MockMember(move_to=Mock(failing_move_to)) for _ in range(5)] members = [] for i in range(5): members.append(MockMember()) - members.append(failing_members[i]) + members.append(erroneous_members[i]) - channel = MockVoiceChannel(members=members) + return erroneous_members, members + + async def test_kick_move_to_error(self): + """Test to ensure move_to gets called on all members during kick, even if some fail.""" + await self.cog._async_init() + failing_members, members = self.create_erroneous_members() - with self.subTest("Kick"): - await self.cog._kick_voice_members(channel) - for member in members: - member.move_to.assert_called_once() - member.reset_mock() + await self.cog._kick_voice_members(MockVoiceChannel(members=members)) + for member in members: + member.move_to.assert_called_once() + member.reset_mock() + + async def test_sync_move_to_error(self): + """Test to ensure move_to gets called on all members during sync, even if some fail.""" + await self.cog._async_init() + failing_members, members = self.create_erroneous_members() - with self.subTest("Sync"): - await self.cog._force_voice_sync(channel) - for member in members: - self.assertEqual(member.move_to.call_count, 1 if member in failing_members else 2) + await self.cog._force_voice_sync(MockVoiceChannel(members=members)) + for member in members: + self.assertEqual(member.move_to.call_count, 1 if member in failing_members else 2) @autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) -- cgit v1.2.3 From 3797474cabac3fae94a381c0e00998d563efdc5a Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Tue, 23 Feb 2021 14:50:35 +0100 Subject: Introduce cache to cleaning as well as fix cancel --- bot/exts/utils/clean.py | 125 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 89 insertions(+), 36 deletions(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index 7ee0287fd..6301ade04 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -2,7 +2,8 @@ import logging import random import re import time -from typing import Dict, Iterable, List, Optional +from collections import defaultdict +from typing import Callable, DefaultDict, Iterable, List, Optional from discord import Colour, Embed, Message, NotFound, TextChannel, User from discord.ext import commands @@ -16,6 +17,9 @@ from bot.exts.moderation.modlog import ModLog log = logging.getLogger(__name__) +# Type alias for checks +CheckHint = Callable[[Message], bool] + class Clean(Cog): """ @@ -39,18 +43,74 @@ class Clean(Cog): async def _delete_messages_individually(self, messages: List[Message]) -> None: for message in messages: + # Ensure that deletion was not canceled + if not self.cleaning: + return try: await message.delete() except NotFound: - # message doesn't exist or was already deleted + # Message doesn't exist or was already deleted continue + def _get_messages_from_cache(self, amount: int, check: CheckHint) -> List[DefaultDict, List[int]]: + """Helper function for getting messages from the cache.""" + message_mappings = defaultdict(lambda: []) + message_ids = [] + for message in self.bot.cached_messages: + if not self.cleaning: + # Cleaning was canceled + return (message_mappings, message_ids) + + if check(message): + message_mappings[message.channel].append(message) + message_ids.append(message.id) + + if len(message_ids) == amount: + # We've got the requested amount of messages + return message_mappings, message_ids + + # Amount exceeds amount of messages matching the check + return message_mappings, message_ids + + async def _get_messages_from_channels( + self, + amount: int, + channels: Iterable[TextChannel], + check: CheckHint, + until_message: Optional[Message] = None + ) -> DefaultDict: + message_mappings = defaultdict(lambda: []) + message_ids = [] + + for channel in channels: + + async for message in channel.history(amount=amount): + + if not self.cleaning: + # Cleaning was canceled + return (message_mappings, message_ids) + + if check(message): + message_mappings[message.channel].append(message) + message_ids.append(message.id) + + if until_message: + + # We could use ID's here however in case if the message we are looking for gets deleted, + # We won't have a way to figure that out thus checking for datetime should be more reliable + if message.created_at < until_message.created_at: + # Means we have found the message until which we were supposed to be deleting. + break + + return message_mappings, message_ids + async def _clean_messages( self, amount: int, ctx: Context, channels: Iterable[TextChannel], bots_only: bool = False, + use_cache: bool = False, user: User = None, regex: Optional[str] = None, until_message: Optional[Message] = None, @@ -126,41 +186,21 @@ class Clean(Cog): self.mod_log.ignore(Event.message_delete, ctx.message.id) await ctx.message.delete() - # we need Channel to Message mapping for easier deletion via TextChannel.delete_messages - message_mappings: Dict[TextChannel, List[Message]] = {} - message_ids = [] self.cleaning = True - # Find the IDs of the messages to delete. IDs are needed in order to ignore mod log events. - for channel in channels: - - messages = [] - - async for message in channel.history(limit=amount): - - # If at any point the cancel command is invoked, we should stop. - if not self.cleaning: - return - - # If the message passes predicate, let's save it. - if predicate(message): - messages.append(message) - message_ids.append(message) - - # if we are looking for specific message - if until_message: - - # we could use ID's here however in case if the message we are looking for gets deleted, - # we won't have a way to figure that out thus checking for datetime should be more reliable - if message.created_at < until_message.created_at: - # means we have found the message until which we were supposed to be deleting. - break - - if len(messages) > 0: - # we don't want to create mappings of TextChannel to empty list - message_mappings[channel] = messages + if use_cache: + message_mappings, message_ids = self._get_messages_from_cache(amount, predicate) + else: + message_mappings, message_ids = await self._get_messages_from_channels( + amount=amount, + channels=channels, + check=predicate, + until_message=until_message + ) - self.cleaning = False + if not self.cleaning: + # Means that the cleaning was canceled + return # Now let's delete the actual messages with purge. self.mod_log.ignore(Event.message_delete, *message_ids) @@ -174,9 +214,16 @@ class Clean(Cog): for current_index, message in enumerate(messages): + if not self.cleaning: + # Means that the cleaning was canceled + return + if message.id < minimum_time: # further messages are too old to be deleted in bulk await self._delete_messages_individually(messages[current_index:]) + if not self.cleaning: + # Means that deletion was canceled while deleting the individual messages + return break to_delete.append(message) @@ -241,7 +288,10 @@ class Clean(Cog): channels: commands.Greedy[TextChannel] = None ) -> None: """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, user=user, channels=channels) + use_cache = True + if channels: + use_cache = False + await self._clean_messages(amount, ctx, user=user, channels=channels, use_cache=use_cache) @clean_group.command(name="all", aliases=["everything"]) @has_any_role(*MODERATION_ROLES) @@ -275,7 +325,10 @@ class Clean(Cog): channels: commands.Greedy[TextChannel] = None ) -> None: """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, regex=regex, channels=channels) + use_cache = True + if channels: + use_cache = False + await self._clean_messages(amount, ctx, regex=regex, channels=channels, use_cache=use_cache) @clean_group.command(name="until") @has_any_role(*MODERATION_ROLES) -- cgit v1.2.3 From 0a7c7283af42e2b2062a4a555781b24508c6ad38 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Tue, 23 Feb 2021 18:48:54 +0100 Subject: Implement range clean command --- bot/exts/utils/clean.py | 63 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 6 deletions(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index 6301ade04..0788eed1d 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -52,7 +52,7 @@ class Clean(Cog): # Message doesn't exist or was already deleted continue - def _get_messages_from_cache(self, amount: int, check: CheckHint) -> List[DefaultDict, List[int]]: + def _get_messages_from_cache(self, amount: int, predicate: CheckHint) -> List[DefaultDict, List[int]]: """Helper function for getting messages from the cache.""" message_mappings = defaultdict(lambda: []) message_ids = [] @@ -61,7 +61,7 @@ class Clean(Cog): # Cleaning was canceled return (message_mappings, message_ids) - if check(message): + if predicate(message): message_mappings[message.channel].append(message) message_ids.append(message.id) @@ -76,7 +76,7 @@ class Clean(Cog): self, amount: int, channels: Iterable[TextChannel], - check: CheckHint, + predicate: CheckHint, until_message: Optional[Message] = None ) -> DefaultDict: message_mappings = defaultdict(lambda: []) @@ -90,7 +90,7 @@ class Clean(Cog): # Cleaning was canceled return (message_mappings, message_ids) - if check(message): + if predicate(message): message_mappings[message.channel].append(message) message_ids.append(message.id) @@ -114,6 +114,7 @@ class Clean(Cog): user: User = None, regex: Optional[str] = None, until_message: Optional[Message] = None, + after_message: Optional[Message] = None, ) -> None: """A helper function that does the actual message cleaning.""" def predicate_bots_only(message: Message) -> bool: @@ -148,6 +149,10 @@ class Clean(Cog): else: return bool(re.search(regex.lower(), content.lower())) + def predicate_range(message: Message) -> bool: + """Check if message is older than message provided in after_message but younger than until_message.""" + return message.created_at > after_message.created_at and message.created_at < until_message.created_at + # Is this an acceptable amount of messages to clean? if amount > CleanMessages.message_limit: embed = Embed( @@ -158,6 +163,38 @@ class Clean(Cog): await ctx.send(embed=embed) return + if after_message: + + # Ensure that until_message is specified. + if not until_message: + embed = Embed( + color=Colour(Colours.soft_red), + title=random.choice(NEGATIVE_REPLIES), + description="`until_message` must be specified if `after_message` is specified." + ) + await ctx.send(embed=embed) + return + + # Check if the messages are not in same channel + if after_message.channel != until_message.channel: + embed = Embed( + color=Colour(Colours.soft_red), + title=random.choice(NEGATIVE_REPLIES), + description="You cannot do range clean across different channel." + ) + await ctx.send(embed=embed) + return + + # Ensure that after_message is younger than until_message + if after_message.created_at >= until_message.created_at: + embed = Embed( + color=Colour(Colours.soft_red), + title=random.choice(NEGATIVE_REPLIES), + description="`after` message must be younger than `until` message" + ) + await ctx.send(embed=embed) + return + # Are we already performing a clean? if self.cleaning: embed = Embed( @@ -175,6 +212,8 @@ class Clean(Cog): predicate = predicate_specific_user # Delete messages from specific user elif regex: predicate = predicate_regex # Delete messages that match regex + elif after_message: + predicate = predicate_range # Delete messages older than specific message else: predicate = lambda m: True # Delete all messages # noqa: E731 @@ -189,12 +228,12 @@ class Clean(Cog): self.cleaning = True if use_cache: - message_mappings, message_ids = self._get_messages_from_cache(amount, predicate) + message_mappings, message_ids = self._get_messages_from_cache(amount=amount, predicate=predicate) else: message_mappings, message_ids = await self._get_messages_from_channels( amount=amount, channels=channels, - check=predicate, + predicate=predicate, until_message=until_message ) @@ -341,6 +380,18 @@ class Clean(Cog): until_message=message ) + @clean_group.command(name="from-to", aliases=["after-until", "range"]) + @has_any_role(*MODERATION_ROLES) + async def clean_from_to(self, ctx: Context, after_message: Message, until_message: Message) -> None: + """Delete all messages within range of messages.""" + await self._clean_messages( + CleanMessages.message_limit, + ctx, + channels=[until_message.channel], + until_message=until_message, + after_message=after_message, + ) + @clean_group.command(name="stop", aliases=["cancel", "abort"]) @has_any_role(*MODERATION_ROLES) async def clean_cancel(self, ctx: Context) -> None: -- cgit v1.2.3 From bb26ed30a27a1fc5951059ceed064422210df91a Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Wed, 24 Feb 2021 06:35:43 +0100 Subject: set `self.cleaning` to False once done cleaning --- bot/exts/utils/clean.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index 0788eed1d..b572f70a7 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -276,6 +276,8 @@ class Clean(Cog): # deleting any leftover messages if there are any await channel.delete_messages(to_delete) + self.cleaning = False + log_messages = [] for messages in message_mappings.values(): -- cgit v1.2.3 From 26c60c13219cdc3db80480016f61cdf90db1a187 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Thu, 4 Mar 2021 10:26:08 +0100 Subject: Change typing, remove `range` alias --- bot/exts/utils/clean.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index f98e5c255..925d42483 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -3,7 +3,7 @@ import random import re import time from collections import defaultdict -from typing import Callable, DefaultDict, Iterable, List, Optional +from typing import Callable, DefaultDict, Iterable, List, Optional, Tuple from discord import Colour, Embed, Message, NotFound, TextChannel, User from discord.ext import commands @@ -52,7 +52,7 @@ class Clean(Cog): # Message doesn't exist or was already deleted continue - def _get_messages_from_cache(self, amount: int, predicate: CheckHint) -> List[DefaultDict, List[int]]: + def _get_messages_from_cache(self, amount: int, predicate: CheckHint) -> Tuple[DefaultDict, List[int]]: """Helper function for getting messages from the cache.""" message_mappings = defaultdict(lambda: []) message_ids = [] @@ -382,7 +382,7 @@ class Clean(Cog): until_message=message ) - @clean_group.command(name="from-to", aliases=["after-until", "range"]) + @clean_group.command(name="from-to", aliases=["after-until"]) @has_any_role(*MODERATION_ROLES) async def clean_from_to(self, ctx: Context, after_message: Message, until_message: Message) -> None: """Delete all messages within range of messages.""" -- cgit v1.2.3 From acf2b643672703d2bc2011558e9fb68c76d0bc17 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Wed, 10 Mar 2021 20:36:36 +0300 Subject: Combine Silence Target Tests Combine two tests that are responsible for checking the silence helper uses the correct channel and message. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/moderation/silence.py | 2 +- tests/bot/exts/moderation/test_silence.py | 53 +++++++++---------------------- 2 files changed, 16 insertions(+), 39 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index dd379b412..616dfbefb 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -173,7 +173,7 @@ class Silence(commands.Cog): if not await self._set_silence_overwrites(channel, kick=kick): log.info(f"Tried to silence channel {channel_info} but the channel was already silenced.") - await self.send_message(MSG_SILENCE_FAIL, ctx.channel, channel) + await self.send_message(MSG_SILENCE_FAIL, ctx.channel, channel, alert_target=False) return if isinstance(channel, VoiceChannel): diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index a52f2447d..c3b30450f 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -375,51 +375,28 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.voice_channel.overwrites_for.return_value = self.voice_overwrite async def test_sent_correct_message(self): - """Appropriate failure/success message was sent by the command.""" + """Appropriate failure/success message was sent by the command to the correct channel.""" + # The following test tuples are made up of: + # duration, expected message, and the success of the _set_silence_overwrites function test_cases = ( (0.0001, silence.MSG_SILENCE_SUCCESS.format(duration=0.0001), True,), (None, silence.MSG_SILENCE_PERMANENT, True,), (5, silence.MSG_SILENCE_FAIL, False,), ) + for duration, message, was_silenced in test_cases: - ctx = MockContext() with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=was_silenced): - with self.subTest(was_silenced=was_silenced, message=message, duration=duration): - await self.cog.silence.callback(self.cog, ctx, duration) - ctx.channel.send.assert_called_once_with(message) - - @mock.patch.object(silence.Silence, "_set_silence_overwrites", return_value=True) - @mock.patch.object(silence.Silence, "_force_voice_sync") - async def test_sent_to_correct_channel(self, voice_sync, _): - """Test function sends messages to the correct channels.""" - text_channel = MockTextChannel() - voice_channel = MockVoiceChannel() - ctx = MockContext() - - test_cases = ( - (None, silence.MSG_SILENCE_SUCCESS.format(duration=10)), - (text_channel, silence.MSG_SILENCE_SUCCESS.format(duration=10)), - (voice_channel, silence.MSG_SILENCE_SUCCESS.format(duration=10)), - (ctx.channel, silence.MSG_SILENCE_SUCCESS.format(duration=10)), - ) - - for target, message in test_cases: - with self.subTest(target_channel=target, message=message): - await self.cog.silence.callback(self.cog, ctx, 10, target, kick=False) - - if ctx.channel == target or target is None: - ctx.channel.send.assert_called_once_with(message) - - else: - ctx.channel.send.assert_called_once_with(message.replace("current channel", target.mention)) - if isinstance(target, MockTextChannel): - target.send.assert_called_once_with(message) - else: - voice_sync.assert_called_once_with(target) - - ctx.channel.send.reset_mock() - if target is not None and isinstance(target, MockTextChannel): - target.send.reset_mock() + for target in [MockTextChannel(), MockVoiceChannel(), None]: + with self.subTest(was_silenced=was_silenced, target=target, message=message): + with mock.patch.object(self.cog, "send_message") as send_message: + ctx = MockContext() + await self.cog.silence.callback(self.cog, ctx, duration, target) + send_message.assert_called_once_with( + message, + ctx.channel, + target or ctx.channel, + alert_target=was_silenced + ) @voice_sync_helper async def test_sync_called(self, ctx, sync, kick): -- cgit v1.2.3 From e62707dfaf2dd833ff057ee472472d27a86ac223 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Wed, 10 Mar 2021 20:55:24 +0300 Subject: Simplifies Redundant Unsilence Target Test Removes redundant functionality from the `test_unsilence_helper_fail` test as it is covered by another test. Keeps the functionality that isn't being tested elsewhere. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- tests/bot/exts/moderation/test_silence.py | 41 ++++++------------------------- 1 file changed, 8 insertions(+), 33 deletions(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index c3b30450f..5f2e67ac2 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -688,42 +688,17 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) - @mock.patch.object(silence.Silence, "_unsilence", return_value=False) - @mock.patch.object(silence.Silence, "send_message") - async def test_unsilence_helper_fail(self, send_message, _): - """Tests unsilence_wrapper when `_unsilence` fails.""" - ctx = MockContext() - - text_channel = MockTextChannel() - text_role = self.cog.bot.get_guild(Guild.id).default_role - - voice_channel = MockVoiceChannel() - voice_role = self.cog.bot.get_guild(Guild.id).get_role(Roles.voice_verified) - + async def test_unsilence_role(self): + """Tests unsilence_wrapper applies permission to the correct role.""" test_cases = ( - (ctx, text_channel, text_role, True, silence.MSG_UNSILENCE_FAIL), - (ctx, text_channel, text_role, False, silence.MSG_UNSILENCE_MANUAL), - (ctx, voice_channel, voice_role, True, silence.MSG_UNSILENCE_FAIL), - (ctx, voice_channel, voice_role, False, silence.MSG_UNSILENCE_MANUAL), + (MockTextChannel(), self.cog.bot.get_guild(Guild.id).default_role), + (MockVoiceChannel(), self.cog.bot.get_guild(Guild.id).get_role(Roles.voice_verified)) ) - class PermClass: - """Class to Mock return permissions""" - def __init__(self, value: bool): - self.val = value - - def __getattr__(self, item): - return self.val - - for context, channel, role, permission, message in test_cases: - with mock.patch.object(channel, "overwrites_for", return_value=PermClass(permission)) as overwrites: - with self.subTest(channel=channel, message=message): - await self.cog._unsilence_wrapper(channel, context) - - overwrites.assert_called_once_with(role) - send_message.assert_called_once_with(message, ctx.channel, channel, alert_target=False) - - send_message.reset_mock() + for channel, role in test_cases: + with self.subTest(channel=channel, role=role): + await self.cog._unsilence_wrapper(channel, MockContext()) + channel.overwrites_for.assert_called_with(role) @mock.patch.object(silence.Silence, "_force_voice_sync") @mock.patch.object(silence.Silence, "send_message") -- cgit v1.2.3 From b867fb21b6a14f91aa608009aa7eef540a4792dc Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Wed, 10 Mar 2021 22:38:59 +0300 Subject: Use Mock Side Effect Instead Of Extra Function Changes the mock used for creating an erroneous function in the silence tests cog to use the side effect property instead of an extra function. Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- tests/bot/exts/moderation/test_silence.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 5f2e67ac2..a297cc8cb 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -233,9 +233,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): Returns the list of erroneous members, as well as a list of regular and erroneous members combined, in that order. """ - def failing_move_to(*_): - raise Exception() - erroneous_members = [MockMember(move_to=Mock(failing_move_to)) for _ in range(5)] + erroneous_members = [MockMember(move_to=Mock(side_effect=Exception())) for _ in range(5)] members = [] for i in range(5): -- cgit v1.2.3 From b0381d7164ceba20784fc244d724962ec35a9b13 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> Date: Fri, 12 Mar 2021 22:58:28 +0300 Subject: Removes Unused Mock Reset Signed-off-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- tests/bot/exts/moderation/test_silence.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index a297cc8cb..d7542c562 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -250,7 +250,6 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): await self.cog._kick_voice_members(MockVoiceChannel(members=members)) for member in members: member.move_to.assert_called_once() - member.reset_mock() async def test_sync_move_to_error(self): """Test to ensure move_to gets called on all members during sync, even if some fail.""" -- cgit v1.2.3 From 78b00dc8f43d7cb25ce72bc97d6236ec9944cd1f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 28 Mar 2021 09:10:35 +0300 Subject: Remove WatchChannel parent of TalentPool and migrate first commands --- bot/exts/recruitment/talentpool/_cog.py | 80 +++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 28 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index b809cea17..09f2fecbd 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -1,6 +1,6 @@ import logging import textwrap -from collections import ChainMap +from collections import ChainMap, defaultdict from typing import Union from discord import Color, Embed, Member, User @@ -8,35 +8,48 @@ from discord.ext.commands import Cog, Context, group, has_any_role from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks +from bot.constants import Guild, MODERATION_ROLES, STAFF_ROLES from bot.converters import FetchedMember -from bot.exts.moderation.watchchannels._watchchannel import WatchChannel from bot.exts.recruitment.talentpool._review import Reviewer from bot.pagination import LinePaginator from bot.utils import time +from bot.utils.time import get_time_delta REASON_MAX_CHARS = 1000 log = logging.getLogger(__name__) -class TalentPool(WatchChannel, Cog, name="Talentpool"): +class TalentPool(Cog, name="Talentpool"): """Relays messages of helper candidates to a watch channel to observe them.""" def __init__(self, bot: Bot) -> None: - super().__init__( - bot, - destination=Channels.talent_pool, - webhook_id=Webhooks.talent_pool, - api_endpoint='bot/nominations', - api_default_params={'active': 'true', 'ordering': '-inserted_at'}, - logger=log, - disable_header=True, - ) - + self.bot = bot self.reviewer = Reviewer(self.__class__.__name__, bot, self) self.bot.loop.create_task(self.reviewer.reschedule_reviews()) + # Stores talentpool users in cache + self.cache = defaultdict(dict) + + async def refresh_cache(self) -> bool: + """Updates TalentPool users cache.""" + try: + data = await self.bot.api_client.get( + 'bot/nominations', + params={'active': 'true', 'ordering': '-inserted_at'} + ) + except ResponseCodeError as err: + log.exception("Failed to fetch the watched users from the API", exc_info=err) + return False + + self.cache = defaultdict(dict) + + for entry in data: + user_id = entry.pop('user') + self.cache[user_id] = entry + + return True + @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) @has_any_role(*MODERATION_ROLES) async def nomination_group(self, ctx: Context) -> None: @@ -78,26 +91,37 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): The optional kwarg `update_cache` specifies whether the cache should be refreshed by polling the API. """ - # TODO Once the watch channel is removed, this can be done in a smarter way, without splitting and overriding - # the list_watched_users function. - watched_data = await self.prepare_watched_users_data(ctx, oldest_first, update_cache) + successful_update = False + if update_cache: + if not (successful_update := await self.refresh_cache()): + await ctx.send(":warning: Unable to update cache. Data may be inaccurate.") - if update_cache and not watched_data["updated"]: - await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") + nominations = self.cache.items() + if oldest_first: + nominations = reversed(nominations) lines = [] - for user_id, line in watched_data["info"].items(): - if self.watched_users[user_id]['reviewed']: + + for user_id, user_data in nominations: + member = ctx.guild.get_member(user_id) + line = f"• `{user_id}`" + if member: + line += f" ({member.name}#{member.discriminator})" + inserted_at = user_data['inserted_at'] + line += f", added {get_time_delta(inserted_at)}" + if not member: # Cross off users who left the server. + line = f"~~{line}~~" + if user_data['reviewed']: line += " *(reviewed)*" elif user_id in self.reviewer: - line += " *(scheduled)*" + line += " *(scheduled)" lines.append(line) if not lines: lines = ("There's nothing here yet.",) embed = Embed( - title=watched_data["title"], + title=f"Talent Pool active nominations ({'updated' if update_cache and successful_update else 'cached'})", color=Color.blue() ) await LinePaginator.paginate(lines, ctx, embed, empty=False) @@ -130,8 +154,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await ctx.send(":x: Nominating staff members, eh? Here's a cookie :cookie:") return - if not await self.fetch_user_cache(): - await ctx.send(f":x: Failed to update the user cache; can't add {user}") + if not await self.refresh_cache(): + await ctx.send(f":x: Failed to update the cache; can't add {user}") return if len(reason) > REASON_MAX_CHARS: @@ -140,7 +164,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): # Manual request with `raise_for_status` as False because we want the actual response session = self.bot.api_client.session - url = self.bot.api_client._url_for(self.api_endpoint) + url = self.bot.api_client._url_for('bot/nominations') kwargs = { 'json': { 'actor': ctx.author.id, @@ -162,13 +186,13 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): else: resp.raise_for_status() - self.watched_users[user.id] = response_data + self.cache[user.id] = response_data if user.id not in self.reviewer: self.reviewer.schedule_review(user.id) history = await self.bot.api_client.get( - self.api_endpoint, + 'bot/nominations', params={ "user__id": str(user.id), "active": "false", -- cgit v1.2.3 From dda3490a0a1a55bb3ccb16b82afc0dd131f335b5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 28 Mar 2021 09:19:51 +0300 Subject: Use more accurate command names and docstring for talent pool commands --- bot/exts/recruitment/talentpool/_cog.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 09f2fecbd..40fb90f11 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -56,25 +56,25 @@ class TalentPool(Cog, name="Talentpool"): """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" await ctx.send_help(ctx.command) - @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",)) + @nomination_group.command(name='list', aliases=('all', 'watched'), root_aliases=("nominees",)) @has_any_role(*MODERATION_ROLES) - async def watched_command( + async def list_command( self, ctx: Context, oldest_first: bool = False, update_cache: bool = True ) -> None: """ - Shows the users that are currently being monitored in the talent pool. + Shows the users that are currently in the talent pool. The optional kwarg `oldest_first` can be used to order the list by oldest nomination. The optional kwarg `update_cache` can be used to update the user cache using the API before listing the users. """ - await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + await self.list_users(ctx, oldest_first=oldest_first, update_cache=update_cache) - async def list_watched_users( + async def list_users( self, ctx: Context, oldest_first: bool = False, @@ -130,21 +130,20 @@ class TalentPool(Cog, name="Talentpool"): @has_any_role(*MODERATION_ROLES) async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: """ - Shows talent pool monitored users ordered by oldest nomination. + Shows talent pool users ordered by oldest nomination. The optional kwarg `update_cache` can be used to update the user cache using the API before listing the users. """ - await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) + await ctx.invoke(self.list_command, oldest_first=True, update_cache=update_cache) - @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",)) + @nomination_group.command(name='add', aliases=('w', 'a', 'watch'), root_aliases=("nominate",)) @has_any_role(*STAFF_ROLES) - async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None: + async def add_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None: """ - Relay messages sent by the given `user` to the `#talent-pool` channel. + Adds user nomination (or nomination entry) to Talent Pool. - A `reason` for adding the user to the talent pool is optional. - If given, it will be displayed in the header when relaying messages of this user to the channel. + If user already have nomination, then entry associated with existing nomination will be created. """ if user.bot: await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") @@ -235,9 +234,9 @@ class TalentPool(Cog, name="Talentpool"): max_size=1000 ) - @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",)) + @nomination_group.command(name='end', aliases=('unwatch',), root_aliases=("unnominate",)) @has_any_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + async def end_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """ Ends the active nomination of the specified user with the given reason. -- cgit v1.2.3 From f144bace9243659887cbb125ea915f809736fc97 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 28 Mar 2021 09:22:13 +0300 Subject: Migrate nominations history command to non-watchchannel system --- bot/exts/recruitment/talentpool/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 40fb90f11..ee5dc5dae 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -210,7 +210,7 @@ class TalentPool(Cog, name="Talentpool"): async def history_command(self, ctx: Context, user: FetchedMember) -> None: """Shows the specified user's nomination history.""" result = await self.bot.api_client.get( - self.api_endpoint, + 'bot/nominations', params={ 'user__id': str(user.id), 'ordering': "-active,-inserted_at" -- cgit v1.2.3 From 807a27ec112684a2a2b8cfd2b470ab8378f0da98 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 28 Mar 2021 09:30:46 +0300 Subject: Migrate Talent Pool Reviewer class to non-watchchannel Talent Pool --- bot/exts/recruitment/talentpool/_review.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index fb3461238..bc2878451 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -47,9 +47,9 @@ class Reviewer: log.trace("Rescheduling reviews") await self.bot.wait_until_guild_available() # TODO Once the watch channel is removed, this can be done in a smarter way, e.g create a sync function. - await self._pool.fetch_user_cache() + await self._pool.refresh_cache() - for user_id, user_data in self._pool.watched_users.items(): + for user_id, user_data in self._pool.cache.items(): if not user_data["reviewed"]: self.schedule_review(user_id) @@ -57,7 +57,7 @@ class Reviewer: """Schedules a single user for review.""" log.trace(f"Scheduling review of user with ID {user_id}") - user_data = self._pool.watched_users[user_id] + user_data = self._pool.cache[user_id] inserted_at = isoparse(user_data['inserted_at']).replace(tzinfo=None) review_at = inserted_at + timedelta(days=MAX_DAYS_IN_POOL) @@ -69,7 +69,7 @@ class Reviewer: """Format a generic review of a user and post it to the nomination voting channel.""" log.trace(f"Posting the review of {user_id}") - nomination = self._pool.watched_users[user_id] + nomination = self._pool.cache[user_id] if not nomination: log.trace(f"There doesn't appear to be an active nomination for {user_id}") return @@ -79,7 +79,7 @@ class Reviewer: member = guild.get_member(user_id) if update_database: - await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) + await self.bot.api_client.patch(f"bot/nominations/{nomination['id']}", json={"reviewed": True}) if not member: await channel.send( @@ -228,7 +228,7 @@ class Reviewer: """ log.trace(f"Fetching the nomination history data for {member.id}'s review") history = await self.bot.api_client.get( - self._pool.api_endpoint, + "bot/nominations", params={ "user__id": str(member.id), "active": "false", @@ -286,18 +286,18 @@ class Reviewer: Returns True if the user was successfully marked as reviewed, False otherwise. """ log.trace(f"Updating user {user_id} as reviewed") - await self._pool.fetch_user_cache() - if user_id not in self._pool.watched_users: + await self._pool.refresh_cache() + if user_id not in self._pool.cache: log.trace(f"Can't find a nominated user with id {user_id}") await ctx.send(f"❌ Can't find a currently nominated user with id `{user_id}`") return False - nomination = self._pool.watched_users[user_id] + nomination = self._pool.cache[user_id] if nomination["reviewed"]: await ctx.send("❌ This nomination was already reviewed, but here's a cookie 🍪") return False - await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) + await self.bot.api_client.patch(f"bot/nominations/{nomination['id']}", json={"reviewed": True}) if user_id in self._review_scheduler: self._review_scheduler.cancel(user_id) -- cgit v1.2.3 From 2d4764041da02ccee64a93c02c0dd392c31e80c4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 28 Mar 2021 09:31:24 +0300 Subject: Migrate unnominate command to non-watchchannel --- bot/exts/recruitment/talentpool/_cog.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index ee5dc5dae..1ad40812a 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -30,13 +30,14 @@ class TalentPool(Cog, name="Talentpool"): # Stores talentpool users in cache self.cache = defaultdict(dict) + self.api_default_params = {'active': 'true', 'ordering': '-inserted_at'} async def refresh_cache(self) -> bool: """Updates TalentPool users cache.""" try: data = await self.bot.api_client.get( 'bot/nominations', - params={'active': 'true', 'ordering': '-inserted_at'} + params=self.api_default_params ) except ResponseCodeError as err: log.exception("Failed to fetch the watched users from the API", exc_info=err) @@ -243,11 +244,11 @@ class TalentPool(Cog, name="Talentpool"): Providing a `reason` is required. """ if len(reason) > REASON_MAX_CHARS: - await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.") + await ctx.send(f":x: Maximum allowed characters for the end reason is {REASON_MAX_CHARS}.") return if await self.unwatch(user.id, reason): - await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") + await ctx.send(f":white_check_mark: Successfully un-nominated {user}") else: await ctx.send(":x: The specified user does not have an active nomination") @@ -349,7 +350,7 @@ class TalentPool(Cog, name="Talentpool"): async def unwatch(self, user_id: int, reason: str) -> bool: """End the active nomination of a user with the given reason and return True on success.""" active_nomination = await self.bot.api_client.get( - self.api_endpoint, + 'bot/nominations', params=ChainMap( {"user__id": str(user_id)}, self.api_default_params, @@ -364,10 +365,9 @@ class TalentPool(Cog, name="Talentpool"): nomination = active_nomination[0] await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination['id']}", + f"bot/nominations/{nomination['id']}", json={'end_reason': reason, 'active': False} ) - self._remove_user(user_id) self.reviewer.cancel(user_id) -- cgit v1.2.3 From ae3c91082f1a456d37c8ce72c65509315e4a2137 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 28 Mar 2021 09:32:58 +0300 Subject: Add missing asterisk to nominees list --- bot/exts/recruitment/talentpool/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 1ad40812a..4ef3af3d1 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -115,7 +115,7 @@ class TalentPool(Cog, name="Talentpool"): if user_data['reviewed']: line += " *(reviewed)*" elif user_id in self.reviewer: - line += " *(scheduled)" + line += " *(scheduled)*" lines.append(line) if not lines: -- cgit v1.2.3 From 4716b8eba3d25f21e277ee20cff2741cece2e0ed Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 28 Mar 2021 09:52:53 +0300 Subject: Migrate nomination reasons editing commands --- bot/exts/recruitment/talentpool/_cog.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 4ef3af3d1..7824d22d7 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -267,10 +267,10 @@ class TalentPool(Cog, name="Talentpool"): return try: - nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") + nomination = await self.bot.api_client.get(f"bot/nominations/{nomination_id}") except ResponseCodeError as e: if e.response.status == 404: - self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}") + log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}") await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") return else: @@ -284,13 +284,13 @@ class TalentPool(Cog, name="Talentpool"): await ctx.send(f":x: {actor} doesn't have an entry in this nomination.") return - self.log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}") + log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}") await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination_id}", + f"bot/nominations/{nomination_id}", json={"actor": actor.id, "reason": reason} ) - await self.fetch_user_cache() # Update cache + await self.refresh_cache() # Update cache await ctx.send(":white_check_mark: Successfully updated nomination reason.") @nomination_edit_group.command(name='end_reason') @@ -302,10 +302,10 @@ class TalentPool(Cog, name="Talentpool"): return try: - nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") + nomination = await self.bot.api_client.get(f"bot/nominations/{nomination_id}") except ResponseCodeError as e: if e.response.status == 404: - self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}") + log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}") await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") return else: @@ -315,13 +315,13 @@ class TalentPool(Cog, name="Talentpool"): await ctx.send(":x: Can't edit the end reason of an active nomination.") return - self.log.trace(f"Changing end reason for nomination with id {nomination_id} to {repr(reason)}") + log.trace(f"Changing end reason for nomination with id {nomination_id} to {repr(reason)}") await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination_id}", + f"bot/nominations/{nomination_id}", json={"end_reason": reason} ) - await self.fetch_user_cache() # Update cache. + await self.refresh_cache() # Update cache. await ctx.send(":white_check_mark: Updated the end reason of the nomination!") @nomination_group.command(aliases=('mr',)) -- cgit v1.2.3 From e9a233f1f2fbc9b0204041dffa0f3ad6a1f7e8cf Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 28 Mar 2021 10:17:47 +0300 Subject: Remove talentpool channel constants --- bot/constants.py | 2 -- config-default.yml | 3 --- 2 files changed, 5 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 467a4a2c4..840f6fbf6 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -457,7 +457,6 @@ class Channels(metaclass=YAMLGetter): voice_chat: int big_brother_logs: int - talent_pool: int class Webhooks(metaclass=YAMLGetter): @@ -470,7 +469,6 @@ class Webhooks(metaclass=YAMLGetter): duck_pond: int incidents_archive: int reddit: int - talent_pool: int class Roles(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index 502f0f861..8fc1f6718 100644 --- a/config-default.yml +++ b/config-default.yml @@ -223,7 +223,6 @@ guild: # Watch big_brother_logs: &BB_LOGS 468507907357409333 - talent_pool: &TALENT_POOL 534321732593647616 moderation_categories: - *MODMAIL @@ -292,7 +291,6 @@ guild: incidents_archive: 720671599790915702 python_news: &PYNEWS_WEBHOOK 704381182279942324 reddit: 635408384794951680 - talent_pool: 569145364800602132 filter: @@ -323,7 +321,6 @@ filter: - *MESSAGE_LOG - *MOD_LOG - *STAFF_LOUNGE - - *TALENT_POOL - *USER_EVENT_A role_whitelist: -- cgit v1.2.3 From 7e74bb3608af5f1f96216db9472ccf5960c9124e Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Thu, 15 Apr 2021 18:44:49 +0200 Subject: swap predicate save order for correct deletion --- bot/exts/utils/clean.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index 925d42483..e080f7caa 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -90,10 +90,6 @@ class Clean(Cog): # Cleaning was canceled return (message_mappings, message_ids) - if predicate(message): - message_mappings[message.channel].append(message) - message_ids.append(message.id) - if until_message: # We could use ID's here however in case if the message we are looking for gets deleted, @@ -102,6 +98,10 @@ class Clean(Cog): # Means we have found the message until which we were supposed to be deleting. break + if predicate(message): + message_mappings[message.channel].append(message) + message_ids.append(message.id) + return message_mappings, message_ids async def _clean_messages( -- cgit v1.2.3 From 11be1666fce272d13e72467563fde53eae0f8419 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 16 Apr 2021 07:09:36 +0200 Subject: replace lambda with list in defaultdict --- bot/exts/utils/clean.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index 117d63632..be92c4994 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -54,7 +54,7 @@ class Clean(Cog): def _get_messages_from_cache(self, amount: int, predicate: CheckHint) -> Tuple[DefaultDict, List[int]]: """Helper function for getting messages from the cache.""" - message_mappings = defaultdict(lambda: []) + message_mappings = defaultdict(list) message_ids = [] for message in self.bot.cached_messages: if not self.cleaning: @@ -79,7 +79,7 @@ class Clean(Cog): predicate: CheckHint, until_message: Optional[Message] = None ) -> DefaultDict: - message_mappings = defaultdict(lambda: []) + message_mappings = defaultdict(list) message_ids = [] for channel in channels: -- cgit v1.2.3 From 5bf42c7d3c4927dae2a130a95d83295db746338d Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 16 Apr 2021 07:10:48 +0200 Subject: Use correct kwarg for channel.history --- bot/exts/utils/clean.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index be92c4994..d0abd6784 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -84,7 +84,7 @@ class Clean(Cog): for channel in channels: - async for message in channel.history(amount=amount): + async for message in channel.history(limit=amount): if not self.cleaning: # Cleaning was canceled -- cgit v1.2.3 From add078121aba4630fef86a44b261211de89f4f95 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 16 Apr 2021 07:13:38 +0200 Subject: simplify use_cache var --- bot/exts/utils/clean.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index d0abd6784..e41164edc 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -333,9 +333,7 @@ class Clean(Cog): channels: commands.Greedy[TextChannel] = None ) -> None: """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages.""" - use_cache = True - if channels: - use_cache = False + use_cache = not channels await self._clean_messages(amount, ctx, user=user, channels=channels, use_cache=use_cache) @clean_group.command(name="all", aliases=["everything"]) @@ -370,9 +368,7 @@ class Clean(Cog): channels: commands.Greedy[TextChannel] = None ) -> None: """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" - use_cache = True - if channels: - use_cache = False + use_cache = not channels await self._clean_messages(amount, ctx, regex=regex, channels=channels, use_cache=use_cache) @clean_group.command(name="until") -- cgit v1.2.3 From 477810f86387c85ae6da0b80ffccb40dac60e26e Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 16 Apr 2021 07:16:17 +0200 Subject: Naming changes for better self documentation --- bot/exts/utils/clean.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index e41164edc..62d9f2dbe 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -18,7 +18,7 @@ from bot.exts.moderation.modlog import ModLog log = logging.getLogger(__name__) # Type alias for checks -CheckHint = Callable[[Message], bool] +Predicate = Callable[[Message], bool] class Clean(Cog): @@ -52,7 +52,7 @@ class Clean(Cog): # Message doesn't exist or was already deleted continue - def _get_messages_from_cache(self, amount: int, predicate: CheckHint) -> Tuple[DefaultDict, List[int]]: + def _get_messages_from_cache(self, amount: int, to_delete: Predicate) -> Tuple[DefaultDict, List[int]]: """Helper function for getting messages from the cache.""" message_mappings = defaultdict(list) message_ids = [] @@ -61,7 +61,7 @@ class Clean(Cog): # Cleaning was canceled return (message_mappings, message_ids) - if predicate(message): + if to_delete(message): message_mappings[message.channel].append(message) message_ids.append(message.id) @@ -76,7 +76,7 @@ class Clean(Cog): self, amount: int, channels: Iterable[TextChannel], - predicate: CheckHint, + to_delete: Predicate, until_message: Optional[Message] = None ) -> DefaultDict: message_mappings = defaultdict(list) @@ -98,7 +98,7 @@ class Clean(Cog): # Means we have found the message until which we were supposed to be deleting. break - if predicate(message): + if to_delete(message): message_mappings[message.channel].append(message) message_ids.append(message.id) -- cgit v1.2.3 From 708063c70bcea2cdeecb1eb66c703817e0195042 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 16 Apr 2021 07:17:00 +0200 Subject: make predicate_range inclusive --- bot/exts/utils/clean.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index 62d9f2dbe..af9405696 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -151,7 +151,7 @@ class Clean(Cog): def predicate_range(message: Message) -> bool: """Check if message is older than message provided in after_message but younger than until_message.""" - return message.created_at > after_message.created_at and message.created_at < until_message.created_at + return message.created_at >= after_message.created_at and message.created_at <= until_message.created_at # Is this an acceptable amount of messages to clean? if amount > CleanMessages.message_limit: -- cgit v1.2.3 From c2705492ec13984aa75f4c31532ca18b1756121d Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 16 Apr 2021 07:18:48 +0200 Subject: Better response wording, added alias --- bot/exts/utils/clean.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index af9405696..985025afe 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -180,7 +180,7 @@ class Clean(Cog): embed = Embed( color=Colour(Colours.soft_red), title=random.choice(NEGATIVE_REPLIES), - description="You cannot do range clean across different channel." + description="You cannot do range clean across several channel." ) await ctx.send(embed=embed) return @@ -382,7 +382,7 @@ class Clean(Cog): until_message=message ) - @clean_group.command(name="from-to", aliases=["after-until"]) + @clean_group.command(name="from-to", aliases=["after-until", "between"]) @has_any_role(*MODERATION_ROLES) async def clean_from_to(self, ctx: Context, after_message: Message, until_message: Message) -> None: """Delete all messages within range of messages.""" -- cgit v1.2.3 From 6c9e4f55a26f9903532a9b72f69fcab84c1a3370 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 16 Apr 2021 11:41:33 +0200 Subject: Don't delete invocation in mod channel --- bot/exts/utils/clean.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index 985025afe..d9164738a 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -14,6 +14,7 @@ from bot.constants import ( Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES ) from bot.exts.moderation.modlog import ModLog +from bot.utils.channel import is_mod_channel log = logging.getLogger(__name__) @@ -221,13 +222,15 @@ class Clean(Cog): if not channels: channels = [ctx.channel] - # Delete the invocation first - self.mod_log.ignore(Event.message_delete, ctx.message.id) - try: - await ctx.message.delete() - except errors.NotFound: - # Invocation message has already been deleted - log.info("Tried to delete invocation message, but it was already deleted.") + if not is_mod_channel(ctx.channel): + + # Delete the invocation first + self.mod_log.ignore(Event.message_delete, ctx.message.id) + try: + await ctx.message.delete() + except errors.NotFound: + # Invocation message has already been deleted + log.info("Tried to delete invocation message, but it was already deleted.") self.cleaning = True -- cgit v1.2.3 From e5e4343435c31f53e4d58cf8cbd180bfccd94023 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Fri, 16 Apr 2021 13:03:13 +0200 Subject: document snowflake check better --- bot/exts/utils/clean.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index d9164738a..e08be79fe 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -105,6 +105,17 @@ class Clean(Cog): return message_mappings, message_ids + def is_older_than_14d(self, message: Message) -> bool: + """ + Precisely checks if message is older than 14 days, bulk deletion limit. + + Inspired by how purge works internally. + Comparison on message age could possibly be less accurate which in turn would resort in problems + with message deletion if said messages are very close to the 14d mark. + """ + two_weeks_old_snowflake = int((time.time() - 14 * 24 * 60 * 60) * 1000.0 - 1420070400000) << 22 + return message.id < two_weeks_old_snowflake + async def _clean_messages( self, amount: int, @@ -251,9 +262,6 @@ class Clean(Cog): # Now let's delete the actual messages with purge. self.mod_log.ignore(Event.message_delete, *message_ids) - # Creates ID like int object that would represent an object that is exactly 14 days old - minimum_time = int((time.time() - 14 * 24 * 60 * 60) * 1000.0 - 1420070400000) << 22 - for channel, messages in message_mappings.items(): to_delete = [] @@ -264,7 +272,7 @@ class Clean(Cog): # Means that the cleaning was canceled return - if message.id < minimum_time: + if self.is_older_than_14d(message): # further messages are too old to be deleted in bulk await self._delete_messages_individually(messages[current_index:]) if not self.cleaning: -- cgit v1.2.3 From 2446c1728c3ba660d33b686d93d62a93debc74d2 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Wed, 21 Apr 2021 00:25:51 +0300 Subject: Removes Unnecessary Members In Silence Tests Reduces the number of members created for each test to the bare minimum required. Signed-off-by: Hassan Abouelela --- tests/bot/exts/moderation/test_silence.py | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index d7542c562..459048f68 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -173,14 +173,15 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): """Tests the _force_voice_sync helper function.""" await self.cog._async_init() - members = [MockMember() for _ in range(10)] - members.extend([MockMember(roles=[MockRole(id=role)]) for role in MODERATION_ROLES]) + # Create a regular member, and one member for each of the moderation roles + moderation_members = [MockMember(roles=[MockRole(id=role)]) for role in MODERATION_ROLES] + members = [MockMember(), *moderation_members] channel = MockVoiceChannel(members=members) await self.cog._force_voice_sync(channel) for member in members: - if any(role.id in MODERATION_ROLES for role in member.roles): + if member in moderation_members: member.move_to.assert_not_called() else: self.assertEqual(member.move_to.call_count, 2) @@ -213,14 +214,15 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): """Test to ensure kick function can remove all members from a voice channel.""" await self.cog._async_init() - members = [MockMember() for _ in range(10)] - members.extend([MockMember(roles=[MockRole(id=role)]) for role in MODERATION_ROLES]) + # Create a regular member, and one member for each of the moderation roles + moderation_members = [MockMember(roles=[MockRole(id=role)]) for role in MODERATION_ROLES] + members = [MockMember(), *moderation_members] channel = MockVoiceChannel(members=members) await self.cog._kick_voice_members(channel) for member in members: - if any(role.id in MODERATION_ROLES for role in member.roles): + if member in moderation_members: member.move_to.assert_not_called() else: self.assertEqual((None,), member.move_to.call_args_list[0].args) @@ -233,19 +235,15 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): Returns the list of erroneous members, as well as a list of regular and erroneous members combined, in that order. """ - erroneous_members = [MockMember(move_to=Mock(side_effect=Exception())) for _ in range(5)] + erroneous_member = MockMember(move_to=Mock(side_effect=Exception())) + members = [MockMember(), erroneous_member] - members = [] - for i in range(5): - members.append(MockMember()) - members.append(erroneous_members[i]) - - return erroneous_members, members + return erroneous_member, members async def test_kick_move_to_error(self): """Test to ensure move_to gets called on all members during kick, even if some fail.""" await self.cog._async_init() - failing_members, members = self.create_erroneous_members() + _, members = self.create_erroneous_members() await self.cog._kick_voice_members(MockVoiceChannel(members=members)) for member in members: @@ -254,11 +252,11 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): async def test_sync_move_to_error(self): """Test to ensure move_to gets called on all members during sync, even if some fail.""" await self.cog._async_init() - failing_members, members = self.create_erroneous_members() + failing_member, members = self.create_erroneous_members() await self.cog._force_voice_sync(MockVoiceChannel(members=members)) for member in members: - self.assertEqual(member.move_to.call_count, 1 if member in failing_members else 2) + self.assertEqual(member.move_to.call_count, 1 if member == failing_member else 2) @autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) -- cgit v1.2.3 From da694fde0f78813a2787f371b8da84c39b72bcd9 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 24 Apr 2021 01:10:13 +0300 Subject: Uses Async Asserts Where Possible Signed-off-by: Hassan Abouelela --- tests/bot/exts/moderation/test_silence.py | 42 +++++++++++++++---------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 459048f68..ce76dc945 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -205,10 +205,10 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): overwrites = { channel.guild.default_role: PermissionOverwrite(speak=False, connect=False, view_channel=False) } - channel.guild.create_voice_channel.assert_called_once_with("mute-temp", overwrites=overwrites) + channel.guild.create_voice_channel.assert_awaited_once_with("mute-temp", overwrites=overwrites) # Check bot deleted channel - new_channel.delete.assert_called_once() + new_channel.delete.assert_awaited_once() async def test_voice_kick(self): """Test to ensure kick function can remove all members from a voice channel.""" @@ -235,7 +235,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): Returns the list of erroneous members, as well as a list of regular and erroneous members combined, in that order. """ - erroneous_member = MockMember(move_to=Mock(side_effect=Exception())) + erroneous_member = MockMember(move_to=AsyncMock(side_effect=Exception())) members = [MockMember(), erroneous_member] return erroneous_member, members @@ -247,7 +247,7 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): await self.cog._kick_voice_members(MockVoiceChannel(members=members)) for member in members: - member.move_to.assert_called_once() + member.move_to.assert_awaited_once() async def test_sync_move_to_error(self): """Test to ensure move_to gets called on all members during sync, even if some fail.""" @@ -399,7 +399,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockVoiceChannel() await self.cog.silence.callback(self.cog, ctx, 10, channel, kick=False) - sync.assert_called_once_with(self.cog, channel) + sync.assert_awaited_once_with(self.cog, channel) kick.assert_not_called() @voice_sync_helper @@ -408,7 +408,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): channel = MockVoiceChannel() await self.cog.silence.callback(self.cog, ctx, 10, channel, kick=True) - kick.assert_called_once_with(channel) + kick.assert_awaited_once_with(channel) sync.assert_not_called() @voice_sync_helper @@ -510,7 +510,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): """Channel's previous overwrites were cached.""" overwrite_json = '{"send_messages": true, "add_reactions": false}' await self.cog._set_silence_overwrites(self.text_channel) - self.cog.previous_overwrites.set.assert_called_once_with(self.text_channel.id, overwrite_json) + self.cog.previous_overwrites.set.assert_awaited_once_with(self.text_channel.id, overwrite_json) @autospec(silence, "datetime") async def test_cached_unsilence_time(self, datetime_mock): @@ -597,7 +597,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog.unsilence.callback(self.cog, ctx, channel=target) call_args = (message, ctx.channel, target or ctx.channel) - send_message.assert_called_once_with(*call_args, alert_target=was_unsilenced) + send_message.assert_awaited_once_with(*call_args, alert_target=was_unsilenced) async def test_skipped_already_unsilenced(self): """Permissions were not set and `False` was returned for an already unsilenced channel.""" @@ -731,11 +731,11 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._unsilence_wrapper(channel, context) if context is None: - send_message.assert_called_once_with(message, channel, channel, alert_target=True) + send_message.assert_awaited_once_with(message, channel, channel, alert_target=True) else: - send_message.assert_called_once_with(message, context.channel, channel, alert_target=True) + send_message.assert_awaited_once_with(message, context.channel, channel, alert_target=True) - channel.set_permissions.assert_called_once_with(role, overwrite=overwrites) + channel.set_permissions.assert_awaited_once_with(role, overwrite=overwrites) if channel != ctx.channel: ctx.channel.send.assert_not_called() @@ -759,7 +759,7 @@ class SendMessageTests(unittest.IsolatedAsyncioTestCase): message = "Test basic message." await self.cog.send_message(message, *self.text_channels, alert_target=False) - self.text_channels[0].send.assert_called_once_with(message) + self.text_channels[0].send.assert_awaited_once_with(message) self.text_channels[1].send.assert_not_called() async def test_send_to_multiple_channels(self): @@ -767,8 +767,8 @@ class SendMessageTests(unittest.IsolatedAsyncioTestCase): message = "Test basic message." await self.cog.send_message(message, *self.text_channels, alert_target=True) - self.text_channels[0].send.assert_called_once_with(message) - self.text_channels[1].send.assert_called_once_with(message) + self.text_channels[0].send.assert_awaited_once_with(message) + self.text_channels[1].send.assert_awaited_once_with(message) async def test_duration_replacement(self): """Tests that the channel name was set correctly for one target channel.""" @@ -776,7 +776,7 @@ class SendMessageTests(unittest.IsolatedAsyncioTestCase): await self.cog.send_message(message, *self.text_channels, alert_target=False) updated_message = message.replace("current channel", self.text_channels[0].mention) - self.text_channels[0].send.assert_called_once_with(updated_message) + self.text_channels[0].send.assert_awaited_once_with(updated_message) self.text_channels[1].send.assert_not_called() async def test_name_replacement_multiple_channels(self): @@ -785,14 +785,14 @@ class SendMessageTests(unittest.IsolatedAsyncioTestCase): await self.cog.send_message(message, *self.text_channels, alert_target=True) updated_message = message.replace("current channel", self.text_channels[0].mention) - self.text_channels[0].send.assert_called_once_with(updated_message) - self.text_channels[1].send.assert_called_once_with(message) + self.text_channels[0].send.assert_awaited_once_with(updated_message) + self.text_channels[1].send.assert_awaited_once_with(message) async def test_silence_voice(self): """Tests that the correct message was sent when a voice channel is muted without alerting.""" message = "This should show up just here." await self.cog.send_message(message, self.text_channels[0], self.voice_channel, alert_target=False) - self.text_channels[0].send.assert_called_once_with(message) + self.text_channels[0].send.assert_awaited_once_with(message) self.text_channels[1].send.assert_not_called() async def test_silence_voice_alert(self): @@ -804,8 +804,8 @@ class SendMessageTests(unittest.IsolatedAsyncioTestCase): await self.cog.send_message(message, self.text_channels[0], self.voice_channel, alert_target=True) updated_message = message.replace("current channel", self.voice_channel.mention) - self.text_channels[0].send.assert_called_once_with(updated_message) - self.text_channels[1].send.assert_called_once_with(updated_message) + self.text_channels[0].send.assert_awaited_once_with(updated_message) + self.text_channels[1].send.assert_awaited_once_with(updated_message) mock_voice_channels.get.assert_called_once_with(self.voice_channel.id) @@ -818,7 +818,7 @@ class SendMessageTests(unittest.IsolatedAsyncioTestCase): await self.cog.send_message(message, self.text_channels[1], self.voice_channel, alert_target=True) updated_message = message.replace("current channel", self.voice_channel.mention) - self.text_channels[1].send.assert_called_once_with(updated_message) + self.text_channels[1].send.assert_awaited_once_with(updated_message) mock_voice_channels.get.assert_called_once_with(self.voice_channel.id) self.bot.get_channel.assert_called_once_with(self.text_channels[1].id) -- cgit v1.2.3 From db5b0751fb9531525e12e6d421aa7d0772b3054a Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Tue, 4 May 2021 04:37:49 +0300 Subject: Copy Existing Text Channel Cache Tests For Voice Duplicates existing silence and unsilence cache tests for voice channels. Signed-off-by: Hassan Abouelela --- tests/bot/exts/moderation/test_silence.py | 179 ++++++++++++++++-------------- 1 file changed, 97 insertions(+), 82 deletions(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index ce76dc945..347471c13 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -362,11 +362,11 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): asyncio.run(self.cog._async_init()) # Populate instance attributes. self.text_channel = MockTextChannel() - self.text_overwrite = PermissionOverwrite(stream=True, send_messages=True, add_reactions=False) + self.text_overwrite = PermissionOverwrite(send_messages=True, add_reactions=False) self.text_channel.overwrites_for.return_value = self.text_overwrite self.voice_channel = MockVoiceChannel() - self.voice_overwrite = PermissionOverwrite(speak=True) + self.voice_overwrite = PermissionOverwrite(connect=True, speak=True) self.voice_channel.overwrites_for.return_value = self.voice_overwrite async def test_sent_correct_message(self): @@ -474,8 +474,8 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): overwrite=self.voice_overwrite ) - async def test_preserved_other_overwrites(self): - """Channel's other unrelated overwrites were not changed.""" + async def test_preserved_other_overwrites_text(self): + """Channel's other unrelated overwrites were not changed for a text channel mute.""" prev_overwrite_dict = dict(self.text_overwrite) await self.cog._set_silence_overwrites(self.text_channel) new_overwrite_dict = dict(self.text_overwrite) @@ -488,6 +488,20 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) + async def test_preserved_other_overwrites_voice(self): + """Channel's other unrelated overwrites were not changed for a voice channel mute.""" + prev_overwrite_dict = dict(self.voice_overwrite) + await self.cog._set_silence_overwrites(self.voice_channel) + new_overwrite_dict = dict(self.voice_overwrite) + + # Remove 'connect' & 'speak' keys because they were changed by the method. + del prev_overwrite_dict['connect'] + del prev_overwrite_dict['speak'] + del new_overwrite_dict['connect'] + del new_overwrite_dict['speak'] + + self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) + async def test_temp_not_added_to_notifier(self): """Channel was not added to notifier if a duration was set for the silence.""" with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True): @@ -568,9 +582,13 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.cog.scheduler.__contains__.return_value = True overwrites_cache.get.return_value = '{"send_messages": true, "add_reactions": false}' - self.channel = MockTextChannel() - self.overwrite = PermissionOverwrite(stream=True, send_messages=False, add_reactions=False) - self.channel.overwrites_for.return_value = self.overwrite + self.text_channel = MockTextChannel() + self.text_overwrite = PermissionOverwrite(send_messages=False, add_reactions=False) + self.text_channel.overwrites_for.return_value = self.text_overwrite + + self.voice_channel = MockVoiceChannel() + self.voice_overwrite = PermissionOverwrite(connect=True, speak=True) + self.voice_channel.overwrites_for.return_value = self.voice_overwrite async def test_sent_correct_message(self): """Appropriate failure/success message was sent by the command.""" @@ -578,7 +596,7 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): test_cases = ( (True, silence.MSG_UNSILENCE_SUCCESS, unsilenced_overwrite), (False, silence.MSG_UNSILENCE_FAIL, unsilenced_overwrite), - (False, silence.MSG_UNSILENCE_MANUAL, self.overwrite), + (False, silence.MSG_UNSILENCE_MANUAL, self.text_overwrite), (False, silence.MSG_UNSILENCE_MANUAL, PermissionOverwrite(send_messages=False)), (False, silence.MSG_UNSILENCE_MANUAL, PermissionOverwrite(add_reactions=False)), ) @@ -608,35 +626,60 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.assertFalse(await self.cog._unsilence(channel)) channel.set_permissions.assert_not_called() - async def test_restored_overwrites(self): - """Channel's `send_message` and `add_reactions` overwrites were restored.""" - await self.cog._unsilence(self.channel) - self.channel.set_permissions.assert_awaited_once_with( + async def test_restored_overwrites_text(self): + """Text channel's `send_message` and `add_reactions` overwrites were restored.""" + await self.cog._unsilence(self.text_channel) + self.text_channel.set_permissions.assert_awaited_once_with( self.cog._everyone_role, - overwrite=self.overwrite, + overwrite=self.text_overwrite, + ) + + # Recall that these values are determined by the fixture. + self.assertTrue(self.text_overwrite.send_messages) + self.assertFalse(self.text_overwrite.add_reactions) + + async def test_restored_overwrites_voice(self): + """Voice channel's `connect` and `speak` overwrites were restored.""" + await self.cog._unsilence(self.voice_channel) + self.voice_channel.set_permissions.assert_awaited_once_with( + self.cog._verified_voice_role, + overwrite=self.voice_overwrite, ) # Recall that these values are determined by the fixture. - self.assertTrue(self.overwrite.send_messages) - self.assertFalse(self.overwrite.add_reactions) + self.assertTrue(self.voice_overwrite.connect) + self.assertTrue(self.voice_overwrite.speak) - async def test_cache_miss_used_default_overwrites(self): - """Both overwrites were set to None due previous values not being found in the cache.""" + async def test_cache_miss_used_default_overwrites_text(self): + """Text overwrites were set to None due previous values not being found in the cache.""" self.cog.previous_overwrites.get.return_value = None - await self.cog._unsilence(self.channel) - self.channel.set_permissions.assert_awaited_once_with( + await self.cog._unsilence(self.text_channel) + self.text_channel.set_permissions.assert_awaited_once_with( self.cog._everyone_role, - overwrite=self.overwrite, + overwrite=self.text_overwrite, ) - self.assertIsNone(self.overwrite.send_messages) - self.assertIsNone(self.overwrite.add_reactions) + self.assertIsNone(self.text_overwrite.send_messages) + self.assertIsNone(self.text_overwrite.add_reactions) + + async def test_cache_miss_used_default_overwrites_voice(self): + """Voice overwrites were set to None due previous values not being found in the cache.""" + self.cog.previous_overwrites.get.return_value = None + + await self.cog._unsilence(self.voice_channel) + self.voice_channel.set_permissions.assert_awaited_once_with( + self.cog._verified_voice_role, + overwrite=self.voice_overwrite, + ) + + self.assertIsNone(self.voice_overwrite.connect) + self.assertIsNone(self.voice_overwrite.speak) async def test_cache_miss_sent_mod_alert_text(self): """A message was sent to the mod alerts channel upon muting a text channel.""" self.cog.previous_overwrites.get.return_value = None - await self.cog._unsilence(self.channel) + await self.cog._unsilence(self.text_channel) self.cog._mod_alerts_channel.send.assert_awaited_once() async def test_cache_miss_sent_mod_alert_voice(self): @@ -647,33 +690,33 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): async def test_removed_notifier(self): """Channel was removed from `notifier`.""" - await self.cog._unsilence(self.channel) - self.cog.notifier.remove_channel.assert_called_once_with(self.channel) + await self.cog._unsilence(self.text_channel) + self.cog.notifier.remove_channel.assert_called_once_with(self.text_channel) async def test_deleted_cached_overwrite(self): """Channel was deleted from the overwrites cache.""" - await self.cog._unsilence(self.channel) - self.cog.previous_overwrites.delete.assert_awaited_once_with(self.channel.id) + await self.cog._unsilence(self.text_channel) + self.cog.previous_overwrites.delete.assert_awaited_once_with(self.text_channel.id) async def test_deleted_cached_time(self): """Channel was deleted from the timestamp cache.""" - await self.cog._unsilence(self.channel) - self.cog.unsilence_timestamps.delete.assert_awaited_once_with(self.channel.id) + await self.cog._unsilence(self.text_channel) + self.cog.unsilence_timestamps.delete.assert_awaited_once_with(self.text_channel.id) async def test_cancelled_task(self): """The scheduled unsilence task should be cancelled.""" - await self.cog._unsilence(self.channel) - self.cog.scheduler.cancel.assert_called_once_with(self.channel.id) + await self.cog._unsilence(self.text_channel) + self.cog.scheduler.cancel.assert_called_once_with(self.text_channel.id) - async def test_preserved_other_overwrites(self): - """Channel's other unrelated overwrites were not changed, including cache misses.""" + async def test_preserved_other_overwrites_text(self): + """Text channel's other unrelated overwrites were not changed, including cache misses.""" for overwrite_json in ('{"send_messages": true, "add_reactions": null}', None): with self.subTest(overwrite_json=overwrite_json): self.cog.previous_overwrites.get.return_value = overwrite_json - prev_overwrite_dict = dict(self.overwrite) - await self.cog._unsilence(self.channel) - new_overwrite_dict = dict(self.overwrite) + prev_overwrite_dict = dict(self.text_overwrite) + await self.cog._unsilence(self.text_channel) + new_overwrite_dict = dict(self.text_overwrite) # Remove these keys because they were modified by the unsilence. del prev_overwrite_dict['send_messages'] @@ -683,6 +726,24 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) + async def test_preserved_other_overwrites_voice(self): + """Voice channel's other unrelated overwrites were not changed, including cache misses.""" + for overwrite_json in ('{"connect": true, "speak": true}', None): + with self.subTest(overwrite_json=overwrite_json): + self.cog.previous_overwrites.get.return_value = overwrite_json + + prev_overwrite_dict = dict(self.voice_overwrite) + await self.cog._unsilence(self.voice_channel) + new_overwrite_dict = dict(self.voice_overwrite) + + # Remove these keys because they were modified by the unsilence. + del prev_overwrite_dict['connect'] + del prev_overwrite_dict['speak'] + del new_overwrite_dict['connect'] + del new_overwrite_dict['speak'] + + self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) + async def test_unsilence_role(self): """Tests unsilence_wrapper applies permission to the correct role.""" test_cases = ( @@ -695,52 +756,6 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._unsilence_wrapper(channel, MockContext()) channel.overwrites_for.assert_called_with(role) - @mock.patch.object(silence.Silence, "_force_voice_sync") - @mock.patch.object(silence.Silence, "send_message") - async def test_correct_overwrites(self, send_message, _): - """Tests the overwrites returned by the _unsilence_wrapper are correct for voice and text channels.""" - ctx = MockContext() - - text_channel = MockTextChannel() - text_role = self.cog.bot.get_guild(Guild.id).default_role - - voice_channel = MockVoiceChannel() - voice_role = self.cog.bot.get_guild(Guild.id).get_role(Roles.voice_verified) - - async def reset(): - await text_channel.set_permissions(text_role, PermissionOverwrite(send_messages=False, add_reactions=False)) - await voice_channel.set_permissions(voice_role, PermissionOverwrite(speak=False, connect=False)) - - text_channel.reset_mock() - voice_channel.reset_mock() - send_message.reset_mock() - await reset() - - default_text_overwrites = text_channel.overwrites_for(text_role) - default_voice_overwrites = voice_channel.overwrites_for(voice_role) - - test_cases = ( - (ctx, text_channel, text_role, default_text_overwrites, silence.MSG_UNSILENCE_SUCCESS), - (ctx, voice_channel, voice_role, default_voice_overwrites, silence.MSG_UNSILENCE_SUCCESS), - (ctx, ctx.channel, text_role, ctx.channel.overwrites_for(text_role), silence.MSG_UNSILENCE_SUCCESS), - (None, text_channel, text_role, default_text_overwrites, silence.MSG_UNSILENCE_SUCCESS), - ) - - for context, channel, role, overwrites, message in test_cases: - with self.subTest(ctx=context, channel=channel): - await self.cog._unsilence_wrapper(channel, context) - - if context is None: - send_message.assert_awaited_once_with(message, channel, channel, alert_target=True) - else: - send_message.assert_awaited_once_with(message, context.channel, channel, alert_target=True) - - channel.set_permissions.assert_awaited_once_with(role, overwrite=overwrites) - if channel != ctx.channel: - ctx.channel.send.assert_not_called() - - await reset() - class SendMessageTests(unittest.IsolatedAsyncioTestCase): """Unittests for the send message helper function.""" -- cgit v1.2.3 From bac68d1d584b398f2bc5cc7a8f3df39ed48174ae Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Tue, 4 May 2021 04:47:15 +0300 Subject: Adds Voice Test Cases To Already Silenced Test Signed-off-by: Hassan Abouelela --- tests/bot/exts/moderation/test_silence.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 347471c13..0d135698e 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -432,15 +432,17 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_skipped_already_silenced(self): """Permissions were not set and `False` was returned for an already silenced channel.""" subtests = ( - (False, PermissionOverwrite(send_messages=False, add_reactions=False)), - (True, PermissionOverwrite(send_messages=True, add_reactions=True)), - (True, PermissionOverwrite(send_messages=False, add_reactions=False)), + (False, MockTextChannel(), PermissionOverwrite(send_messages=False, add_reactions=False)), + (True, MockTextChannel(), PermissionOverwrite(send_messages=True, add_reactions=True)), + (True, MockTextChannel(), PermissionOverwrite(send_messages=False, add_reactions=False)), + (False, MockVoiceChannel(), PermissionOverwrite(connect=False, speak=False)), + (True, MockVoiceChannel(), PermissionOverwrite(connect=True, speak=True)), + (True, MockVoiceChannel(), PermissionOverwrite(connect=False, speak=False)), ) - for contains, overwrite in subtests: - with self.subTest(contains=contains, overwrite=overwrite): + for contains, channel, overwrite in subtests: + with self.subTest(contains=contains, is_text=isinstance(channel, MockTextChannel), overwrite=overwrite): self.cog.scheduler.__contains__.return_value = contains - channel = MockTextChannel() channel.overwrites_for.return_value = overwrite self.assertFalse(await self.cog._set_silence_overwrites(channel)) -- cgit v1.2.3 From 4bf9a7a545e2ffb507fbb379df1695755a2eea1b Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Tue, 4 May 2021 05:09:04 +0300 Subject: Adds Missing Voice Version Of Tests Signed-off-by: Hassan Abouelela --- tests/bot/exts/moderation/test_silence.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 0d135698e..729b28412 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -623,10 +623,11 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): """Permissions were not set and `False` was returned for an already unsilenced channel.""" self.cog.scheduler.__contains__.return_value = False self.cog.previous_overwrites.get.return_value = None - channel = MockTextChannel() - self.assertFalse(await self.cog._unsilence(channel)) - channel.set_permissions.assert_not_called() + for channel in (MockVoiceChannel(), MockTextChannel()): + with self.subTest(channel=channel): + self.assertFalse(await self.cog._unsilence(channel)) + channel.set_permissions.assert_not_called() async def test_restored_overwrites_text(self): """Text channel's `send_message` and `add_reactions` overwrites were restored.""" -- cgit v1.2.3 From 2722352236bcad411bd6417e7009e43625a7da9a Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sun, 9 May 2021 00:32:13 +0300 Subject: Uses Itertools Product To Reduce Nesting Uses itertools.product to eliminate some nested for loops in tests. Signed-off-by: Hassan Abouelela --- tests/bot/exts/moderation/test_silence.py | 50 ++++++++++++++++--------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 729b28412..de7230ae5 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -1,4 +1,5 @@ import asyncio +import itertools import unittest from datetime import datetime, timezone from typing import List, Tuple @@ -379,19 +380,20 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): (5, silence.MSG_SILENCE_FAIL, False,), ) - for duration, message, was_silenced in test_cases: + targets = (MockTextChannel(), MockVoiceChannel(), None) + + for (duration, message, was_silenced), target in itertools.product(test_cases, targets): with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=was_silenced): - for target in [MockTextChannel(), MockVoiceChannel(), None]: - with self.subTest(was_silenced=was_silenced, target=target, message=message): - with mock.patch.object(self.cog, "send_message") as send_message: - ctx = MockContext() - await self.cog.silence.callback(self.cog, ctx, duration, target) - send_message.assert_called_once_with( - message, - ctx.channel, - target or ctx.channel, - alert_target=was_silenced - ) + with self.subTest(was_silenced=was_silenced, target=target, message=message): + with mock.patch.object(self.cog, "send_message") as send_message: + ctx = MockContext() + await self.cog.silence.callback(self.cog, ctx, duration, target) + send_message.assert_called_once_with( + message, + ctx.channel, + target or ctx.channel, + alert_target=was_silenced + ) @voice_sync_helper async def test_sync_called(self, ctx, sync, kick): @@ -603,21 +605,21 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase): (False, silence.MSG_UNSILENCE_MANUAL, PermissionOverwrite(add_reactions=False)), ) - for was_unsilenced, message, overwrite in test_cases: - ctx = MockContext() + targets = (None, MockTextChannel()) - for target in [None, MockTextChannel()]: - ctx.channel.overwrites_for.return_value = overwrite - if target: - target.overwrites_for.return_value = overwrite + for (was_unsilenced, message, overwrite), target in itertools.product(test_cases, targets): + ctx = MockContext() + ctx.channel.overwrites_for.return_value = overwrite + if target: + target.overwrites_for.return_value = overwrite - with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced): - with mock.patch.object(self.cog, "send_message") as send_message: - with self.subTest(was_unsilenced=was_unsilenced, overwrite=overwrite, target=target): - await self.cog.unsilence.callback(self.cog, ctx, channel=target) + with mock.patch.object(self.cog, "_unsilence", return_value=was_unsilenced): + with mock.patch.object(self.cog, "send_message") as send_message: + with self.subTest(was_unsilenced=was_unsilenced, overwrite=overwrite, target=target): + await self.cog.unsilence.callback(self.cog, ctx, channel=target) - call_args = (message, ctx.channel, target or ctx.channel) - send_message.assert_awaited_once_with(*call_args, alert_target=was_unsilenced) + call_args = (message, ctx.channel, target or ctx.channel) + send_message.assert_awaited_once_with(*call_args, alert_target=was_unsilenced) async def test_skipped_already_unsilenced(self): """Permissions were not set and `False` was returned for an already unsilenced channel.""" -- cgit v1.2.3 From c896414b1669ce63e747df292b96e0a23595140b Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Mon, 10 May 2021 19:54:38 +0300 Subject: Fixes Function Signature Formatting Signed-off-by: Hassan Abouelela --- bot/exts/moderation/silence.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 616dfbefb..a3174bc5d 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -128,7 +128,8 @@ class Silence(commands.Cog): message: str, source_channel: TextChannel, target_channel: TextOrVoiceChannel, - *, alert_target: bool = False + *, + alert_target: bool = False ) -> None: """Helper function to send message confirmation to `source_channel`, and notification to `target_channel`.""" # Reply to invocation channel @@ -154,7 +155,8 @@ class Silence(commands.Cog): ctx: Context, duration: HushDurationConverter = 10, channel: TextOrVoiceChannel = None, - *, kick: bool = False + *, + kick: bool = False ) -> None: """ Silence the current channel for `duration` minutes or `forever`. -- cgit v1.2.3 From d9ff75759cfd3728ba95c89c5aa2dbeae4b1f339 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Mon, 10 May 2021 20:10:17 +0300 Subject: Fixes Logging Statement Changes logging statement levels and messages to correctly express intent. Signed-off-by: Hassan Abouelela --- bot/exts/moderation/silence.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index a3174bc5d..b2b781673 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -274,7 +274,7 @@ class Silence(commands.Cog): guild.default_role: PermissionOverwrite(speak=False, connect=False, view_channel=False) } afk_channel = await guild.create_voice_channel("mute-temp", overwrites=overwrites) - log.info(f"Failed to get afk-channel, created temporary channel #{afk_channel} ({afk_channel.id})") + log.info(f"Failed to get afk-channel, created #{afk_channel} ({afk_channel.id})") return afk_channel @@ -290,7 +290,7 @@ class Silence(commands.Cog): try: await member.move_to(None, reason="Kicking member from voice channel.") - log.debug(f"Kicked {member.name} from voice channel.") + log.trace(f"Kicked {member.name} from voice channel.") except Exception as e: log.debug(f"Failed to move {member.name}. Reason: {e}") continue @@ -316,10 +316,10 @@ class Silence(commands.Cog): try: await member.move_to(afk_channel, reason="Muting VC member.") - log.debug(f"Moved {member.name} to afk channel.") + log.trace(f"Moved {member.name} to afk channel.") await member.move_to(channel, reason="Muting VC member.") - log.debug(f"Moved {member.name} to original voice channel.") + log.trace(f"Moved {member.name} to original voice channel.") except Exception as e: log.debug(f"Failed to move {member.name}. Reason: {e}") continue -- cgit v1.2.3 From 0253f8f6ac0d0cd4878beb320f55d34300bea716 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Mon, 10 May 2021 20:18:35 +0300 Subject: Restructures Silence Cog Restructures silence cog helper methods to group relation functions in a more logical manner. Signed-off-by: Hassan Abouelela --- bot/exts/moderation/silence.py | 156 ++++++++++++++++++++--------------------- 1 file changed, 78 insertions(+), 78 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index b2b781673..289072e8b 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -196,6 +196,41 @@ class Silence(commands.Cog): formatted_message = MSG_SILENCE_SUCCESS.format(duration=duration) await self.send_message(formatted_message, ctx.channel, channel, alert_target=True) + async def _set_silence_overwrites(self, channel: TextOrVoiceChannel, *, kick: bool = False) -> bool: + """Set silence permission overwrites for `channel` and return True if successful.""" + # Get the original channel overwrites + if isinstance(channel, TextChannel): + role = self._everyone_role + overwrite = channel.overwrites_for(role) + prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) + + else: + role = self._verified_voice_role + overwrite = channel.overwrites_for(role) + prev_overwrites = dict(speak=overwrite.speak) + if kick: + prev_overwrites.update(connect=overwrite.connect) + + # Stop if channel was already silenced + if channel.id in self.scheduler or all(val is False for val in prev_overwrites.values()): + return False + + # Set new permissions, store + overwrite.update(**dict.fromkeys(prev_overwrites, False)) + await channel.set_permissions(role, overwrite=overwrite) + await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites)) + + return True + + async def _schedule_unsilence(self, ctx: Context, channel: TextOrVoiceChannel, duration: Optional[int]) -> None: + """Schedule `ctx.channel` to be unsilenced if `duration` is not None.""" + if duration is None: + await self.unsilence_timestamps.set(channel.id, -1) + else: + self.scheduler.schedule_later(duration * 60, channel.id, ctx.invoke(self.unsilence, channel=channel)) + unsilence_time = datetime.now(tz=timezone.utc) + timedelta(minutes=duration) + await self.unsilence_timestamps.set(channel.id, unsilence_time.timestamp()) + @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context, *, channel: TextOrVoiceChannel = None) -> None: """ @@ -238,29 +273,58 @@ class Silence(commands.Cog): else: await self.send_message(MSG_UNSILENCE_SUCCESS, msg_channel, channel, alert_target=True) - async def _set_silence_overwrites(self, channel: TextOrVoiceChannel, *, kick: bool = False) -> bool: - """Set silence permission overwrites for `channel` and return True if successful.""" - # Get the original channel overwrites + async def _unsilence(self, channel: TextOrVoiceChannel) -> bool: + """ + Unsilence `channel`. + + If `channel` has a silence task scheduled or has its previous overwrites cached, unsilence + it, cancel the task, and remove it from the notifier. Notify admins if it has a task but + not cached overwrites. + + Return `True` if channel permissions were changed, `False` otherwise. + """ + # Get stored overwrites, and return if channel is unsilenced + prev_overwrites = await self.previous_overwrites.get(channel.id) + if channel.id not in self.scheduler and prev_overwrites is None: + log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") + return False + + # Select the role based on channel type, and get current overwrites if isinstance(channel, TextChannel): role = self._everyone_role overwrite = channel.overwrites_for(role) - prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) - + permissions = "`Send Messages` and `Add Reactions`" else: role = self._verified_voice_role overwrite = channel.overwrites_for(role) - prev_overwrites = dict(speak=overwrite.speak) - if kick: - prev_overwrites.update(connect=overwrite.connect) + permissions = "`Speak` and `Connect`" - # Stop if channel was already silenced - if channel.id in self.scheduler or all(val is False for val in prev_overwrites.values()): - return False + # Check if old overwrites were not stored + if prev_overwrites is None: + log.info(f"Missing previous overwrites for #{channel} ({channel.id}); defaulting to None.") + overwrite.update(send_messages=None, add_reactions=None, speak=None, connect=None) + else: + overwrite.update(**json.loads(prev_overwrites)) - # Set new permissions, store - overwrite.update(**dict.fromkeys(prev_overwrites, False)) + # Update Permissions await channel.set_permissions(role, overwrite=overwrite) - await self.previous_overwrites.set(channel.id, json.dumps(prev_overwrites)) + if isinstance(channel, VoiceChannel): + await self._force_voice_sync(channel) + + log.info(f"Unsilenced channel #{channel} ({channel.id}).") + + self.scheduler.cancel(channel.id) + self.notifier.remove_channel(channel) + await self.previous_overwrites.delete(channel.id) + await self.unsilence_timestamps.delete(channel.id) + + # Alert Admin team if old overwrites were not available + if prev_overwrites is None: + await self._mod_alerts_channel.send( + f"<@&{constants.Roles.admins}> Restored overwrites with default values after unsilencing " + f"{channel.mention}. Please check that the {permissions} " + f"overwrites for {role.mention} are at their desired values." + ) return True @@ -329,70 +393,6 @@ class Silence(commands.Cog): if delete_channel: await afk_channel.delete(reason="Deleting temporary mute channel.") - async def _schedule_unsilence(self, ctx: Context, channel: TextOrVoiceChannel, duration: Optional[int]) -> None: - """Schedule `ctx.channel` to be unsilenced if `duration` is not None.""" - if duration is None: - await self.unsilence_timestamps.set(channel.id, -1) - else: - self.scheduler.schedule_later(duration * 60, channel.id, ctx.invoke(self.unsilence, channel=channel)) - unsilence_time = datetime.now(tz=timezone.utc) + timedelta(minutes=duration) - await self.unsilence_timestamps.set(channel.id, unsilence_time.timestamp()) - - async def _unsilence(self, channel: TextOrVoiceChannel) -> bool: - """ - Unsilence `channel`. - - If `channel` has a silence task scheduled or has its previous overwrites cached, unsilence - it, cancel the task, and remove it from the notifier. Notify admins if it has a task but - not cached overwrites. - - Return `True` if channel permissions were changed, `False` otherwise. - """ - # Get stored overwrites, and return if channel is unsilenced - prev_overwrites = await self.previous_overwrites.get(channel.id) - if channel.id not in self.scheduler and prev_overwrites is None: - log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") - return False - - # Select the role based on channel type, and get current overwrites - if isinstance(channel, TextChannel): - role = self._everyone_role - overwrite = channel.overwrites_for(role) - permissions = "`Send Messages` and `Add Reactions`" - else: - role = self._verified_voice_role - overwrite = channel.overwrites_for(role) - permissions = "`Speak` and `Connect`" - - # Check if old overwrites were not stored - if prev_overwrites is None: - log.info(f"Missing previous overwrites for #{channel} ({channel.id}); defaulting to None.") - overwrite.update(send_messages=None, add_reactions=None, speak=None, connect=None) - else: - overwrite.update(**json.loads(prev_overwrites)) - - # Update Permissions - await channel.set_permissions(role, overwrite=overwrite) - if isinstance(channel, VoiceChannel): - await self._force_voice_sync(channel) - - log.info(f"Unsilenced channel #{channel} ({channel.id}).") - - self.scheduler.cancel(channel.id) - self.notifier.remove_channel(channel) - await self.previous_overwrites.delete(channel.id) - await self.unsilence_timestamps.delete(channel.id) - - # Alert Admin team if old overwrites were not available - if prev_overwrites is None: - await self._mod_alerts_channel.send( - f"<@&{constants.Roles.admins}> Restored overwrites with default values after unsilencing " - f"{channel.mention}. Please check that the {permissions} " - f"overwrites for {role.mention} are at their desired values." - ) - - return True - async def _reschedule(self) -> None: """Reschedule unsilencing of active silences and add permanent ones to the notifier.""" for channel_id, timestamp in await self.unsilence_timestamps.items(): -- cgit v1.2.3 From 52bfbf314c4a0de8b6de434718589d484a6da95c Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Mon, 10 May 2021 20:23:50 +0300 Subject: Rename `Manual` Variable To Clarify Intentions Signed-off-by: Hassan Abouelela --- bot/exts/moderation/silence.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 289072e8b..2012d75d9 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -259,13 +259,13 @@ class Silence(commands.Cog): if not await self._unsilence(channel): if isinstance(channel, VoiceChannel): overwrite = channel.overwrites_for(self._verified_voice_role) - manual = overwrite.speak is False + has_channel_overwrites = overwrite.speak is False else: overwrite = channel.overwrites_for(self._everyone_role) - manual = overwrite.send_messages is False or overwrite.add_reactions is False + has_channel_overwrites = overwrite.send_messages is False or overwrite.add_reactions is False # Send fail message to muted channel or voice chat channel, and invocation channel - if manual: + if has_channel_overwrites: await self.send_message(MSG_UNSILENCE_MANUAL, msg_channel, channel, alert_target=False) else: await self.send_message(MSG_UNSILENCE_FAIL, msg_channel, channel, alert_target=False) -- cgit v1.2.3 From 2f772082f288671354b6bbd71d9a9caad6fe87af Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Wed, 12 May 2021 00:03:26 +0300 Subject: Updates Silence To Use `.format` Uses `.format` to create silence and unsilence messages instead of `.replace`. Signed-off-by: Hassan Abouelela --- bot/exts/moderation/silence.py | 20 +++++++++++--------- tests/bot/exts/moderation/test_silence.py | 19 +++++++++---------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 2012d75d9..e5c96e76f 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -19,17 +19,17 @@ log = logging.getLogger(__name__) LOCK_NAMESPACE = "silence" -MSG_SILENCE_FAIL = f"{constants.Emojis.cross_mark} current channel is already silenced." -MSG_SILENCE_PERMANENT = f"{constants.Emojis.check_mark} silenced current channel indefinitely." -MSG_SILENCE_SUCCESS = f"{constants.Emojis.check_mark} silenced current channel for {{duration}} minute(s)." +MSG_SILENCE_FAIL = f"{constants.Emojis.cross_mark} {{channel}} is already silenced." +MSG_SILENCE_PERMANENT = f"{constants.Emojis.check_mark} silenced {{channel}} indefinitely." +MSG_SILENCE_SUCCESS = f"{constants.Emojis.check_mark} silenced {{{{channel}}}} for {{duration}} minute(s)." -MSG_UNSILENCE_FAIL = f"{constants.Emojis.cross_mark} current channel was not silenced." +MSG_UNSILENCE_FAIL = f"{constants.Emojis.cross_mark} {{channel}} was not silenced." MSG_UNSILENCE_MANUAL = ( - f"{constants.Emojis.cross_mark} current channel was not unsilenced because the current overwrites were " + f"{constants.Emojis.cross_mark} {{channel}} was not unsilenced because the current overwrites were " f"set manually or the cache was prematurely cleared. " f"Please edit the overwrites manually to unsilence." ) -MSG_UNSILENCE_SUCCESS = f"{constants.Emojis.check_mark} unsilenced current channel." +MSG_UNSILENCE_SUCCESS = f"{constants.Emojis.check_mark} unsilenced {{channel}}." TextOrVoiceChannel = Union[TextChannel, VoiceChannel] @@ -135,7 +135,9 @@ class Silence(commands.Cog): # Reply to invocation channel source_reply = message if source_channel != target_channel: - source_reply = source_reply.replace("current channel", target_channel.mention) + source_reply = source_reply.format(channel=target_channel.mention) + else: + source_reply = source_reply.format(channel="current channel") await source_channel.send(source_reply) # Reply to target channel @@ -143,10 +145,10 @@ class Silence(commands.Cog): if isinstance(target_channel, VoiceChannel): voice_chat = self.bot.get_channel(VOICE_CHANNELS.get(target_channel.id)) if voice_chat and source_channel != voice_chat: - await voice_chat.send(message.replace("current channel", target_channel.mention)) + await voice_chat.send(message.format(channel=target_channel.mention)) elif source_channel != target_channel: - await target_channel.send(message) + await target_channel.send(message.format(channel="current channel")) @commands.command(aliases=("hush",)) @lock(LOCK_NAMESPACE, _select_lock_channel, raise_error=True) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index de7230ae5..af6dd5a37 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -792,21 +792,20 @@ class SendMessageTests(unittest.IsolatedAsyncioTestCase): async def test_duration_replacement(self): """Tests that the channel name was set correctly for one target channel.""" - message = "Current. The following should be replaced: current channel." + message = "Current. The following should be replaced: {channel}." await self.cog.send_message(message, *self.text_channels, alert_target=False) - updated_message = message.replace("current channel", self.text_channels[0].mention) + updated_message = message.format(channel=self.text_channels[0].mention) self.text_channels[0].send.assert_awaited_once_with(updated_message) self.text_channels[1].send.assert_not_called() async def test_name_replacement_multiple_channels(self): """Tests that the channel name was set correctly for two channels.""" - message = "Current. The following should be replaced: current channel." + message = "Current. The following should be replaced: {channel}." await self.cog.send_message(message, *self.text_channels, alert_target=True) - updated_message = message.replace("current channel", self.text_channels[0].mention) - self.text_channels[0].send.assert_awaited_once_with(updated_message) - self.text_channels[1].send.assert_awaited_once_with(message) + self.text_channels[0].send.assert_awaited_once_with(message.format(channel=self.text_channels[0].mention)) + self.text_channels[1].send.assert_awaited_once_with(message.format(channel="current channel")) async def test_silence_voice(self): """Tests that the correct message was sent when a voice channel is muted without alerting.""" @@ -820,10 +819,10 @@ class SendMessageTests(unittest.IsolatedAsyncioTestCase): with unittest.mock.patch.object(silence, "VOICE_CHANNELS") as mock_voice_channels: mock_voice_channels.get.return_value = self.text_channels[1].id - message = "This should show up as current channel." + message = "This should show up as {channel}." await self.cog.send_message(message, self.text_channels[0], self.voice_channel, alert_target=True) - updated_message = message.replace("current channel", self.voice_channel.mention) + updated_message = message.format(channel=self.voice_channel.mention) self.text_channels[0].send.assert_awaited_once_with(updated_message) self.text_channels[1].send.assert_awaited_once_with(updated_message) @@ -834,10 +833,10 @@ class SendMessageTests(unittest.IsolatedAsyncioTestCase): with unittest.mock.patch.object(silence, "VOICE_CHANNELS") as mock_voice_channels: mock_voice_channels.get.return_value = self.text_channels[1].id - message = "This should show up as current channel." + message = "This should show up as {channel}." await self.cog.send_message(message, self.text_channels[1], self.voice_channel, alert_target=True) - updated_message = message.replace("current channel", self.voice_channel.mention) + updated_message = message.format(channel=self.voice_channel.mention) self.text_channels[1].send.assert_awaited_once_with(updated_message) mock_voice_channels.get.assert_called_once_with(self.voice_channel.id) -- cgit v1.2.3 From 0fe7755733bafefe5f4dfba6458f4dbec27ac9f4 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Thu, 13 May 2021 00:32:58 +0300 Subject: Updates Silence To Accept Duration Or Channel Updates the silence command to accept the silence duration or channel as the first argument to the command. Updates tests. Signed-off-by: Hassan Abouelela --- bot/exts/moderation/silence.py | 35 +++++++++--- tests/bot/exts/moderation/test_silence.py | 91 ++++++++++++++++++++++++++++--- 2 files changed, 109 insertions(+), 17 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index e5c96e76f..8e4ce7ae2 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -1,5 +1,6 @@ import json import logging +import typing from contextlib import suppress from datetime import datetime, timedelta, timezone from typing import Optional, OrderedDict, Union @@ -84,12 +85,8 @@ class SilenceNotifier(tasks.Loop): async def _select_lock_channel(args: OrderedDict[str, any]) -> TextOrVoiceChannel: """Passes the channel to be silenced to the resource lock.""" - channel = args["channel"] - if channel is not None: - return channel - - else: - return args["ctx"].channel + channel, _ = Silence.parse_silence_args(args["ctx"], args["duration_or_channel"], args["duration"]) + return channel class Silence(commands.Cog): @@ -155,8 +152,8 @@ class Silence(commands.Cog): async def silence( self, ctx: Context, + duration_or_channel: typing.Union[TextOrVoiceChannel, HushDurationConverter] = None, duration: HushDurationConverter = 10, - channel: TextOrVoiceChannel = None, *, kick: bool = False ) -> None: @@ -170,8 +167,8 @@ class Silence(commands.Cog): If `kick` is True, members will not be added back to the voice channel, and members will be unable to rejoin. """ await self._init_task - if channel is None: - channel = ctx.channel + channel, duration = self.parse_silence_args(ctx, duration_or_channel, duration) + channel_info = f"#{channel} ({channel.id})" log.debug(f"{ctx.author} is silencing channel {channel_info}.") @@ -198,6 +195,26 @@ class Silence(commands.Cog): formatted_message = MSG_SILENCE_SUCCESS.format(duration=duration) await self.send_message(formatted_message, ctx.channel, channel, alert_target=True) + @staticmethod + def parse_silence_args( + ctx: Context, + duration_or_channel: typing.Union[TextOrVoiceChannel, int], + duration: HushDurationConverter + ) -> typing.Tuple[TextOrVoiceChannel, int]: + """Helper method to parse the arguments of the silence command.""" + duration: int + + if duration_or_channel: + if isinstance(duration_or_channel, (TextChannel, VoiceChannel)): + channel = duration_or_channel + else: + channel = ctx.channel + duration = duration_or_channel + else: + channel = ctx.channel + + return channel, duration + async def _set_silence_overwrites(self, channel: TextOrVoiceChannel, *, kick: bool = False) -> bool: """Set silence permission overwrites for `channel` and return True if successful.""" # Get the original channel overwrites diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index af6dd5a37..a7ea733c5 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -260,6 +260,81 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(member.move_to.call_count, 1 if member == failing_member else 2) +class SilenceArgumentParserTests(unittest.IsolatedAsyncioTestCase): + """Tests for the silence argument parser utility function.""" + + def setUp(self): + self.bot = MockBot() + self.cog = silence.Silence(self.bot) + self.cog._init_task = asyncio.Future() + self.cog._init_task.set_result(None) + + @autospec(silence.Silence, "send_message", pass_mocks=False) + @autospec(silence.Silence, "_set_silence_overwrites", return_value=False, pass_mocks=False) + @autospec(silence.Silence, "parse_silence_args") + async def test_command(self, parser_mock): + """Test that the command passes in the correct arguments for different calls.""" + test_cases = ( + (), + (15, ), + (MockTextChannel(),), + (MockTextChannel(), 15), + ) + + ctx = MockContext() + parser_mock.return_value = (ctx.channel, 10) + + for case in test_cases: + with self.subTest("Test command converters", args=case): + await self.cog.silence.callback(self.cog, ctx, *case) + + try: + first_arg = case[0] + except IndexError: + # Default value when the first argument is not passed + first_arg = None + + try: + second_arg = case[1] + except IndexError: + # Default value when the second argument is not passed + second_arg = 10 + + parser_mock.assert_called_with(ctx, first_arg, second_arg) + + async def test_no_arguments(self): + """Test the parser when no arguments are passed to the command.""" + ctx = MockContext() + channel, duration = self.cog.parse_silence_args(ctx, None, 10) + + self.assertEqual(ctx.channel, channel) + self.assertEqual(10, duration) + + async def test_channel_only(self): + """Test the parser when just the channel argument is passed.""" + expected_channel = MockTextChannel() + actual_channel, duration = self.cog.parse_silence_args(MockContext(), expected_channel, 10) + + self.assertEqual(expected_channel, actual_channel) + self.assertEqual(10, duration) + + async def test_duration_only(self): + """Test the parser when just the duration argument is passed.""" + ctx = MockContext() + channel, duration = self.cog.parse_silence_args(ctx, 15, 10) + + self.assertEqual(ctx.channel, channel) + self.assertEqual(15, duration) + + async def test_all_args(self): + """Test the parser when both channel and duration are passed.""" + expected_channel = MockTextChannel() + actual_channel, duration = self.cog.parse_silence_args(MockContext(), expected_channel, 15) + + self.assertEqual(expected_channel, actual_channel) + self.assertEqual(15, duration) + + @autospec(silence.Silence, "previous_overwrites", "unsilence_timestamps", pass_mocks=False) class RescheduleTests(unittest.IsolatedAsyncioTestCase): """Tests for the rescheduling of cached unsilences.""" @@ -387,7 +462,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): with self.subTest(was_silenced=was_silenced, target=target, message=message): with mock.patch.object(self.cog, "send_message") as send_message: ctx = MockContext() - await self.cog.silence.callback(self.cog, ctx, duration, target) + await self.cog.silence.callback(self.cog, ctx, target, duration) send_message.assert_called_once_with( message, ctx.channel, @@ -399,7 +474,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_sync_called(self, ctx, sync, kick): """Tests if silence command calls sync on a voice channel.""" channel = MockVoiceChannel() - await self.cog.silence.callback(self.cog, ctx, 10, channel, kick=False) + await self.cog.silence.callback(self.cog, ctx, channel, 10, kick=False) sync.assert_awaited_once_with(self.cog, channel) kick.assert_not_called() @@ -408,7 +483,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_kick_called(self, ctx, sync, kick): """Tests if silence command calls kick on a voice channel.""" channel = MockVoiceChannel() - await self.cog.silence.callback(self.cog, ctx, 10, channel, kick=True) + await self.cog.silence.callback(self.cog, ctx, channel, 10, kick=True) kick.assert_awaited_once_with(channel) sync.assert_not_called() @@ -417,7 +492,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_sync_not_called(self, ctx, sync, kick): """Tests that silence command does not call sync on a text channel.""" channel = MockTextChannel() - await self.cog.silence.callback(self.cog, ctx, 10, channel, kick=False) + await self.cog.silence.callback(self.cog, ctx, channel, 10, kick=False) sync.assert_not_called() kick.assert_not_called() @@ -426,7 +501,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_kick_not_called(self, ctx, sync, kick): """Tests that silence command does not call kick on a text channel.""" channel = MockTextChannel() - await self.cog.silence.callback(self.cog, ctx, 10, channel, kick=True) + await self.cog.silence.callback(self.cog, ctx, channel, 10, kick=True) sync.assert_not_called() kick.assert_not_called() @@ -515,7 +590,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_indefinite_added_to_notifier(self): """Channel was added to notifier if a duration was not set for the silence.""" with mock.patch.object(self.cog, "_set_silence_overwrites", return_value=True): - await self.cog.silence.callback(self.cog, MockContext(), None) + await self.cog.silence.callback(self.cog, MockContext(), None, None) self.cog.notifier.add_channel.assert_called_once() async def test_silenced_not_added_to_notifier(self): @@ -547,7 +622,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_cached_indefinite_time(self): """A value of -1 was cached for a permanent silence.""" ctx = MockContext(channel=self.text_channel) - await self.cog.silence.callback(self.cog, ctx, None) + await self.cog.silence.callback(self.cog, ctx, None, None) self.cog.unsilence_timestamps.set.assert_awaited_once_with(ctx.channel.id, -1) async def test_scheduled_task(self): @@ -563,7 +638,7 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_permanent_not_scheduled(self): """A task was not scheduled for a permanent silence.""" ctx = MockContext(channel=self.text_channel) - await self.cog.silence.callback(self.cog, ctx, None) + await self.cog.silence.callback(self.cog, ctx, None, None) self.cog.scheduler.schedule_later.assert_not_called() -- cgit v1.2.3 From 50294816787562abd5f3f984f513d039612d2f3b Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Thu, 13 May 2021 06:13:31 +0300 Subject: Updates Shh Command To Mirror Silence Updates the shh and unshh commands from the error handler to accept channel and kick arguments, to give them the same interface as the silence and unsilence command. Signed-off-by: Hassan Abouelela --- bot/exts/backend/error_handler.py | 27 ++++++++- tests/bot/exts/backend/test_error_handler.py | 84 ++++++++++++++++++++++++---- 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index d8de177f5..a3c04437f 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -3,7 +3,7 @@ import logging import typing as t from discord import Embed -from discord.ext.commands import Cog, Context, errors +from discord.ext.commands import ChannelNotFound, Cog, Context, TextChannelConverter, VoiceChannelConverter, errors from sentry_sdk import push_scope from bot.api import ResponseCodeError @@ -115,8 +115,10 @@ class ErrorHandler(Cog): Return bool depending on success of command. """ command = ctx.invoked_with.lower() + args = ctx.message.content.lower().split(" ") silence_command = self.bot.get_command("silence") ctx.invoked_from_error_handler = True + try: if not await silence_command.can_run(ctx): log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") @@ -124,11 +126,30 @@ class ErrorHandler(Cog): except errors.CommandError: log.debug("Cancelling attempt to invoke silence/unsilence due to failed checks.") return False + + # Parse optional args + channel = None + duration = min(command.count("h") * 2, 15) + kick = False + + if len(args) > 1: + # Parse channel + for converter in (TextChannelConverter(), VoiceChannelConverter()): + try: + channel = await converter.convert(ctx, args[1]) + break + except ChannelNotFound: + continue + + if len(args) > 2 and channel is not None: + # Parse kick + kick = args[2].lower() == "true" + if command.startswith("shh"): - await ctx.invoke(silence_command, duration=min(command.count("h")*2, 15)) + await ctx.invoke(silence_command, duration_or_channel=channel, duration=duration, kick=kick) return True elif command.startswith("unshh"): - await ctx.invoke(self.bot.get_command("unsilence")) + await ctx.invoke(self.bot.get_command("unsilence"), channel=channel) return True return False diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index bd4fb5942..37e8108fc 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -9,7 +9,7 @@ from bot.exts.backend.error_handler import ErrorHandler, setup from bot.exts.info.tags import Tags from bot.exts.moderation.silence import Silence from bot.utils.checks import InWhitelistCheckFailure -from tests.helpers import MockBot, MockContext, MockGuild, MockRole +from tests.helpers import MockBot, MockContext, MockGuild, MockRole, MockTextChannel class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): @@ -226,8 +226,8 @@ class TrySilenceTests(unittest.IsolatedAsyncioTestCase): self.bot.get_command.return_value.can_run = AsyncMock(side_effect=errors.CommandError()) self.assertFalse(await self.cog.try_silence(self.ctx)) - async def test_try_silence_silencing(self): - """Should run silence command with correct arguments.""" + async def test_try_silence_silence_duration(self): + """Should run silence command with correct duration argument.""" self.bot.get_command.return_value.can_run = AsyncMock(return_value=True) test_cases = ("shh", "shhh", "shhhhhh", "shhhhhhhhhhhhhhhhhhh") @@ -238,21 +238,85 @@ class TrySilenceTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(await self.cog.try_silence(self.ctx)) self.ctx.invoke.assert_awaited_once_with( self.bot.get_command.return_value, - duration=min(case.count("h")*2, 15) + duration_or_channel=None, + duration=min(case.count("h")*2, 15), + kick=False ) + async def test_try_silence_silence_arguments(self): + """Should run silence with the correct channel, duration, and kick arguments.""" + self.bot.get_command.return_value.can_run = AsyncMock(return_value=True) + + test_cases = ( + (MockTextChannel(), None), # None represents the case when no argument is passed + (MockTextChannel(), False), + (MockTextChannel(), True) + ) + + for channel, kick in test_cases: + with self.subTest(kick=kick, channel=channel): + self.ctx.reset_mock() + self.ctx.invoked_with = "shh" + + self.ctx.message.content = f"!shh {channel.name} {kick if kick is not None else ''}" + self.ctx.guild.text_channels = [channel] + + self.assertTrue(await self.cog.try_silence(self.ctx)) + self.ctx.invoke.assert_awaited_once_with( + self.bot.get_command.return_value, + duration_or_channel=channel, + duration=4, + kick=(kick if kick is not None else False) + ) + + async def test_try_silence_silence_message(self): + """If the words after the command could not be converted to a channel, None should be passed as channel.""" + self.bot.get_command.return_value.can_run = AsyncMock(return_value=True) + self.ctx.invoked_with = "shh" + self.ctx.message.content = "!shh not_a_channel true" + + self.assertTrue(await self.cog.try_silence(self.ctx)) + self.ctx.invoke.assert_awaited_once_with( + self.bot.get_command.return_value, + duration_or_channel=None, + duration=4, + kick=False + ) + async def test_try_silence_unsilence(self): - """Should call unsilence command.""" + """Should call unsilence command with correct duration and channel arguments.""" self.silence.silence.can_run = AsyncMock(return_value=True) - test_cases = ("unshh", "unshhhhh", "unshhhhhhhhh") + test_cases = ( + ("unshh", None), + ("unshhhhh", None), + ("unshhhhhhhhh", None), + ("unshh", MockTextChannel()) + ) - for case in test_cases: - with self.subTest(message=case): + for invoke, channel in test_cases: + with self.subTest(message=invoke, channel=channel): self.bot.get_command.side_effect = (self.silence.silence, self.silence.unsilence) self.ctx.reset_mock() - self.ctx.invoked_with = case + + self.ctx.invoked_with = invoke + self.ctx.message.content = f"!{invoke}" + if channel is not None: + self.ctx.message.content += f" {channel.name}" + self.ctx.guild.text_channels = [channel] + self.assertTrue(await self.cog.try_silence(self.ctx)) - self.ctx.invoke.assert_awaited_once_with(self.silence.unsilence) + self.ctx.invoke.assert_awaited_once_with(self.silence.unsilence, channel=channel) + + async def test_try_silence_unsilence_message(self): + """If the words after the command could not be converted to a channel, None should be passed as channel.""" + self.silence.silence.can_run = AsyncMock(return_value=True) + self.bot.get_command.side_effect = (self.silence.silence, self.silence.unsilence) + + self.ctx.invoked_with = "unshh" + self.ctx.message.content = "!unshh not_a_channel" + + self.assertTrue(await self.cog.try_silence(self.ctx)) + self.ctx.invoke.assert_awaited_once_with(self.silence.unsilence, channel=None) async def test_try_silence_no_match(self): """Should return `False` when message don't match.""" -- cgit v1.2.3 From f168c05a881061b2032f5abe5b76b0ce80b7d64e Mon Sep 17 00:00:00 2001 From: swfarnsworth Date: Thu, 13 May 2021 23:25:19 -0400 Subject: Cooldown role only removed when help channel closes, removing a true cooldown. This implicitly creates a one channel per user rule. --- bot/exts/help_channels/_cog.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 262b18e16..6cd31df38 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -12,7 +12,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.exts.help_channels import _caches, _channel, _cooldown, _message, _name, _stats +from bot.exts.help_channels import _caches, _channel, _message, _name, _stats from bot.utils import channel as channel_utils, lock, scheduling log = logging.getLogger(__name__) @@ -106,9 +106,11 @@ class HelpChannels(commands.Cog): """ log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.") await self.move_to_in_use(message.channel) - await _cooldown.revoke_send_permissions(message.author, self.scheduler) + cooldown_role = self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.help_cooldown) + await message.author.add_roles(cooldown_role) await _message.pin(message) + try: await _message.dm_on_open(message) except Exception as e: @@ -276,7 +278,6 @@ class HelpChannels(commands.Cog): log.trace("Initialising the cog.") await self.init_categories() - await _cooldown.check_cooldowns(self.scheduler) self.channel_queue = self.create_channel_queue() self.name_queue = _name.create_name_queue( @@ -407,16 +408,12 @@ class HelpChannels(commands.Cog): """Actual implementation of `unclaim_channel`. See that for full documentation.""" await _caches.claimants.delete(channel.id) - # Ignore missing tasks because a channel may still be dormant after the cooldown expires. - if claimant_id in self.scheduler: - self.scheduler.cancel(claimant_id) - claimant = self.bot.get_guild(constants.Guild.id).get_member(claimant_id) if claimant is None: log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed") - elif not any(claimant.id == user_id for _, user_id in await _caches.claimants.items()): - # Remove the cooldown role if the claimant has no other channels left - await _cooldown.remove_cooldown_role(claimant) + else: + cooldown_role = self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.help_cooldown) + await claimant.remove_roles(cooldown_role) await _message.unpin(channel) await _stats.report_complete_session(channel.id, closed_on) -- cgit v1.2.3 From b4f91dd6fc054b78143d446f9693065612dad6bf Mon Sep 17 00:00:00 2001 From: rohan Date: Sat, 15 May 2021 00:49:29 +0530 Subject: Prioratize DM over channel message for voice verification ping. --- bot/exts/moderation/voice_gate.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 0cbce6a51..a786e1b1a 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -40,6 +40,12 @@ VOICE_PING = ( "If you don't yet qualify, you'll be told why!" ) +VOICE_PING_DM = ( + "Wondering why you can't talk in the voice channels? " + "Use the `!voiceverify` command in {channel_mention} to verify. " + "If you don't yet qualify, you'll be told why!" +) + class VoiceGate(Cog): """Voice channels verification management.""" @@ -75,7 +81,7 @@ class VoiceGate(Cog): log.trace(f"Voice gate reminder message for user {member_id} was already removed") @redis_cache.atomic_transaction - async def _ping_newcomer(self, member: discord.Member) -> bool: + async def _ping_newcomer(self, member: discord.Member) -> tuple: """ See if `member` should be sent a voice verification notification, and send it if so. @@ -87,23 +93,28 @@ class VoiceGate(Cog): """ if await self.redis_cache.contains(member.id): log.trace("User already in cache. Ignore.") - return False + return False, None log.trace("User not in cache and is in a voice channel.") verified = any(Roles.voice_verified == role.id for role in member.roles) if verified: log.trace("User is verified, add to the cache and ignore.") await self.redis_cache.set(member.id, NO_MSG) - return False + return False, None log.trace("User is unverified. Send ping.") + await self.bot.wait_until_guild_available() - voice_verification_channel = self.bot.get_channel(Channels.voice_gate) + voice_verification_channel = self.bot.get_channel(756769546777395203) - message = await voice_verification_channel.send(f"Hello, {member.mention}! {VOICE_PING}") - await self.redis_cache.set(member.id, message.id) + try: + message = await member.send(VOICE_PING_DM.format(channel_mention=voice_verification_channel.mention)) + except discord.Forbidden: + log.trace("DM failed for Voice ping message. Sending in channel.") + message = await voice_verification_channel.send(f"Hello, {member.mention}! {VOICE_PING}") - return True + await self.redis_cache.set(member.id, message.id) + return True, message.channel @command(aliases=('voiceverify',)) @has_no_roles(Roles.voice_verified) @@ -239,11 +250,11 @@ class VoiceGate(Cog): # To avoid race conditions, checking if the user should receive a notification # and sending it if appropriate is delegated to an atomic helper - notification_sent = await self._ping_newcomer(member) + notification_sent, message_channel = await self._ping_newcomer(member) # Schedule the notification to be deleted after the configured delay, which is # again delegated to an atomic helper - if notification_sent: + if notification_sent and isinstance(message_channel, discord.TextChannel): await asyncio.sleep(GateConf.voice_ping_delete_delay) await self._delete_ping(member.id) -- cgit v1.2.3 From e7d3a1c5bed4e007145f4d8220d57a3c871da073 Mon Sep 17 00:00:00 2001 From: rohan Date: Sat, 15 May 2021 00:51:11 +0530 Subject: Remove debug values. --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index a786e1b1a..0b7839cdd 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -105,7 +105,7 @@ class VoiceGate(Cog): log.trace("User is unverified. Send ping.") await self.bot.wait_until_guild_available() - voice_verification_channel = self.bot.get_channel(756769546777395203) + voice_verification_channel = self.bot.get_channel(Channels.voice_gate) try: message = await member.send(VOICE_PING_DM.format(channel_mention=voice_verification_channel.mention)) -- cgit v1.2.3 From b6f0e75616d311ace1ec1be1f481b408a85885e2 Mon Sep 17 00:00:00 2001 From: rohan Date: Sat, 15 May 2021 00:53:27 +0530 Subject: Update documentation. --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 0b7839cdd..467931e7e 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -252,7 +252,7 @@ class VoiceGate(Cog): # and sending it if appropriate is delegated to an atomic helper notification_sent, message_channel = await self._ping_newcomer(member) - # Schedule the notification to be deleted after the configured delay, which is + # Schedule the channel ping notification to be deleted after the configured delay, which is # again delegated to an atomic helper if notification_sent and isinstance(message_channel, discord.TextChannel): await asyncio.sleep(GateConf.voice_ping_delete_delay) -- cgit v1.2.3 From 7fe090e6f6f8a6ebd63ca7d9c1bdd93479306658 Mon Sep 17 00:00:00 2001 From: rohan Date: Mon, 17 May 2021 00:35:34 +0530 Subject: handle closed DMs during execution of voiceverify command. --- bot/exts/moderation/voice_gate.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 467931e7e..4558bbf94 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -155,8 +155,13 @@ class VoiceGate(Cog): color=Colour.red() ) log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} Metricity data.") - - await ctx.author.send(embed=embed) + try: + await ctx.author.send(embed=embed) + except discord.Forbidden: + log.info(f"Could not send user DM. Sending in voice-verify channel and scheduling delete.") + message = await ctx.send(embed=embed) + await asyncio.sleep(GateConf.voice_ping_delete_delay) + await message.delete() return checks = { -- cgit v1.2.3 From 3ead648f03e56d3134056c96527e0a2e22c16e6c Mon Sep 17 00:00:00 2001 From: rohan Date: Mon, 17 May 2021 00:40:43 +0530 Subject: Remove redundant f-string. --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 4558bbf94..9d51d37f5 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -158,7 +158,7 @@ class VoiceGate(Cog): try: await ctx.author.send(embed=embed) except discord.Forbidden: - log.info(f"Could not send user DM. Sending in voice-verify channel and scheduling delete.") + log.info("Could not send user DM. Sending in voice-verify channel and scheduling delete.") message = await ctx.send(embed=embed) await asyncio.sleep(GateConf.voice_ping_delete_delay) await message.delete() -- cgit v1.2.3 From 17d3520743cc3843bc928ad7ecc8cc055422a146 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Tue, 18 May 2021 14:40:58 +0530 Subject: Let on_message event handler delete bot voice pings. --- bot/exts/moderation/voice_gate.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 9d51d37f5..976ab2653 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -159,9 +159,8 @@ class VoiceGate(Cog): await ctx.author.send(embed=embed) except discord.Forbidden: log.info("Could not send user DM. Sending in voice-verify channel and scheduling delete.") - message = await ctx.send(embed=embed) - await asyncio.sleep(GateConf.voice_ping_delete_delay) - await message.delete() + await ctx.send(embed=embed) + return checks = { -- cgit v1.2.3 From a7d1297ea5926284a18f0c4ef23a42be1646cbe4 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Tue, 18 May 2021 14:47:38 +0530 Subject: Update _ping_newcomer() func docstring. --- bot/exts/moderation/voice_gate.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 976ab2653..94b23a344 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -8,6 +8,7 @@ from async_rediscache import RedisCache from discord import Colour, Member, VoiceState from discord.ext.commands import Cog, Context, command + from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels, Event, MODERATION_ROLES, Roles, VoiceGate as GateConf @@ -85,11 +86,12 @@ class VoiceGate(Cog): """ See if `member` should be sent a voice verification notification, and send it if so. - Returns False if the notification was not sent. This happens when: + Returns (False, None) if the notification was not sent. This happens when: * The `member` has already received the notification * The `member` is already voice-verified - Otherwise, the notification message ID is stored in `redis_cache` and True is returned. + Otherwise, the notification message ID is stored in `redis_cache` and return (True, channel). + channel is either [discord.TextChannel, discord.DMChannel]. """ if await self.redis_cache.contains(member.id): log.trace("User already in cache. Ignore.") -- cgit v1.2.3 From 734e4ef5594b4d4336477f63ca2afd64684f54b6 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Fri, 21 May 2021 19:53:33 +0200 Subject: Prefer using the package name as a prefix when handling symbol conflicts When renaming, having label.symbol etc. in the list of the renamed symbol in the embed doesn't really provide much context on what that symbol may be to the user. Prioritizing the package name instead should makes it clearer that it's from an another package. Conflicts within a package are still resolved using the previous logic --- bot/exts/info/doc/_cog.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 2a8016fb8..ad244db4e 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -181,22 +181,22 @@ class DocCog(commands.Cog): else: return new_name - # Certain groups are added as prefixes to disambiguate the symbols. - if group_name in FORCE_PREFIX_GROUPS: - return rename(group_name) - - # The existing symbol with which the current symbol conflicts should have a group prefix. - # It currently doesn't have the group prefix because it's only added once there's a conflict. - elif item.group in FORCE_PREFIX_GROUPS: - return rename(item.group, rename_extant=True) + # When there's a conflict, and the package names of the items differ, use the package name as a prefix. + if package_name != item.package: + if package_name in PRIORITY_PACKAGES: + return rename(item.package, rename_extant=True) + else: + return rename(package_name) - elif package_name in PRIORITY_PACKAGES: - return rename(item.package, rename_extant=True) + # If the symbol's group is a non-priority group from FORCE_PREFIX_GROUPS, + # add it as a prefix to disambiguate the symbols. + elif group_name in FORCE_PREFIX_GROUPS: + return rename(item.group) - # If we can't specially handle the symbol through its group or package, - # fall back to prepending its package name to the front. + # If the above conditions didn't pass, either the existing symbol has its group in FORCE_PREFIX_GROUPS, + # or deciding which item to rename would be arbitrary, so we rename the existing symbol. else: - return rename(package_name) + return rename(item.group, rename_extant=True) async def refresh_inventories(self) -> None: """Refresh internal documentation inventories.""" -- cgit v1.2.3 From b6ccd0396a551e471ddfb80ec129e38e3ff88d01 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Fri, 21 May 2021 20:04:15 +0200 Subject: Prioritize symbols depending on their group's pos in FORCE_PREFIX_GROUPS When both symbols are in FORCE_PREFIX_GROUPS, instead of relying on which symbol comes later and always renaming the latter one, the symbols with a lower priority group are moved out of the way instead of forcing the new symbol to be moved. This will help make relevant symbols be more likely to come up when searching the docs. The constant was reordered by the priority of the groups to work with this change --- bot/exts/info/doc/_cog.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index ad244db4e..d969f6fd4 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -27,11 +27,11 @@ log = logging.getLogger(__name__) # symbols with a group contained here will get the group prefixed on duplicates FORCE_PREFIX_GROUPS = ( - "2to3fixer", - "token", + "term", "label", + "token", "pdbcommand", - "term", + "2to3fixer", ) NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay # Delay to wait before trying to reach a rescheduled inventory again, in minutes @@ -191,7 +191,11 @@ class DocCog(commands.Cog): # If the symbol's group is a non-priority group from FORCE_PREFIX_GROUPS, # add it as a prefix to disambiguate the symbols. elif group_name in FORCE_PREFIX_GROUPS: - return rename(item.group) + if item.group in FORCE_PREFIX_GROUPS: + needs_moving = FORCE_PREFIX_GROUPS.index(group_name) < FORCE_PREFIX_GROUPS.index(item.group) + else: + needs_moving = False + return rename(item.group if needs_moving else group_name, rename_extant=needs_moving) # If the above conditions didn't pass, either the existing symbol has its group in FORCE_PREFIX_GROUPS, # or deciding which item to rename would be arbitrary, so we rename the existing symbol. -- cgit v1.2.3 From f00fe172bcfdbe17b3e9889b8f2be619936dcafe Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Fri, 21 May 2021 20:05:57 +0200 Subject: Add the doc group to FORCE_PREFIX_GROUPS Symbols with the doc group refer to the pages themselves without pointing at a specific element in the HTML. In most cases we can't properly parse those so this change will move them out of the way for other symbols to take priority. --- bot/exts/info/doc/_cog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index d969f6fd4..c54a3ee1c 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -30,6 +30,7 @@ FORCE_PREFIX_GROUPS = ( "term", "label", "token", + "doc", "pdbcommand", "2to3fixer", ) -- cgit v1.2.3 From ec6aac5f219cf1b6dda16171ae05e18c75810efc Mon Sep 17 00:00:00 2001 From: vcokltfre Date: Fri, 21 May 2021 22:35:41 +0100 Subject: chore: use new moderators role --- bot/resources/tags/modmail.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/modmail.md b/bot/resources/tags/modmail.md index 7545419ee..412468174 100644 --- a/bot/resources/tags/modmail.md +++ b/bot/resources/tags/modmail.md @@ -6,4 +6,4 @@ It supports attachments, codeblocks, and reactions. As communication happens ove **To use it, simply send a direct message to the bot.** -Should there be an urgent and immediate need for a moderator or admin to look at a channel, feel free to ping the <@&267629731250176001> or <@&267628507062992896> role instead. +Should there be an urgent and immediate need for a moderator or admin to look at a channel, feel free to ping the <@&831776746206265384> or <@&267628507062992896> role instead. -- cgit v1.2.3 From e5f93bc1a37656ab8919bd0fa6b482eb2fc51d36 Mon Sep 17 00:00:00 2001 From: swfarnsworth Date: Fri, 21 May 2021 18:19:04 -0400 Subject: Delete `_cooldown.py`, which is no longer needed. --- bot/exts/help_channels/_cooldown.py | 95 ------------------------------------- 1 file changed, 95 deletions(-) delete mode 100644 bot/exts/help_channels/_cooldown.py diff --git a/bot/exts/help_channels/_cooldown.py b/bot/exts/help_channels/_cooldown.py deleted file mode 100644 index c5c39297f..000000000 --- a/bot/exts/help_channels/_cooldown.py +++ /dev/null @@ -1,95 +0,0 @@ -import logging -from typing import Callable, Coroutine - -import discord - -import bot -from bot import constants -from bot.exts.help_channels import _caches, _channel -from bot.utils.scheduling import Scheduler - -log = logging.getLogger(__name__) -CoroutineFunc = Callable[..., Coroutine] - - -async def add_cooldown_role(member: discord.Member) -> None: - """Add the help cooldown role to `member`.""" - log.trace(f"Adding cooldown role for {member} ({member.id}).") - await _change_cooldown_role(member, member.add_roles) - - -async def check_cooldowns(scheduler: Scheduler) -> None: - """Remove expired cooldowns and re-schedule active ones.""" - log.trace("Checking all cooldowns to remove or re-schedule them.") - guild = bot.instance.get_guild(constants.Guild.id) - cooldown = constants.HelpChannels.claim_minutes * 60 - - for channel_id, member_id in await _caches.claimants.items(): - member = guild.get_member(member_id) - if not member: - continue # Member probably left the guild. - - in_use_time = await _channel.get_in_use_time(channel_id) - - if not in_use_time or in_use_time.seconds > cooldown: - # Remove the role if no claim time could be retrieved or if the cooldown expired. - # Since the channel is in the claimants cache, it is definitely strange for a time - # to not exist. However, it isn't a reason to keep the user stuck with a cooldown. - await remove_cooldown_role(member) - else: - # The member is still on a cooldown; re-schedule it for the remaining time. - delay = cooldown - in_use_time.seconds - scheduler.schedule_later(delay, member.id, remove_cooldown_role(member)) - - -async def remove_cooldown_role(member: discord.Member) -> None: - """Remove the help cooldown role from `member`.""" - log.trace(f"Removing cooldown role for {member} ({member.id}).") - await _change_cooldown_role(member, member.remove_roles) - - -async def revoke_send_permissions(member: discord.Member, scheduler: Scheduler) -> None: - """ - Disallow `member` to send messages in the Available category for a certain time. - - The time until permissions are reinstated can be configured with - `HelpChannels.claim_minutes`. - """ - log.trace( - f"Revoking {member}'s ({member.id}) send message permissions in the Available category." - ) - - await add_cooldown_role(member) - - # Cancel the existing task, if any. - # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). - if member.id in scheduler: - scheduler.cancel(member.id) - - delay = constants.HelpChannels.claim_minutes * 60 - scheduler.schedule_later(delay, member.id, remove_cooldown_role(member)) - - -async def _change_cooldown_role(member: discord.Member, coro_func: CoroutineFunc) -> None: - """ - Change `member`'s cooldown role via awaiting `coro_func` and handle errors. - - `coro_func` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. - """ - guild = bot.instance.get_guild(constants.Guild.id) - role = guild.get_role(constants.Roles.help_cooldown) - if role is None: - log.warning(f"Help cooldown role ({constants.Roles.help_cooldown}) could not be found!") - return - - try: - await coro_func(role) - except discord.NotFound: - log.debug(f"Failed to change role for {member} ({member.id}): member not found") - except discord.Forbidden: - log.debug( - f"Forbidden to change role for {member} ({member.id}); " - f"possibly due to role hierarchy" - ) - except discord.HTTPException as e: - log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") -- cgit v1.2.3 From a05d2842d4be8458b36ce6dca10e73da9bd127e6 Mon Sep 17 00:00:00 2001 From: swfarnsworth Date: Fri, 21 May 2021 18:20:05 -0400 Subject: Remove the `claim_minutes` configuration. There is essentially no cooldown as the "help cooldown" role is now always applied when one has an open help channel. --- config-default.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/config-default.yml b/config-default.yml index c5c9b12ce..406038d8c 100644 --- a/config-default.yml +++ b/config-default.yml @@ -450,9 +450,6 @@ free: help_channels: enable: true - # Minimum interval before allowing a certain user to claim a new help channel - claim_minutes: 15 - # Roles which are allowed to use the command which makes channels dormant cmd_whitelist: - *HELPERS_ROLE -- cgit v1.2.3 From 3c043263a976dbeefda9213ebfff9299bd6458f3 Mon Sep 17 00:00:00 2001 From: swfarnsworth Date: Fri, 21 May 2021 19:40:36 -0400 Subject: Remove `claim_minutes` constant. --- bot/constants.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index 885b5c822..ab55da482 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -602,7 +602,6 @@ class HelpChannels(metaclass=YAMLGetter): section = 'help_channels' enable: bool - claim_minutes: int cmd_whitelist: List[int] idle_minutes_claimant: int idle_minutes_others: int -- cgit v1.2.3 From e8b110d12e183c4b9ff1fac63bb509d910d0ccd7 Mon Sep 17 00:00:00 2001 From: Bast Date: Sat, 22 May 2021 13:17:02 -0700 Subject: Fix infraction rescheduler breaking with more than 100 in flight reactions Make sure to only fetch infractions to reschedule by filtering by type and permanent status. We don't reschedule permanents as they will never be automatically expired, so they're a waste and clog to filter out manually. There is a PR for `site` to add the requisite filters (`types` and `permanent`). We also only reschedule the soonest-expiring infractions, waiting until we've processed all of them before fetching the next batch by ordering them by expiration time. --- bot/exts/moderation/infraction/_scheduler.py | 29 ++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 988fb7220..c94874787 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -48,11 +48,32 @@ class InfractionScheduler: infractions = await self.bot.api_client.get( 'bot/infractions', - params={'active': 'true'} + params={ + 'active': 'true', + 'ordering': 'expires_at', + 'permanent': 'false', + 'types': ','.join(supported_infractions), + }, ) - for infraction in infractions: - if infraction["expires_at"] is not None and infraction["type"] in supported_infractions: - self.schedule_expiration(infraction) + + to_schedule = [i for i in infractions if not i['id'] in self.scheduler] + + for infraction in to_schedule: + log.trace("Scheduling %r", infraction) + self.schedule_expiration(infraction) + + # Call ourselves again when the last infraction would expire. This will be the "oldest" infraction we've seen + # from the database so far, and new ones are scheduled as part of application. + # We make sure to fire this + if to_schedule: + next_reschedule_point = max( + dateutil.parser.isoparse(infr["expires_at"]).replace(tzinfo=None) for infr in to_schedule + ) + log.trace("Will reschedule remaining infractions at %s", next_reschedule_point) + + self.scheduler.schedule_at(next_reschedule_point, -1, self.reschedule_infractions(supported_infractions)) + + log.trace("Done rescheduling") async def reapply_infraction( self, -- cgit v1.2.3 From 0c17bde519de0ed6196380e00ea0b69a592c1e17 Mon Sep 17 00:00:00 2001 From: Bast Date: Sat, 22 May 2021 15:19:25 -0700 Subject: Cleanup styles in infraction rescheduler --- bot/exts/moderation/infraction/_scheduler.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index c94874787..8286d3635 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -47,16 +47,16 @@ class InfractionScheduler: log.trace(f"Rescheduling infractions for {self.__class__.__name__}.") infractions = await self.bot.api_client.get( - 'bot/infractions', + "bot/infractions", params={ - 'active': 'true', - 'ordering': 'expires_at', - 'permanent': 'false', - 'types': ','.join(supported_infractions), + "active": "true", + "ordering": "expires_at", + "permanent": "false", + "types": ",".join(supported_infractions), }, ) - to_schedule = [i for i in infractions if not i['id'] in self.scheduler] + to_schedule = [i for i in infractions if i["id"] not in self.scheduler] for infraction in to_schedule: log.trace("Scheduling %r", infraction) -- cgit v1.2.3 From d16df7914c486cab6e6758c8788a21eddf7a6452 Mon Sep 17 00:00:00 2001 From: ToxicKidz Date: Sun, 23 May 2021 11:37:06 -0400 Subject: fix: Remove the extra 'as' in floats.md --- bot/resources/tags/floats.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/floats.md b/bot/resources/tags/floats.md index 7129b91bb..03fcd7268 100644 --- a/bot/resources/tags/floats.md +++ b/bot/resources/tags/floats.md @@ -5,7 +5,7 @@ You may have noticed that when doing arithmetic with floats in Python you someti 0.30000000000000004 ``` **Why this happens** -Internally your computer stores floats as as binary fractions. Many decimal values cannot be stored as exact binary fractions, which means an approximation has to be used. +Internally your computer stores floats as binary fractions. Many decimal values cannot be stored as exact binary fractions, which means an approximation has to be used. **How you can avoid this** You can use [math.isclose](https://docs.python.org/3/library/math.html#math.isclose) to check if two floats are close, or to get an exact decimal representation, you can use the [decimal](https://docs.python.org/3/library/decimal.html) or [fractions](https://docs.python.org/3/library/fractions.html) module. Here are some examples: -- cgit v1.2.3 From 20fd49666519fa8228ce942a8f2bb6f15aa79e34 Mon Sep 17 00:00:00 2001 From: Gustav Odinger <65498475+gustavwilliam@users.noreply.github.com> Date: Mon, 24 May 2021 19:38:40 +0200 Subject: Up duck pond threshold to 7 Makes duck pond entries less common, by requiring more ducks for a message to be ducked --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 394c51c26..8099a0860 100644 --- a/config-default.yml +++ b/config-default.yml @@ -511,7 +511,7 @@ redirect_output: duck_pond: - threshold: 5 + threshold: 7 channel_blacklist: - *ANNOUNCEMENTS - *PYNEWS_CHANNEL -- cgit v1.2.3 From 725b8acc6d1b7216b7e7ab24350838c6893cf0bc Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 24 May 2021 21:24:18 +0100 Subject: Add filter for pixels API tokens --- bot/exts/filters/pixels_token_remover.py | 108 +++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 bot/exts/filters/pixels_token_remover.py diff --git a/bot/exts/filters/pixels_token_remover.py b/bot/exts/filters/pixels_token_remover.py new file mode 100644 index 000000000..11f35261f --- /dev/null +++ b/bot/exts/filters/pixels_token_remover.py @@ -0,0 +1,108 @@ +import logging +import re +import typing as t + +from discord import Colour, Message, NotFound +from discord.ext.commands import Cog + +from bot.bot import Bot +from bot.constants import Channels, Colours, Event, Icons +from bot.exts.moderation.modlog import ModLog +from bot.utils.messages import format_user + +log = logging.getLogger(__name__) + +LOG_MESSAGE = "Censored a valid Pixels token sent by {author} in {channel}, token was `{token}`" +DELETION_MESSAGE_TEMPLATE = ( + "Hey {mention}! I noticed you posted a valid Pixels API " + "token in your message and have removed your message. " + "This means that your token has been **compromised**. " + "I have taken the liberty of invalidating the token for you. " + "You can go to to get a new key." +) + +PIXELS_TOKEN_RE = re.compile(r"[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+\=]*") + + +class PixelsTokenRemover(Cog): + """Scans messages for Pixels API tokens, removes and invalidates them.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """Check each message for a string that matches the RS-256 token pattern.""" + # Ignore DMs; can't delete messages in there anyway. + if not msg.guild or msg.author.bot: + return + + found_token = await self.find_token_in_message(msg) + if found_token: + await self.take_action(msg, found_token) + + @Cog.listener() + async def on_message_edit(self, before: Message, after: Message) -> None: + """Check each edit for a string that matches the RS-256 token pattern.""" + await self.on_message(after) + + async def take_action(self, msg: Message, found_token: str) -> None: + """Remove the `msg` containing the `found_token` and send a mod log message.""" + self.mod_log.ignore(Event.message_delete, msg.id) + + try: + await msg.delete() + except NotFound: + log.debug(f"Failed to remove token in message {msg.id}: message already deleted.") + return + + await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) + + log_message = self.format_log_message(msg, found_token) + log.debug(log_message) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=Icons.token_removed, + colour=Colour(Colours.soft_red), + title="Token removed!", + text=log_message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ping_everyone=False, + ) + + self.bot.stats.incr("tokens.removed_pixels_tokens") + + @staticmethod + def format_log_message(msg: Message, token: str) -> str: + """Return the generic portion of the log message to send for `token` being censored in `msg`.""" + return LOG_MESSAGE.format( + author=format_user(msg.author), + channel=msg.channel.mention, + token=token + ) + + async def find_token_in_message(self, msg: Message) -> t.Optional[str]: + """Return a seemingly valid token found in `msg` or `None` if no token is found.""" + # Use finditer rather than search to guard against method calls prematurely returning the + # token check (e.g. `message.channel.send` also matches our token pattern) + for match in PIXELS_TOKEN_RE.finditer(msg.content): + auth_header = {"Authorization": f"Bearer {match[0]}"} + async with self.bot.http_session.delete("https://pixels.pythondiscord.com/token", headers=auth_header) as r: + if r.status == 204: + # Short curcuit on first match. + return match[0] + + # No matching substring + return + + +def setup(bot: Bot) -> None: + """Load the PixelsTokenRemover cog.""" + bot.add_cog(PixelsTokenRemover(bot)) -- cgit v1.2.3 From c9860a217eea8e19d15f5cc2d30debe089f394fb Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 25 May 2021 00:21:52 +0100 Subject: Update pixels token regex to false match less --- bot/exts/filters/pixels_token_remover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/filters/pixels_token_remover.py b/bot/exts/filters/pixels_token_remover.py index 11f35261f..2356491e5 100644 --- a/bot/exts/filters/pixels_token_remover.py +++ b/bot/exts/filters/pixels_token_remover.py @@ -21,7 +21,7 @@ DELETION_MESSAGE_TEMPLATE = ( "You can go to to get a new key." ) -PIXELS_TOKEN_RE = re.compile(r"[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+\=]*") +PIXELS_TOKEN_RE = re.compile(r"[A-Za-z0-9-_=]{30,}\.[A-Za-z0-9-_=]{50,}\.[A-Za-z0-9-_.+\=]{30,}") class PixelsTokenRemover(Cog): -- cgit v1.2.3 From 179db2c896100996360af9aba72c3644291e6b7e Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 27 May 2021 15:39:51 +0200 Subject: Recruitment: reverse nomination order This makes review appear in chronological order, making it easier to say things like "^^ what they said". --- bot/exts/recruitment/talentpool/_review.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index d53c3b074..b9ff61986 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -120,7 +120,8 @@ class Reviewer: opening = f"<@&{Roles.mod_team}> <@&{Roles.admins}>\n{member.mention} ({member}) for Helper!" current_nominations = "\n\n".join( - f"**<@{entry['actor']}>:** {entry['reason'] or '*no reason given*'}" for entry in nomination['entries'] + f"**<@{entry['actor']}>:** {entry['reason'] or '*no reason given*'}" + for entry in nomination['entries'][::-1] ) current_nominations = f"**Nominated by:**\n{current_nominations}" -- cgit v1.2.3 From 7224b61e50f1caaaad96c09a2532e46a600988b6 Mon Sep 17 00:00:00 2001 From: swfarnsworth Date: Sat, 29 May 2021 21:57:41 -0400 Subject: Re-introduced static method for role change exception handling. A function that did the same thing previously existed in `_cooldown.py`. --- bot/exts/help_channels/_cog.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 6cd31df38..49640dda7 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -94,6 +94,25 @@ class HelpChannels(commands.Cog): self.scheduler.cancel_all() + @staticmethod + async def _handle_role_change(member: discord.Member, coro: t.Coroutine) -> None: + """ + Change `member`'s cooldown role via awaiting `coro` and handle errors. + + `coro` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. + """ + try: + await coro + except discord.NotFound: + log.debug(f"Failed to change role for {member} ({member.id}): member not found") + except discord.Forbidden: + log.debug( + f"Forbidden to change role for {member} ({member.id}); " + f"possibly due to role hierarchy" + ) + except discord.HTTPException as e: + log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") + @lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id")) @lock.lock_arg(NAMESPACE, "message", attrgetter("author.id")) @lock.lock_arg(f"{NAMESPACE}.unclaim", "message", attrgetter("author.id"), wait=True) @@ -107,7 +126,7 @@ class HelpChannels(commands.Cog): log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.") await self.move_to_in_use(message.channel) cooldown_role = self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.help_cooldown) - await message.author.add_roles(cooldown_role) + await self._handle_role_change(message.author, message.author.add_roles(cooldown_role)) await _message.pin(message) @@ -413,7 +432,7 @@ class HelpChannels(commands.Cog): log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed") else: cooldown_role = self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.help_cooldown) - await claimant.remove_roles(cooldown_role) + await self._handle_role_change(claimant, claimant.remove_roles(cooldown_role)) await _message.unpin(channel) await _stats.report_complete_session(channel.id, closed_on) -- cgit v1.2.3 From 0aec67d3bc7699bcdcba75d59214bb14b7e4cb07 Mon Sep 17 00:00:00 2001 From: swfarnsworth Date: Mon, 31 May 2021 13:07:23 -0400 Subject: Role lookup takes place only in `_handle_role_change`. --- bot/exts/help_channels/_cog.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 49640dda7..5c410a0a1 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -94,15 +94,14 @@ class HelpChannels(commands.Cog): self.scheduler.cancel_all() - @staticmethod - async def _handle_role_change(member: discord.Member, coro: t.Coroutine) -> None: + async def _handle_role_change(self, member: discord.Member, coro: t.Callable[..., t.Coroutine]) -> None: """ Change `member`'s cooldown role via awaiting `coro` and handle errors. `coro` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. """ try: - await coro + await coro(self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.help_cooldown)) except discord.NotFound: log.debug(f"Failed to change role for {member} ({member.id}): member not found") except discord.Forbidden: @@ -125,8 +124,7 @@ class HelpChannels(commands.Cog): """ log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.") await self.move_to_in_use(message.channel) - cooldown_role = self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.help_cooldown) - await self._handle_role_change(message.author, message.author.add_roles(cooldown_role)) + await self._handle_role_change(message.author, message.author.add_roles) await _message.pin(message) @@ -431,8 +429,7 @@ class HelpChannels(commands.Cog): if claimant is None: log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed") else: - cooldown_role = self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.help_cooldown) - await self._handle_role_change(claimant, claimant.remove_roles(cooldown_role)) + await self._handle_role_change(claimant, claimant.remove_roles) await _message.unpin(channel) await _stats.report_complete_session(channel.id, closed_on) -- cgit v1.2.3 From 13cdd562f6c586c2b5a47a021141c729712427eb Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Wed, 2 Jun 2021 02:30:27 +0100 Subject: Add discord.li to invite filter (#1616) Discord.li is an alias for discord.io, a domain already on the denylist. --- bot/utils/regex.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/utils/regex.py b/bot/utils/regex.py index 0d2068f90..a8efe1446 100644 --- a/bot/utils/regex.py +++ b/bot/utils/regex.py @@ -5,6 +5,7 @@ INVITE_RE = re.compile( r"discord(?:[\.,]|dot)com(?:\/|slash)invite|" # or discord.com/invite/ r"discordapp(?:[\.,]|dot)com(?:\/|slash)invite|" # or discordapp.com/invite/ r"discord(?:[\.,]|dot)me|" # or discord.me + r"discord(?:[\.,]|dot)li|" # or discord.li r"discord(?:[\.,]|dot)io" # or discord.io. r")(?:[\/]|slash)" # / or 'slash' r"([a-zA-Z0-9\-]+)", # the invite code itself -- cgit v1.2.3 From c8e7eecfa7afbe7f9209b29f2a81eeaca7a1f175 Mon Sep 17 00:00:00 2001 From: vcokltfre Date: Fri, 4 Jun 2021 13:34:10 +0100 Subject: chore: ensmallen the star-imports tag --- bot/resources/tags/star-imports.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/bot/resources/tags/star-imports.md b/bot/resources/tags/star-imports.md index 2be6aab6e..3b1b6a858 100644 --- a/bot/resources/tags/star-imports.md +++ b/bot/resources/tags/star-imports.md @@ -16,33 +16,24 @@ Example: >>> from math import * >>> sin(pi / 2) # uses sin from math rather than your custom sin ``` - • Potential namespace collision. Names defined from a previous import might get shadowed by a wildcard import. - • Causes ambiguity. From the example, it is unclear which `sin` function is actually being used. From the Zen of Python **[3]**: `Explicit is better than implicit.` - • Makes import order significant, which they shouldn't. Certain IDE's `sort import` functionality may end up breaking code due to namespace collision. **How should you import?** • Import the module under the module's namespace (Only import the name of the module, and names defined in the module can be used by prefixing the module's name) - ```python >>> import math >>> math.sin(math.pi / 2) ``` - • Explicitly import certain names from the module - ```python >>> from math import sin, pi >>> sin(pi / 2) ``` - Conclusion: Namespaces are one honking great idea -- let's do more of those! *[3]* **[1]** If the module defines the variable `__all__`, the names defined in `__all__` will get imported by the wildcard import, otherwise all the names in the module get imported (except for names with a leading underscore) - **[2]** [Namespaces and scopes](https://www.programiz.com/python-programming/namespace) - **[3]** [Zen of Python](https://www.python.org/dev/peps/pep-0020/) -- cgit v1.2.3 From 4c17af4a71f95e9709b290958dedf292e9258e3a Mon Sep 17 00:00:00 2001 From: vcokltfre Date: Fri, 4 Jun 2021 14:36:42 +0100 Subject: feat: add async-await tag (#1594) * feat: add async-await tag Co-authored-by: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/resources/tags/async-await.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 bot/resources/tags/async-await.md diff --git a/bot/resources/tags/async-await.md b/bot/resources/tags/async-await.md new file mode 100644 index 000000000..ff71ace07 --- /dev/null +++ b/bot/resources/tags/async-await.md @@ -0,0 +1,28 @@ +**Concurrency in Python** + +Python provides the ability to run multiple tasks and coroutines simultaneously with the use of the `asyncio` library, which is included in the Python standard library. + +This works by running these coroutines in an event loop, where the context of which coroutine is being run is switches periodically to allow all of them to run, giving the appearance of running at the same time. This is different to using threads or processes in that all code is run in the main process and thread, although it is possible to run coroutines in threads. + +To call an async function we can either `await` it, or run it in an event loop which we get from `asyncio`. + +To create a coroutine that can be used with asyncio we need to define a function using the async keyword: +```py +async def main(): + await something_awaitable() +``` +Which means we can call `await something_awaitable()` directly from within the function. If this were a non-async function this would have raised an exception like: `SyntaxError: 'await' outside async function` + +To run the top level async function from outside of the event loop we can get an event loop from `asyncio`, and then use that loop to run the function: +```py +from asyncio import get_event_loop + +async def main(): + await something_awaitable() + +loop = get_event_loop() +loop.run_until_complete(main()) +``` +Note that in the `run_until_complete()` where we appear to be calling `main()`, this does not execute the code in `main`, rather it returns a `coroutine` object which is then handled and run by the event loop via `run_until_complete()`. + +To learn more about asyncio and its use, see the [asyncio documentation](https://docs.python.org/3/library/asyncio.html). -- cgit v1.2.3 From f92338ef18d1bc5d11405a5d9e6ede4f9e080110 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 5 Jun 2021 14:04:06 +0300 Subject: Properly Handles Indefinite Silences Fixes a bug that stopped the duration `forever` from getting used as a valid duration for silence. Signed-off-by: Hassan Abouelela --- bot/converters.py | 6 +++--- bot/exts/moderation/silence.py | 7 +++++-- tests/bot/exts/moderation/test_silence.py | 7 +++++++ tests/bot/test_converters.py | 2 +- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 2a3943831..595809517 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -416,11 +416,11 @@ class HushDurationConverter(Converter): MINUTES_RE = re.compile(r"(\d+)(?:M|m|$)") - async def convert(self, ctx: Context, argument: str) -> t.Optional[int]: + async def convert(self, ctx: Context, argument: str) -> int: """ Convert `argument` to a duration that's max 15 minutes or None. - If `"forever"` is passed, None is returned; otherwise an int of the extracted time. + If `"forever"` is passed, -1 is returned; otherwise an int of the extracted time. Accepted formats are: * , * m, @@ -428,7 +428,7 @@ class HushDurationConverter(Converter): * forever. """ if argument == "forever": - return None + return -1 match = self.MINUTES_RE.match(argument) if not match: raise BadArgument(f"{argument} is not a valid minutes duration.") diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 8e4ce7ae2..8025f3df6 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -200,9 +200,9 @@ class Silence(commands.Cog): ctx: Context, duration_or_channel: typing.Union[TextOrVoiceChannel, int], duration: HushDurationConverter - ) -> typing.Tuple[TextOrVoiceChannel, int]: + ) -> typing.Tuple[TextOrVoiceChannel, Optional[int]]: """Helper method to parse the arguments of the silence command.""" - duration: int + duration: Optional[int] if duration_or_channel: if isinstance(duration_or_channel, (TextChannel, VoiceChannel)): @@ -213,6 +213,9 @@ class Silence(commands.Cog): else: channel = ctx.channel + if duration == -1: + duration = None + return channel, duration async def _set_silence_overwrites(self, channel: TextOrVoiceChannel, *, kick: bool = False) -> bool: diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index a7ea733c5..59a5893ef 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -641,6 +641,13 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog.silence.callback(self.cog, ctx, None, None) self.cog.scheduler.schedule_later.assert_not_called() + async def test_indefinite_silence(self): + """Test silencing a channel forever.""" + with mock.patch.object(self.cog, "_schedule_unsilence") as unsilence: + ctx = MockContext(channel=self.text_channel) + await self.cog.silence.callback(self.cog, ctx, -1) + unsilence.assert_awaited_once_with(ctx, ctx.channel, None) + @autospec(silence.Silence, "unsilence_timestamps", pass_mocks=False) class UnsilenceTests(unittest.IsolatedAsyncioTestCase): diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index 4af84dde5..2a1c4e543 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -291,7 +291,7 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase): ("10", 10), ("5m", 5), ("5M", 5), - ("forever", None), + ("forever", -1), ) converter = HushDurationConverter() for minutes_string, expected_minutes in test_values: -- cgit v1.2.3 From a71d37be61baf5b9f157f430d59081ca881689d5 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Sat, 5 Jun 2021 23:46:49 -0700 Subject: Allowed text format warning to have multiple formats. --- bot/exts/filters/antimalware.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index 26f00e91f..f8d303389 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/antimalware.py @@ -15,9 +15,11 @@ PY_EMBED_DESCRIPTION = ( f"please use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}" ) +TXT_LIKE_FILES = {".txt", ".csv", ".json"} TXT_EMBED_DESCRIPTION = ( "**Uh-oh!** It looks like your message got zapped by our spam filter. " - "We currently don't allow `.txt` attachments, so here are some tips to help you travel safely: \n\n" + "We currently don't allow `{blocked_extension_str}` attachments, " + "so here are some tips to help you travel safely: \n\n" "• If you attempted to send a message longer than 2000 characters, try shortening your message " "to fit within the character limit or use a pasting service (see below) \n\n" "• If you tried to show someone your code, you can use codeblocks \n(run `!code-blocks` in " @@ -70,10 +72,13 @@ class AntiMalware(Cog): if ".py" in extensions_blocked: # Short-circuit on *.py files to provide a pastebin link embed.description = PY_EMBED_DESCRIPTION - elif ".txt" in extensions_blocked: + elif extensions := TXT_LIKE_FILES.intersection(extensions_blocked): # Work around Discord AutoConversion of messages longer than 2000 chars to .txt cmd_channel = self.bot.get_channel(Channels.bot_commands) - embed.description = TXT_EMBED_DESCRIPTION.format(cmd_channel_mention=cmd_channel.mention) + embed.description = TXT_EMBED_DESCRIPTION.format( + blocked_extension_str=extensions.pop(), + cmd_channel_mention=cmd_channel.mention + ) elif extensions_blocked: meta_channel = self.bot.get_channel(Channels.meta) embed.description = DISALLOWED_EMBED_DESCRIPTION.format( -- cgit v1.2.3 From 4edecf659c3148c8e4427054b7d841c65d0f67be Mon Sep 17 00:00:00 2001 From: Xithrius Date: Sun, 6 Jun 2021 00:11:41 -0700 Subject: Added .txt file extension to antimalware test. --- tests/bot/exts/filters/test_antimalware.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/bot/exts/filters/test_antimalware.py b/tests/bot/exts/filters/test_antimalware.py index 3393c6cdc..9f020c964 100644 --- a/tests/bot/exts/filters/test_antimalware.py +++ b/tests/bot/exts/filters/test_antimalware.py @@ -118,7 +118,10 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): cmd_channel = self.bot.get_channel(Channels.bot_commands) self.assertEqual(embed.description, antimalware.TXT_EMBED_DESCRIPTION.format.return_value) - antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with(cmd_channel_mention=cmd_channel.mention) + antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with( + blocked_extension_str=".txt", + cmd_channel_mention=cmd_channel.mention + ) async def test_other_disallowed_extension_embed_description(self): """Test the description for a non .py/.txt disallowed extension.""" -- cgit v1.2.3 From d510a6af7d6158009ef23fefd44f1e06bdb33876 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Sun, 6 Jun 2021 00:34:20 -0700 Subject: Added subtests for `.txt`, `.json`, and `.csv` files. --- tests/bot/exts/filters/test_antimalware.py | 44 +++++++++++++++++++----------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/tests/bot/exts/filters/test_antimalware.py b/tests/bot/exts/filters/test_antimalware.py index 9f020c964..359401814 100644 --- a/tests/bot/exts/filters/test_antimalware.py +++ b/tests/bot/exts/filters/test_antimalware.py @@ -105,24 +105,36 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): async def test_txt_file_redirect_embed_description(self): """A message containing a .txt file should result in the correct embed.""" - attachment = MockAttachment(filename="python.txt") - self.message.attachments = [attachment] - self.message.channel.send = AsyncMock() - antimalware.TXT_EMBED_DESCRIPTION = Mock() - antimalware.TXT_EMBED_DESCRIPTION.format.return_value = "test" - - await self.cog.on_message(self.message) - self.message.channel.send.assert_called_once() - args, kwargs = self.message.channel.send.call_args - embed = kwargs.pop("embed") - cmd_channel = self.bot.get_channel(Channels.bot_commands) - - self.assertEqual(embed.description, antimalware.TXT_EMBED_DESCRIPTION.format.return_value) - antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with( - blocked_extension_str=".txt", - cmd_channel_mention=cmd_channel.mention + test_values = ( + ("text", ".txt"), + ("json", ".json"), + ("csv", ".csv"), ) + for file_name, disallowed_extension in test_values: + with self.subTest(file_name=file_name, disallowed_extension=disallowed_extension): + + attachment = MockAttachment(filename=f"{file_name}{disallowed_extension}") + self.message.attachments = [attachment] + self.message.channel.send = AsyncMock() + antimalware.TXT_EMBED_DESCRIPTION = Mock() + antimalware.TXT_EMBED_DESCRIPTION.format.return_value = "test" + + await self.cog.on_message(self.message) + self.message.channel.send.assert_called_once() + args, kwargs = self.message.channel.send.call_args + embed = kwargs.pop("embed") + cmd_channel = self.bot.get_channel(Channels.bot_commands) + + self.assertEqual( + embed.description, + antimalware.TXT_EMBED_DESCRIPTION.format.return_value + ) + antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with( + blocked_extension_str=disallowed_extension, + cmd_channel_mention=cmd_channel.mention + ) + async def test_other_disallowed_extension_embed_description(self): """Test the description for a non .py/.txt disallowed extension.""" attachment = MockAttachment(filename="python.disallowed") -- cgit v1.2.3 From 222b3305db381f93d7f665932080869a161b68d7 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 6 Jun 2021 22:35:52 +0100 Subject: Change to unless-stopped restart policy Since we use the same port for redis on all out projects, having this always restart causes conflicts for people starting up docker and wanting to use redis for anyother project. --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index bdfedf5c2..1761d8940 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ x-logging: &logging max-size: "10m" x-restart-policy: &restart_policy - restart: always + restart: unless-stopped services: postgres: -- cgit v1.2.3 From aeaef8ff604c9ea62fdf1602200ee87f2adf7f6a Mon Sep 17 00:00:00 2001 From: Xithrius Date: Sun, 6 Jun 2021 15:55:19 -0700 Subject: Added new formats to unittest docstrings. --- tests/bot/exts/filters/test_antimalware.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/exts/filters/test_antimalware.py b/tests/bot/exts/filters/test_antimalware.py index 359401814..c07bde8d7 100644 --- a/tests/bot/exts/filters/test_antimalware.py +++ b/tests/bot/exts/filters/test_antimalware.py @@ -104,7 +104,7 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(embed.description, antimalware.PY_EMBED_DESCRIPTION) async def test_txt_file_redirect_embed_description(self): - """A message containing a .txt file should result in the correct embed.""" + """A message containing a .txt/.json/.csv file should result in the correct embed.""" test_values = ( ("text", ".txt"), ("json", ".json"), @@ -136,7 +136,7 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): ) async def test_other_disallowed_extension_embed_description(self): - """Test the description for a non .py/.txt disallowed extension.""" + """Test the description for a non .py/.txt/.json/.csv disallowed extension.""" attachment = MockAttachment(filename="python.disallowed") self.message.attachments = [attachment] self.message.channel.send = AsyncMock() -- cgit v1.2.3 From a305d3983350fbf30b873fce76a44707b549fd55 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Sun, 6 Jun 2021 15:57:27 -0700 Subject: Renamed blocked_extension_str to blocked_extension. --- bot/exts/filters/antimalware.py | 4 ++-- tests/bot/exts/filters/test_antimalware.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index f8d303389..89e539e7b 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/antimalware.py @@ -18,7 +18,7 @@ PY_EMBED_DESCRIPTION = ( TXT_LIKE_FILES = {".txt", ".csv", ".json"} TXT_EMBED_DESCRIPTION = ( "**Uh-oh!** It looks like your message got zapped by our spam filter. " - "We currently don't allow `{blocked_extension_str}` attachments, " + "We currently don't allow `{blocked_extension}` attachments, " "so here are some tips to help you travel safely: \n\n" "• If you attempted to send a message longer than 2000 characters, try shortening your message " "to fit within the character limit or use a pasting service (see below) \n\n" @@ -76,7 +76,7 @@ class AntiMalware(Cog): # Work around Discord AutoConversion of messages longer than 2000 chars to .txt cmd_channel = self.bot.get_channel(Channels.bot_commands) embed.description = TXT_EMBED_DESCRIPTION.format( - blocked_extension_str=extensions.pop(), + blocked_extension=extensions.pop(), cmd_channel_mention=cmd_channel.mention ) elif extensions_blocked: diff --git a/tests/bot/exts/filters/test_antimalware.py b/tests/bot/exts/filters/test_antimalware.py index c07bde8d7..06d78de9d 100644 --- a/tests/bot/exts/filters/test_antimalware.py +++ b/tests/bot/exts/filters/test_antimalware.py @@ -131,7 +131,7 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): antimalware.TXT_EMBED_DESCRIPTION.format.return_value ) antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with( - blocked_extension_str=disallowed_extension, + blocked_extension=disallowed_extension, cmd_channel_mention=cmd_channel.mention ) -- cgit v1.2.3 From 13442f859f452578397766dedc7904928794610a Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Mon, 7 Jun 2021 05:28:07 +0300 Subject: Switches To Pytest As Test Runner Switches the test runner from unittest to pytest, to allow the usage of plugins such as xdist. This commit also adds pytest-cov purely as a generator for .coverage files. Signed-off-by: Hassan Abouelela --- .coveragerc | 5 - .github/workflows/lint-test.yml | 5 +- poetry.lock | 340 +++++++++++++++++++++++++++++++--------- pyproject.toml | 5 +- tests/README.md | 6 +- 5 files changed, 277 insertions(+), 84 deletions(-) delete mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index d572bd705..000000000 --- a/.coveragerc +++ /dev/null @@ -1,5 +0,0 @@ -[run] -branch = true -source = - bot - tests diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index d96f324ec..370b0b38b 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -97,12 +97,9 @@ jobs: --format='::error file=%(path)s,line=%(row)d,col=%(col)d::\ [flake8] %(code)s: %(text)s'" - # We run `coverage` using the `python` command so we can suppress - # irrelevant warnings in our CI output. - name: Run tests and generate coverage report run: | - python -Wignore -m coverage run -m unittest - coverage report -m + pytest -n auto --cov bot --disable-warnings -q # This step will publish the coverage reports coveralls.io and # print a "job" link in the output of the GitHub Action diff --git a/poetry.lock b/poetry.lock index ba8b7af4b..a671d8a35 100644 --- a/poetry.lock +++ b/poetry.lock @@ -82,6 +82,14 @@ yarl = "*" [package.extras] develop = ["aiomisc (>=11.0,<12.0)", "async-generator", "coverage (!=4.3)", "coveralls", "pylava", "pytest", "pytest-cov", "tox (>=2.4)"] +[[package]] +name = "apipkg" +version = "1.5" +description = "apipkg: namespace control and lazy-import mechanism" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + [[package]] name = "appdirs" version = "1.4.4" @@ -124,6 +132,14 @@ category = "main" optional = false python-versions = ">=3.5.3" +[[package]] +name = "atomicwrites" +version = "1.4.0" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + [[package]] name = "attrs" version = "21.2.0" @@ -155,7 +171,7 @@ lxml = ["lxml"] [[package]] name = "certifi" -version = "2020.12.5" +version = "2021.5.30" description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = false @@ -174,7 +190,7 @@ pycparser = "*" [[package]] name = "cfgv" -version = "3.2.0" +version = "3.3.0" description = "Validate configuration and produce human readable error messages." category = "dev" optional = false @@ -268,7 +284,7 @@ voice = ["PyNaCl (>=1.3.0,<1.5)"] [[package]] name = "distlib" -version = "0.3.1" +version = "0.3.2" description = "Distribution utilities" category = "dev" optional = false @@ -293,9 +309,23 @@ python-versions = "*" [package.extras] dev = ["pytest", "coverage", "coveralls"] +[[package]] +name = "execnet" +version = "1.8.1" +description = "execnet: rapid multi-Python deployment" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +apipkg = ">=1.4" + +[package.extras] +testing = ["pre-commit"] + [[package]] name = "fakeredis" -version = "1.5.0" +version = "1.5.1" description = "Fake implementation of redis API for testing purposes." category = "main" optional = false @@ -467,7 +497,7 @@ pyreadline = {version = "*", markers = "sys_platform == \"win32\""} [[package]] name = "identify" -version = "2.2.4" +version = "2.2.9" description = "File identification library for Python" category = "dev" optional = false @@ -478,11 +508,19 @@ license = ["editdistance-s"] [[package]] name = "idna" -version = "3.1" +version = "3.2" description = "Internationalized Domain Names in Applications (IDNA)" category = "main" optional = false -python-versions = ">=3.4" +python-versions = ">=3.5" + +[[package]] +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = "*" [[package]] name = "lxml" @@ -520,7 +558,7 @@ python-versions = "*" [[package]] name = "more-itertools" -version = "8.7.0" +version = "8.8.0" description = "More routines for operating on iterables, beyond itertools" category = "main" optional = false @@ -558,6 +596,17 @@ category = "main" optional = false python-versions = ">=3.5" +[[package]] +name = "packaging" +version = "20.9" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +pyparsing = ">=2.0.2" + [[package]] name = "pamqp" version = "2.3.0" @@ -580,9 +629,20 @@ python-versions = "*" [package.dependencies] flake8-polyfill = ">=1.0.2,<2" +[[package]] +name = "pluggy" +version = "0.13.1" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +dev = ["pre-commit", "tox"] + [[package]] name = "pre-commit" -version = "2.12.1" +version = "2.13.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -607,9 +667,17 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.extras] test = ["ipaddress", "mock", "unittest2", "enum34", "pywin32", "wmi"] +[[package]] +name = "py" +version = "1.10.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + [[package]] name = "pycares" -version = "3.2.3" +version = "4.0.0" description = "Python interface for c-ares" category = "main" optional = false @@ -639,7 +707,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pydocstyle" -version = "6.0.0" +version = "6.1.1" description = "Python docstring style checker" category = "dev" optional = false @@ -648,6 +716,9 @@ python-versions = ">=3.6" [package.dependencies] snowballstemmer = "*" +[package.extras] +toml = ["toml"] + [[package]] name = "pyflakes" version = "2.3.1" @@ -656,6 +727,14 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +[[package]] +name = "pyparsing" +version = "2.4.7" +description = "Python parsing module" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + [[package]] name = "pyreadline" version = "2.1" @@ -664,6 +743,73 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "pytest" +version = "6.2.4" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<1.0.0a1" +py = ">=1.8.2" +toml = "*" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "2.12.1" +description = "Pytest plugin for measuring coverage." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +coverage = ">=5.2.1" +pytest = ">=4.6" +toml = "*" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pytest-forked" +version = "1.3.0" +description = "run tests in isolated forked subprocesses" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +py = "*" +pytest = ">=3.10" + +[[package]] +name = "pytest-xdist" +version = "2.2.1" +description = "pytest xdist plugin for distributed testing and loop-on-failing modes" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +execnet = ">=1.1" +psutil = {version = ">=3.0", optional = true, markers = "extra == \"psutil\""} +pytest = ">=6.0.0" +pytest-forked = "*" + +[package.extras] +psutil = ["psutil (>=3.0)"] +testing = ["filelock"] + [[package]] name = "python-dateutil" version = "2.8.1" @@ -794,7 +940,7 @@ python-versions = "*" [[package]] name = "sortedcontainers" -version = "2.3.0" +version = "2.4.0" description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" category = "main" optional = false @@ -847,20 +993,20 @@ python-versions = "*" [[package]] name = "urllib3" -version = "1.26.4" +version = "1.26.5" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" [package.extras] +brotli = ["brotlipy (>=0.6.0)"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] -brotli = ["brotlipy (>=0.6.0)"] [[package]] name = "virtualenv" -version = "20.4.6" +version = "20.4.7" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -891,7 +1037,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "ece3b915901a62911ff7ff4a616b3972e815c0e1c7097c8994163af13cadde0e" +content-hash = "c1163e748d2fabcbcc267ea0eeccf4be6dfe5a468d769b6e5bc9023e8ab0a2bf" [metadata.files] aio-pika = [ @@ -953,6 +1099,10 @@ aiormq = [ {file = "aiormq-3.3.1-py3-none-any.whl", hash = "sha256:e584dac13a242589aaf42470fd3006cb0dc5aed6506cbd20357c7ec8bbe4a89e"}, {file = "aiormq-3.3.1.tar.gz", hash = "sha256:8218dd9f7198d6e7935855468326bbacf0089f926c70baa8dd92944cb2496573"}, ] +apipkg = [ + {file = "apipkg-1.5-py2.py3-none-any.whl", hash = "sha256:58587dd4dc3daefad0487f6d9ae32b4542b185e1c36db6993290e7c41ca2b47c"}, + {file = "apipkg-1.5.tar.gz", hash = "sha256:37228cda29411948b422fae072f57e31d3396d2ee1c9783775980ee9c9990af6"}, +] appdirs = [ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, @@ -969,6 +1119,10 @@ async-timeout = [ {file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"}, {file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"}, ] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] attrs = [ {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, @@ -979,8 +1133,8 @@ beautifulsoup4 = [ {file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"}, ] certifi = [ - {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, - {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, + {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, + {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, ] cffi = [ {file = "cffi-1.14.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991"}, @@ -1022,8 +1176,8 @@ cffi = [ {file = "cffi-1.14.5.tar.gz", hash = "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c"}, ] cfgv = [ - {file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"}, - {file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"}, + {file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"}, + {file = "cfgv-3.3.0.tar.gz", hash = "sha256:9e600479b3b99e8af981ecdfc80a0296104ee610cab48a5ae4ffd0b668650eb1"}, ] chardet = [ {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, @@ -1104,8 +1258,8 @@ deepdiff = [ {file = "discord.py-1.6.0.tar.gz", hash = "sha256:ba8be99ff1b8c616f7b6dcb700460d0222b29d4c11048e74366954c465fdd05f"}, ] distlib = [ - {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"}, - {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"}, + {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, + {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"}, ] docopt = [ {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, @@ -1113,9 +1267,13 @@ docopt = [ emoji = [ {file = "emoji-0.6.0.tar.gz", hash = "sha256:e42da4f8d648f8ef10691bc246f682a1ec6b18373abfd9be10ec0b398823bd11"}, ] +execnet = [ + {file = "execnet-1.8.1-py2.py3-none-any.whl", hash = "sha256:e840ce25562e414ee5684864d510dbeeb0bce016bc89b22a6e5ce323b5e6552f"}, + {file = "execnet-1.8.1.tar.gz", hash = "sha256:7e3c2cdb6389542a91e9855a9cc7545fbed679e96f8808bcbb1beb325345b189"}, +] fakeredis = [ - {file = "fakeredis-1.5.0-py3-none-any.whl", hash = "sha256:e0416e4941cecd3089b0d901e60c8dc3c944f6384f5e29e2261c0d3c5fa99669"}, - {file = "fakeredis-1.5.0.tar.gz", hash = "sha256:1ac0cef767c37f51718874a33afb5413e69d132988cb6a80c6e6dbeddf8c7623"}, + {file = "fakeredis-1.5.1-py3-none-any.whl", hash = "sha256:afeb843b031697b3faff0eef8eedadef110741486b37e2bfb95167617785040f"}, + {file = "fakeredis-1.5.1.tar.gz", hash = "sha256:7f85faf640a0da564d8342a7d62936b07f23f4a85f756118fbd35b55f64f281c"}, ] feedparser = [ {file = "feedparser-6.0.2-py3-none-any.whl", hash = "sha256:f596c4b34fb3e2dc7e6ac3a8191603841e8d5d267210064e94d4238737452ddd"}, @@ -1212,12 +1370,16 @@ humanfriendly = [ {file = "humanfriendly-9.1.tar.gz", hash = "sha256:066562956639ab21ff2676d1fda0b5987e985c534fc76700a19bd54bcb81121d"}, ] identify = [ - {file = "identify-2.2.4-py2.py3-none-any.whl", hash = "sha256:ad9f3fa0c2316618dc4d840f627d474ab6de106392a4f00221820200f490f5a8"}, - {file = "identify-2.2.4.tar.gz", hash = "sha256:9bcc312d4e2fa96c7abebcdfb1119563b511b5e3985ac52f60d9116277865b2e"}, + {file = "identify-2.2.9-py2.py3-none-any.whl", hash = "sha256:96c57d493184daecc7299acdeef0ad7771c18a59931ea927942df393688fe849"}, + {file = "identify-2.2.9.tar.gz", hash = "sha256:3a8493cf49cfe4b28d50865e38f942c11be07a7b0aab8e715073e17f145caacc"}, ] idna = [ - {file = "idna-3.1-py3-none-any.whl", hash = "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16"}, - {file = "idna-3.1.tar.gz", hash = "sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1"}, + {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, + {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, +] +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] lxml = [ {file = "lxml-4.6.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2"}, @@ -1276,8 +1438,8 @@ mccabe = [ {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] more-itertools = [ - {file = "more-itertools-8.7.0.tar.gz", hash = "sha256:c5d6da9ca3ff65220c3bfd2a8db06d698f05d4d2b9be57e1deb2be5a45019713"}, - {file = "more_itertools-8.7.0-py3-none-any.whl", hash = "sha256:5652a9ac72209ed7df8d9c15daf4e1aa0e3d2ccd3c87f8265a0673cd9cbc9ced"}, + {file = "more-itertools-8.8.0.tar.gz", hash = "sha256:83f0308e05477c68f56ea3a888172c78ed5d5b3c282addb67508e7ba6c8f813a"}, + {file = "more_itertools-8.8.0-py3-none-any.whl", hash = "sha256:2cf89ec599962f2ddc4d568a05defc40e0a587fbc10d5989713638864c36be4d"}, ] mslex = [ {file = "mslex-0.3.0-py2.py3-none-any.whl", hash = "sha256:380cb14abf8fabf40e56df5c8b21a6d533dc5cbdcfe42406bbf08dda8f42e42a"}, @@ -1329,6 +1491,10 @@ nodeenv = [ ordered-set = [ {file = "ordered-set-4.0.2.tar.gz", hash = "sha256:ba93b2df055bca202116ec44b9bead3df33ea63a7d5827ff8e16738b97f33a95"}, ] +packaging = [ + {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, + {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, +] pamqp = [ {file = "pamqp-2.3.0-py2.py3-none-any.whl", hash = "sha256:2f81b5c186f668a67f165193925b6bfd83db4363a6222f599517f29ecee60b02"}, {file = "pamqp-2.3.0.tar.gz", hash = "sha256:5cd0f5a85e89f20d5f8e19285a1507788031cfca4a9ea6f067e3cf18f5e294e8"}, @@ -1337,9 +1503,13 @@ pep8-naming = [ {file = "pep8-naming-0.11.1.tar.gz", hash = "sha256:a1dd47dd243adfe8a83616e27cf03164960b507530f155db94e10b36a6cd6724"}, {file = "pep8_naming-0.11.1-py2.py3-none-any.whl", hash = "sha256:f43bfe3eea7e0d73e8b5d07d6407ab47f2476ccaeff6937c84275cd30b016738"}, ] +pluggy = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +] pre-commit = [ - {file = "pre_commit-2.12.1-py2.py3-none-any.whl", hash = "sha256:70c5ec1f30406250b706eda35e868b87e3e4ba099af8787e3e8b4b01e84f4712"}, - {file = "pre_commit-2.12.1.tar.gz", hash = "sha256:900d3c7e1bf4cf0374bb2893c24c23304952181405b4d88c9c40b72bda1bb8a9"}, + {file = "pre_commit-2.13.0-py2.py3-none-any.whl", hash = "sha256:b679d0fddd5b9d6d98783ae5f10fd0c4c59954f375b70a58cbe1ce9bcf9809a4"}, + {file = "pre_commit-2.13.0.tar.gz", hash = "sha256:764972c60693dc668ba8e86eb29654ec3144501310f7198742a767bec385a378"}, ] psutil = [ {file = "psutil-5.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0066a82f7b1b37d334e68697faba68e5ad5e858279fd6351c8ca6024e8d6ba64"}, @@ -1371,40 +1541,44 @@ psutil = [ {file = "psutil-5.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:f4634b033faf0d968bb9220dd1c793b897ab7f1189956e1aa9eae752527127d3"}, {file = "psutil-5.8.0.tar.gz", hash = "sha256:0c9ccb99ab76025f2f0bbecf341d4656e9c1351db8cc8a03ccd62e318ab4b5c6"}, ] +py = [ + {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, + {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, +] pycares = [ - {file = "pycares-3.2.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ebff743643e54aa70dce0b7098094edefd371641cf79d9c944e9f4a25e9242b0"}, - {file = "pycares-3.2.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:55272411b46787936e8db475b9b6e9b81a8d8cdc253fa8779a45ef979f554fab"}, - {file = "pycares-3.2.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:f33ed0e403f98e746f721aeacde917f1bdc7558cb714d713c264848bddff660f"}, - {file = "pycares-3.2.3-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:72807e0c80b705e21c3a39347c12edf43aa4f80373bb37777facf810169372ed"}, - {file = "pycares-3.2.3-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:a51df0a8b3eaf225e0dae3a737fd6ce6f3cb2a3bc947e884582fdda9a159d55f"}, - {file = "pycares-3.2.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:663b5c7bd0f66436adac7257ee22ccfe185c3e7830b9bada3d19b79870e1d134"}, - {file = "pycares-3.2.3-cp36-cp36m-win32.whl", hash = "sha256:c2b1e19262ce91c3288b1905b0d41f7ad0fff4b258ce37b517aa2c8d22eb82f1"}, - {file = "pycares-3.2.3-cp36-cp36m-win_amd64.whl", hash = "sha256:e16399654a6c81cfaee2745857c119c20357b5d93de2f169f506b048b5e75d1d"}, - {file = "pycares-3.2.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88e5131570d7323b29866aa5ac245a9a5788d64677111daa1bde5817acdf012f"}, - {file = "pycares-3.2.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1552ffd823dc595fa8744c996926097a594f4f518d7c147657234b22cf17649d"}, - {file = "pycares-3.2.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f9e28b917373818817aca746238fcd621ec7e4ae9cbc8615f1a045e234eec298"}, - {file = "pycares-3.2.3-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:206d5a652990f10a1f1f3f62bc23d7fe46d99c2dc4b8b8a5101e5a472986cd02"}, - {file = "pycares-3.2.3-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:b8c9670225cdeeeb2b85ea92a807484622ca59f8f578ec73e8ec292515f35a91"}, - {file = "pycares-3.2.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:6329160885fc318f80692d4d0a83a8854f9144e7a80c4f25245d0c26f11a4b84"}, - {file = "pycares-3.2.3-cp37-cp37m-win32.whl", hash = "sha256:cd0f7fb40e1169f00b26a12793136bf5c711f155e647cd045a0ce6c98a527b57"}, - {file = "pycares-3.2.3-cp37-cp37m-win_amd64.whl", hash = "sha256:a5d419215543d154587590d9d4485e985387ca10c7d3e1a2e5689dd6c0f20e5f"}, - {file = "pycares-3.2.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54f1c0642935515f27549f09486e72b6b2b1d51ad27a90ce17b760e9ce5e86d"}, - {file = "pycares-3.2.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6ce80eed538dd6106cd7e6136ceb3af10178d1254f07096a827c12e82e5e45c8"}, - {file = "pycares-3.2.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:ed972a04067e91f552da84945d38b94c3984c898f699faa8bb066e9f3a114c32"}, - {file = "pycares-3.2.3-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:99a62b101cfb36ab6ebf19cb1ad60db2f9b080dc52db4ca985fe90924f60c758"}, - {file = "pycares-3.2.3-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:2246adcbc948dd31925c9bff5cc41c06fc640f7d982e6b41b6d09e4f201e5c11"}, - {file = "pycares-3.2.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7fd15d3f32be5548f38f95f4762ca73eef9fd623b101218a35d433ee0d4e3b58"}, - {file = "pycares-3.2.3-cp38-cp38-win32.whl", hash = "sha256:4bb0c708d8713741af7c4649d2f11e47c5f4e43131831243aeb18cff512c5469"}, - {file = "pycares-3.2.3-cp38-cp38-win_amd64.whl", hash = "sha256:a53d921956d1e985e510ca0ffa84fbd7ecc6ac7d735d8355cba4395765efcd31"}, - {file = "pycares-3.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0312d25fa9d7c242f66115c4b3ae6ed8aedb457513ba33acef31fa265fc602b4"}, - {file = "pycares-3.2.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9960de8254525d9c3b485141809910c39d5eb1bb8119b1453702aacf72234934"}, - {file = "pycares-3.2.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:929f708a7bb4b2548cbbfc2094b2f90c4d8712056cdc0204788b570ab69c8838"}, - {file = "pycares-3.2.3-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4dd1237f01037cf5b90dd599c7fa79d9d8fb2ab2f401e19213d24228b2d17838"}, - {file = "pycares-3.2.3-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:5eea61a74097976502ce377bb75c4fed381d4986bc7fb85e70b691165133d3da"}, - {file = "pycares-3.2.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:1c72c0fda4b08924fe04680475350e09b8d210365d950a6dcdde8c449b8d5b98"}, - {file = "pycares-3.2.3-cp39-cp39-win32.whl", hash = "sha256:b1555d51ce29510ffd20f9e0339994dff8c5d1cb093c8e81d5d98f474e345aa7"}, - {file = "pycares-3.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:43c15138f620ed28e61e51b884490eb8387e5954668f919313753f88dd8134fd"}, - {file = "pycares-3.2.3.tar.gz", hash = "sha256:da1899fde778f9b8736712283eccbf7b654248779b349d139cd28eb30b0fa8cd"}, + {file = "pycares-4.0.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:db5a533111a3cfd481e7e4fb2bf8bef69f4fa100339803e0504dd5aecafb96a5"}, + {file = "pycares-4.0.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:fdff88393c25016f417770d82678423fc7a56995abb2df3d2a1e55725db6977d"}, + {file = "pycares-4.0.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0aa97f900a7ffb259be77d640006585e2a907b0cd4edeee0e85cf16605995d5a"}, + {file = "pycares-4.0.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:a34b0e3e693dceb60b8a1169668d606c75cb100ceba0a2df53c234a0eb067fbc"}, + {file = "pycares-4.0.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7661d6bbd51a337e7373cb356efa8be9b4655fda484e068f9455e939aec8d54e"}, + {file = "pycares-4.0.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:57315b8eb8fdbc56b3ad4932bc4b17132bb7c7fd2bd590f7fb84b6b522098aa9"}, + {file = "pycares-4.0.0-cp36-cp36m-win32.whl", hash = "sha256:dca9dc58845a9d083f302732a3130c68ded845ad5d463865d464e53c75a3dd45"}, + {file = "pycares-4.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:c95c964d5dd307e104b44b193095c67bb6b10c9eda1ffe7d44ab7a9e84c476d9"}, + {file = "pycares-4.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:26e67e4f81c80a5955dcf6193f3d9bee3c491fc0056299b383b84d792252fba4"}, + {file = "pycares-4.0.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:cd3011ffd5e1ad55880f7256791dbab9c43ebeda260474a968f19cd0319e1aef"}, + {file = "pycares-4.0.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1b959dd5921d207d759d421eece1b60416df33a7f862465739d5f2c363c2f523"}, + {file = "pycares-4.0.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:6f258c1b74c048a9501a25f732f11b401564005e5e3c18f1ca6cad0c3dc0fb19"}, + {file = "pycares-4.0.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:b17ef48729786e62b574c6431f675f4cb02b27691b49e7428a605a50cd59c072"}, + {file = "pycares-4.0.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:82b3259cb590ddd107a6d2dc52da2a2e9a986bf242e893d58c786af2f8191047"}, + {file = "pycares-4.0.0-cp37-cp37m-win32.whl", hash = "sha256:4876fc790ae32832ae270c4a010a1a77e12ddf8d8e6ad70ad0b0a9d506c985f7"}, + {file = "pycares-4.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:f60c04c5561b1ddf85ca4e626943cc09d7fb684e1adb22abb632095415a40fd7"}, + {file = "pycares-4.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:615406013cdcd1b445e5d1a551d276c6200b3abe77e534f8a7f7e1551208d14f"}, + {file = "pycares-4.0.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6580aef5d1b29a88c3d72fe73c691eacfd454f86e74d3fdd18f4bad8e8def98b"}, + {file = "pycares-4.0.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8ebb3ba0485f66cae8eed7ce3e9ed6f2c0bfd5e7319d5d0fbbb511064f17e1d4"}, + {file = "pycares-4.0.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:c5362b7690ca481440f6b98395ac6df06aa50518ccb183c560464d1e5e2ab5d4"}, + {file = "pycares-4.0.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:eb60be66accc9a9ea1018b591a1f5800cba83491d07e9acc8c56bc6e6607ab54"}, + {file = "pycares-4.0.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:44896d6e191a6b5a914dbe3aa7c748481bf6ad19a9df33c1e76f8f2dc33fc8f0"}, + {file = "pycares-4.0.0-cp38-cp38-win32.whl", hash = "sha256:09b28fc7bc2cc05f7f69bf1636ddf46086e0a1837b62961e2092fcb40477320d"}, + {file = "pycares-4.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:d4a5081e232c1d181883dcac4675807f3a6cf33911c4173fbea00c0523687ed4"}, + {file = "pycares-4.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:103353577a6266a53e71bfee4cf83825f1401fefa60f0fb8bdec35f13be6a5f2"}, + {file = "pycares-4.0.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ad6caf580ee69806fc6534be93ddbb6e99bf94296d79ab351c37b2992b17abfd"}, + {file = "pycares-4.0.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:3d5e50c95849f6905d2a9dbf02ed03f82580173e3c5604a39e2ad054185631f1"}, + {file = "pycares-4.0.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:53bc4f181b19576499b02cea4b45391e8dcbe30abd4cd01492f66bfc15615a13"}, + {file = "pycares-4.0.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:d52f9c725d2a826d5ffa37681eb07ffb996bfe21788590ef257664a3898fc0b5"}, + {file = "pycares-4.0.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:3c7fb8d34ee11971c39acfaf98d0fac66725385ccef3bfe1b174c92b210e1aa4"}, + {file = "pycares-4.0.0-cp39-cp39-win32.whl", hash = "sha256:e9773e07684a55f54657df05237267611a77b294ec3bacb5f851c4ffca38a465"}, + {file = "pycares-4.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:38e54037f36c149146ff15f17a4a963fbdd0f9871d4a21cd94ff9f368140f57e"}, + {file = "pycares-4.0.0.tar.gz", hash = "sha256:d0154fc5753b088758fbec9bc137e1b24bb84fc0c6a09725c8bac25a342311cd"}, ] pycodestyle = [ {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, @@ -1415,18 +1589,38 @@ pycparser = [ {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, ] pydocstyle = [ - {file = "pydocstyle-6.0.0-py3-none-any.whl", hash = "sha256:d4449cf16d7e6709f63192146706933c7a334af7c0f083904799ccb851c50f6d"}, - {file = "pydocstyle-6.0.0.tar.gz", hash = "sha256:164befb520d851dbcf0e029681b91f4f599c62c5cd8933fd54b1bfbd50e89e1f"}, + {file = "pydocstyle-6.1.1-py3-none-any.whl", hash = "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"}, + {file = "pydocstyle-6.1.1.tar.gz", hash = "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc"}, ] pyflakes = [ {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, ] +pyparsing = [ + {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, + {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, +] pyreadline = [ {file = "pyreadline-2.1.win-amd64.exe", hash = "sha256:9ce5fa65b8992dfa373bddc5b6e0864ead8f291c94fbfec05fbd5c836162e67b"}, {file = "pyreadline-2.1.win32.exe", hash = "sha256:65540c21bfe14405a3a77e4c085ecfce88724743a4ead47c66b84defcf82c32e"}, {file = "pyreadline-2.1.zip", hash = "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1"}, ] +pytest = [ + {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"}, + {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"}, +] +pytest-cov = [ + {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, + {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, +] +pytest-forked = [ + {file = "pytest-forked-1.3.0.tar.gz", hash = "sha256:6aa9ac7e00ad1a539c41bec6d21011332de671e938c7637378ec9710204e37ca"}, + {file = "pytest_forked-1.3.0-py2.py3-none-any.whl", hash = "sha256:dc4147784048e70ef5d437951728825a131b81714b398d5d52f17c7c144d8815"}, +] +pytest-xdist = [ + {file = "pytest-xdist-2.2.1.tar.gz", hash = "sha256:718887296892f92683f6a51f25a3ae584993b06f7076ce1e1fd482e59a8220a2"}, + {file = "pytest_xdist-2.2.1-py3-none-any.whl", hash = "sha256:2447a1592ab41745955fb870ac7023026f20a5f0bfccf1b52a879bd193d46450"}, +] python-dateutil = [ {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, @@ -1529,8 +1723,8 @@ snowballstemmer = [ {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"}, ] sortedcontainers = [ - {file = "sortedcontainers-2.3.0-py2.py3-none-any.whl", hash = "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f"}, - {file = "sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"}, + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, ] soupsieve = [ {file = "soupsieve-2.2.1-py3-none-any.whl", hash = "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"}, @@ -1554,12 +1748,12 @@ typing-extensions = [ {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, ] urllib3 = [ - {file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"}, - {file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"}, + {file = "urllib3-1.26.5-py2.py3-none-any.whl", hash = "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c"}, + {file = "urllib3-1.26.5.tar.gz", hash = "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098"}, ] virtualenv = [ - {file = "virtualenv-20.4.6-py2.py3-none-any.whl", hash = "sha256:307a555cf21e1550885c82120eccaf5acedf42978fd362d32ba8410f9593f543"}, - {file = "virtualenv-20.4.6.tar.gz", hash = "sha256:72cf267afc04bf9c86ec932329b7e94db6a0331ae9847576daaa7ca3c86b29a4"}, + {file = "virtualenv-20.4.7-py2.py3-none-any.whl", hash = "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76"}, + {file = "virtualenv-20.4.7.tar.gz", hash = "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467"}, ] yarl = [ {file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"}, diff --git a/pyproject.toml b/pyproject.toml index 320bf88cc..2c9181889 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,9 @@ pep8-naming = "~=0.9" pre-commit = "~=2.1" taskipy = "~=1.7.0" python-dotenv = "~=0.17.1" +pytest = "~=6.2.4" +pytest-cov = "~=2.12.1" +pytest-xdist = { version = "~=2.2.1", extras = ["psutil"] } [build-system] requires = ["poetry-core>=1.0.0"] @@ -58,6 +61,6 @@ lint = "pre-commit run --all-files" precommit = "pre-commit install" build = "docker build -t ghcr.io/python-discord/bot:latest -f Dockerfile ." push = "docker push ghcr.io/python-discord/bot:latest" -test = "coverage run -m unittest" +test = "pytest -n auto --cov-report= --cov bot " html = "coverage html" report = "coverage report" diff --git a/tests/README.md b/tests/README.md index 1a17c09bd..a757f96c6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -11,10 +11,14 @@ We are using the following modules and packages for our unit tests: - [unittest](https://docs.python.org/3/library/unittest.html) (standard library) - [unittest.mock](https://docs.python.org/3/library/unittest.mock.html) (standard library) - [coverage.py](https://coverage.readthedocs.io/en/stable/) +- [pytest-cov](https://pytest-cov.readthedocs.io/en/latest/index.html) + +We also use the following package as a test runner: +- [pytest](https://docs.pytest.org/en/6.2.x/) To ensure the results you obtain on your personal machine are comparable to those generated in the CI, please make sure to run your tests with the virtual environment defined by our [Poetry Project](/pyproject.toml). To run your tests with `poetry`, we've provided two "scripts" shortcuts: -- `poetry run task test` will run `unittest` with `coverage.py` +- `poetry run task test` will run `pytest` with `pytest-cov`. - `poetry run task test path/to/test.py` will run a specific test. - `poetry run task report` will generate a coverage report of the tests you've run with `poetry run task test`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report. -- cgit v1.2.3 From 37584a8b8774c04b4111c29d96f8d06b31c89d84 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Mon, 7 Jun 2021 05:58:57 +0300 Subject: Adds Fast-Test Task Signed-off-by: Hassan Abouelela --- pyproject.toml | 3 ++- tests/README.md | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2c9181889..774fe075c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ lint = "pre-commit run --all-files" precommit = "pre-commit install" build = "docker build -t ghcr.io/python-discord/bot:latest -f Dockerfile ." push = "docker push ghcr.io/python-discord/bot:latest" -test = "pytest -n auto --cov-report= --cov bot " +fast-test = "pytest -n auto" +test = "pytest -n auto --cov-report= --cov bot" html = "coverage html" report = "coverage report" diff --git a/tests/README.md b/tests/README.md index a757f96c6..b5fba9611 100644 --- a/tests/README.md +++ b/tests/README.md @@ -18,6 +18,7 @@ We also use the following package as a test runner: To ensure the results you obtain on your personal machine are comparable to those generated in the CI, please make sure to run your tests with the virtual environment defined by our [Poetry Project](/pyproject.toml). To run your tests with `poetry`, we've provided two "scripts" shortcuts: +- `poetry run task fast-test` will run `pytest`. - `poetry run task test` will run `pytest` with `pytest-cov`. - `poetry run task test path/to/test.py` will run a specific test. - `poetry run task report` will generate a coverage report of the tests you've run with `poetry run task test`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report. -- cgit v1.2.3 From a87143cbf535d2f3685e45bbecc01afeade2d3a0 Mon Sep 17 00:00:00 2001 From: Slushs Date: Mon, 7 Jun 2021 14:06:45 -0400 Subject: nothing to see here --- bot/exts/help_channels/_cog.py | 60 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 5c410a0a1..0395418a3 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -469,6 +469,8 @@ class HelpChannels(commands.Cog): else: await _message.update_message_caches(message) + await self.notify_session_participants(message) + @commands.Cog.listener() async def on_message_delete(self, msg: discord.Message) -> None: """ @@ -535,3 +537,61 @@ class HelpChannels(commands.Cog): ) self.dynamic_message = new_dynamic_message["id"] await _caches.dynamic_message.set("message_id", self.dynamic_message) + + async def notify_session_participants(self, message: discord.Message) -> None: + if await _caches.claimants.get(message.channel.id) == message.author.id: + return # Ignore messages sent by claimants + + if not await _caches.help_dm.get(message.author.id): + return # Ignore message if user is opted out of help dms + + await self.notify_session_participants(message) + + session_participants = _caches.session_participants.get(message.channel.id) + + @commands.group(name="helpdm") + async def help_dm_group(self, ctx: commands.Context) -> None: + if ctx.invoked_subcommand is None: + await ctx.send_help(ctx.command) + + @help_dm_group.command(name="on") + async def on_command(self, ctx: commands.Context) -> None: + if await _caches.help_dm.get(ctx.author.id): + await ctx.send(f"{constants.Emojis.cross_mark}{ctx.author.mention} Help DMs are already ON!") + + log.trace(f"{ctx.author.id} Attempted to turn Help DMs on but they are already ON") + return + + await _caches.help_dm.set(ctx.author.id, True) + + await ctx.send(f"{constants.Emojis.ok_hand} {ctx.author.mention} Help DMs ON!") + + log.trace(f"{ctx.author.id} Help DMs OFF") + + @help_dm_group.command(name="off") + async def off_command(self, ctx: commands.Context) -> None: + if not await _caches.help_dm.get(ctx.author.id): + await ctx.send(f"{constants.Emojis.cross_mark} {ctx.author.mention} Help DMs are already OFF!") + + log.trace(f"{ctx.author.id} Attempted to turn Help DMs on but they are already OFF") + return + + await _caches.help_dm.set(ctx.author.id, False) + + await ctx.send(f"{constants.Emojis.ok_hand} {ctx.author.mention} Help DMs OFF!") + + log.trace(f"{ctx.author.id} Help DMs OFF") + + @help_dm_group.command() + async def embed_test(self, ctx): + user = self.bot.get_user(ctx.author.id) + + embed = discord.Embed( + title="Currently Helping", + description=f"You're currently helping in <#{ctx.channel.id}>", + color=discord.Colour.green(), + timestamp=ctx.message.created_at + ) + embed.add_field(name="Conversation", value=f"[Jump to message]({ctx.message.jump_url})") + await user.send(embed=embed) + -- cgit v1.2.3 From fb053e488308885a7980812d5c790b9fb33ea575 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Mon, 7 Jun 2021 22:30:11 +0300 Subject: Adds Tests To Coverage Source Signed-off-by: Hassan Abouelela --- .github/workflows/lint-test.yml | 2 +- poetry.lock | 14 +++++++------- pyproject.toml | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index 370b0b38b..35e02f0d3 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -99,7 +99,7 @@ jobs: - name: Run tests and generate coverage report run: | - pytest -n auto --cov bot --disable-warnings -q + pytest -n auto --cov bot --cov tests --disable-warnings -q # This step will publish the coverage reports coveralls.io and # print a "job" link in the output of the GitHub Action diff --git a/poetry.lock b/poetry.lock index a671d8a35..290746cc3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -325,7 +325,7 @@ testing = ["pre-commit"] [[package]] name = "fakeredis" -version = "1.5.1" +version = "1.5.2" description = "Fake implementation of redis API for testing purposes." category = "main" optional = false @@ -337,7 +337,7 @@ six = ">=1.12" sortedcontainers = "*" [package.extras] -aioredis = ["aioredis"] +aioredis = ["aioredis (<2)"] lua = ["lupa"] [[package]] @@ -497,7 +497,7 @@ pyreadline = {version = "*", markers = "sys_platform == \"win32\""} [[package]] name = "identify" -version = "2.2.9" +version = "2.2.10" description = "File identification library for Python" category = "dev" optional = false @@ -1272,8 +1272,8 @@ execnet = [ {file = "execnet-1.8.1.tar.gz", hash = "sha256:7e3c2cdb6389542a91e9855a9cc7545fbed679e96f8808bcbb1beb325345b189"}, ] fakeredis = [ - {file = "fakeredis-1.5.1-py3-none-any.whl", hash = "sha256:afeb843b031697b3faff0eef8eedadef110741486b37e2bfb95167617785040f"}, - {file = "fakeredis-1.5.1.tar.gz", hash = "sha256:7f85faf640a0da564d8342a7d62936b07f23f4a85f756118fbd35b55f64f281c"}, + {file = "fakeredis-1.5.2-py3-none-any.whl", hash = "sha256:f1ffdb134538e6d7c909ddfb4fc5edeb4a73d0ea07245bc69b8135fbc4144b04"}, + {file = "fakeredis-1.5.2.tar.gz", hash = "sha256:18fc1808d2ce72169d3f11acdb524a00ef96bd29970c6d34cfeb2edb3fc0c020"}, ] feedparser = [ {file = "feedparser-6.0.2-py3-none-any.whl", hash = "sha256:f596c4b34fb3e2dc7e6ac3a8191603841e8d5d267210064e94d4238737452ddd"}, @@ -1370,8 +1370,8 @@ humanfriendly = [ {file = "humanfriendly-9.1.tar.gz", hash = "sha256:066562956639ab21ff2676d1fda0b5987e985c534fc76700a19bd54bcb81121d"}, ] identify = [ - {file = "identify-2.2.9-py2.py3-none-any.whl", hash = "sha256:96c57d493184daecc7299acdeef0ad7771c18a59931ea927942df393688fe849"}, - {file = "identify-2.2.9.tar.gz", hash = "sha256:3a8493cf49cfe4b28d50865e38f942c11be07a7b0aab8e715073e17f145caacc"}, + {file = "identify-2.2.10-py2.py3-none-any.whl", hash = "sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421"}, + {file = "identify-2.2.10.tar.gz", hash = "sha256:5b41f71471bc738e7b586308c3fca172f78940195cb3bf6734c1e66fdac49306"}, ] idna = [ {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, diff --git a/pyproject.toml b/pyproject.toml index 774fe075c..12c37348f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,6 @@ precommit = "pre-commit install" build = "docker build -t ghcr.io/python-discord/bot:latest -f Dockerfile ." push = "docker push ghcr.io/python-discord/bot:latest" fast-test = "pytest -n auto" -test = "pytest -n auto --cov-report= --cov bot" +test = "pytest -n auto --cov-report= --cov bot --cov tests" html = "coverage html" report = "coverage report" -- cgit v1.2.3 From e45843ebe0a199d30b09ec7a0dcffc1ed9d4d9d7 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Mon, 7 Jun 2021 22:33:15 +0300 Subject: Fix Script Count In Documentation Signed-off-by: Hassan Abouelela --- tests/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/README.md b/tests/README.md index b5fba9611..339108951 100644 --- a/tests/README.md +++ b/tests/README.md @@ -16,7 +16,7 @@ We are using the following modules and packages for our unit tests: We also use the following package as a test runner: - [pytest](https://docs.pytest.org/en/6.2.x/) -To ensure the results you obtain on your personal machine are comparable to those generated in the CI, please make sure to run your tests with the virtual environment defined by our [Poetry Project](/pyproject.toml). To run your tests with `poetry`, we've provided two "scripts" shortcuts: +To ensure the results you obtain on your personal machine are comparable to those generated in the CI, please make sure to run your tests with the virtual environment defined by our [Poetry Project](/pyproject.toml). To run your tests with `poetry`, we've provided the following "script" shortcuts: - `poetry run task fast-test` will run `pytest`. - `poetry run task test` will run `pytest` with `pytest-cov`. -- cgit v1.2.3 From f18786813984e1fadffc34e886b36d8094bab526 Mon Sep 17 00:00:00 2001 From: GDWR Date: Mon, 7 Jun 2021 21:12:13 +0100 Subject: Add caches required for help-dm --- bot/exts/help_channels/_caches.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bot/exts/help_channels/_caches.py b/bot/exts/help_channels/_caches.py index c5e4ee917..8d45c2466 100644 --- a/bot/exts/help_channels/_caches.py +++ b/bot/exts/help_channels/_caches.py @@ -24,3 +24,12 @@ question_messages = RedisCache(namespace="HelpChannels.question_messages") # This cache keeps track of the dynamic message ID for # the continuously updated message in the #How-to-get-help channel. dynamic_message = RedisCache(namespace="HelpChannels.dynamic_message") + +# This cache keeps track of who has help-dms on. +# RedisCache[discord.User.id, bool] +help_dm = RedisCache(namespace="HelpChannels.help_dm") + +# This cache tracks member who are participating and opted in to help channel dms. +# serialise the set as a comma separated string to allow usage with redis +# RedisCache[discord.TextChannel.id, str[set[discord.User.id]]] +session_participants = RedisCache(namespace="HelpChannels.session_participants") -- cgit v1.2.3 From 9dd194a2298244988ad7cf76f6147d65d420c9c7 Mon Sep 17 00:00:00 2001 From: GDWR Date: Mon, 7 Jun 2021 21:14:06 +0100 Subject: Add help dm feature --- bot/exts/help_channels/_cog.py | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 0395418a3..919115e95 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -424,6 +424,7 @@ class HelpChannels(commands.Cog): ) -> None: """Actual implementation of `unclaim_channel`. See that for full documentation.""" await _caches.claimants.delete(channel.id) + await _caches.session_participants.delete(channel.id) claimant = self.bot.get_guild(constants.Guild.id).get_member(claimant_id) if claimant is None: @@ -466,10 +467,13 @@ class HelpChannels(commands.Cog): if channel_utils.is_in_category(message.channel, constants.Categories.help_available): if not _channel.is_excluded_channel(message.channel): await self.claim_channel(message) + + elif channel_utils.is_in_category(message.channel, constants.Categories.help_in_use): + await self.notify_session_participants(message) + else: await _message.update_message_caches(message) - await self.notify_session_participants(message) @commands.Cog.listener() async def on_message_delete(self, msg: discord.Message) -> None: @@ -538,16 +542,43 @@ class HelpChannels(commands.Cog): self.dynamic_message = new_dynamic_message["id"] await _caches.dynamic_message.set("message_id", self.dynamic_message) + @staticmethod + def _serialise_session_participants(participants: set[int]) -> str: + """Convert a set to a comma separated string.""" + return ','.join(str(p) for p in participants) + + @staticmethod + def _deserialise_session_participants(s: str) -> set[int]: + """Convert a comma separated string into a set.""" + return set(int(user_id) for user_id in s.split(",") if user_id != "") + + @lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id")) + @lock.lock_arg(NAMESPACE, "message", attrgetter("author.id")) async def notify_session_participants(self, message: discord.Message) -> None: + """ + Check if the message author meets the requirements to be notified. + If they meet the requirements they are notified. + """ if await _caches.claimants.get(message.channel.id) == message.author.id: return # Ignore messages sent by claimants if not await _caches.help_dm.get(message.author.id): return # Ignore message if user is opted out of help dms - await self.notify_session_participants(message) + if message.content == f"{self.bot.command_prefix}close": + return # Ignore messages that are closing the channel - session_participants = _caches.session_participants.get(message.channel.id) + session_participants = self._deserialise_session_participants( + await _caches.session_participants.get(message.channel.id) or "" + ) + + if not message.author.id in session_participants: + session_participants.add(message.author.id) + await message.author.send("Purple") + await _caches.session_participants.set( + message.channel.id, + self._serialise_session_participants(session_participants) + ) @commands.group(name="helpdm") async def help_dm_group(self, ctx: commands.Context) -> None: @@ -582,6 +613,7 @@ class HelpChannels(commands.Cog): log.trace(f"{ctx.author.id} Help DMs OFF") + # TODO: REMOVE BEFORE COMMIT @help_dm_group.command() async def embed_test(self, ctx): user = self.bot.get_user(ctx.author.id) @@ -594,4 +626,3 @@ class HelpChannels(commands.Cog): ) embed.add_field(name="Conversation", value=f"[Jump to message]({ctx.message.jump_url})") await user.send(embed=embed) - -- cgit v1.2.3 From 8dac3ec26caa819f83316169ac6911119e376356 Mon Sep 17 00:00:00 2001 From: Slushs Date: Mon, 7 Jun 2021 16:50:21 -0400 Subject: Add helpdm participating embed --- bot/exts/help_channels/_cog.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 919115e95..32e082949 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -574,7 +574,18 @@ class HelpChannels(commands.Cog): if not message.author.id in session_participants: session_participants.add(message.author.id) - await message.author.send("Purple") + + user = self.bot.get_user(message.author.id) + + embed = discord.Embed( + title="Currently Helping", + description=f"You're currently helping in <#{message.channel.id}>", + color=discord.Colour.green(), + timestamp=message.created_at + ) + embed.add_field(name="Conversation", value=f"[Jump to message]({message.message.jump_url})") + await user.send(embed=embed) + await _caches.session_participants.set( message.channel.id, self._serialise_session_participants(session_participants) -- cgit v1.2.3 From 2d5247e401bafca25a4f086e77ee7a8fffd2b5d8 Mon Sep 17 00:00:00 2001 From: Slushs Date: Mon, 7 Jun 2021 16:58:54 -0400 Subject: Remove embed test --- bot/exts/help_channels/_cog.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 32e082949..dccb39119 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -623,17 +623,3 @@ class HelpChannels(commands.Cog): await ctx.send(f"{constants.Emojis.ok_hand} {ctx.author.mention} Help DMs OFF!") log.trace(f"{ctx.author.id} Help DMs OFF") - - # TODO: REMOVE BEFORE COMMIT - @help_dm_group.command() - async def embed_test(self, ctx): - user = self.bot.get_user(ctx.author.id) - - embed = discord.Embed( - title="Currently Helping", - description=f"You're currently helping in <#{ctx.channel.id}>", - color=discord.Colour.green(), - timestamp=ctx.message.created_at - ) - embed.add_field(name="Conversation", value=f"[Jump to message]({ctx.message.jump_url})") - await user.send(embed=embed) -- cgit v1.2.3 From f05169e3a145c7d6bacab44b883c6e331ef96694 Mon Sep 17 00:00:00 2001 From: Slushs Date: Mon, 7 Jun 2021 17:15:43 -0400 Subject: Add docstring to commands --- bot/exts/help_channels/_cog.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index dccb39119..7246a8bfc 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -417,10 +417,10 @@ class HelpChannels(commands.Cog): return await _unclaim_channel(channel, claimant_id, closed_on) async def _unclaim_channel( - self, - channel: discord.TextChannel, - claimant_id: int, - closed_on: _channel.ClosingReason + self, + channel: discord.TextChannel, + claimant_id: int, + closed_on: _channel.ClosingReason ) -> None: """Actual implementation of `unclaim_channel`. See that for full documentation.""" await _caches.claimants.delete(channel.id) @@ -474,7 +474,6 @@ class HelpChannels(commands.Cog): else: await _message.update_message_caches(message) - @commands.Cog.listener() async def on_message_delete(self, msg: discord.Message) -> None: """ @@ -593,11 +592,17 @@ class HelpChannels(commands.Cog): @commands.group(name="helpdm") async def help_dm_group(self, ctx: commands.Context) -> None: + """ + Users who are participating in the help channel(not the claimant) + will receive a dm showing what help channel they are "Helping in." + This will be ignored if the message content == close. + """ if ctx.invoked_subcommand is None: await ctx.send_help(ctx.command) @help_dm_group.command(name="on") async def on_command(self, ctx: commands.Context) -> None: + """Turns help dms on so the user will receive the participating dm""" if await _caches.help_dm.get(ctx.author.id): await ctx.send(f"{constants.Emojis.cross_mark}{ctx.author.mention} Help DMs are already ON!") @@ -612,6 +617,7 @@ class HelpChannels(commands.Cog): @help_dm_group.command(name="off") async def off_command(self, ctx: commands.Context) -> None: + """Turns help dms off so the user wont receive the participating dm""" if not await _caches.help_dm.get(ctx.author.id): await ctx.send(f"{constants.Emojis.cross_mark} {ctx.author.mention} Help DMs are already OFF!") -- cgit v1.2.3 From 849b0bf5746e505d88b6b75870b9e070e0ef286c Mon Sep 17 00:00:00 2001 From: Slushs Date: Mon, 7 Jun 2021 17:33:22 -0400 Subject: Fix failed linting --- bot/exts/help_channels/_cog.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 7246a8bfc..c92a7ae7e 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -556,6 +556,7 @@ class HelpChannels(commands.Cog): async def notify_session_participants(self, message: discord.Message) -> None: """ Check if the message author meets the requirements to be notified. + If they meet the requirements they are notified. """ if await _caches.claimants.get(message.channel.id) == message.author.id: @@ -571,7 +572,7 @@ class HelpChannels(commands.Cog): await _caches.session_participants.get(message.channel.id) or "" ) - if not message.author.id in session_participants: + if message.author.id not in session_participants: session_participants.add(message.author.id) user = self.bot.get_user(message.author.id) @@ -593,16 +594,17 @@ class HelpChannels(commands.Cog): @commands.group(name="helpdm") async def help_dm_group(self, ctx: commands.Context) -> None: """ - Users who are participating in the help channel(not the claimant) - will receive a dm showing what help channel they are "Helping in." - This will be ignored if the message content == close. + User will receive a embed when they are "helping" in a help channel. + + If they have helpdms off they will won't receive an embed. + If they have helpdms on they will receive an embed. """ if ctx.invoked_subcommand is None: await ctx.send_help(ctx.command) @help_dm_group.command(name="on") async def on_command(self, ctx: commands.Context) -> None: - """Turns help dms on so the user will receive the participating dm""" + """Turns help dms on so the user will receive the participating dm.""" if await _caches.help_dm.get(ctx.author.id): await ctx.send(f"{constants.Emojis.cross_mark}{ctx.author.mention} Help DMs are already ON!") @@ -617,7 +619,7 @@ class HelpChannels(commands.Cog): @help_dm_group.command(name="off") async def off_command(self, ctx: commands.Context) -> None: - """Turns help dms off so the user wont receive the participating dm""" + """Turns help dms off so the user wont receive the participating dm.""" if not await _caches.help_dm.get(ctx.author.id): await ctx.send(f"{constants.Emojis.cross_mark} {ctx.author.mention} Help DMs are already OFF!") -- cgit v1.2.3 From 016b5427614a892c82c40a95350162164c2bf48c Mon Sep 17 00:00:00 2001 From: Slushs Date: Mon, 7 Jun 2021 18:48:49 -0400 Subject: Remove useless else and if statement --- bot/exts/help_channels/_cog.py | 2 -- bot/exts/help_channels/_message.py | 27 ++++++++++++--------------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index c92a7ae7e..d85d46b57 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -470,8 +470,6 @@ class HelpChannels(commands.Cog): elif channel_utils.is_in_category(message.channel, constants.Categories.help_in_use): await self.notify_session_participants(message) - - else: await _message.update_message_caches(message) @commands.Cog.listener() diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index afd698ffe..4c7c39764 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -9,7 +9,6 @@ from arrow import Arrow import bot from bot import constants from bot.exts.help_channels import _caches -from bot.utils.channel import is_in_category log = logging.getLogger(__name__) @@ -47,23 +46,21 @@ async def update_message_caches(message: discord.Message) -> None: """Checks the source of new content in a help channel and updates the appropriate cache.""" channel = message.channel - # Confirm the channel is an in use help channel - if is_in_category(channel, constants.Categories.help_in_use): - log.trace(f"Checking if #{channel} ({channel.id}) has had a reply.") + log.trace(f"Checking if #{channel} ({channel.id}) has had a reply.") - claimant_id = await _caches.claimants.get(channel.id) - if not claimant_id: - # The mapping for this channel doesn't exist, we can't do anything. - return + claimant_id = await _caches.claimants.get(channel.id) + if not claimant_id: + # The mapping for this channel doesn't exist, we can't do anything. + return - # datetime.timestamp() would assume it's local, despite d.py giving a (naïve) UTC time. - timestamp = Arrow.fromdatetime(message.created_at).timestamp() + # datetime.timestamp() would assume it's local, despite d.py giving a (naïve) UTC time. + timestamp = Arrow.fromdatetime(message.created_at).timestamp() - # Overwrite the appropriate last message cache depending on the author of the message - if message.author.id == claimant_id: - await _caches.claimant_last_message_times.set(channel.id, timestamp) - else: - await _caches.non_claimant_last_message_times.set(channel.id, timestamp) + # Overwrite the appropriate last message cache depending on the author of the message + if message.author.id == claimant_id: + await _caches.claimant_last_message_times.set(channel.id, timestamp) + else: + await _caches.non_claimant_last_message_times.set(channel.id, timestamp) async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]: -- cgit v1.2.3 From 0799f24acab9c60d07d2485400fa7418d161b40c Mon Sep 17 00:00:00 2001 From: Jake <77035482+JakeM0001@users.noreply.github.com> Date: Tue, 8 Jun 2021 14:24:07 -0400 Subject: Change mention format Co-authored-by: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> --- bot/exts/help_channels/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index d85d46b57..595ae18fe 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -577,7 +577,7 @@ class HelpChannels(commands.Cog): embed = discord.Embed( title="Currently Helping", - description=f"You're currently helping in <#{message.channel.id}>", + description=f"You're currently helping in {message.channel.mention}", color=discord.Colour.green(), timestamp=message.created_at ) -- cgit v1.2.3 From cc0f9eadb86d354f653d71723af91f75f92d8a36 Mon Sep 17 00:00:00 2001 From: Slushs Date: Tue, 8 Jun 2021 15:04:38 -0400 Subject: Make toggle command one command instead of 2 --- bot/exts/help_channels/_cog.py | 52 ++++++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 595ae18fe..f78d4e306 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -12,6 +12,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot +from bot.converters import allowed_strings from bot.exts.help_channels import _caches, _channel, _message, _name, _stats from bot.utils import channel as channel_utils, lock, scheduling @@ -577,7 +578,7 @@ class HelpChannels(commands.Cog): embed = discord.Embed( title="Currently Helping", - description=f"You're currently helping in {message.channel.mention}", + description=f"You're currently helping in <#{message.channel.id}>", color=discord.Colour.green(), timestamp=message.created_at ) @@ -589,42 +590,43 @@ class HelpChannels(commands.Cog): self._serialise_session_participants(session_participants) ) - @commands.group(name="helpdm") - async def help_dm_group(self, ctx: commands.Context) -> None: + @commands.command(name="helpdm") + async def helpdm_command( + self, + ctx: commands.Context, + state: allowed_strings("on", "off") = None # noqa: F821 + ) -> None: """ - User will receive a embed when they are "helping" in a help channel. + Allows user to toggle "Helping" dms. - If they have helpdms off they will won't receive an embed. - If they have helpdms on they will receive an embed. + If this is set to off the user will not receive a dm for channel that they are participating in. + If this is set to on the user will receive a dm for the channel they are participating in. """ - if ctx.invoked_subcommand is None: - await ctx.send_help(ctx.command) + state_bool = state.lower() == "on" - @help_dm_group.command(name="on") - async def on_command(self, ctx: commands.Context) -> None: - """Turns help dms on so the user will receive the participating dm.""" - if await _caches.help_dm.get(ctx.author.id): - await ctx.send(f"{constants.Emojis.cross_mark}{ctx.author.mention} Help DMs are already ON!") + requested_state_bool = state.lower() == "on" + if requested_state_bool == await _caches.help_dm.get(ctx.author.id, False): + if await _caches.help_dm.get(ctx.author.id): + await ctx.send(f"{constants.Emojis.cross_mark}{ctx.author.mention} Help DMs are already ON!") - log.trace(f"{ctx.author.id} Attempted to turn Help DMs on but they are already ON") - return + log.trace(f"{ctx.author.id} Attempted to turn Help DMs on but they are already ON") + return - await _caches.help_dm.set(ctx.author.id, True) + if not await _caches.help_dm.get(ctx.author.id): + await ctx.send(f"{constants.Emojis.cross_mark}{ctx.author.mention} Help DMs are already OFF!") - await ctx.send(f"{constants.Emojis.ok_hand} {ctx.author.mention} Help DMs ON!") + log.trace(f"{ctx.author.id} Attempted to turn Help DMs on but they are already OFF") + return - log.trace(f"{ctx.author.id} Help DMs OFF") + if state_bool: + await _caches.help_dm.set(ctx.author.id, True) - @help_dm_group.command(name="off") - async def off_command(self, ctx: commands.Context) -> None: - """Turns help dms off so the user wont receive the participating dm.""" - if not await _caches.help_dm.get(ctx.author.id): - await ctx.send(f"{constants.Emojis.cross_mark} {ctx.author.mention} Help DMs are already OFF!") + await ctx.send(f"{constants.Emojis.ok_hand} {ctx.author.mention} Help DMs ON!") - log.trace(f"{ctx.author.id} Attempted to turn Help DMs on but they are already OFF") + log.trace(f"{ctx.author.id} Help DMs ON") return - await _caches.help_dm.set(ctx.author.id, False) + await _caches.help_dm.delete(ctx.author.id) await ctx.send(f"{constants.Emojis.ok_hand} {ctx.author.mention} Help DMs OFF!") -- cgit v1.2.3 From fd9ec20c97c899f352e1d6eaafabb4fe6ccb2b3f Mon Sep 17 00:00:00 2001 From: Slushs Date: Tue, 8 Jun 2021 15:10:59 -0400 Subject: Cleanup indentation and participant dm --- bot/exts/help_channels/_cog.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index f78d4e306..71dfc2c78 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -418,10 +418,10 @@ class HelpChannels(commands.Cog): return await _unclaim_channel(channel, claimant_id, closed_on) async def _unclaim_channel( - self, - channel: discord.TextChannel, - claimant_id: int, - closed_on: _channel.ClosingReason + self, + channel: discord.TextChannel, + claimant_id: int, + closed_on: _channel.ClosingReason ) -> None: """Actual implementation of `unclaim_channel`. See that for full documentation.""" await _caches.claimants.delete(channel.id) @@ -574,16 +574,14 @@ class HelpChannels(commands.Cog): if message.author.id not in session_participants: session_participants.add(message.author.id) - user = self.bot.get_user(message.author.id) - embed = discord.Embed( title="Currently Helping", description=f"You're currently helping in <#{message.channel.id}>", color=discord.Colour.green(), timestamp=message.created_at ) - embed.add_field(name="Conversation", value=f"[Jump to message]({message.message.jump_url})") - await user.send(embed=embed) + embed.add_field(name="Conversation", value=f"[Jump to message]({message.jump_url})") + await message.author.send(embed=embed) await _caches.session_participants.set( message.channel.id, -- cgit v1.2.3 From 0dfaf215206de4e3e392b74805eafce028172fbd Mon Sep 17 00:00:00 2001 From: Slushs Date: Tue, 8 Jun 2021 16:19:11 -0400 Subject: Make helpdm command more concise --- bot/exts/help_channels/_cog.py | 37 ++++++++++--------------------------- 1 file changed, 10 insertions(+), 27 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 71dfc2c78..27cf10796 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -592,40 +592,23 @@ class HelpChannels(commands.Cog): async def helpdm_command( self, ctx: commands.Context, - state: allowed_strings("on", "off") = None # noqa: F821 + state: allowed_strings("on", "off") # noqa: F821 ) -> None: """ Allows user to toggle "Helping" dms. - If this is set to off the user will not receive a dm for channel that they are participating in. If this is set to on the user will receive a dm for the channel they are participating in. - """ - state_bool = state.lower() == "on" + If this is set to off the user will not receive a dm for channel that they are participating in. + """ requested_state_bool = state.lower() == "on" - if requested_state_bool == await _caches.help_dm.get(ctx.author.id, False): - if await _caches.help_dm.get(ctx.author.id): - await ctx.send(f"{constants.Emojis.cross_mark}{ctx.author.mention} Help DMs are already ON!") - - log.trace(f"{ctx.author.id} Attempted to turn Help DMs on but they are already ON") - return - - if not await _caches.help_dm.get(ctx.author.id): - await ctx.send(f"{constants.Emojis.cross_mark}{ctx.author.mention} Help DMs are already OFF!") - - log.trace(f"{ctx.author.id} Attempted to turn Help DMs on but they are already OFF") - return - - if state_bool: - await _caches.help_dm.set(ctx.author.id, True) - await ctx.send(f"{constants.Emojis.ok_hand} {ctx.author.mention} Help DMs ON!") - - log.trace(f"{ctx.author.id} Help DMs ON") + if requested_state_bool == await _caches.help_dm.get(ctx.author.id, False): + await ctx.send(f"{constants.Emojis.cross_mark} {ctx.author.mention} Help DMs are already {state.upper()}") return - await _caches.help_dm.delete(ctx.author.id) - - await ctx.send(f"{constants.Emojis.ok_hand} {ctx.author.mention} Help DMs OFF!") - - log.trace(f"{ctx.author.id} Help DMs OFF") + if requested_state_bool: + await _caches.help_dm.set(ctx.author.id, True) + else: + await _caches.help_dm.delete(ctx.author.id) + await ctx.send(f"{constants.Emojis.ok_hand} {ctx.author.mention} Help DMs {state.upper()}!") -- cgit v1.2.3 From f9d57b423d8baefab9514b67bbe96c98172efe0f Mon Sep 17 00:00:00 2001 From: Slushs Date: Tue, 8 Jun 2021 16:21:20 -0400 Subject: Fix reverted change --- bot/exts/help_channels/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 27cf10796..8ceff624b 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -576,7 +576,7 @@ class HelpChannels(commands.Cog): embed = discord.Embed( title="Currently Helping", - description=f"You're currently helping in <#{message.channel.id}>", + description=f"You're currently helping in {message.channel.mention}", color=discord.Colour.green(), timestamp=message.created_at ) -- cgit v1.2.3 From 6b98aaf27b5e858f4ed4b632944b664d8e67b132 Mon Sep 17 00:00:00 2001 From: Slushs Date: Tue, 8 Jun 2021 20:21:33 -0400 Subject: Change discord.py colour to constants colour --- bot/exts/help_channels/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 8ceff624b..99e530b66 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -577,7 +577,7 @@ class HelpChannels(commands.Cog): embed = discord.Embed( title="Currently Helping", description=f"You're currently helping in {message.channel.mention}", - color=discord.Colour.green(), + color=constants.Colours.soft_green, timestamp=message.created_at ) embed.add_field(name="Conversation", value=f"[Jump to message]({message.jump_url})") -- cgit v1.2.3 From 05fbc0af6e4cb6b58432d0b7c111ca6b2db5ee57 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Thu, 10 Jun 2021 16:36:41 +0530 Subject: Add modpings schedule feature --- bot/exts/moderation/modpings.py | 82 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 1ad5005de..17afe3b77 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -1,8 +1,9 @@ +import asyncio import datetime import logging from async_rediscache import RedisCache -from dateutil.parser import isoparse +from dateutil.parser import isoparse, parse from discord import Embed, Member from discord.ext.commands import Cog, Context, group, has_any_role @@ -13,6 +14,7 @@ from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) +MINIMUM_WORK_LIMIT = 16 class ModPings(Cog): """Commands for a moderator to turn moderator pings on and off.""" @@ -22,6 +24,12 @@ class ModPings(Cog): # The cache's values are the times when the role should be re-applied to them, stored in ISO format. pings_off_mods = RedisCache() + + # RedisCache[discord.Member.id, 'start timestamp|total worktime in seconds'] + # The cache's keys are mod's ID + # The cache's values are their pings on schedule timestamp and the total seconds (work time) until pings off + modpings_schedule = RedisCache() + def __init__(self, bot: Bot): self.bot = bot self._role_scheduler = Scheduler(self.__class__.__name__) @@ -30,6 +38,7 @@ class ModPings(Cog): self.moderators_role = None self.reschedule_task = self.bot.loop.create_task(self.reschedule_roles(), name="mod-pings-reschedule") + self.modpings_schedule_task = self.bot.loop.create_task(self.reschedule_modpings_schedule()) async def reschedule_roles(self) -> None: """Reschedule moderators role re-apply times.""" @@ -55,6 +64,50 @@ class ModPings(Cog): expiry = isoparse(pings_off[mod.id]).replace(tzinfo=None) self._role_scheduler.schedule_at(expiry, mod.id, self.reapply_role(mod)) + async def reschedule_modpings_schedule(self) -> None: + """Reschedule moderators schedule ping.""" + await self.bot.wait_until_guild_available() + schedule_cache = await self.modpings_schedule.to_dict() + + log.info("Scheduling modpings schedule for applicable moderators found in cache.") + for mod_id, schedule in schedule_cache: + start_timestamp, work_time = schedule.split("|") + start = datetime.datetime.fromtimestamp(start_timestamp) + + mod = self.bot.fetch_user(mod_id) + self._role_scheduler.schedule_at( + start, + mod_id, + self.add_role_schedule(mod, work_time, start) + ) + + async def remove_role_schedule(self, mod: Member, work_time: int, schedule_start: datetime.datetime) -> None: + """Removes the moderator's role to the given moderator.""" + log.trace(f"Removing moderator role to mod with ID {mod.id}") + await mod.remove_roles(self.moderators_role, reason="Moderator schedule time expired.") + + # Add the task again + log.trace(f"Adding mod pings schedule task again for mod with ID {mod.id}") + schedule_start += datetime.timedelta(minutes=1) + self._role_scheduler.schedule_at( + schedule_start, + mod.id, + self.add_role_schedule(mod, work_time, schedule_start) + ) + + async def add_role_schedule(self, mod: Member, work_time: int, schedule_start: datetime.datetime) -> None: + """Adds the moderator's role to the given moderator.""" + # If the moderator has pings off, then skip adding role + if mod in await self.pings_off_mods.to_dict(): + log.trace(f"Skipping adding moderator role to mod with ID {mod.id}") + else: + log.trace(f"Applying moderator role to mod with ID {mod.id}") + await mod.add_roles(self.moderators_role, reason="Moderator schedule time started!") + + log.trace(f"Sleeping for {work_time} seconds, worktime for mod with ID {mod.id}") + await asyncio.sleep(work_time) + await self.remove_role_schedule(mod, work_time, schedule_start) + async def reapply_role(self, mod: Member) -> None: """Reapply the moderator's role to the given moderator.""" log.trace(f"Re-applying role to mod with ID {mod.id}.") @@ -126,6 +179,33 @@ class ModPings(Cog): await ctx.send(f"{Emojis.check_mark} Moderators role has been re-applied.") + @modpings_group.command(name='schedule') + @has_any_role(*MODERATION_ROLES) + async def schedule_modpings(self, ctx: Context, start: str, end: str) -> None: + start, end = parse(start), parse(end) + + if end < start: + end += datetime.timedelta(days=1) + + if (end - start) < datetime.timedelta(hours=MINIMUM_WORK_LIMIT): + await ctx.send(f":x: {ctx.author.mention} You need to have the role on for a minimum of {MINIMUM_WORK_LIMIT} hours!") + return + + start, end = start.replace(tzinfo=None), end.replace(tzinfo=None) + work_time = (end - start).total_seconds() + + await self.modpings_schedule.set(ctx.author.id, f"{start.timestamp()}|{work_time}") + + self._role_scheduler.schedule_at( + start, + ctx.author.id, + self.add_role_schedule(ctx.author, work_time, start) + ) + + await ctx.send( + f"{Emojis.ok_hand} {ctx.author.mention} Scheduled mod pings from {start: %I:%M%p} to {end: %I:%M%p} UTC Timing!" + ) + def cog_unload(self) -> None: """Cancel role tasks when the cog unloads.""" log.trace("Cog unload: canceling role tasks.") -- cgit v1.2.3 From 1d7527f501f81a826c16e3024ac1f90c7ee7e6bc Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 10 Jun 2021 14:59:48 +0200 Subject: Infraction: DM mention that the expiration is in UTC time We have a few users DMing ModMail to ask why they haven't been unmuted and their mute should have expired. Most of the time it is simply that they forgot to convert their local time to UTC time. This can hopefully avoid some of those instances. --- bot/exts/moderation/infraction/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index a98b4828b..e4eb7f79c 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -164,7 +164,7 @@ async def notify_infraction( text = INFRACTION_DESCRIPTION_TEMPLATE.format( type=infr_type.title(), - expires=expires_at or "N/A", + expires=f"{expires_at} UTC" if expires_at else "N/A", reason=reason or "No reason provided." ) -- cgit v1.2.3 From e75a46a4e8facec815ec374a12eaf400a404ee9c Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 10 Jun 2021 15:26:42 +0200 Subject: Tests: update infraction DM to mention UTC --- tests/bot/exts/moderation/infraction/test_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index ee9ff650c..50a717bb5 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -137,7 +137,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): title=utils.INFRACTION_TITLE, description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Ban", - expires="2020-02-26 09:20 (23 hours and 59 minutes)", + expires="2020-02-26 09:20 (23 hours and 59 minutes) UTC", reason="No reason provided." ), colour=Colours.soft_red, @@ -193,7 +193,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): title=utils.INFRACTION_TITLE, description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Mute", - expires="2020-02-26 09:20 (23 hours and 59 minutes)", + expires="2020-02-26 09:20 (23 hours and 59 minutes) UTC", reason="Test" ), colour=Colours.soft_red, -- cgit v1.2.3 From 6d801a6500692302351af0e1af9d1519444bfc19 Mon Sep 17 00:00:00 2001 From: Slushs Date: Thu, 10 Jun 2021 10:21:40 -0400 Subject: Edit ignore close messages if statement --- bot/exts/help_channels/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 99e530b66..dff5198a9 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -564,7 +564,7 @@ class HelpChannels(commands.Cog): if not await _caches.help_dm.get(message.author.id): return # Ignore message if user is opted out of help dms - if message.content == f"{self.bot.command_prefix}close": + if (await self.bot.get_context(message)).command == self.close_command: return # Ignore messages that are closing the channel session_participants = self._deserialise_session_participants( -- cgit v1.2.3 From a97f0680e70769e4a59015bf5e791198936a9c7b Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Fri, 11 Jun 2021 07:19:04 +0530 Subject: (modpings): Cancel the task before scheduling it again --- bot/exts/moderation/modpings.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 17afe3b77..2aff2ded2 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -86,6 +86,9 @@ class ModPings(Cog): log.trace(f"Removing moderator role to mod with ID {mod.id}") await mod.remove_roles(self.moderators_role, reason="Moderator schedule time expired.") + # Remove the task before scheduling it again + self._role_scheduler.cancel(mod.id) + # Add the task again log.trace(f"Adding mod pings schedule task again for mod with ID {mod.id}") schedule_start += datetime.timedelta(minutes=1) -- cgit v1.2.3 From 547f8837cf6f8cabcdac4209c373bc68776c3fc4 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Fri, 11 Jun 2021 07:50:05 +0530 Subject: (modpings): Use separate scheduler for modpings schedule --- bot/exts/moderation/modpings.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 2aff2ded2..f5ce9160d 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -32,7 +32,8 @@ class ModPings(Cog): def __init__(self, bot: Bot): self.bot = bot - self._role_scheduler = Scheduler(self.__class__.__name__) + self._role_scheduler = Scheduler("ModPingsOnOff") + self._modpings_scheduler = Scheduler("ModPingsSchedule") self.guild = None self.moderators_role = None @@ -75,7 +76,7 @@ class ModPings(Cog): start = datetime.datetime.fromtimestamp(start_timestamp) mod = self.bot.fetch_user(mod_id) - self._role_scheduler.schedule_at( + self._modpings_scheduler.schedule_at( start, mod_id, self.add_role_schedule(mod, work_time, start) @@ -87,12 +88,12 @@ class ModPings(Cog): await mod.remove_roles(self.moderators_role, reason="Moderator schedule time expired.") # Remove the task before scheduling it again - self._role_scheduler.cancel(mod.id) + self._modpings_scheduler.cancel(mod.id) # Add the task again log.trace(f"Adding mod pings schedule task again for mod with ID {mod.id}") schedule_start += datetime.timedelta(minutes=1) - self._role_scheduler.schedule_at( + self._modpings_scheduler.schedule_at( schedule_start, mod.id, self.add_role_schedule(mod, work_time, schedule_start) @@ -101,8 +102,8 @@ class ModPings(Cog): async def add_role_schedule(self, mod: Member, work_time: int, schedule_start: datetime.datetime) -> None: """Adds the moderator's role to the given moderator.""" # If the moderator has pings off, then skip adding role - if mod in await self.pings_off_mods.to_dict(): - log.trace(f"Skipping adding moderator role to mod with ID {mod.id}") + if mod.id in await self.pings_off_mods.to_dict(): + log.trace(f"Skipping adding moderator role to mod with ID {mod.id} - found in pings off cache.") else: log.trace(f"Applying moderator role to mod with ID {mod.id}") await mod.add_roles(self.moderators_role, reason="Moderator schedule time started!") @@ -199,7 +200,7 @@ class ModPings(Cog): await self.modpings_schedule.set(ctx.author.id, f"{start.timestamp()}|{work_time}") - self._role_scheduler.schedule_at( + self._modpings_scheduler.schedule_at( start, ctx.author.id, self.add_role_schedule(ctx.author, work_time, start) -- cgit v1.2.3 From f8fa9ba626a404aa825b3554ba136cf4196bd87c Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Fri, 11 Jun 2021 07:54:09 +0530 Subject: (modpings): Make flake8 happy! --- bot/exts/moderation/modpings.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index f5ce9160d..1154bce9c 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -16,6 +16,7 @@ log = logging.getLogger(__name__) MINIMUM_WORK_LIMIT = 16 + class ModPings(Cog): """Commands for a moderator to turn moderator pings on and off.""" @@ -24,7 +25,6 @@ class ModPings(Cog): # The cache's values are the times when the role should be re-applied to them, stored in ISO format. pings_off_mods = RedisCache() - # RedisCache[discord.Member.id, 'start timestamp|total worktime in seconds'] # The cache's keys are mod's ID # The cache's values are their pings on schedule timestamp and the total seconds (work time) until pings off @@ -186,13 +186,17 @@ class ModPings(Cog): @modpings_group.command(name='schedule') @has_any_role(*MODERATION_ROLES) async def schedule_modpings(self, ctx: Context, start: str, end: str) -> None: + """Schedule modpings role to be added at and removed at everyday at UTC time!""" start, end = parse(start), parse(end) if end < start: end += datetime.timedelta(days=1) if (end - start) < datetime.timedelta(hours=MINIMUM_WORK_LIMIT): - await ctx.send(f":x: {ctx.author.mention} You need to have the role on for a minimum of {MINIMUM_WORK_LIMIT} hours!") + await ctx.send( + f":x: {ctx.author.mention} You need to have the role on for " + f"a minimum of {MINIMUM_WORK_LIMIT} hours!" + ) return start, end = start.replace(tzinfo=None), end.replace(tzinfo=None) @@ -207,7 +211,8 @@ class ModPings(Cog): ) await ctx.send( - f"{Emojis.ok_hand} {ctx.author.mention} Scheduled mod pings from {start: %I:%M%p} to {end: %I:%M%p} UTC Timing!" + f"{Emojis.ok_hand} {ctx.author.mention} Scheduled mod pings from " + f"{start: %I:%M%p} to {end: %I:%M%p} UTC Timing!" ) def cog_unload(self) -> None: -- cgit v1.2.3 From 9bf22be683fa6e1b6ae542855e983bf360ed1b20 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Fri, 11 Jun 2021 16:04:21 +0530 Subject: (modpings): Cancel scheduler and tasks on cog unload --- bot/exts/moderation/modpings.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 1154bce9c..bafd40580 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -221,6 +221,9 @@ class ModPings(Cog): self.reschedule_task.cancel() self._role_scheduler.cancel_all() + self.modpings_schedule_task.cancel() + self._modpings_scheduler.cancel_all() + def setup(bot: Bot) -> None: """Load the ModPings cog.""" -- cgit v1.2.3 From c26a03a90a7d7d92fc07a1074965371d007428d8 Mon Sep 17 00:00:00 2001 From: Richard Si <63936253+ichard26@users.noreply.github.com> Date: Fri, 11 Jun 2021 18:03:16 -0400 Subject: Add black-formatter to reminders overrides Adds the black-formatter channel to the remind command overrides to allow usage of the command in the channel. This isn't the cleanest patch, ideally the whole OSS category would be whitelisted but the filter currently doesn't support categories. Co-authored-by: Hassan Abouelela --- bot/constants.py | 2 ++ config-default.yml | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index ab55da482..3d960f22b 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -433,6 +433,8 @@ class Channels(metaclass=YAMLGetter): off_topic_1: int off_topic_2: int + black_formatter: int + bot_commands: int discord_py: int esoteric: int diff --git a/config-default.yml b/config-default.yml index 55388247c..48fd7c47e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -176,6 +176,9 @@ guild: user_log: 528976905546760203 voice_log: 640292421988646961 + # Open Source Projects + black_formatter: &BLACK_FORMATTER 846434317021741086 + # Off-topic off_topic_0: 291284109232308226 off_topic_1: 463035241142026251 @@ -244,6 +247,7 @@ guild: reminder_whitelist: - *BOT_CMD - *DEV_CONTRIB + - *BLACK_FORMATTER roles: announcements: 463658397560995840 -- cgit v1.2.3 From 0b726398d2135ab414374412fa85f791569e3640 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sat, 12 Jun 2021 16:07:24 +0200 Subject: Add an optional loop kwarg to scheduling.create_task Before this change, the create_task util couldn't be used to schedule tasks from the init of cogs, as it relied on asyncio.create_task that uses the currently running loop to create the task. The loop kwarg allows the caller to pass the loop itself if there's no running loop yet. --- bot/utils/scheduling.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 2dc485f24..b99874508 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -161,9 +161,21 @@ class Scheduler: self._log.error(f"Error in task #{task_id} {id(done_task)}!", exc_info=exception) -def create_task(coro: t.Awaitable, *suppressed_exceptions: t.Type[Exception], **kwargs) -> asyncio.Task: - """Wrapper for `asyncio.create_task` which logs exceptions raised in the task.""" - task = asyncio.create_task(coro, **kwargs) +def create_task( + coro: t.Awaitable, + *suppressed_exceptions: t.Type[Exception], + event_loop: asyncio.AbstractEventLoop = None, + **kwargs +) -> asyncio.Task: + """ + Wrapper for creating asyncio `Task`s which logs exceptions raised in the task. + + If the loop kwarg is provided, the task is created from that event loop, otherwise the running loop is used. + """ + if event_loop is not None: + task = event_loop.create_task(coro, **kwargs) + else: + task = asyncio.create_task(coro, **kwargs) task.add_done_callback(partial(_log_task_exception, suppressed_exceptions=suppressed_exceptions)) return task -- cgit v1.2.3 From e2064b4f8831495472a5e410295bacc07b9da6b8 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 12 Jun 2021 19:46:31 +0300 Subject: Uses .coveragerc File Signed-off-by: Hassan Abouelela --- .coveragerc | 5 +++++ .github/workflows/lint-test.yml | 2 +- pyproject.toml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..d572bd705 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,5 @@ +[run] +branch = true +source = + bot + tests diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index 35e02f0d3..512e30771 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -99,7 +99,7 @@ jobs: - name: Run tests and generate coverage report run: | - pytest -n auto --cov bot --cov tests --disable-warnings -q + pytest -n auto --cov --disable-warnings -q # This step will publish the coverage reports coveralls.io and # print a "job" link in the output of the GitHub Action diff --git a/pyproject.toml b/pyproject.toml index 12c37348f..652af0c55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,6 @@ precommit = "pre-commit install" build = "docker build -t ghcr.io/python-discord/bot:latest -f Dockerfile ." push = "docker push ghcr.io/python-discord/bot:latest" fast-test = "pytest -n auto" -test = "pytest -n auto --cov-report= --cov bot --cov tests" +test = "pytest -n auto --cov-report= --cov" html = "coverage html" report = "coverage report" -- cgit v1.2.3 From 6e775d01174dc359929b93951fa8d6e7067563e3 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sat, 12 Jun 2021 18:49:20 +0200 Subject: Add Optional typehint to parameter The indentation was also dedented one level and a trailing comma added to be consistent over the project Co-authored-by: ToxicKidz --- bot/utils/scheduling.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index b99874508..d3704b7d1 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -162,10 +162,10 @@ class Scheduler: def create_task( - coro: t.Awaitable, - *suppressed_exceptions: t.Type[Exception], - event_loop: asyncio.AbstractEventLoop = None, - **kwargs + coro: t.Awaitable, + *suppressed_exceptions: t.Type[Exception], + event_loop: t.Optional[asyncio.AbstractEventLoop] = None, + **kwargs, ) -> asyncio.Task: """ Wrapper for creating asyncio `Task`s which logs exceptions raised in the task. -- cgit v1.2.3 From c2122316e5a34a2b9776e5e965a9434e748ab601 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sat, 12 Jun 2021 19:01:32 +0200 Subject: Move the suppressed_exceptions argument to an optional kwarg Forcing it to be passed as a kwarg makes it clearer what the exceptions are for from the caller's side. --- bot/utils/messages.py | 2 +- bot/utils/scheduling.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index b6f6c1f66..d4a921161 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -54,7 +54,7 @@ def reaction_check( log.trace(f"Removing reaction {reaction} by {user} on {reaction.message.id}: disallowed user.") scheduling.create_task( reaction.message.remove_reaction(reaction.emoji, user), - HTTPException, # Suppress the HTTPException if adding the reaction fails + suppressed_exceptions=(HTTPException,), name=f"remove_reaction-{reaction}-{reaction.message.id}-{user}" ) return False diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index d3704b7d1..bb83b5c0d 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -163,7 +163,8 @@ class Scheduler: def create_task( coro: t.Awaitable, - *suppressed_exceptions: t.Type[Exception], + *, + suppressed_exceptions: tuple[t.Type[Exception]] = (), event_loop: t.Optional[asyncio.AbstractEventLoop] = None, **kwargs, ) -> asyncio.Task: -- cgit v1.2.3 From 63682ce11a9fca7903ac78ca50adfc7f99fb220a Mon Sep 17 00:00:00 2001 From: Slushs Date: Sat, 12 Jun 2021 13:06:47 -0400 Subject: Add bool converter to allow a variety of inputs --- bot/exts/help_channels/_cog.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index dff5198a9..9352689ab 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -12,7 +12,6 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.converters import allowed_strings from bot.exts.help_channels import _caches, _channel, _message, _name, _stats from bot.utils import channel as channel_utils, lock, scheduling @@ -418,10 +417,10 @@ class HelpChannels(commands.Cog): return await _unclaim_channel(channel, claimant_id, closed_on) async def _unclaim_channel( - self, - channel: discord.TextChannel, - claimant_id: int, - closed_on: _channel.ClosingReason + self, + channel: discord.TextChannel, + claimant_id: int, + closed_on: _channel.ClosingReason ) -> None: """Actual implementation of `unclaim_channel`. See that for full documentation.""" await _caches.claimants.delete(channel.id) @@ -592,7 +591,7 @@ class HelpChannels(commands.Cog): async def helpdm_command( self, ctx: commands.Context, - state: allowed_strings("on", "off") # noqa: F821 + state_bool: bool ) -> None: """ Allows user to toggle "Helping" dms. @@ -601,14 +600,14 @@ class HelpChannels(commands.Cog): If this is set to off the user will not receive a dm for channel that they are participating in. """ - requested_state_bool = state.lower() == "on" + state_str = "ON" if state_bool else "OFF" - if requested_state_bool == await _caches.help_dm.get(ctx.author.id, False): - await ctx.send(f"{constants.Emojis.cross_mark} {ctx.author.mention} Help DMs are already {state.upper()}") + if state_bool == await _caches.help_dm.get(ctx.author.id, False): + await ctx.send(f"{constants.Emojis.cross_mark} {ctx.author.mention} Help DMs are already {state_str}") return - if requested_state_bool: + if state_bool: await _caches.help_dm.set(ctx.author.id, True) else: await _caches.help_dm.delete(ctx.author.id) - await ctx.send(f"{constants.Emojis.ok_hand} {ctx.author.mention} Help DMs {state.upper()}!") + await ctx.send(f"{constants.Emojis.ok_hand} {ctx.author.mention} Help DMs {state_str}!") -- cgit v1.2.3 From 4b73cc28d4d2048580e5b90dd8044c46a1e04979 Mon Sep 17 00:00:00 2001 From: Slushs Date: Sat, 12 Jun 2021 13:38:27 -0400 Subject: Add bool converter to allow a variety of inputs --- bot/exts/help_channels/_cog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 9352689ab..b8296fa76 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -417,10 +417,10 @@ class HelpChannels(commands.Cog): return await _unclaim_channel(channel, claimant_id, closed_on) async def _unclaim_channel( - self, - channel: discord.TextChannel, - claimant_id: int, - closed_on: _channel.ClosingReason + self, + channel: discord.TextChannel, + claimant_id: int, + closed_on: _channel.ClosingReason ) -> None: """Actual implementation of `unclaim_channel`. See that for full documentation.""" await _caches.claimants.delete(channel.id) -- cgit v1.2.3 From 1b65c65a0eda9a7cc17a9f3e0d04d55561721fa1 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 12 Jun 2021 21:00:28 +0300 Subject: Rename Test Task Co-authored-by: Mark --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 652af0c55..40de23487 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ lint = "pre-commit run --all-files" precommit = "pre-commit install" build = "docker build -t ghcr.io/python-discord/bot:latest -f Dockerfile ." push = "docker push ghcr.io/python-discord/bot:latest" -fast-test = "pytest -n auto" +test-nocov = "pytest -n auto" test = "pytest -n auto --cov-report= --cov" html = "coverage html" report = "coverage report" -- cgit v1.2.3 From 9bb61c4c5bde51e5074f568a2f2f563c32c4780c Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 12 Jun 2021 21:02:36 +0300 Subject: Removes Redundant Line Break Signed-off-by: Hassan Abouelela --- .github/workflows/lint-test.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index 512e30771..e99e6d181 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -98,8 +98,7 @@ jobs: [flake8] %(code)s: %(text)s'" - name: Run tests and generate coverage report - run: | - pytest -n auto --cov --disable-warnings -q + run: pytest -n auto --cov --disable-warnings -q # This step will publish the coverage reports coveralls.io and # print a "job" link in the output of the GitHub Action -- cgit v1.2.3 From 43659c7bac2e8127df402c97bbc3a26edf8256b6 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 12 Jun 2021 21:12:31 +0300 Subject: Renamed Test Task In Documentation Signed-off-by: Hassan Abouelela --- tests/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/README.md b/tests/README.md index 339108951..0192f916e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -18,7 +18,7 @@ We also use the following package as a test runner: To ensure the results you obtain on your personal machine are comparable to those generated in the CI, please make sure to run your tests with the virtual environment defined by our [Poetry Project](/pyproject.toml). To run your tests with `poetry`, we've provided the following "script" shortcuts: -- `poetry run task fast-test` will run `pytest`. +- `poetry run task test-nocov` will run `pytest`. - `poetry run task test` will run `pytest` with `pytest-cov`. - `poetry run task test path/to/test.py` will run a specific test. - `poetry run task report` will generate a coverage report of the tests you've run with `poetry run task test`. If you append the `-m` flag to this command, the report will include the lines and branches not covered by tests in addition to the test coverage report. -- cgit v1.2.3 From a2ecf1c3e78a8c0c6803c8e85ee5691b5847bfa2 Mon Sep 17 00:00:00 2001 From: Jake <77035482+JakeM0001@users.noreply.github.com> Date: Sat, 12 Jun 2021 16:11:35 -0400 Subject: Edit indentation Co-authored-by: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> --- bot/exts/help_channels/_cog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index b8296fa76..7fb72d7ef 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -589,9 +589,9 @@ class HelpChannels(commands.Cog): @commands.command(name="helpdm") async def helpdm_command( - self, - ctx: commands.Context, - state_bool: bool + self, + ctx: commands.Context, + state_bool: bool ) -> None: """ Allows user to toggle "Helping" dms. -- cgit v1.2.3 From d1a2d9965fadc5292685d3ca78a75fd16f07af26 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Sat, 12 Jun 2021 14:08:52 -0700 Subject: Switched back to default value getting. --- bot/exts/recruitment/talentpool/_review.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 585640699..4e61ecb3e 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -65,7 +65,7 @@ class Reviewer: """Schedules a single user for review.""" log.trace(f"Scheduling review of user with ID {user_id}") - user_data = self._pool.cache[user_id] + user_data = self._pool.cache.get(user_id) inserted_at = isoparse(user_data['inserted_at']).replace(tzinfo=None) review_at = inserted_at + timedelta(days=MAX_DAYS_IN_POOL) @@ -93,7 +93,7 @@ class Reviewer: await last_message.add_reaction(reaction) if update_database: - nomination = self._pool.cache[user_id] + nomination = self._pool.cache.get(user_id) await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji]]: @@ -104,7 +104,7 @@ class Reviewer: # not to accidentally insert the IDs of users that have no # active nominated by using the `watched_users.get(user_id)` # instead of `watched_users[user_id]`. - nomination = self._pool.cache[user_id] + nomination = self._pool.cache.get(user_id) if not nomination: log.trace(f"There doesn't appear to be an active nomination for {user_id}") return "", None @@ -393,7 +393,7 @@ class Reviewer: await ctx.send(f":x: Can't find a currently nominated user with id `{user_id}`") return False - nomination = self._pool.cache[user_id] + nomination = self._pool.cache.get(user_id) if nomination["reviewed"]: await ctx.send(":x: This nomination was already reviewed, but here's a cookie :cookie:") return False -- cgit v1.2.3 From fb4d167117843b317713fdd8945f49c6fca2e1e2 Mon Sep 17 00:00:00 2001 From: swfarnsworth Date: Sat, 12 Jun 2021 19:17:36 -0400 Subject: Proposed alternative "available help channel" message. These changes are intended to help people ask better questions from the onset of their help session. --- bot/exts/help_channels/_message.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index 4c7c39764..2dec9ce67 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -15,15 +15,13 @@ log = logging.getLogger(__name__) ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" AVAILABLE_MSG = f""" -**Send your question here to claim the channel** -This channel will be dedicated to answering your question only. Others will try to answer and help you solve the issue. +**Send your question here to claim the channel.** -**Keep in mind:** -• It's always ok to just ask your question. You don't need permission. -• Explain what you expect to happen and what actually happens. -• Include a code sample and error message, if you got any. +• **Ask your actual question, not if you can ask a question.** +• **Provide a code sample as text (not as a screenshot) and the error message, if you got one.** +• **Explain what you expect to happen and what actually happens.** -For more tips, check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**. +For more tips, check out our guide on [asking good questions]({ASKING_GUIDE_URL}). """ AVAILABLE_TITLE = "Available help channel" -- cgit v1.2.3 From 8e39e8bbc71e3964046b05022be9bc0060c6d75c Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 13 Jun 2021 07:25:34 +0530 Subject: (modpings): Use 24 hour format --- bot/exts/moderation/modpings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index bafd40580..0922d068f 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -212,7 +212,7 @@ class ModPings(Cog): await ctx.send( f"{Emojis.ok_hand} {ctx.author.mention} Scheduled mod pings from " - f"{start: %I:%M%p} to {end: %I:%M%p} UTC Timing!" + f"{start: %H:%M} to {end: %H:%M} UTC Timing!" ) def cog_unload(self) -> None: -- cgit v1.2.3 From 4aa45f6dd3ca0aba401731ff21c242df60fb2c94 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 13 Jun 2021 07:53:59 +0530 Subject: (modpings): Use scheduling.create_task wrapper --- bot/exts/moderation/modpings.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 0922d068f..f0a1ce590 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -10,7 +10,7 @@ from discord.ext.commands import Cog, Context, group, has_any_role from bot.bot import Bot from bot.constants import Colours, Emojis, Guild, Icons, MODERATION_ROLES, Roles from bot.converters import Expiry -from bot.utils.scheduling import Scheduler +from bot.utils.scheduling import Scheduler, create_task log = logging.getLogger(__name__) @@ -39,7 +39,7 @@ class ModPings(Cog): self.moderators_role = None self.reschedule_task = self.bot.loop.create_task(self.reschedule_roles(), name="mod-pings-reschedule") - self.modpings_schedule_task = self.bot.loop.create_task(self.reschedule_modpings_schedule()) + self.modpings_schedule_task = create_task(self.reschedule_modpings_schedule(), event_loop=self.bot.loop) async def reschedule_roles(self) -> None: """Reschedule moderators role re-apply times.""" @@ -92,7 +92,7 @@ class ModPings(Cog): # Add the task again log.trace(f"Adding mod pings schedule task again for mod with ID {mod.id}") - schedule_start += datetime.timedelta(minutes=1) + schedule_start += datetime.timedelta(day=1) self._modpings_scheduler.schedule_at( schedule_start, mod.id, @@ -192,12 +192,12 @@ class ModPings(Cog): if end < start: end += datetime.timedelta(days=1) - if (end - start) < datetime.timedelta(hours=MINIMUM_WORK_LIMIT): - await ctx.send( - f":x: {ctx.author.mention} You need to have the role on for " - f"a minimum of {MINIMUM_WORK_LIMIT} hours!" - ) - return + # if (end - start) < datetime.timedelta(hours=MINIMUM_WORK_LIMIT): + # await ctx.send( + # f":x: {ctx.author.mention} You need to have the role on for " + # f"a minimum of {MINIMUM_WORK_LIMIT} hours!" + # ) + # return start, end = start.replace(tzinfo=None), end.replace(tzinfo=None) work_time = (end - start).total_seconds() -- cgit v1.2.3 From ced656c7518e35626e17d165976283f3d894d722 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 13 Jun 2021 12:27:03 +0530 Subject: (modpings): 16 hours is the maximum schedule limit --- bot/exts/moderation/modpings.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index f0a1ce590..c0e742699 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -14,7 +14,7 @@ from bot.utils.scheduling import Scheduler, create_task log = logging.getLogger(__name__) -MINIMUM_WORK_LIMIT = 16 +MAXIMUM_WORK_LIMIT = 16 class ModPings(Cog): @@ -192,12 +192,12 @@ class ModPings(Cog): if end < start: end += datetime.timedelta(days=1) - # if (end - start) < datetime.timedelta(hours=MINIMUM_WORK_LIMIT): - # await ctx.send( - # f":x: {ctx.author.mention} You need to have the role on for " - # f"a minimum of {MINIMUM_WORK_LIMIT} hours!" - # ) - # return + if (end - start) > datetime.timedelta(hours=MAXIMUM_WORK_LIMIT): + await ctx.send( + f":x: {ctx.author.mention} You can't have the modpings role for" + f" more than {MAXIMUM_WORK_LIMIT} hours!" + ) + return start, end = start.replace(tzinfo=None), end.replace(tzinfo=None) work_time = (end - start).total_seconds() -- cgit v1.2.3 From 5b6500aa1630d774c13d40ef317b8b5e4f07d6da Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 13 Jun 2021 12:39:25 +0530 Subject: (modpings): Add subcommand to delete your modpings schedule --- bot/exts/moderation/modpings.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index c0e742699..9b7843e20 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -183,7 +183,11 @@ class ModPings(Cog): await ctx.send(f"{Emojis.check_mark} Moderators role has been re-applied.") - @modpings_group.command(name='schedule') + @modpings_group.group( + name='schedule', + aliases=('s',), + invoke_without_command=True + ) @has_any_role(*MODERATION_ROLES) async def schedule_modpings(self, ctx: Context, start: str, end: str) -> None: """Schedule modpings role to be added at and removed at everyday at UTC time!""" @@ -215,6 +219,14 @@ class ModPings(Cog): f"{start: %H:%M} to {end: %H:%M} UTC Timing!" ) + @schedule_modpings.command(name='delete', aliases=('del', 'd')) + async def modpings_schedule_delete(self, ctx: Context): + """Delete your modpings schedule.""" + self._modpings_scheduler.cancel(ctx.author.id) + await self.modpings_schedule.delete(ctx.author.id) + await ctx.send(f"{Emojis.ok_hand} {ctx.author.mention} Deleted your modpings schedule!") + + def cog_unload(self) -> None: """Cancel role tasks when the cog unloads.""" log.trace("Cog unload: canceling role tasks.") -- cgit v1.2.3 From 99e597584516c1495e165647e5b2c131e232175f Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 13 Jun 2021 13:20:08 +0530 Subject: (modpings): Add a day to datetime if already passed --- bot/exts/moderation/modpings.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 9b7843e20..207480a68 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -92,7 +92,7 @@ class ModPings(Cog): # Add the task again log.trace(f"Adding mod pings schedule task again for mod with ID {mod.id}") - schedule_start += datetime.timedelta(day=1) + schedule_start += datetime.timedelta(days=1) self._modpings_scheduler.schedule_at( schedule_start, mod.id, @@ -203,11 +203,19 @@ class ModPings(Cog): ) return + if start < datetime.datetime.utcnow(): + # The datetime has already gone for the day, so make it tomorrow + # otherwise the scheduler would schedule it immediately + start += datetime.timedelta(days=1) + start, end = start.replace(tzinfo=None), end.replace(tzinfo=None) work_time = (end - start).total_seconds() await self.modpings_schedule.set(ctx.author.id, f"{start.timestamp()}|{work_time}") + if ctx.author.id in self._modpings_scheduler: + self._modpings_scheduler.cancel(ctx.author.id) + self._modpings_scheduler.schedule_at( start, ctx.author.id, -- cgit v1.2.3 From b47228ee03b1f39b91209ed04d39e66c1bee9b54 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 13 Jun 2021 13:21:38 +0530 Subject: (modpings): Make flake8 happy! --- bot/exts/moderation/modpings.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 207480a68..cf45a2182 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -228,13 +228,12 @@ class ModPings(Cog): ) @schedule_modpings.command(name='delete', aliases=('del', 'd')) - async def modpings_schedule_delete(self, ctx: Context): + async def modpings_schedule_delete(self, ctx: Context) -> None: """Delete your modpings schedule.""" self._modpings_scheduler.cancel(ctx.author.id) await self.modpings_schedule.delete(ctx.author.id) await ctx.send(f"{Emojis.ok_hand} {ctx.author.mention} Deleted your modpings schedule!") - def cog_unload(self) -> None: """Cancel role tasks when the cog unloads.""" log.trace("Cog unload: canceling role tasks.") -- cgit v1.2.3 From efbce1cacd21accbb72c869d72abd4c628f17de8 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Sun, 13 Jun 2021 02:40:52 -0700 Subject: Added `python_community` and `partners` role havers to `!poll`. --- bot/exts/utils/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 4c39a7c2a..3b8564aee 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -40,6 +40,7 @@ If the implementation is hard to explain, it's a bad idea. If the implementation is easy to explain, it may be a good idea. Namespaces are one honking great idea -- let's do more of those! """ +LEADS_AND_COMMUNITY = (Roles.project_leads, Roles.domain_leads, Roles.partners, Roles.python_community) class Utils(Cog): @@ -185,7 +186,7 @@ class Utils(Cog): ) @command(aliases=("poll",)) - @has_any_role(*MODERATION_ROLES, Roles.project_leads, Roles.domain_leads) + @has_any_role(*MODERATION_ROLES, *LEADS_AND_COMMUNITY) async def vote(self, ctx: Context, title: clean_content(fix_channel_mentions=True), *options: str) -> None: """ Build a quick voting poll with matching reactions with the provided options. -- cgit v1.2.3 From 141ff51444570f275b69e102cd246073692f4560 Mon Sep 17 00:00:00 2001 From: swfarnsworth Date: Mon, 14 Jun 2021 11:05:06 -0400 Subject: Modified the proposed message after discussion in yesterday's staff meeting. --- bot/exts/help_channels/_message.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index 2dec9ce67..befacd263 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -15,11 +15,12 @@ log = logging.getLogger(__name__) ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" AVAILABLE_MSG = f""" -**Send your question here to claim the channel.** +Send your question here to claim the channel. -• **Ask your actual question, not if you can ask a question.** -• **Provide a code sample as text (not as a screenshot) and the error message, if you got one.** -• **Explain what you expect to happen and what actually happens.** +**Remember to:** +• **Ask** your Python question, not if you can ask or if there's an expert who can help. +• **Show** a code sample as text (rather than a screenshot) and the error message, if you got one. +• **Explain** what you expect to happen and what actually happens. For more tips, check out our guide on [asking good questions]({ASKING_GUIDE_URL}). """ -- cgit v1.2.3 From a9efe128df533281bf91c807a1a3cbabe5aab31b Mon Sep 17 00:00:00 2001 From: Fares Ahmed Date: Tue, 15 Jun 2021 16:39:31 +0000 Subject: Fix helpdm couldn't DM the user (#1640) --- bot/exts/help_channels/_cog.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 7fb72d7ef..5d9a6600a 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -14,6 +14,7 @@ from bot import constants from bot.bot import Bot from bot.exts.help_channels import _caches, _channel, _message, _name, _stats from bot.utils import channel as channel_utils, lock, scheduling +from bot.constants import Channels log = logging.getLogger(__name__) @@ -580,7 +581,24 @@ class HelpChannels(commands.Cog): timestamp=message.created_at ) embed.add_field(name="Conversation", value=f"[Jump to message]({message.jump_url})") - await message.author.send(embed=embed) + + try: + await message.author.send(embed=embed) + except discord.errors.ForBidden: + log.trace( + f"Failed to send helpdm message to {message.author.id}. DMs Closed/Blocked. " + "Removing user from helpdm." + ) + bot_commands_channel = self.bot.get_channel(Channels.bot_commands) + await _caches.help_dm.delete(message.author.id) + await bot_commands_channel.send( + f"Hi, {message.author.mention} {constants.Emojis.cross_mark}. " + f"Couldn't DM you helpdm message regarding {message.channel.mention} " + "because your DMs are closed. helpdm has been automatically turned to off. " + "to activate again type `!helpdm on`.", + delete_after=10 + ) + return await _caches.session_participants.set( message.channel.id, -- cgit v1.2.3 From 3d80b196a890bf1ec2024fdc7f624c1141a0a914 Mon Sep 17 00:00:00 2001 From: Fares Ahmed Date: Wed, 16 Jun 2021 05:53:29 +0000 Subject: Update helpdm error message Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/exts/help_channels/_cog.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 5d9a6600a..9168cc04a 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -592,10 +592,8 @@ class HelpChannels(commands.Cog): bot_commands_channel = self.bot.get_channel(Channels.bot_commands) await _caches.help_dm.delete(message.author.id) await bot_commands_channel.send( - f"Hi, {message.author.mention} {constants.Emojis.cross_mark}. " - f"Couldn't DM you helpdm message regarding {message.channel.mention} " - "because your DMs are closed. helpdm has been automatically turned to off. " - "to activate again type `!helpdm on`.", + f"{message.author.mention} {constants.Emojis.cross_mark} " + "To receive updates on help channels you're active in, enable your DMs." delete_after=10 ) return -- cgit v1.2.3 From 464c6599b2ea64b32e917074662d6723876fde21 Mon Sep 17 00:00:00 2001 From: Fares Ahmed Date: Wed, 16 Jun 2021 05:56:51 +0000 Subject: Fix wrong exception Co-authored-by: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> --- bot/exts/help_channels/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 9168cc04a..3af474592 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -584,7 +584,7 @@ class HelpChannels(commands.Cog): try: await message.author.send(embed=embed) - except discord.errors.ForBidden: + except discord.Forbidden: log.trace( f"Failed to send helpdm message to {message.author.id}. DMs Closed/Blocked. " "Removing user from helpdm." -- cgit v1.2.3 From 1b9500b5f7ea0c44ea5023bb264567b05bec1da6 Mon Sep 17 00:00:00 2001 From: FaresAhmedb Date: Wed, 16 Jun 2021 08:45:42 +0200 Subject: Use RedirectOutput.delete_delay --- bot/exts/help_channels/_cog.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 3af474592..ce1530bc9 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -12,9 +12,10 @@ from discord.ext import commands from bot import constants from bot.bot import Bot +from bot.constants import Channels +from bot.constants import RedirectOutput from bot.exts.help_channels import _caches, _channel, _message, _name, _stats from bot.utils import channel as channel_utils, lock, scheduling -from bot.constants import Channels log = logging.getLogger(__name__) @@ -593,8 +594,8 @@ class HelpChannels(commands.Cog): await _caches.help_dm.delete(message.author.id) await bot_commands_channel.send( f"{message.author.mention} {constants.Emojis.cross_mark} " - "To receive updates on help channels you're active in, enable your DMs." - delete_after=10 + "To receive updates on help channels you're active in, enable your DMs.", + delete_after=RedirectOutput.delete_after ) return -- cgit v1.2.3 From b9456b4047b658b868c5ea5a965c0610ce7d9736 Mon Sep 17 00:00:00 2001 From: Fares Ahmed Date: Wed, 16 Jun 2021 07:31:23 +0000 Subject: Update bot/exts/help_channels/_cog.py Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/exts/help_channels/_cog.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index ce1530bc9..a9b847582 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -12,8 +12,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot -from bot.constants import Channels -from bot.constants import RedirectOutput +from bot.constants import Channels, RedirectOutput from bot.exts.help_channels import _caches, _channel, _message, _name, _stats from bot.utils import channel as channel_utils, lock, scheduling -- cgit v1.2.3 From 4b971de3d470128cd680f6472f9569df4cc0b852 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Fri, 18 Jun 2021 20:49:24 +0300 Subject: Add mods channel to config explicitly --- config-default.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config-default.yml b/config-default.yml index 48fd7c47e..863a4535e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -198,6 +198,7 @@ guild: incidents: 714214212200562749 incidents_archive: 720668923636351037 mod_alerts: 473092532147060736 + mods: &MODS 305126844661760000 nominations: 822920136150745168 nomination_voting: 822853512709931008 organisation: &ORGANISATION 551789653284356126 @@ -234,6 +235,7 @@ guild: moderation_channels: - *ADMINS - *ADMIN_SPAM + - *MODS # Modlog cog ignores events which occur in these channels modlog_blacklist: -- cgit v1.2.3 From 45dbf0650b45dd6704dedada636d6330ea5efd59 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 18 Jun 2021 19:50:09 +0100 Subject: Update discord.py 1.7.3 This is needed as 1.7 added support for stage channels --- poetry.lock | 177 ++++++++++++++++++++++++++++++--------------------------- pyproject.toml | 2 +- 2 files changed, 95 insertions(+), 84 deletions(-) diff --git a/poetry.lock b/poetry.lock index ba8b7af4b..e2d39c587 100644 --- a/poetry.lock +++ b/poetry.lock @@ -155,7 +155,7 @@ lxml = ["lxml"] [[package]] name = "certifi" -version = "2020.12.5" +version = "2021.5.30" description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = false @@ -174,7 +174,7 @@ pycparser = "*" [[package]] name = "cfgv" -version = "3.2.0" +version = "3.3.0" description = "Validate configuration and produce human readable error messages." category = "dev" optional = false @@ -253,7 +253,7 @@ murmur = ["mmh3"] [[package]] name = "discord.py" -version = "1.6.0" +version = "1.7.3" description = "A Python wrapper for the Discord API" category = "main" optional = false @@ -268,7 +268,7 @@ voice = ["PyNaCl (>=1.3.0,<1.5)"] [[package]] name = "distlib" -version = "0.3.1" +version = "0.3.2" description = "Distribution utilities" category = "dev" optional = false @@ -295,7 +295,7 @@ dev = ["pytest", "coverage", "coveralls"] [[package]] name = "fakeredis" -version = "1.5.0" +version = "1.5.2" description = "Fake implementation of redis API for testing purposes." category = "main" optional = false @@ -307,12 +307,12 @@ six = ">=1.12" sortedcontainers = "*" [package.extras] -aioredis = ["aioredis"] +aioredis = ["aioredis (<2)"] lua = ["lupa"] [[package]] name = "feedparser" -version = "6.0.2" +version = "6.0.6" description = "Universal feed parser, handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds" category = "main" optional = false @@ -456,7 +456,7 @@ python-versions = ">=3.6" [[package]] name = "humanfriendly" -version = "9.1" +version = "9.2" description = "Human friendly output for text interfaces using Python" category = "main" optional = false @@ -467,7 +467,7 @@ pyreadline = {version = "*", markers = "sys_platform == \"win32\""} [[package]] name = "identify" -version = "2.2.4" +version = "2.2.10" description = "File identification library for Python" category = "dev" optional = false @@ -478,11 +478,11 @@ license = ["editdistance-s"] [[package]] name = "idna" -version = "3.1" +version = "3.2" description = "Internationalized Domain Names in Applications (IDNA)" category = "main" optional = false -python-versions = ">=3.4" +python-versions = ">=3.5" [[package]] name = "lxml" @@ -520,7 +520,7 @@ python-versions = "*" [[package]] name = "more-itertools" -version = "8.7.0" +version = "8.8.0" description = "More routines for operating on iterables, beyond itertools" category = "main" optional = false @@ -582,7 +582,7 @@ flake8-polyfill = ">=1.0.2,<2" [[package]] name = "pre-commit" -version = "2.12.1" +version = "2.13.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -609,7 +609,7 @@ test = ["ipaddress", "mock", "unittest2", "enum34", "pywin32", "wmi"] [[package]] name = "pycares" -version = "3.2.3" +version = "4.0.0" description = "Python interface for c-ares" category = "main" optional = false @@ -639,7 +639,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pydocstyle" -version = "6.0.0" +version = "6.1.1" description = "Python docstring style checker" category = "dev" optional = false @@ -648,6 +648,9 @@ python-versions = ">=3.6" [package.dependencies] snowballstemmer = "*" +[package.extras] +toml = ["toml"] + [[package]] name = "pyflakes" version = "2.3.1" @@ -794,7 +797,7 @@ python-versions = "*" [[package]] name = "sortedcontainers" -version = "2.3.0" +version = "2.4.0" description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" category = "main" optional = false @@ -847,20 +850,20 @@ python-versions = "*" [[package]] name = "urllib3" -version = "1.26.4" +version = "1.26.5" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" [package.extras] +brotli = ["brotlipy (>=0.6.0)"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] -brotli = ["brotlipy (>=0.6.0)"] [[package]] name = "virtualenv" -version = "20.4.6" +version = "20.4.7" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -891,7 +894,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "ece3b915901a62911ff7ff4a616b3972e815c0e1c7097c8994163af13cadde0e" +content-hash = "e9e1f46fc3ebf590d001bb98d836bcbb2aa884feb8d177796c2b49b4fe3e46e3" [metadata.files] aio-pika = [ @@ -979,8 +982,8 @@ beautifulsoup4 = [ {file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"}, ] certifi = [ - {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, - {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, + {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, + {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, ] cffi = [ {file = "cffi-1.14.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991"}, @@ -1022,8 +1025,8 @@ cffi = [ {file = "cffi-1.14.5.tar.gz", hash = "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c"}, ] cfgv = [ - {file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"}, - {file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"}, + {file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"}, + {file = "cfgv-3.3.0.tar.gz", hash = "sha256:9e600479b3b99e8af981ecdfc80a0296104ee610cab48a5ae4ffd0b668650eb1"}, ] chardet = [ {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, @@ -1100,12 +1103,12 @@ deepdiff = [ {file = "deepdiff-4.3.2.tar.gz", hash = "sha256:91360be1d9d93b1d9c13ae9c5048fa83d9cff17a88eb30afaa0d7ff2d0fee17d"}, ] "discord.py" = [ - {file = "discord.py-1.6.0-py3-none-any.whl", hash = "sha256:3df148daf6fbcc7ab5b11042368a3cd5f7b730b62f09fb5d3cbceff59bcfbb12"}, - {file = "discord.py-1.6.0.tar.gz", hash = "sha256:ba8be99ff1b8c616f7b6dcb700460d0222b29d4c11048e74366954c465fdd05f"}, + {file = "discord.py-1.7.3-py3-none-any.whl", hash = "sha256:c6f64db136de0e18e090f6752ea68bdd4ab0a61b82dfe7acecefa22d6477bb0c"}, + {file = "discord.py-1.7.3.tar.gz", hash = "sha256:462cd0fe307aef8b29cbfa8dd613e548ae4b2cb581d46da9ac0d46fb6ea19408"}, ] distlib = [ - {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"}, - {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"}, + {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, + {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"}, ] docopt = [ {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, @@ -1114,12 +1117,12 @@ emoji = [ {file = "emoji-0.6.0.tar.gz", hash = "sha256:e42da4f8d648f8ef10691bc246f682a1ec6b18373abfd9be10ec0b398823bd11"}, ] fakeredis = [ - {file = "fakeredis-1.5.0-py3-none-any.whl", hash = "sha256:e0416e4941cecd3089b0d901e60c8dc3c944f6384f5e29e2261c0d3c5fa99669"}, - {file = "fakeredis-1.5.0.tar.gz", hash = "sha256:1ac0cef767c37f51718874a33afb5413e69d132988cb6a80c6e6dbeddf8c7623"}, + {file = "fakeredis-1.5.2-py3-none-any.whl", hash = "sha256:f1ffdb134538e6d7c909ddfb4fc5edeb4a73d0ea07245bc69b8135fbc4144b04"}, + {file = "fakeredis-1.5.2.tar.gz", hash = "sha256:18fc1808d2ce72169d3f11acdb524a00ef96bd29970c6d34cfeb2edb3fc0c020"}, ] feedparser = [ - {file = "feedparser-6.0.2-py3-none-any.whl", hash = "sha256:f596c4b34fb3e2dc7e6ac3a8191603841e8d5d267210064e94d4238737452ddd"}, - {file = "feedparser-6.0.2.tar.gz", hash = "sha256:1b00a105425f492f3954fd346e5b524ca9cef3a4bbf95b8809470e9857aa1074"}, + {file = "feedparser-6.0.6-py3-none-any.whl", hash = "sha256:1c35e9ef43d8f95959cf8cfa337b68a2cb0888cab7cd982868d23850bb1e08ae"}, + {file = "feedparser-6.0.6.tar.gz", hash = "sha256:78f62a5b872fdef451502bb96e64a8fd4180535eb749954f1ad528604809cdeb"}, ] filelock = [ {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, @@ -1208,16 +1211,16 @@ hiredis = [ {file = "hiredis-2.0.0.tar.gz", hash = "sha256:81d6d8e39695f2c37954d1011c0480ef7cf444d4e3ae24bc5e89ee5de360139a"}, ] humanfriendly = [ - {file = "humanfriendly-9.1-py2.py3-none-any.whl", hash = "sha256:d5c731705114b9ad673754f3317d9fa4c23212f36b29bdc4272a892eafc9bc72"}, - {file = "humanfriendly-9.1.tar.gz", hash = "sha256:066562956639ab21ff2676d1fda0b5987e985c534fc76700a19bd54bcb81121d"}, + {file = "humanfriendly-9.2-py2.py3-none-any.whl", hash = "sha256:332da98c24cc150efcc91b5508b19115209272bfdf4b0764a56795932f854271"}, + {file = "humanfriendly-9.2.tar.gz", hash = "sha256:f7dba53ac7935fd0b4a2fc9a29e316ddd9ea135fb3052d3d0279d10c18ff9c48"}, ] identify = [ - {file = "identify-2.2.4-py2.py3-none-any.whl", hash = "sha256:ad9f3fa0c2316618dc4d840f627d474ab6de106392a4f00221820200f490f5a8"}, - {file = "identify-2.2.4.tar.gz", hash = "sha256:9bcc312d4e2fa96c7abebcdfb1119563b511b5e3985ac52f60d9116277865b2e"}, + {file = "identify-2.2.10-py2.py3-none-any.whl", hash = "sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421"}, + {file = "identify-2.2.10.tar.gz", hash = "sha256:5b41f71471bc738e7b586308c3fca172f78940195cb3bf6734c1e66fdac49306"}, ] idna = [ - {file = "idna-3.1-py3-none-any.whl", hash = "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16"}, - {file = "idna-3.1.tar.gz", hash = "sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1"}, + {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, + {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, ] lxml = [ {file = "lxml-4.6.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2"}, @@ -1276,8 +1279,8 @@ mccabe = [ {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] more-itertools = [ - {file = "more-itertools-8.7.0.tar.gz", hash = "sha256:c5d6da9ca3ff65220c3bfd2a8db06d698f05d4d2b9be57e1deb2be5a45019713"}, - {file = "more_itertools-8.7.0-py3-none-any.whl", hash = "sha256:5652a9ac72209ed7df8d9c15daf4e1aa0e3d2ccd3c87f8265a0673cd9cbc9ced"}, + {file = "more-itertools-8.8.0.tar.gz", hash = "sha256:83f0308e05477c68f56ea3a888172c78ed5d5b3c282addb67508e7ba6c8f813a"}, + {file = "more_itertools-8.8.0-py3-none-any.whl", hash = "sha256:2cf89ec599962f2ddc4d568a05defc40e0a587fbc10d5989713638864c36be4d"}, ] mslex = [ {file = "mslex-0.3.0-py2.py3-none-any.whl", hash = "sha256:380cb14abf8fabf40e56df5c8b21a6d533dc5cbdcfe42406bbf08dda8f42e42a"}, @@ -1338,8 +1341,8 @@ pep8-naming = [ {file = "pep8_naming-0.11.1-py2.py3-none-any.whl", hash = "sha256:f43bfe3eea7e0d73e8b5d07d6407ab47f2476ccaeff6937c84275cd30b016738"}, ] pre-commit = [ - {file = "pre_commit-2.12.1-py2.py3-none-any.whl", hash = "sha256:70c5ec1f30406250b706eda35e868b87e3e4ba099af8787e3e8b4b01e84f4712"}, - {file = "pre_commit-2.12.1.tar.gz", hash = "sha256:900d3c7e1bf4cf0374bb2893c24c23304952181405b4d88c9c40b72bda1bb8a9"}, + {file = "pre_commit-2.13.0-py2.py3-none-any.whl", hash = "sha256:b679d0fddd5b9d6d98783ae5f10fd0c4c59954f375b70a58cbe1ce9bcf9809a4"}, + {file = "pre_commit-2.13.0.tar.gz", hash = "sha256:764972c60693dc668ba8e86eb29654ec3144501310f7198742a767bec385a378"}, ] psutil = [ {file = "psutil-5.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0066a82f7b1b37d334e68697faba68e5ad5e858279fd6351c8ca6024e8d6ba64"}, @@ -1372,39 +1375,39 @@ psutil = [ {file = "psutil-5.8.0.tar.gz", hash = "sha256:0c9ccb99ab76025f2f0bbecf341d4656e9c1351db8cc8a03ccd62e318ab4b5c6"}, ] pycares = [ - {file = "pycares-3.2.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ebff743643e54aa70dce0b7098094edefd371641cf79d9c944e9f4a25e9242b0"}, - {file = "pycares-3.2.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:55272411b46787936e8db475b9b6e9b81a8d8cdc253fa8779a45ef979f554fab"}, - {file = "pycares-3.2.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:f33ed0e403f98e746f721aeacde917f1bdc7558cb714d713c264848bddff660f"}, - {file = "pycares-3.2.3-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:72807e0c80b705e21c3a39347c12edf43aa4f80373bb37777facf810169372ed"}, - {file = "pycares-3.2.3-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:a51df0a8b3eaf225e0dae3a737fd6ce6f3cb2a3bc947e884582fdda9a159d55f"}, - {file = "pycares-3.2.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:663b5c7bd0f66436adac7257ee22ccfe185c3e7830b9bada3d19b79870e1d134"}, - {file = "pycares-3.2.3-cp36-cp36m-win32.whl", hash = "sha256:c2b1e19262ce91c3288b1905b0d41f7ad0fff4b258ce37b517aa2c8d22eb82f1"}, - {file = "pycares-3.2.3-cp36-cp36m-win_amd64.whl", hash = "sha256:e16399654a6c81cfaee2745857c119c20357b5d93de2f169f506b048b5e75d1d"}, - {file = "pycares-3.2.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88e5131570d7323b29866aa5ac245a9a5788d64677111daa1bde5817acdf012f"}, - {file = "pycares-3.2.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1552ffd823dc595fa8744c996926097a594f4f518d7c147657234b22cf17649d"}, - {file = "pycares-3.2.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f9e28b917373818817aca746238fcd621ec7e4ae9cbc8615f1a045e234eec298"}, - {file = "pycares-3.2.3-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:206d5a652990f10a1f1f3f62bc23d7fe46d99c2dc4b8b8a5101e5a472986cd02"}, - {file = "pycares-3.2.3-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:b8c9670225cdeeeb2b85ea92a807484622ca59f8f578ec73e8ec292515f35a91"}, - {file = "pycares-3.2.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:6329160885fc318f80692d4d0a83a8854f9144e7a80c4f25245d0c26f11a4b84"}, - {file = "pycares-3.2.3-cp37-cp37m-win32.whl", hash = "sha256:cd0f7fb40e1169f00b26a12793136bf5c711f155e647cd045a0ce6c98a527b57"}, - {file = "pycares-3.2.3-cp37-cp37m-win_amd64.whl", hash = "sha256:a5d419215543d154587590d9d4485e985387ca10c7d3e1a2e5689dd6c0f20e5f"}, - {file = "pycares-3.2.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54f1c0642935515f27549f09486e72b6b2b1d51ad27a90ce17b760e9ce5e86d"}, - {file = "pycares-3.2.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6ce80eed538dd6106cd7e6136ceb3af10178d1254f07096a827c12e82e5e45c8"}, - {file = "pycares-3.2.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:ed972a04067e91f552da84945d38b94c3984c898f699faa8bb066e9f3a114c32"}, - {file = "pycares-3.2.3-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:99a62b101cfb36ab6ebf19cb1ad60db2f9b080dc52db4ca985fe90924f60c758"}, - {file = "pycares-3.2.3-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:2246adcbc948dd31925c9bff5cc41c06fc640f7d982e6b41b6d09e4f201e5c11"}, - {file = "pycares-3.2.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7fd15d3f32be5548f38f95f4762ca73eef9fd623b101218a35d433ee0d4e3b58"}, - {file = "pycares-3.2.3-cp38-cp38-win32.whl", hash = "sha256:4bb0c708d8713741af7c4649d2f11e47c5f4e43131831243aeb18cff512c5469"}, - {file = "pycares-3.2.3-cp38-cp38-win_amd64.whl", hash = "sha256:a53d921956d1e985e510ca0ffa84fbd7ecc6ac7d735d8355cba4395765efcd31"}, - {file = "pycares-3.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0312d25fa9d7c242f66115c4b3ae6ed8aedb457513ba33acef31fa265fc602b4"}, - {file = "pycares-3.2.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9960de8254525d9c3b485141809910c39d5eb1bb8119b1453702aacf72234934"}, - {file = "pycares-3.2.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:929f708a7bb4b2548cbbfc2094b2f90c4d8712056cdc0204788b570ab69c8838"}, - {file = "pycares-3.2.3-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4dd1237f01037cf5b90dd599c7fa79d9d8fb2ab2f401e19213d24228b2d17838"}, - {file = "pycares-3.2.3-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:5eea61a74097976502ce377bb75c4fed381d4986bc7fb85e70b691165133d3da"}, - {file = "pycares-3.2.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:1c72c0fda4b08924fe04680475350e09b8d210365d950a6dcdde8c449b8d5b98"}, - {file = "pycares-3.2.3-cp39-cp39-win32.whl", hash = "sha256:b1555d51ce29510ffd20f9e0339994dff8c5d1cb093c8e81d5d98f474e345aa7"}, - {file = "pycares-3.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:43c15138f620ed28e61e51b884490eb8387e5954668f919313753f88dd8134fd"}, - {file = "pycares-3.2.3.tar.gz", hash = "sha256:da1899fde778f9b8736712283eccbf7b654248779b349d139cd28eb30b0fa8cd"}, + {file = "pycares-4.0.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:db5a533111a3cfd481e7e4fb2bf8bef69f4fa100339803e0504dd5aecafb96a5"}, + {file = "pycares-4.0.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:fdff88393c25016f417770d82678423fc7a56995abb2df3d2a1e55725db6977d"}, + {file = "pycares-4.0.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0aa97f900a7ffb259be77d640006585e2a907b0cd4edeee0e85cf16605995d5a"}, + {file = "pycares-4.0.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:a34b0e3e693dceb60b8a1169668d606c75cb100ceba0a2df53c234a0eb067fbc"}, + {file = "pycares-4.0.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7661d6bbd51a337e7373cb356efa8be9b4655fda484e068f9455e939aec8d54e"}, + {file = "pycares-4.0.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:57315b8eb8fdbc56b3ad4932bc4b17132bb7c7fd2bd590f7fb84b6b522098aa9"}, + {file = "pycares-4.0.0-cp36-cp36m-win32.whl", hash = "sha256:dca9dc58845a9d083f302732a3130c68ded845ad5d463865d464e53c75a3dd45"}, + {file = "pycares-4.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:c95c964d5dd307e104b44b193095c67bb6b10c9eda1ffe7d44ab7a9e84c476d9"}, + {file = "pycares-4.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:26e67e4f81c80a5955dcf6193f3d9bee3c491fc0056299b383b84d792252fba4"}, + {file = "pycares-4.0.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:cd3011ffd5e1ad55880f7256791dbab9c43ebeda260474a968f19cd0319e1aef"}, + {file = "pycares-4.0.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1b959dd5921d207d759d421eece1b60416df33a7f862465739d5f2c363c2f523"}, + {file = "pycares-4.0.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:6f258c1b74c048a9501a25f732f11b401564005e5e3c18f1ca6cad0c3dc0fb19"}, + {file = "pycares-4.0.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:b17ef48729786e62b574c6431f675f4cb02b27691b49e7428a605a50cd59c072"}, + {file = "pycares-4.0.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:82b3259cb590ddd107a6d2dc52da2a2e9a986bf242e893d58c786af2f8191047"}, + {file = "pycares-4.0.0-cp37-cp37m-win32.whl", hash = "sha256:4876fc790ae32832ae270c4a010a1a77e12ddf8d8e6ad70ad0b0a9d506c985f7"}, + {file = "pycares-4.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:f60c04c5561b1ddf85ca4e626943cc09d7fb684e1adb22abb632095415a40fd7"}, + {file = "pycares-4.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:615406013cdcd1b445e5d1a551d276c6200b3abe77e534f8a7f7e1551208d14f"}, + {file = "pycares-4.0.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6580aef5d1b29a88c3d72fe73c691eacfd454f86e74d3fdd18f4bad8e8def98b"}, + {file = "pycares-4.0.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8ebb3ba0485f66cae8eed7ce3e9ed6f2c0bfd5e7319d5d0fbbb511064f17e1d4"}, + {file = "pycares-4.0.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:c5362b7690ca481440f6b98395ac6df06aa50518ccb183c560464d1e5e2ab5d4"}, + {file = "pycares-4.0.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:eb60be66accc9a9ea1018b591a1f5800cba83491d07e9acc8c56bc6e6607ab54"}, + {file = "pycares-4.0.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:44896d6e191a6b5a914dbe3aa7c748481bf6ad19a9df33c1e76f8f2dc33fc8f0"}, + {file = "pycares-4.0.0-cp38-cp38-win32.whl", hash = "sha256:09b28fc7bc2cc05f7f69bf1636ddf46086e0a1837b62961e2092fcb40477320d"}, + {file = "pycares-4.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:d4a5081e232c1d181883dcac4675807f3a6cf33911c4173fbea00c0523687ed4"}, + {file = "pycares-4.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:103353577a6266a53e71bfee4cf83825f1401fefa60f0fb8bdec35f13be6a5f2"}, + {file = "pycares-4.0.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ad6caf580ee69806fc6534be93ddbb6e99bf94296d79ab351c37b2992b17abfd"}, + {file = "pycares-4.0.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:3d5e50c95849f6905d2a9dbf02ed03f82580173e3c5604a39e2ad054185631f1"}, + {file = "pycares-4.0.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:53bc4f181b19576499b02cea4b45391e8dcbe30abd4cd01492f66bfc15615a13"}, + {file = "pycares-4.0.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:d52f9c725d2a826d5ffa37681eb07ffb996bfe21788590ef257664a3898fc0b5"}, + {file = "pycares-4.0.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:3c7fb8d34ee11971c39acfaf98d0fac66725385ccef3bfe1b174c92b210e1aa4"}, + {file = "pycares-4.0.0-cp39-cp39-win32.whl", hash = "sha256:e9773e07684a55f54657df05237267611a77b294ec3bacb5f851c4ffca38a465"}, + {file = "pycares-4.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:38e54037f36c149146ff15f17a4a963fbdd0f9871d4a21cd94ff9f368140f57e"}, + {file = "pycares-4.0.0.tar.gz", hash = "sha256:d0154fc5753b088758fbec9bc137e1b24bb84fc0c6a09725c8bac25a342311cd"}, ] pycodestyle = [ {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, @@ -1415,8 +1418,8 @@ pycparser = [ {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, ] pydocstyle = [ - {file = "pydocstyle-6.0.0-py3-none-any.whl", hash = "sha256:d4449cf16d7e6709f63192146706933c7a334af7c0f083904799ccb851c50f6d"}, - {file = "pydocstyle-6.0.0.tar.gz", hash = "sha256:164befb520d851dbcf0e029681b91f4f599c62c5cd8933fd54b1bfbd50e89e1f"}, + {file = "pydocstyle-6.1.1-py3-none-any.whl", hash = "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"}, + {file = "pydocstyle-6.1.1.tar.gz", hash = "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc"}, ] pyflakes = [ {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, @@ -1446,18 +1449,26 @@ pyyaml = [ {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"}, {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"}, {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"}, + {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"}, + {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"}, {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"}, {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"}, {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"}, {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"}, + {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"}, + {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"}, {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"}, {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"}, {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"}, {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"}, + {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"}, + {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"}, {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"}, {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"}, {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, + {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"}, + {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"}, {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, @@ -1529,8 +1540,8 @@ snowballstemmer = [ {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"}, ] sortedcontainers = [ - {file = "sortedcontainers-2.3.0-py2.py3-none-any.whl", hash = "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f"}, - {file = "sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"}, + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, ] soupsieve = [ {file = "soupsieve-2.2.1-py3-none-any.whl", hash = "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"}, @@ -1554,12 +1565,12 @@ typing-extensions = [ {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, ] urllib3 = [ - {file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"}, - {file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"}, + {file = "urllib3-1.26.5-py2.py3-none-any.whl", hash = "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c"}, + {file = "urllib3-1.26.5.tar.gz", hash = "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098"}, ] virtualenv = [ - {file = "virtualenv-20.4.6-py2.py3-none-any.whl", hash = "sha256:307a555cf21e1550885c82120eccaf5acedf42978fd362d32ba8410f9593f543"}, - {file = "virtualenv-20.4.6.tar.gz", hash = "sha256:72cf267afc04bf9c86ec932329b7e94db6a0331ae9847576daaa7ca3c86b29a4"}, + {file = "virtualenv-20.4.7-py2.py3-none-any.whl", hash = "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76"}, + {file = "virtualenv-20.4.7.tar.gz", hash = "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467"}, ] yarl = [ {file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"}, diff --git a/pyproject.toml b/pyproject.toml index 320bf88cc..04be1bf33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ beautifulsoup4 = "~=4.9" colorama = { version = "~=0.4.3", markers = "sys_platform == 'win32'" } coloredlogs = "~=14.0" deepdiff = "~=4.0" -"discord.py" = "~=1.6.0" +"discord.py" = "~=1.7.3" emoji = "~=0.6" feedparser = "~=6.0.2" fuzzywuzzy = "~=0.17" -- cgit v1.2.3 From 488c36d6ea7e7f4dd114481b9209e372b77dd3ba Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 18 Jun 2021 20:39:04 +0100 Subject: Update LinePaginator with new Paginator param added in d.py 1.7 --- bot/pagination.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/bot/pagination.py b/bot/pagination.py index c5c84afd9..1c5b94b07 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -51,22 +51,25 @@ class LinePaginator(Paginator): suffix: str = '```', max_size: int = 2000, scale_to_size: int = 2000, - max_lines: t.Optional[int] = None + max_lines: t.Optional[int] = None, + linesep: str = "\n" ) -> None: """ This function overrides the Paginator.__init__ from inside discord.ext.commands. It overrides in order to allow us to configure the maximum number of lines per page. """ - self.prefix = prefix - self.suffix = suffix - # Embeds that exceed 2048 characters will result in an HTTPException # (Discord API limit), so we've set a limit of 2000 if max_size > 2000: raise ValueError(f"max_size must be <= 2,000 characters. ({max_size} > 2000)") - self.max_size = max_size - len(suffix) + super().__init__( + prefix, + suffix, + max_size - len(suffix), + linesep + ) if scale_to_size < max_size: raise ValueError(f"scale_to_size must be >= max_size. ({scale_to_size} < {max_size})") -- cgit v1.2.3 From d1ba19ab849aa21944fb12b23925f61b454bfd09 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 18 Jun 2021 20:41:50 +0100 Subject: Don't voice verify ping users who join a stage channel --- bot/exts/moderation/voice_gate.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 94b23a344..84ffc3ee7 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -254,6 +254,10 @@ class VoiceGate(Cog): log.trace("User not in a voice channel. Ignore.") return + if isinstance(after.channel, discord.StageChannel): + log.trace("User joined a stage chanel. Ignore.") + return + # To avoid race conditions, checking if the user should receive a notification # and sending it if appropriate is delegated to an atomic helper notification_sent, message_channel = await self._ping_newcomer(member) -- cgit v1.2.3 From 3b118904b8c6d6dd7c16330491808c795a110418 Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Fri, 18 Jun 2021 21:19:45 +0100 Subject: Correct spelling error in voice_gate trace log Co-authored-by: Numerlor <25886452+Numerlor@users.noreply.github.com> --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 84ffc3ee7..aa8a4d209 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -255,7 +255,7 @@ class VoiceGate(Cog): return if isinstance(after.channel, discord.StageChannel): - log.trace("User joined a stage chanel. Ignore.") + log.trace("User joined a stage channel. Ignore.") return # To avoid race conditions, checking if the user should receive a notification -- cgit v1.2.3 From d4daa9cc96d75271aecf7886aeb4fb3947f69994 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 19 Jun 2021 11:34:11 +0200 Subject: Filters: up character limit to 4,200 Since Discord Nitro now unlock a 4,000 character limit, some of our code broke, including our filters which limit you to only post up to 3,000 character in 10s. This limit has been upped to 4,200. I added 200 characters so if a full length message is sent with another small comment it wouldn't trigger the filter. I can think of some past instances where that would have happened. --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 863a4535e..f4fdc7606 100644 --- a/config-default.yml +++ b/config-default.yml @@ -398,7 +398,7 @@ anti_spam: chars: interval: 5 - max: 3_000 + max: 4_200 discord_emojis: interval: 10 -- cgit v1.2.3 From 4bd92ca667807cdc1ebfb2f8e8fd4dc3fdd4c422 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sat, 19 Jun 2021 15:56:02 +0100 Subject: Change seen emoji to reviewed emoji --- bot/exts/recruitment/talentpool/_review.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index b9ff61986..0cb786e4b 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -75,7 +75,7 @@ class Reviewer: async def post_review(self, user_id: int, update_database: bool) -> None: """Format the review of a user and post it to the nomination voting channel.""" - review, seen_emoji = await self.make_review(user_id) + review, reviewed_emoji = await self.make_review(user_id) if not review: return @@ -88,8 +88,8 @@ class Reviewer: await pin_no_system_message(messages[0]) last_message = messages[-1] - if seen_emoji: - for reaction in (seen_emoji, "\N{THUMBS UP SIGN}", "\N{THUMBS DOWN SIGN}"): + if reviewed_emoji: + for reaction in (reviewed_emoji, "\N{THUMBS UP SIGN}", "\N{THUMBS DOWN SIGN}"): await last_message.add_reaction(reaction) if update_database: @@ -97,7 +97,7 @@ class Reviewer: await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji]]: - """Format a generic review of a user and return it with the seen emoji.""" + """Format a generic review of a user and return it with the reviewed emoji.""" log.trace(f"Formatting the review of {user_id}") # Since `watched_users` is a defaultdict, we should take care @@ -127,15 +127,15 @@ class Reviewer: review_body = await self._construct_review_body(member) - seen_emoji = self._random_ducky(guild) + reviewed_emoji = self._random_ducky(guild) vote_request = ( "*Refer to their nomination and infraction histories for further details*.\n" - f"*Please react {seen_emoji} if you've seen this post." - " Then react :+1: for approval, or :-1: for disapproval*." + f"*Please react {reviewed_emoji} once you have reviewed this user," + " and react :+1: for approval, or :-1: for disapproval*." ) review = "\n\n".join((opening, current_nominations, review_body, vote_request)) - return review, seen_emoji + return review, reviewed_emoji async def archive_vote(self, message: PartialMessage, passed: bool) -> None: """Archive this vote to #nomination-archive.""" @@ -163,7 +163,7 @@ class Reviewer: user_id = int(MENTION_RE.search(content).group(1)) # Get reaction counts - seen = await count_unique_users_reaction( + reviewed = await count_unique_users_reaction( messages[0], lambda r: "ducky" in str(r) or str(r) == "\N{EYES}", count_bots=False @@ -188,7 +188,7 @@ class Reviewer: embed_content = ( f"{result} on {timestamp}\n" - f"With {seen} {Emojis.ducky_dave} {upvotes} :+1: {downvotes} :-1:\n\n" + f"With {reviewed} {Emojis.ducky_dave} {upvotes} :+1: {downvotes} :-1:\n\n" f"{stripped_content}" ) @@ -357,7 +357,7 @@ class Reviewer: @staticmethod def _random_ducky(guild: Guild) -> Union[Emoji, str]: - """Picks a random ducky emoji to be used to mark the vote as seen. If no duckies found returns :eyes:.""" + """Picks a random ducky emoji. If no duckies found returns :eyes:.""" duckies = [emoji for emoji in guild.emojis if emoji.name.startswith("ducky")] if not duckies: return ":eyes:" -- cgit v1.2.3 From 19c50beceda92a2d3b2a3fff7420aebec7e2e014 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sat, 19 Jun 2021 18:53:00 +0100 Subject: Only fetch message counts if in mod channel Changes to only fetch message counts for the user command if in a mod channel, as they are not displayed otherwise, and so an unnecessary api call. --- bot/exts/info/information.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 834fee1b4..1b1243118 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -241,8 +241,6 @@ class Information(Cog): if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)): badges.append(emoji) - activity = await self.user_messages(user) - if on_server: joined = time_since(user.joined_at, max_units=3) roles = ", ".join(role.mention for role in user.roles[1:]) @@ -272,8 +270,7 @@ class Information(Cog): # Show more verbose output in moderation channels for infractions and nominations if is_mod_channel(ctx.channel): - fields.append(activity) - + fields.append(await self.user_messages(user)) fields.append(await self.expanded_user_infraction_counts(user)) fields.append(await self.user_nomination_counts(user)) else: -- cgit v1.2.3 From 9a0ccf993d1ed5a55fc0e604d32d8196e125ae5d Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 20 Jun 2021 11:17:48 +0100 Subject: Use the correct constant name for deleteing after a delay --- bot/exts/help_channels/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index a9b847582..35658d117 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -594,7 +594,7 @@ class HelpChannels(commands.Cog): await bot_commands_channel.send( f"{message.author.mention} {constants.Emojis.cross_mark} " "To receive updates on help channels you're active in, enable your DMs.", - delete_after=RedirectOutput.delete_after + delete_after=RedirectOutput.delete_delay ) return -- cgit v1.2.3 From e74c6bfdd2d2d8883f27dfa157b88c903d432e7c Mon Sep 17 00:00:00 2001 From: Objectivitix <79152594+Objectivitix@users.noreply.github.com> Date: Sun, 20 Jun 2021 21:53:21 -0400 Subject: Add dunder methods tag (#1644) * Create dunder-methods.md --- bot/resources/tags/dunder-methods.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 bot/resources/tags/dunder-methods.md diff --git a/bot/resources/tags/dunder-methods.md b/bot/resources/tags/dunder-methods.md new file mode 100644 index 000000000..be2b97b7b --- /dev/null +++ b/bot/resources/tags/dunder-methods.md @@ -0,0 +1,28 @@ +**Dunder methods** + +Double-underscore methods, or "dunder" methods, are special methods defined in a class that are invoked implicitly. Like the name suggests, they are prefixed and suffixed with dunders. You've probably already seen some, such as the `__init__` dunder method, also known as the "constructor" of a class, which is implicitly invoked when you instantiate an instance of a class. + +When you create a new class, there will be default dunder methods inherited from the `object` class. However, we can override them by redefining these methods within the new class. For example, the default `__init__` method from `object` doesn't take any arguments, so we almost always override that to fit our needs. + +Other common dunder methods to override are `__str__` and `__repr__`. `__repr__` is the developer-friendly string representation of an object - usually the syntax to recreate it - and is implicitly called on arguments passed into the `repr` function. `__str__` is the user-friendly string representation of an object, and is called by the `str` function. Note here that, if not overriden, the default `__str__` invokes `__repr__` as a fallback. + +```py +class Foo: + def __init__(self, value): # constructor + self.value = value + def __str__(self): + return f"This is a Foo object, with a value of {self.value}!" # string representation + def __repr__(self): + return f"Foo({self.value!r})" # way to recreate this object + + +bar = Foo(5) + +# print also implicitly calls __str__ +print(bar) # Output: This is a Foo object, with a value of 5! + +# dev-friendly representation +print(repr(bar)) # Output: Foo(5) +``` + +Another example: did you know that when you use the ` + ` syntax, you're implicitly calling `.__add__()`? The same applies to other operators, and you can look at the [`operator` built-in module documentation](https://docs.python.org/3/library/operator.html) for more information! -- cgit v1.2.3 From 7294943d307d8c58b2b37214c186872d6be24162 Mon Sep 17 00:00:00 2001 From: Bast Date: Tue, 22 Jun 2021 03:26:55 -0700 Subject: Reorder everyone ping filter so it fires after watch_regex This means messages with both @everyone and a watched term ping the mod team instead of hiding beneath the everyone ping silent alert --- bot/exts/filters/filtering.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 464732453..661d6c9a2 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -103,19 +103,6 @@ class Filtering(Cog): ), "schedule_deletion": False }, - "filter_everyone_ping": { - "enabled": Filter.filter_everyone_ping, - "function": self._has_everyone_ping, - "type": "filter", - "content_only": True, - "user_notification": Filter.notify_user_everyone_ping, - "notification_msg": ( - "Please don't try to ping `@everyone` or `@here`. " - f"Your message has been removed. {staff_mistake_str}" - ), - "schedule_deletion": False, - "ping_everyone": False - }, "watch_regex": { "enabled": Filter.watch_regex, "function": self._has_watch_regex_match, @@ -129,7 +116,20 @@ class Filtering(Cog): "type": "watchlist", "content_only": False, "schedule_deletion": False - } + }, + "filter_everyone_ping": { + "enabled": Filter.filter_everyone_ping, + "function": self._has_everyone_ping, + "type": "filter", + "content_only": True, + "user_notification": Filter.notify_user_everyone_ping, + "notification_msg": ( + "Please don't try to ping `@everyone` or `@here`. " + f"Your message has been removed. {staff_mistake_str}" + ), + "schedule_deletion": False, + "ping_everyone": False + }, } self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) -- cgit v1.2.3 From 57bc7ba839bfaf2cebd48ad7199c5021d76e5920 Mon Sep 17 00:00:00 2001 From: swfarnsworth Date: Tue, 22 Jun 2021 17:41:28 -0400 Subject: Prevent one from using the `!echo` command for a channel in which one can't speak. --- bot/exts/utils/bot.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py index a4c828f95..f372a54e4 100644 --- a/bot/exts/utils/bot.py +++ b/bot/exts/utils/bot.py @@ -44,6 +44,8 @@ class BotCog(Cog, name="Bot"): """Repeat the given message in either a specified channel or the current channel.""" if channel is None: await ctx.send(text) + elif not channel.permissions_for(ctx.author).send_messages: + await ctx.send('You don\'t have permission to speak in that channel.') else: await channel.send(text) -- cgit v1.2.3 From 0c87e5a7198c1f11238841244a221c48c6d1b25d Mon Sep 17 00:00:00 2001 From: Steele Farnsworth <32915757+swfarnsworth@users.noreply.github.com> Date: Tue, 22 Jun 2021 18:14:45 -0400 Subject: Update bot/exts/utils/bot.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change single quotes to double quotes and un-escape the internal single quote. Co-authored-by: Leon Sandøy --- bot/exts/utils/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py index f372a54e4..d84709616 100644 --- a/bot/exts/utils/bot.py +++ b/bot/exts/utils/bot.py @@ -45,7 +45,7 @@ class BotCog(Cog, name="Bot"): if channel is None: await ctx.send(text) elif not channel.permissions_for(ctx.author).send_messages: - await ctx.send('You don\'t have permission to speak in that channel.') + await ctx.send("You don't have permission to speak in that channel.") else: await channel.send(text) -- cgit v1.2.3 From 9eb774dee66ce91a31120170dfdbde8e7986435a Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sun, 27 Jun 2021 16:41:34 +0200 Subject: Docker-compose: upgrade to postgres 13 See https://git.pydis.com/site/pull/545 --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 1761d8940..0f0355dac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,7 @@ services: postgres: << : *logging << : *restart_policy - image: postgres:12-alpine + image: postgres:13-alpine environment: POSTGRES_DB: pysite POSTGRES_PASSWORD: pysite -- cgit v1.2.3 From f7f73f28e1bae94e000a4b0cd4d89d646d1843ea Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sun, 27 Jun 2021 16:00:17 +0100 Subject: Add alias for voice_verify command Added voice-verify alias as the channel and tag are hyphenated so it may be confusing as it is currently. --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index aa8a4d209..8494a1e2e 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -118,7 +118,7 @@ class VoiceGate(Cog): await self.redis_cache.set(member.id, message.id) return True, message.channel - @command(aliases=('voiceverify',)) + @command(aliases=("voiceverify", "voice-verify",)) @has_no_roles(Roles.voice_verified) @in_whitelist(channels=(Channels.voice_gate,), redirect=None) async def voice_verify(self, ctx: Context, *_) -> None: -- cgit v1.2.3 From 12e8bdca8257940f85ac53716b01cb851c11eaf3 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sun, 27 Jun 2021 23:00:14 +0100 Subject: move cov config to toml --- .coveragerc | 5 ----- pyproject.toml | 5 +++++ 2 files changed, 5 insertions(+), 5 deletions(-) delete mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index d572bd705..000000000 --- a/.coveragerc +++ /dev/null @@ -1,5 +0,0 @@ -[run] -branch = true -source = - bot - tests diff --git a/pyproject.toml b/pyproject.toml index 8368f80eb..c80ad1ce9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,3 +65,8 @@ test-nocov = "pytest -n auto" test = "pytest -n auto --cov-report= --cov" html = "coverage html" report = "coverage report" + +[tool.coverage.run] +branch = true +source_pkgs = ["bot"] +source = ["tests"] -- cgit v1.2.3 From 93238c4596c69b29e513e2519df7d248d4a4a5af Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sun, 27 Jun 2021 23:02:16 +0100 Subject: upgrade pytest-xdist --- poetry.lock | 10 +++++----- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index f1d158a68..2041824e2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -783,11 +783,11 @@ pytest = ">=3.10" [[package]] name = "pytest-xdist" -version = "2.2.1" +version = "2.3.0" description = "pytest xdist plugin for distributed testing and loop-on-failing modes" category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" [package.dependencies] execnet = ">=1.1" @@ -1026,7 +1026,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "040b5fa5c6f398bbcc6dfd6b27bc729032989fc5853881d21c032e92b2395a82" +content-hash = "feec7372374cc4025f407b64b2e5b45c2c9c8d49c4538b91dc372f2bad89a624" [metadata.files] aio-pika = [ @@ -1603,8 +1603,8 @@ pytest-forked = [ {file = "pytest_forked-1.3.0-py2.py3-none-any.whl", hash = "sha256:dc4147784048e70ef5d437951728825a131b81714b398d5d52f17c7c144d8815"}, ] pytest-xdist = [ - {file = "pytest-xdist-2.2.1.tar.gz", hash = "sha256:718887296892f92683f6a51f25a3ae584993b06f7076ce1e1fd482e59a8220a2"}, - {file = "pytest_xdist-2.2.1-py3-none-any.whl", hash = "sha256:2447a1592ab41745955fb870ac7023026f20a5f0bfccf1b52a879bd193d46450"}, + {file = "pytest-xdist-2.3.0.tar.gz", hash = "sha256:e8ecde2f85d88fbcadb7d28cb33da0fa29bca5cf7d5967fa89fc0e97e5299ea5"}, + {file = "pytest_xdist-2.3.0-py3-none-any.whl", hash = "sha256:ed3d7da961070fce2a01818b51f6888327fb88df4379edeb6b9d990e789d9c8d"}, ] python-dateutil = [ {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, diff --git a/pyproject.toml b/pyproject.toml index c80ad1ce9..c76bb47d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ taskipy = "~=1.7.0" python-dotenv = "~=0.17.1" pytest = "~=6.2.4" pytest-cov = "~=2.12.1" -pytest-xdist = { version = "~=2.2.1", extras = ["psutil"] } +pytest-xdist = { version = "~=2.3.0", extras = ["psutil"] } [build-system] requires = ["poetry-core>=1.0.0"] -- cgit v1.2.3 From fc30c540d62ae2d0a04c3f33313a3d2744c3fa94 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 29 Jun 2021 18:26:03 +0100 Subject: Ensure mods cannot be watched This meant admins could also be watched, meaning their messages in ancy channel would be relayed to the BB channel. --- bot/exts/moderation/watchchannels/bigbrother.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/exts/moderation/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py index 3b44056d3..c6ee844ef 100644 --- a/bot/exts/moderation/watchchannels/bigbrother.py +++ b/bot/exts/moderation/watchchannels/bigbrother.py @@ -94,6 +94,11 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await ctx.send(f":x: {user} is already being watched.") return + # FetchedUser instances don't have a roles attribute + if hasattr(user, "roles") and any(role.id in MODERATION_ROLES for role in user.roles): + await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I must be kind to my masters.") + return + response = await post_infraction(ctx, user, 'watch', reason, hidden=True, active=True) if response is not None: -- cgit v1.2.3 From 317a06fc0d09f2c1a8c60f08bbad7dcef1b4c7ba Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 30 Jun 2021 14:30:11 +0100 Subject: Remove the pixels token detector --- bot/exts/filters/pixels_token_remover.py | 108 ------------------------------- 1 file changed, 108 deletions(-) delete mode 100644 bot/exts/filters/pixels_token_remover.py diff --git a/bot/exts/filters/pixels_token_remover.py b/bot/exts/filters/pixels_token_remover.py deleted file mode 100644 index 2356491e5..000000000 --- a/bot/exts/filters/pixels_token_remover.py +++ /dev/null @@ -1,108 +0,0 @@ -import logging -import re -import typing as t - -from discord import Colour, Message, NotFound -from discord.ext.commands import Cog - -from bot.bot import Bot -from bot.constants import Channels, Colours, Event, Icons -from bot.exts.moderation.modlog import ModLog -from bot.utils.messages import format_user - -log = logging.getLogger(__name__) - -LOG_MESSAGE = "Censored a valid Pixels token sent by {author} in {channel}, token was `{token}`" -DELETION_MESSAGE_TEMPLATE = ( - "Hey {mention}! I noticed you posted a valid Pixels API " - "token in your message and have removed your message. " - "This means that your token has been **compromised**. " - "I have taken the liberty of invalidating the token for you. " - "You can go to to get a new key." -) - -PIXELS_TOKEN_RE = re.compile(r"[A-Za-z0-9-_=]{30,}\.[A-Za-z0-9-_=]{50,}\.[A-Za-z0-9-_.+\=]{30,}") - - -class PixelsTokenRemover(Cog): - """Scans messages for Pixels API tokens, removes and invalidates them.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """Check each message for a string that matches the RS-256 token pattern.""" - # Ignore DMs; can't delete messages in there anyway. - if not msg.guild or msg.author.bot: - return - - found_token = await self.find_token_in_message(msg) - if found_token: - await self.take_action(msg, found_token) - - @Cog.listener() - async def on_message_edit(self, before: Message, after: Message) -> None: - """Check each edit for a string that matches the RS-256 token pattern.""" - await self.on_message(after) - - async def take_action(self, msg: Message, found_token: str) -> None: - """Remove the `msg` containing the `found_token` and send a mod log message.""" - self.mod_log.ignore(Event.message_delete, msg.id) - - try: - await msg.delete() - except NotFound: - log.debug(f"Failed to remove token in message {msg.id}: message already deleted.") - return - - await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) - - log_message = self.format_log_message(msg, found_token) - log.debug(log_message) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=Icons.token_removed, - colour=Colour(Colours.soft_red), - title="Token removed!", - text=log_message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=False, - ) - - self.bot.stats.incr("tokens.removed_pixels_tokens") - - @staticmethod - def format_log_message(msg: Message, token: str) -> str: - """Return the generic portion of the log message to send for `token` being censored in `msg`.""" - return LOG_MESSAGE.format( - author=format_user(msg.author), - channel=msg.channel.mention, - token=token - ) - - async def find_token_in_message(self, msg: Message) -> t.Optional[str]: - """Return a seemingly valid token found in `msg` or `None` if no token is found.""" - # Use finditer rather than search to guard against method calls prematurely returning the - # token check (e.g. `message.channel.send` also matches our token pattern) - for match in PIXELS_TOKEN_RE.finditer(msg.content): - auth_header = {"Authorization": f"Bearer {match[0]}"} - async with self.bot.http_session.delete("https://pixels.pythondiscord.com/token", headers=auth_header) as r: - if r.status == 204: - # Short curcuit on first match. - return match[0] - - # No matching substring - return - - -def setup(bot: Bot) -> None: - """Load the PixelsTokenRemover cog.""" - bot.add_cog(PixelsTokenRemover(bot)) -- cgit v1.2.3 From c301f3e2a5a3ca80f88ff13539bebd86f95a8833 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 30 Jun 2021 02:35:07 +0200 Subject: Base functionality of tag fetching with groups and in file metadata The code was restructured to hold tags and their identifiers in individual classes and some methods moved to function to detach some of the not directly related functionality from the cog class --- bot/exts/backend/error_handler.py | 12 +- bot/exts/info/tags.py | 244 ++++++++++++++++++++++++-------------- 2 files changed, 167 insertions(+), 89 deletions(-) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index d8de177f5..78822aece 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -11,6 +11,7 @@ from bot.bot import Bot from bot.constants import Colours, Icons, MODERATION_ROLES from bot.converters import TagNameConverter from bot.errors import InvalidInfractedUser, LockedResourceError +from bot.exts.info import tags from bot.utils.checks import ContextCheckFailure log = logging.getLogger(__name__) @@ -154,14 +155,21 @@ class ErrorHandler(Cog): return try: - tag_name = await TagNameConverter.convert(ctx, ctx.invoked_with) + tag_identifier = tags.extract_tag_identifier(ctx.message.content) + if tag_identifier.group is not None: + tag_name = await TagNameConverter.convert(ctx, tag_identifier.name) + tag_name_or_group = await TagNameConverter.convert(ctx, tag_identifier.group) + else: + tag_name = None + tag_name_or_group = await TagNameConverter.convert(ctx, tag_identifier.name) + except errors.BadArgument: log.debug( f"{ctx.author} tried to use an invalid command " f"and the fallback tag failed validation in TagNameConverter." ) else: - if await ctx.invoke(tags_get_command, tag_name=tag_name): + if await ctx.invoke(tags_get_command, tag_name_or_group, tag_name): return if not any(role.id in MODERATION_ROLES for role in ctx.author.roles): diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index bb91a8563..3c7b9ea0b 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -1,9 +1,13 @@ +from __future__ import annotations + import logging import re import time from pathlib import Path -from typing import Callable, Dict, Iterable, List, Optional +from typing import Callable, Iterable, List, NamedTuple, Optional +import discord +import frontmatter from discord import Colour, Embed, Member from discord.ext.commands import Cog, Context, group @@ -24,90 +28,128 @@ REGEX_NON_ALPHABET = re.compile(r"[^a-z]", re.MULTILINE & re.IGNORECASE) FOOTER_TEXT = f"To show a tag, type {constants.Bot.prefix}tags ." +class TagIdentifier(NamedTuple): + """Stores the group and name used as an identifier for a tag.""" + + group: Optional[str] + name: str + + def get_fuzzy_score(self, fuzz_tag_identifier: TagIdentifier) -> float: + """Get fuzzy score, using `fuzz_tag_identifier` as the identifier to fuzzy match with.""" + if self.group is None: + if fuzz_tag_identifier.group is None: + # We're only fuzzy matching the name + group_score = 1 + else: + # Ignore tags without groups if the identifier contains a group + return .0 + else: + if fuzz_tag_identifier.group is None: + # Ignore tags with groups if the identifier does not have a group + return .0 + else: + group_score = _fuzzy_search(fuzz_tag_identifier.group, self.group) + + fuzzy_score = group_score * _fuzzy_search(fuzz_tag_identifier.name, self.name) * 100 + if fuzzy_score: + log.trace(f"Fuzzy score {fuzzy_score:=06.2f} for tag {self!r} with fuzz {fuzz_tag_identifier!r}") + return fuzzy_score + + def __str__(self) -> str: + return f"{self.group or ''} {self.name}" + + +class Tag: + """Provide an interface to a tag from resources with `file_content`.""" + + def __init__(self, file_content: str): + post = frontmatter.loads(file_content) + self.content = post.content + self.metadata = post.metadata + self._restricted_to: set[int] = set(self.metadata.get("restricted_to", ())) + + @property + def embed(self) -> Embed: + """Create an embed for the tag.""" + embed = Embed.from_dict(self.metadata.get("embed", {})) + embed.description = self.content + return embed + + def accessible_by(self, member: discord.Member) -> bool: + """Check whether `member` can access the tag.""" + return bool( + not self._restricted_to + or self._restricted_to & {role.id for role in member.roles} + ) + + +def _fuzzy_search(search: str, target: str) -> float: + """A simple scoring algorithm based on how many letters are found / total, with order in mind.""" + current, index = 0, 0 + _search = REGEX_NON_ALPHABET.sub("", search.lower()) + _targets = iter(REGEX_NON_ALPHABET.split(target.lower())) + _target = next(_targets) + try: + while True: + while index < len(_target) and _search[current] == _target[index]: + current += 1 + index += 1 + index, _target = 0, next(_targets) + except (StopIteration, IndexError): + pass + return current / len(_search) + + class Tags(Cog): """Save new tags and fetch existing tags.""" def __init__(self, bot: Bot): self.bot = bot self.tag_cooldowns = {} - self._cache = self.get_tags() - - @staticmethod - def get_tags() -> dict: - """Get all tags.""" - cache = {} + self._tags: dict[TagIdentifier, Tag] = {} + self.initialize_tags() + def initialize_tags(self) -> None: + """Load all tags from resources into `self._tags`.""" base_path = Path("bot", "resources", "tags") + for file in base_path.glob("**/*"): if file.is_file(): - tag_title = file.stem - tag = { - "title": tag_title, - "embed": { - "description": file.read_text(encoding="utf8"), - }, - "restricted_to": None, - "location": f"/bot/{file}" - } - - # Convert to a list to allow negative indexing. - parents = list(file.relative_to(base_path).parents) - if len(parents) > 1: - # -1 would be '.' hence -2 is used as the index. - tag["restricted_to"] = parents[-2].name - - cache[tag_title] = tag - - return cache - - @staticmethod - def check_accessibility(user: Member, tag: dict) -> bool: - """Check if user can access a tag.""" - return not tag["restricted_to"] or tag["restricted_to"].lower() in [role.name.lower() for role in user.roles] - - @staticmethod - def _fuzzy_search(search: str, target: str) -> float: - """A simple scoring algorithm based on how many letters are found / total, with order in mind.""" - current, index = 0, 0 - _search = REGEX_NON_ALPHABET.sub('', search.lower()) - _targets = iter(REGEX_NON_ALPHABET.split(target.lower())) - _target = next(_targets) - try: - while True: - while index < len(_target) and _search[current] == _target[index]: - current += 1 - index += 1 - index, _target = 0, next(_targets) - except (StopIteration, IndexError): - pass - return current / len(_search) * 100 + parent_dir = file.relative_to(base_path).parent - def _get_suggestions(self, tag_name: str, thresholds: Optional[List[int]] = None) -> List[str]: - """Return a list of suggested tags.""" - scores: Dict[str, int] = { - tag_title: Tags._fuzzy_search(tag_name, tag['title']) - for tag_title, tag in self._cache.items() - } + tag_name = file.stem + tag_group = parent_dir.name if parent_dir.name else None + self._tags[TagIdentifier(tag_group, tag_name)] = Tag(file.read_text("utf-8")) + + def _get_suggestions( + self, + tag_identifier: TagIdentifier, + thresholds: Optional[list[int]] = None + ) -> list[tuple[TagIdentifier, Tag]]: + """Return a list of suggested tags for `tag_identifier`.""" thresholds = thresholds or [100, 90, 80, 70, 60] for threshold in thresholds: suggestions = [ - self._cache[tag_title] - for tag_title, matching_score in scores.items() - if matching_score >= threshold + (identifier, tag) + for identifier, tag in self._tags.items() + if identifier.get_fuzzy_score(tag_identifier) >= threshold ] if suggestions: return suggestions return [] - def _get_tag(self, tag_name: str) -> list: - """Get a specific tag.""" - found = [self._cache.get(tag_name.lower(), None)] - if not found[0]: - return self._get_suggestions(tag_name) - return found + def get_fuzzy_matches(self, tag_identifier: TagIdentifier) -> list[tuple[TagIdentifier, Tag]]: + """Get tags with identifiers similar to `tag_identifier`.""" + if tag_identifier.group is None: + suggestions = self._get_suggestions(tag_identifier) + else: + # Try fuzzy matching with only a name first + suggestions = self._get_suggestions(TagIdentifier(None, tag_identifier.group)) + suggestions += self._get_suggestions(tag_identifier) + return suggestions def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str, user: Member) -> list: """ @@ -158,9 +200,14 @@ 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: + async def tags_group( + self, + ctx: Context, + tag_name_or_group: TagNameConverter = None, + tag_name: TagNameConverter = None, + ) -> None: """Show all known tags, a single tag, or run a subcommand.""" - await self.get_command(ctx, tag_name=tag_name) + await self.get_command(ctx, tag_name_or_group=tag_name_or_group, tag_name=tag_name) @tags_group.group(name='search', invoke_without_command=True) async def search_tag_content(self, ctx: Context, *, keywords: str) -> None: @@ -182,7 +229,7 @@ class Tags(Cog): matching_tags = self._get_tags_via_content(any, keywords or 'any', ctx.author) await self._send_matching_tags(ctx, keywords, matching_tags) - async def display_tag(self, ctx: Context, tag_name: str = None) -> bool: + async def display_tag(self, ctx: Context, tag_identifier: TagIdentifier) -> bool: """ If a tag is not found, display similar tag names as suggestions. @@ -210,45 +257,50 @@ class Tags(Cog): return True return False - if _command_on_cooldown(tag_name): - time_elapsed = time.time() - self.tag_cooldowns[tag_name]["time"] + if _command_on_cooldown(tag_identifier.name): + time_elapsed = time.time() - self.tag_cooldowns[tag_identifier.name]["time"] time_left = constants.Cooldowns.tags - time_elapsed log.info( - f"{ctx.author} tried to get the '{tag_name}' tag, but the tag is on cooldown. " + f"{ctx.author} tried to get the '{tag_identifier.name}' tag, but the tag is on cooldown. " f"Cooldown ends in {time_left:.1f} seconds." ) return True - if tag_name is not None: - temp_founds = self._get_tag(tag_name) - - founds = [] + if tag_identifier.name is not None: - for found_tag in temp_founds: - if self.check_accessibility(ctx.author, found_tag): - founds.append(found_tag) - - if len(founds) == 1: - tag = founds[0] + if (tag := self._tags.get(tag_identifier)) is not None and tag.accessible_by(ctx.author): if ctx.channel.id not in TEST_CHANNELS: - self.tag_cooldowns[tag_name] = { + self.tag_cooldowns[tag_identifier.name] = { "time": time.time(), "channel": ctx.channel.id } - self.bot.stats.incr(f"tags.usages.{tag['title'].replace('-', '_')}") + self.bot.stats.incr( + f"tags.usages" + f"{'.' + tag_identifier.group.replace('-', '_') if tag_identifier.group else ''}" + f".{tag_identifier.name.replace('-', '_')}" + ) await wait_for_deletion( - await ctx.send(embed=Embed.from_dict(tag['embed'])), + await ctx.send(embed=tag.embed), [ctx.author.id], ) return True - elif founds and len(tag_name) >= 3: + + elif len(tag_identifier.name) >= 3: + suggested_tags = self.get_fuzzy_matches(tag_identifier)[:10] + if not suggested_tags: + return False + suggested_tags_text = "\n".join( + str(identifier) + for identifier, tag in suggested_tags + if tag.accessible_by(ctx.author) + ) await wait_for_deletion( await ctx.send( embed=Embed( - title='Did you mean ...', - description='\n'.join(tag['title'] for tag in founds[:10]) + title="Did you mean ...", + description=suggested_tags_text ) ), [ctx.author.id], @@ -281,16 +333,34 @@ class Tags(Cog): return False @tags_group.command(name='get', aliases=('show', 'g')) - async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> bool: + async def get_command( + self, ctx: Context, + tag_name_or_group: TagNameConverter = None, + tag_name: TagNameConverter = None, + ) -> bool: """ Get a specified tag, or a list of all tags if no tag is specified. Returns True if something can be sent, or if the tag is on cooldown. Returns False if no matches are found. """ - return await self.display_tag(ctx, tag_name) + if tag_name is None: + tag_name = tag_name_or_group + tag_group = None + else: + tag_group = tag_name_or_group + return await self.display_tag(ctx, TagIdentifier(tag_group, tag_name)) def setup(bot: Bot) -> None: """Load the Tags cog.""" bot.add_cog(Tags(bot)) + + +def extract_tag_identifier(string: str) -> TagIdentifier: + """Create a `TagIdentifier` instance from beginning of `string`.""" + split_string = string.removeprefix(constants.Bot.prefix).split(" ", maxsplit=2) + if len(split_string) == 1: + return TagIdentifier(None, split_string[0]) + else: + return TagIdentifier(split_string[0], split_string[1]) -- cgit v1.2.3 From 605768d14d450dd46057c19560599bbbf0d8d597 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 30 Jun 2021 17:00:47 +0200 Subject: Move cooldown handling to the Tag class Instead of the Cog keeping track of cooldowns of all tags, every tag now handles its own cooldowns which are registered with the `set_cooldown_for` method. This change also fixes the bug where cooldowns can only be on cooldown in only one channel at a time, with invokations in other places cancelling cooldowns. --- bot/exts/info/tags.py | 49 +++++++++++++++---------------------------------- 1 file changed, 15 insertions(+), 34 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 3c7b9ea0b..1665275b9 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -67,6 +67,7 @@ class Tag: self.content = post.content self.metadata = post.metadata self._restricted_to: set[int] = set(self.metadata.get("restricted_to", ())) + self._cooldowns: dict[discord.TextChannel, float] = {} @property def embed(self) -> Embed: @@ -82,6 +83,14 @@ class Tag: or self._restricted_to & {role.id for role in member.roles} ) + def on_cooldown_in(self, channel: discord.TextChannel) -> bool: + """Check whether the tag is on cooldown in `channel`.""" + return channel in self._cooldowns and self._cooldowns[channel] > time.time() + + def set_cooldown_for(self, channel: discord.TextChannel) -> None: + """Set the tag to be on cooldown in `channel` for `constants.Cooldowns.tags` seconds.""" + self._cooldowns[channel] = time.time() + constants.Cooldowns.tags + def _fuzzy_search(search: str, target: str) -> float: """A simple scoring algorithm based on how many letters are found / total, with order in mind.""" @@ -105,7 +114,6 @@ class Tags(Cog): def __init__(self, bot: Bot): self.bot = bot - self.tag_cooldowns = {} self._tags: dict[TagIdentifier, Tag] = {} self.initialize_tags() @@ -238,42 +246,14 @@ class Tags(Cog): Tags are on cooldowns on a per-tag, per-channel basis. If a tag is on cooldown, display nothing and return True. """ - def _command_on_cooldown(tag_name: str) -> bool: - """ - Check if the command is currently on cooldown, on a per-tag, per-channel basis. - - The cooldown duration is set in constants.py. - """ - now = time.time() - - cooldown_conditions = ( - tag_name - and tag_name in self.tag_cooldowns - and (now - self.tag_cooldowns[tag_name]["time"]) < constants.Cooldowns.tags - and self.tag_cooldowns[tag_name]["channel"] == ctx.channel.id - ) - - if cooldown_conditions: - return True - return False - - if _command_on_cooldown(tag_identifier.name): - time_elapsed = time.time() - self.tag_cooldowns[tag_identifier.name]["time"] - time_left = constants.Cooldowns.tags - time_elapsed - log.info( - f"{ctx.author} tried to get the '{tag_identifier.name}' tag, but the tag is on cooldown. " - f"Cooldown ends in {time_left:.1f} seconds." - ) - return True - if tag_identifier.name is not None: if (tag := self._tags.get(tag_identifier)) is not None and tag.accessible_by(ctx.author): - if ctx.channel.id not in TEST_CHANNELS: - self.tag_cooldowns[tag_identifier.name] = { - "time": time.time(), - "channel": ctx.channel.id - } + + if tag.on_cooldown_in(ctx.channel): + log.debug(f"Tag {str(tag_identifier)!r} is on cooldown.") + return True + tag.set_cooldown_for(ctx.channel) self.bot.stats.incr( f"tags.usages" @@ -295,6 +275,7 @@ class Tags(Cog): str(identifier) for identifier, tag in suggested_tags if tag.accessible_by(ctx.author) + and not tag.on_cooldown_in(ctx.channel) ) await wait_for_deletion( await ctx.send( -- cgit v1.2.3 From 6f45d6896adb3f05962733cec8e5db199def20bc Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 2 Jul 2021 10:29:13 +0200 Subject: Bump embed limit to 4096 characters --- bot/exts/backend/branding/_cog.py | 6 +++--- bot/exts/filters/antispam.py | 2 +- bot/exts/info/doc/_parsing.py | 2 +- bot/exts/info/python_news.py | 2 +- bot/exts/moderation/infraction/_utils.py | 4 ++-- bot/exts/moderation/modlog.py | 4 ++-- bot/exts/moderation/watchchannels/_watchchannel.py | 2 +- bot/exts/recruitment/talentpool/_review.py | 4 +++- bot/pagination.py | 18 +++++++++--------- tests/bot/exts/moderation/infraction/test_utils.py | 2 +- tests/bot/exts/moderation/test_modlog.py | 2 +- 11 files changed, 25 insertions(+), 23 deletions(-) diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py index 47c379a34..0ba146635 100644 --- a/bot/exts/backend/branding/_cog.py +++ b/bot/exts/backend/branding/_cog.py @@ -50,7 +50,7 @@ def make_embed(title: str, description: str, *, success: bool) -> discord.Embed: For both `title` and `description`, empty string are valid values ~ fields will be empty. """ colour = Colours.soft_green if success else Colours.soft_red - return discord.Embed(title=title[:256], description=description[:2048], colour=colour) + return discord.Embed(title=title[:256], description=description[:4096], colour=colour) def extract_event_duration(event: Event) -> str: @@ -293,8 +293,8 @@ class Branding(commands.Cog): else: content = "Python Discord is entering a new event!" if is_notification else None - embed = discord.Embed(description=description[:2048], colour=discord.Colour.blurple()) - embed.set_footer(text=duration[:2048]) + embed = discord.Embed(description=description[:4096], colour=discord.Colour.blurple()) + embed.set_footer(text=duration[:4096]) await channel.send(content=content, embed=embed) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 7555e25a2..2f0771396 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -84,7 +84,7 @@ class DeletionContext: mod_alert_message += "Message:\n" [message] = self.messages.values() content = message.clean_content - remaining_chars = 2040 - len(mod_alert_message) + remaining_chars = 4080 - len(mod_alert_message) if len(content) > remaining_chars: content = content[:remaining_chars] + "..." diff --git a/bot/exts/info/doc/_parsing.py b/bot/exts/info/doc/_parsing.py index bf840b96f..1a0d42c47 100644 --- a/bot/exts/info/doc/_parsing.py +++ b/bot/exts/info/doc/_parsing.py @@ -34,7 +34,7 @@ _EMBED_CODE_BLOCK_LINE_LENGTH = 61 # _MAX_SIGNATURE_AMOUNT code block wrapped lines with py syntax highlight _MAX_SIGNATURES_LENGTH = (_EMBED_CODE_BLOCK_LINE_LENGTH + 8) * MAX_SIGNATURE_AMOUNT # Maximum embed description length - signatures on top -_MAX_DESCRIPTION_LENGTH = 2048 - _MAX_SIGNATURES_LENGTH +_MAX_DESCRIPTION_LENGTH = 4096 - _MAX_SIGNATURES_LENGTH _TRUNCATE_STRIP_CHARACTERS = "!?:;." + string.whitespace BracketPair = namedtuple("BracketPair", ["opening_bracket", "closing_bracket"]) diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index 0ab5738a4..a7837c93a 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -173,7 +173,7 @@ class PythonNews(Cog): # Build an embed and send a message to the webhook embed = discord.Embed( title=thread_information["subject"], - description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content, + description=content[:1000] + f"... [continue reading]({link})" if len(content) > 1000 else content, timestamp=new_date, url=link, colour=constants.Colours.soft_green diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index e4eb7f79c..cfb238fa3 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -169,8 +169,8 @@ async def notify_infraction( ) # For case when other fields than reason is too long and this reach limit, then force-shorten string - if len(text) > 2048: - text = f"{text[:2045]}..." + if len(text) > 4096: + text = f"{text[:4093]}..." embed = discord.Embed( description=text, diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index be65ade6e..be2245650 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -99,7 +99,7 @@ class ModLog(Cog, name="ModLog"): """Generate log embed and send to logging channel.""" # Truncate string directly here to avoid removing newlines embed = discord.Embed( - description=text[:2045] + "..." if len(text) > 2048 else text + description=text[:4093] + "..." if len(text) > 4096 else text ) if title and icon_url: @@ -564,7 +564,7 @@ class ModLog(Cog, name="ModLog"): # Shorten the message content if necessary content = message.clean_content - remaining_chars = 2040 - len(response) + remaining_chars = 4090 - len(response) if len(content) > remaining_chars: botlog_url = await self.upload_log(messages=[message], actor_id=message.author.id) diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index 9f26c34f2..146426569 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -295,7 +295,7 @@ class WatchChannel(metaclass=CogABCMeta): footer = f"Added {time_delta} by {actor} | Reason: {reason}" embed = Embed(description=f"{msg.author.mention} {message_jump}") - embed.set_footer(text=textwrap.shorten(footer, width=128, placeholder="...")) + embed.set_footer(text=textwrap.shorten(footer, width=256, placeholder="...")) await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 0cb786e4b..c4c68dbc3 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -31,6 +31,8 @@ MAX_DAYS_IN_POOL = 30 # Maximum amount of characters allowed in a message MAX_MESSAGE_SIZE = 2000 +# Maximum amount of characters allowed in an embed +MAX_EMBED_SIZE = 4000 # Regex finding the user ID of a user mention MENTION_RE = re.compile(r"<@!?(\d+?)>") @@ -199,7 +201,7 @@ class Reviewer: channel = self.bot.get_channel(Channels.nomination_archive) for number, part in enumerate( - textwrap.wrap(embed_content, width=MAX_MESSAGE_SIZE, replace_whitespace=False, placeholder="") + textwrap.wrap(embed_content, width=MAX_EMBED_SIZE, replace_whitespace=False, placeholder="") ): await channel.send(embed=Embed( title=embed_title if number == 0 else None, diff --git a/bot/pagination.py b/bot/pagination.py index 1c5b94b07..865acce41 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -49,8 +49,8 @@ class LinePaginator(Paginator): self, prefix: str = '```', suffix: str = '```', - max_size: int = 2000, - scale_to_size: int = 2000, + max_size: int = 4000, + scale_to_size: int = 4000, max_lines: t.Optional[int] = None, linesep: str = "\n" ) -> None: @@ -59,10 +59,10 @@ class LinePaginator(Paginator): It overrides in order to allow us to configure the maximum number of lines per page. """ - # Embeds that exceed 2048 characters will result in an HTTPException - # (Discord API limit), so we've set a limit of 2000 - if max_size > 2000: - raise ValueError(f"max_size must be <= 2,000 characters. ({max_size} > 2000)") + # Embeds that exceed 4096 characters will result in an HTTPException + # (Discord API limit), so we've set a limit of 4000 + if max_size > 4000: + raise ValueError(f"max_size must be <= 4,000 characters. ({max_size} > 4000)") super().__init__( prefix, @@ -74,8 +74,8 @@ class LinePaginator(Paginator): if scale_to_size < max_size: raise ValueError(f"scale_to_size must be >= max_size. ({scale_to_size} < {max_size})") - if scale_to_size > 2000: - raise ValueError(f"scale_to_size must be <= 2,000 characters. ({scale_to_size} > 2000)") + if scale_to_size > 4000: + raise ValueError(f"scale_to_size must be <= 2,000 characters. ({scale_to_size} > 4000)") self.scale_to_size = scale_to_size - len(suffix) self.max_lines = max_lines @@ -197,7 +197,7 @@ class LinePaginator(Paginator): suffix: str = "", max_lines: t.Optional[int] = None, max_size: int = 500, - scale_to_size: int = 2000, + scale_to_size: int = 4000, empty: bool = True, restrict_to_user: User = None, timeout: int = 300, diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index 50a717bb5..c6ae76984 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -213,7 +213,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): type="Mute", expires="N/A", reason="foo bar" * 4000 - )[:2045] + "...", + )[:4093] + "...", colour=Colours.soft_red, url=utils.RULES_URL ).set_author( diff --git a/tests/bot/exts/moderation/test_modlog.py b/tests/bot/exts/moderation/test_modlog.py index f8f142484..79e04837d 100644 --- a/tests/bot/exts/moderation/test_modlog.py +++ b/tests/bot/exts/moderation/test_modlog.py @@ -25,5 +25,5 @@ class ModLogTests(unittest.IsolatedAsyncioTestCase): ) embed = self.channel.send.call_args[1]["embed"] self.assertEqual( - embed.description, ("foo bar" * 3000)[:2045] + "..." + embed.description, ("foo bar" * 3000)[:4093] + "..." ) -- cgit v1.2.3 From db410e8def7d1a0d2cc7dde625cabb78958f0af7 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 2 Jul 2021 16:48:20 +0200 Subject: Make use of Discord timestamps --- bot/exts/info/information.py | 8 +- bot/exts/moderation/defcon.py | 6 +- bot/exts/moderation/infraction/_utils.py | 2 +- bot/exts/moderation/infraction/management.py | 18 +++-- bot/exts/moderation/stream.py | 13 +--- bot/exts/recruitment/talentpool/_review.py | 9 +-- bot/exts/utils/reminders.py | 27 +++---- bot/exts/utils/utils.py | 2 +- bot/utils/time.py | 85 ++++++++++++++-------- tests/bot/exts/info/test_information.py | 8 +- tests/bot/exts/moderation/infraction/test_utils.py | 4 +- tests/bot/utils/test_time.py | 73 ++++++++----------- 12 files changed, 126 insertions(+), 129 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 1b1243118..2c89d39e8 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -17,7 +17,7 @@ from bot.decorators import in_whitelist from bot.pagination import LinePaginator from bot.utils.channel import is_mod_channel, is_staff_channel from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check -from bot.utils.time import humanize_delta, time_since +from bot.utils.time import TimestampFormats, discord_timestamp, humanize_delta log = logging.getLogger(__name__) @@ -154,7 +154,7 @@ class Information(Cog): """Returns an embed full of server information.""" embed = Embed(colour=Colour.blurple(), title="Server Information") - created = time_since(ctx.guild.created_at, precision="days") + created = discord_timestamp(ctx.guild.created_at, TimestampFormats.RELATIVE) region = ctx.guild.region num_roles = len(ctx.guild.roles) - 1 # Exclude @everyone @@ -224,7 +224,7 @@ class Information(Cog): """Creates an embed containing information on the `user`.""" on_server = bool(ctx.guild.get_member(user.id)) - created = time_since(user.created_at, max_units=3) + created = discord_timestamp(user.created_at, TimestampFormats.RELATIVE) name = str(user) if on_server and user.nick: @@ -242,7 +242,7 @@ class Information(Cog): badges.append(emoji) if on_server: - joined = time_since(user.joined_at, max_units=3) + joined = discord_timestamp(user.joined_at, TimestampFormats.RELATIVE) roles = ", ".join(role.mention for role in user.roles[1:]) membership = {"Joined": joined, "Verified": not user.pending, "Roles": roles or None} if not is_mod_channel(ctx.channel): diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index dfb1afd19..9801d45ad 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -19,7 +19,9 @@ from bot.converters import DurationDelta, Expiry from bot.exts.moderation.modlog import ModLog from bot.utils.messages import format_user from bot.utils.scheduling import Scheduler -from bot.utils.time import humanize_delta, parse_duration_string, relativedelta_to_timedelta +from bot.utils.time import ( + TimestampFormats, discord_timestamp, humanize_delta, parse_duration_string, relativedelta_to_timedelta +) log = logging.getLogger(__name__) @@ -150,7 +152,7 @@ class Defcon(Cog): colour=Colour.blurple(), title="DEFCON Status", description=f""" **Threshold:** {humanize_delta(self.threshold) if self.threshold else "-"} - **Expires in:** {humanize_delta(relativedelta(self.expiry, datetime.utcnow())) if self.expiry else "-"} + **Expires:** {discord_timestamp(self.expiry, TimestampFormats.RELATIVE) if self.expiry else "-"} **Verification level:** {ctx.guild.verification_level.name} """ ) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index cfb238fa3..92e0596df 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -164,7 +164,7 @@ async def notify_infraction( text = INFRACTION_DESCRIPTION_TEMPLATE.format( type=infr_type.title(), - expires=f"{expires_at} UTC" if expires_at else "N/A", + expires=expires_at or "N/A", reason=reason or "No reason provided." ) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index b3783cd60..4b0cb78a5 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -3,7 +3,9 @@ import textwrap import typing as t from datetime import datetime +import dateutil.parser import discord +from dateutil.relativedelta import relativedelta from discord.ext import commands from discord.ext.commands import Context from discord.utils import escape_markdown @@ -16,6 +18,7 @@ from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator from bot.utils import messages, time from bot.utils.channel import is_mod_channel +from bot.utils.time import humanize_delta, until_expiration log = logging.getLogger(__name__) @@ -164,8 +167,8 @@ class ModManagement(commands.Cog): self.infractions_cog.schedule_expiration(new_infraction) log_text += f""" - Previous expiry: {infraction['expires_at'] or "Permanent"} - New expiry: {new_infraction['expires_at'] or "Permanent"} + Previous expiry: {until_expiration(infraction['expires_at']) or "Permanent"} + New expiry: {until_expiration(new_infraction['expires_at']) or "Permanent"} """.rstrip() changes = ' & '.join(confirm_messages) @@ -288,10 +291,11 @@ class ModManagement(commands.Cog): remaining = "Inactive" if expires_at is None: - expires = "*Permanent*" + duration = "*Permanent*" else: - date_from = datetime.strptime(created, time.INFRACTION_FORMAT) - expires = time.format_infraction_with_duration(expires_at, date_from) + date_from = datetime.fromtimestamp(float(time.DISCORD_TIMESTAMP_REGEX.match(created).group(1))) + date_to = dateutil.parser.isoparse(expires_at).replace(tzinfo=None) + duration = humanize_delta(relativedelta(date_to, date_from)) lines = textwrap.dedent(f""" {"**===============**" if active else "==============="} @@ -300,8 +304,8 @@ class ModManagement(commands.Cog): Type: **{infraction["type"]}** Shadow: {infraction["hidden"]} Created: {created} - Expires: {expires} - Remaining: {remaining} + Expires: {remaining} + Duration: {duration} Actor: <@{infraction["actor"]["id"]}> ID: `{infraction["id"]}` Reason: {infraction["reason"] or "*None*"} diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index fd856a7f4..07ee4099e 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -13,7 +13,7 @@ from bot.constants import Colours, Emojis, Guild, MODERATION_ROLES, Roles, STAFF from bot.converters import Expiry from bot.pagination import LinePaginator from bot.utils.scheduling import Scheduler -from bot.utils.time import format_infraction_with_duration +from bot.utils.time import discord_timestamp, format_infraction_with_duration log = logging.getLogger(__name__) @@ -134,16 +134,7 @@ class Stream(commands.Cog): await member.add_roles(discord.Object(Roles.video), reason="Temporary streaming access granted") - # Use embed as embed timestamps do timezone conversions. - embed = discord.Embed( - description=f"{Emojis.check_mark} {member.mention} can now stream.", - colour=Colours.soft_green - ) - embed.set_footer(text=f"Streaming permission has been given to {member} until") - embed.timestamp = duration - - # Mention in content as mentions in embeds don't ping - await ctx.send(content=member.mention, embed=embed) + await ctx.send(f"{Emojis.check_mark} {member.mention} can now stream until {discord_timestamp(duration)}.") # Convert here for nicer logging revoke_time = format_infraction_with_duration(str(duration)) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index c4c68dbc3..aebb401e0 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -10,7 +10,6 @@ from datetime import datetime, timedelta from typing import List, Optional, Union from dateutil.parser import isoparse -from dateutil.relativedelta import relativedelta from discord import Embed, Emoji, Member, Message, NoMoreItems, PartialMessage, TextChannel from discord.ext.commands import Context @@ -19,7 +18,7 @@ from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Guild, Roles from bot.utils.messages import count_unique_users_reaction, pin_no_system_message from bot.utils.scheduling import Scheduler -from bot.utils.time import get_time_delta, humanize_delta, time_since +from bot.utils.time import get_time_delta, time_since if typing.TYPE_CHECKING: from bot.exts.recruitment.talentpool._cog import TalentPool @@ -255,9 +254,9 @@ class Reviewer: last_channel = user_activity["top_channel_activity"][-1] channels += f", and {last_channel[1]} in {last_channel[0]}" - time_on_server = humanize_delta(relativedelta(datetime.utcnow(), member.joined_at), max_units=2) + joined_at_formatted = time_since(member.join_at) review = ( - f"{member.name} has been on the server for **{time_on_server}**" + f"{member.name} joined the server **{joined_at_formatted}**" f" and has **{messages} messages**{channels}." ) @@ -347,7 +346,7 @@ class Reviewer: nomination_times = f"{num_entries} times" if num_entries > 1 else "once" rejection_times = f"{len(history)} times" if len(history) > 1 else "once" - end_time = time_since(isoparse(history[0]['ended_at']).replace(tzinfo=None), max_units=2) + end_time = time_since(isoparse(history[0]['ended_at']).replace(tzinfo=None)) review = ( f"They were nominated **{nomination_times}** before" diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 6c21920a1..c7ce8b9e9 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -3,12 +3,11 @@ import logging import random import textwrap import typing as t -from datetime import datetime, timedelta +from datetime import datetime from operator import itemgetter import discord from dateutil.parser import isoparse -from dateutil.relativedelta import relativedelta from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot @@ -19,7 +18,7 @@ from bot.utils.checks import has_any_role_check, has_no_roles_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 +from bot.utils.time import TimestampFormats, discord_timestamp, time_since log = logging.getLogger(__name__) @@ -62,8 +61,7 @@ class Reminders(Cog): # If the reminder is already overdue ... if remind_at < now: - late = relativedelta(now, remind_at) - await self.send_reminder(reminder, late) + await self.send_reminder(reminder, remind_at) else: self.schedule_reminder(reminder) @@ -174,7 +172,7 @@ class Reminders(Cog): self.schedule_reminder(reminder) @lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True) - async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: + async def send_reminder(self, reminder: dict, expected_time: datetime = None) -> None: """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) if not is_valid: @@ -188,16 +186,17 @@ class Reminders(Cog): name="It has arrived!" ) - embed.description = f"Here's your reminder: `{reminder['content']}`." + # Let's not use a codeblock to keep emojis and mentions working. Embeds are safe anyway. + embed.description = f"Here's your reminder: {reminder['content']}." if reminder.get("jump_url"): # keep backward compatibility embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})" - if late: + if expected_time: embed.colour = discord.Colour.red() embed.set_author( icon_url=Icons.remind_red, - name=f"Sorry it arrived {humanize_delta(late, max_units=2)} late!" + name=f"Sorry it should have arrived {time_since(expected_time)} !" ) additional_mentions = ' '.join( @@ -270,9 +269,7 @@ 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}" + mention_string = f"Your reminder will arrive {discord_timestamp(expiration, TimestampFormats.RELATIVE)}" if mentions: mention_string += f" and will mention {len(mentions)} other(s)" @@ -297,8 +294,6 @@ class Reminders(Cog): params={'author__id': str(ctx.author.id)} ) - now = datetime.utcnow() - # Make a list of tuples so it can be sorted by time. reminders = sorted( ( @@ -313,7 +308,7 @@ class Reminders(Cog): for content, remind_at, id_, mentions in reminders: # Parse and humanize the time, make it pretty :D remind_datetime = isoparse(remind_at).replace(tzinfo=None) - time = humanize_delta(relativedelta(remind_datetime, now)) + time = discord_timestamp(remind_datetime, TimestampFormats.RELATIVE) mentions = ", ".join( # Both Role and User objects have the `name` attribute @@ -322,7 +317,7 @@ class Reminders(Cog): mention_string = f"\n**Mentions:** {mentions}" if mentions else "" text = textwrap.dedent(f""" - **Reminder #{id_}:** *expires in {time}* (ID: {id_}){mention_string} + **Reminder #{id_}:** *expires {time}* (ID: {id_}){mention_string} {content} """).strip() diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 3b8564aee..2831e30cc 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -175,7 +175,7 @@ class Utils(Cog): lines = [] for snowflake in snowflakes: created_at = snowflake_time(snowflake) - lines.append(f"**{snowflake}**\nCreated at {created_at} ({time_since(created_at, max_units=3)}).") + lines.append(f"**{snowflake}**\nCreated at {created_at} ({time_since(created_at)}).") await LinePaginator.paginate( lines, diff --git a/bot/utils/time.py b/bot/utils/time.py index d55a0e532..8cf7d623b 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -1,12 +1,13 @@ import datetime import re -from typing import Optional +from enum import Enum +from typing import Optional, Union import dateutil.parser from dateutil.relativedelta import relativedelta RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" -INFRACTION_FORMAT = "%Y-%m-%d %H:%M" +DISCORD_TIMESTAMP_REGEX = re.compile(r"") _DURATION_REGEX = re.compile( r"((?P\d+?) ?(years|year|Y|y) ?)?" @@ -19,6 +20,25 @@ _DURATION_REGEX = re.compile( ) +ValidTimestamp = Union[int, datetime.datetime, datetime.date, datetime.timedelta, relativedelta] + + +class TimestampFormats(Enum): + """ + Represents the different formats possible for Discord timestamps. + + Examples are given in epoch time. + """ + + DATE_TIME = "f" # January 1, 1970 1:00 AM + DAY_TIME = "F" # Thursday, January 1, 1970 1:00 AM + DATE_SHORT = "d" # 01/01/1970 + DATE = "D" # January 1, 1970 + TIME = "t" # 1:00 AM + TIME_SECONDS = "T" # 1:00:00 AM + RELATIVE = "R" # 52 years ago + + def _stringify_time_unit(value: int, unit: str) -> str: """ Returns a string to represent a value and time unit, ensuring that it uses the right plural form of the unit. @@ -40,6 +60,24 @@ def _stringify_time_unit(value: int, unit: str) -> str: return f"{value} {unit}" +def discord_timestamp(timestamp: ValidTimestamp, format: TimestampFormats = TimestampFormats.DATE_TIME) -> str: + """Create and format a Discord flavored markdown timestamp.""" + if format not in TimestampFormats: + raise ValueError(f"Format can only be one of {', '.join(TimestampFormats.args)}, not {format}.") + + # Convert each possible timestamp class to an integer. + if isinstance(timestamp, datetime.datetime): + timestamp = (timestamp.replace(tzinfo=None) - datetime.datetime.utcfromtimestamp(0)).total_seconds() + elif isinstance(timestamp, datetime.date): + timestamp = (timestamp - datetime.date.fromtimestamp(0)).total_seconds() + elif isinstance(timestamp, datetime.timedelta): + timestamp = timestamp.total_seconds() + elif isinstance(timestamp, relativedelta): + timestamp = timestamp.seconds + + return f"" + + def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str: """ Returns a human-readable version of the relativedelta. @@ -87,7 +125,7 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: def get_time_delta(time_string: str) -> str: """Returns the time in human-readable time delta format.""" date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None) - time_delta = time_since(date_time, precision="minutes", max_units=1) + time_delta = time_since(date_time) return time_delta @@ -123,19 +161,9 @@ def relativedelta_to_timedelta(delta: relativedelta) -> datetime.timedelta: return utcnow + delta - utcnow -def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max_units: int = 6) -> str: - """ - Takes a datetime and returns a human-readable string that describes how long ago that datetime was. - - precision specifies the smallest unit of time to include (e.g. "seconds", "minutes"). - max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). - """ - now = datetime.datetime.utcnow() - delta = abs(relativedelta(now, past_datetime)) - - humanized = humanize_delta(delta, precision, max_units) - - return f"{humanized} ago" +def time_since(past_datetime: datetime.datetime) -> str: + """Takes a datetime and returns a discord timestamp that describes how long ago that datetime was.""" + return discord_timestamp(past_datetime, TimestampFormats.RELATIVE) def parse_rfc1123(stamp: str) -> datetime.datetime: @@ -144,8 +172,8 @@ def parse_rfc1123(stamp: str) -> datetime.datetime: def format_infraction(timestamp: str) -> str: - """Format an infraction timestamp to a more readable ISO 8601 format.""" - return dateutil.parser.isoparse(timestamp).strftime(INFRACTION_FORMAT) + """Format an infraction timestamp to a discord timestamp.""" + return discord_timestamp(dateutil.parser.isoparse(timestamp)) def format_infraction_with_duration( @@ -155,11 +183,7 @@ def format_infraction_with_duration( absolute: bool = True ) -> Optional[str]: """ - Return `date_to` formatted as a readable ISO-8601 with the humanized duration since `date_from`. - - `date_from` must be an ISO-8601 formatted timestamp. The duration is calculated as from - `date_from` until `date_to` with a precision of seconds. If `date_from` is unspecified, the - current time is used. + Return `date_to` formatted as a discord timestamp with the timestamp duration since `date_from`. `max_units` specifies the maximum number of units of time to include in the duration. For example, a value of 1 may include days but not hours. @@ -186,25 +210,22 @@ def format_infraction_with_duration( def until_expiration( - expiry: Optional[str], - now: Optional[datetime.datetime] = None, - max_units: int = 2 + expiry: Optional[str] ) -> Optional[str]: """ - Get the remaining time until infraction's expiration, in a human-readable version of the relativedelta. + Get the remaining time until infraction's expiration, in a discord timestamp. Returns a human-readable version of the remaining duration between datetime.utcnow() and an expiry. - Unlike `humanize_delta`, this function will force the `precision` to be `seconds` by not passing it. - `max_units` specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). - By default, max_units is 2. + Similar to time_since, except that this function doesn't error on a null input + and return null if the expiry is in the paste """ if not expiry: return None - now = now or datetime.datetime.utcnow() + now = datetime.datetime.utcnow() since = dateutil.parser.isoparse(expiry).replace(tzinfo=None, microsecond=0) if since < now: return None - return humanize_delta(relativedelta(since, now), max_units=max_units) + return discord_timestamp(since, TimestampFormats.RELATIVE) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 770660fe3..ced3a2449 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -347,7 +347,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( textwrap.dedent(f""" - Created: {"1 year ago"} + Created: {""} Profile: {user.mention} ID: {user.id} """).strip(), @@ -356,7 +356,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( textwrap.dedent(f""" - Joined: {"1 year ago"} + Joined: {""} Verified: {"True"} Roles: &Moderators """).strip(), @@ -379,7 +379,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( textwrap.dedent(f""" - Created: {"1 year ago"} + Created: {""} Profile: {user.mention} ID: {user.id} """).strip(), @@ -388,7 +388,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( textwrap.dedent(f""" - Joined: {"1 year ago"} + Joined: {""} Roles: &Moderators """).strip(), embed.fields[1].value diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index c6ae76984..5f95ced9f 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -137,7 +137,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): title=utils.INFRACTION_TITLE, description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Ban", - expires="2020-02-26 09:20 (23 hours and 59 minutes) UTC", + expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="No reason provided." ), colour=Colours.soft_red, @@ -193,7 +193,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): title=utils.INFRACTION_TITLE, description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Mute", - expires="2020-02-26 09:20 (23 hours and 59 minutes) UTC", + expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="Test" ), colour=Colours.soft_red, diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 115ddfb0d..8edffd1c9 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -52,7 +52,7 @@ class TimeTests(unittest.TestCase): def test_format_infraction(self): """Testing format_infraction.""" - self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '2019-12-12 00:01') + self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '') def test_format_infraction_with_duration_none_expiry(self): """format_infraction_with_duration should work for None expiry.""" @@ -72,10 +72,10 @@ class TimeTests(unittest.TestCase): def test_format_infraction_with_duration_custom_units(self): """format_infraction_with_duration should work for custom max_units.""" test_cases = ( - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, - '2019-12-12 00:01 (11 hours, 55 minutes and 55 seconds)'), - ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20, - '2019-11-23 20:09 (6 months, 28 days, 23 hours and 54 minutes)') + ('3000-12-12T00:01:00Z', datetime(3000, 12, 11, 12, 5, 5), 6, + ' (11 hours, 55 minutes and 55 seconds)'), + ('3000-11-23T20:09:00Z', datetime(3000, 4, 25, 20, 15), 20, + ' (6 months, 28 days, 23 hours and 54 minutes)') ) for expiry, date_from, max_units, expected in test_cases: @@ -85,16 +85,16 @@ class TimeTests(unittest.TestCase): def test_format_infraction_with_duration_normal_usage(self): """format_infraction_with_duration should work for normal usage, across various durations.""" test_cases = ( - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '2019-12-12 00:01 (12 hours and 55 seconds)'), - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '2019-12-12 00:01 (12 hours)'), - ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '2019-12-12 00:00 (1 minute)'), - ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '2019-11-23 20:09 (7 days and 23 hours)'), - ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '2019-11-23 20:09 (6 months and 28 days)'), - ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '2019-11-23 20:58 (5 minutes)'), - ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '2019-11-24 00:00 (1 minute)'), - ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '2019-11-23 23:59 (2 years and 4 months)'), + ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, ' (12 hours and 55 seconds)'), + ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, ' (12 hours)'), + ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, ' (1 minute)'), + ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, ' (7 days and 23 hours)'), + ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, ' (6 months and 28 days)'), + ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, ' (5 minutes)'), + ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, ' (1 minute)'), + ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, ' (2 years and 4 months)'), ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2, - '2019-11-23 23:59 (9 minutes and 55 seconds)'), + ' (9 minutes and 55 seconds)'), (None, datetime(2019, 11, 23, 23, 49, 5), 2, None), ) @@ -104,45 +104,30 @@ class TimeTests(unittest.TestCase): def test_until_expiration_with_duration_none_expiry(self): """until_expiration should work for None expiry.""" - test_cases = ( - (None, None, None, None), - - # To make sure that now and max_units are not touched - (None, 'Why hello there!', None, None), - (None, None, float('inf'), None), - (None, 'Why hello there!', float('inf'), None), - ) - - for expiry, now, max_units, expected in test_cases: - with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected): - self.assertEqual(time.until_expiration(expiry, now, max_units), expected) + self.assertEqual(time.until_expiration(None), None) def test_until_expiration_with_duration_custom_units(self): """until_expiration should work for custom max_units.""" test_cases = ( - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, '11 hours, 55 minutes and 55 seconds'), - ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20, '6 months, 28 days, 23 hours and 54 minutes') + ('3000-12-12T00:01:00Z', ''), + ('3000-11-23T20:09:00Z', '') ) - for expiry, now, max_units, expected in test_cases: - with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected): - self.assertEqual(time.until_expiration(expiry, now, max_units), expected) + for expiry, expected in test_cases: + with self.subTest(expiry=expiry, expected=expected): + self.assertEqual(time.until_expiration(expiry,), expected) def test_until_expiration_normal_usage(self): """until_expiration should work for normal usage, across various durations.""" test_cases = ( - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '12 hours and 55 seconds'), - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '12 hours'), - ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '1 minute'), - ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '7 days and 23 hours'), - ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '6 months and 28 days'), - ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '5 minutes'), - ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '1 minute'), - ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '2 years and 4 months'), - ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2, '9 minutes and 55 seconds'), - (None, datetime(2019, 11, 23, 23, 49, 5), 2, None), + ('3000-12-12T00:01:00Z', ''), + ('3000-12-12T00:01:00Z', ''), + ('3000-12-12T00:00:00Z', ''), + ('3000-11-23T20:09:00Z', ''), + ('3000-11-23T20:09:00Z', ''), + (None, None), ) - for expiry, now, max_units, expected in test_cases: - with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected): - self.assertEqual(time.until_expiration(expiry, now, max_units), expected) + for expiry, expected in test_cases: + with self.subTest(expiry=expiry, expected=expected): + self.assertEqual(time.until_expiration(expiry), expected) -- cgit v1.2.3 From 231f6dc1d7e9fe478c1fcd517c2ec94a54e0763a Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 2 Jul 2021 16:58:32 +0200 Subject: Tests: remove stale patch of time_since --- tests/bot/exts/info/test_information.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index ced3a2449..0aa41d889 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -262,7 +262,6 @@ class UserInfractionHelperMethodTests(unittest.IsolatedAsyncioTestCase): await self._method_subtests(self.cog.user_nomination_counts, test_values, header) -@unittest.mock.patch("bot.exts.info.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago")) @unittest.mock.patch("bot.exts.info.information.constants.MODERATION_CHANNELS", new=[50]) class UserEmbedTests(unittest.IsolatedAsyncioTestCase): """Tests for the creation of the `!user` embed.""" -- cgit v1.2.3 From 7d1ee897b565daef1a8cc073d4dbaf0602185528 Mon Sep 17 00:00:00 2001 From: ToxicKidz Date: Mon, 5 Jul 2021 17:26:11 -0400 Subject: chore: Add the codejam create command This command takes a CSV file or a link to one. This CSV file has three rows: 'Team Name', 'Team Member Discord ID', and 'Team Leader'. The Team Name will be the name of the team's channel, the Member ID tells which user belongs to this team, and leam leader, which is either Y/N, tells if this user is the team leader. It will create text channels for each team and make a team leaders chat channel as well. It will ping the Events Lead role with updates for this command. --- bot/constants.py | 6 +- bot/exts/utils/jams.py | 171 +++++++++++++++++++++++++++++-------------------- config-default.yml | 5 +- 3 files changed, 111 insertions(+), 71 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 885b5c822..f33c14798 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -400,6 +400,8 @@ class Categories(metaclass=YAMLGetter): modmail: int voice: int + # 2021 Summer Code Jam + summer_code_jam: int class Channels(metaclass=YAMLGetter): section = "guild" @@ -437,6 +439,7 @@ class Channels(metaclass=YAMLGetter): discord_py: int esoteric: int voice_gate: int + code_jam_planning: int admins: int admin_spam: int @@ -495,8 +498,10 @@ class Roles(metaclass=YAMLGetter): admins: int core_developers: int + code_jam_event_team: int devops: int domain_leads: int + events_lead: int helpers: int moderators: int mod_team: int @@ -504,7 +509,6 @@ class Roles(metaclass=YAMLGetter): project_leads: int jammers: int - team_leaders: int class Guild(metaclass=YAMLGetter): diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py index 98fbcb303..d45f9b57f 100644 --- a/bot/exts/utils/jams.py +++ b/bot/exts/utils/jams.py @@ -1,17 +1,19 @@ +import csv import logging import typing as t +from collections import defaultdict -from discord import CategoryChannel, Guild, Member, PermissionOverwrite, Role +import discord from discord.ext import commands -from more_itertools import unique_everseen from bot.bot import Bot -from bot.constants import Roles +from bot.constants import Categories, Channels, Emojis, Roles log = logging.getLogger(__name__) MAX_CHANNELS = 50 CATEGORY_NAME = "Code Jam" +TEAM_LEADERS_COLOUR = 0x11806a class CodeJams(commands.Cog): @@ -20,39 +22,57 @@ class CodeJams(commands.Cog): def __init__(self, bot: Bot): self.bot = bot - @commands.command() + @commands.group() @commands.has_any_role(Roles.admins) - async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None: + async def codejam(self, ctx: commands.Context) -> None: + """A Group of commands for managing Code Jams.""" + if ctx.invoked_subcommand is None: + await ctx.send_help(ctx.command) + + @codejam.command() + async def create(self, ctx: commands.Context, csv_file: t.Optional[str]) -> None: """ - Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team. + Create code-jam teams from a CSV file or a link to one, specifying the team names, leaders and members. + + The CSV file must have 3 columns: 'Team Name', 'Team Member Discord ID', and 'Team Leader'. - The first user passed will always be the team leader. + This will create the text channels for the teams, and give the team leaders their roles. """ - # Ignore duplicate members - members = list(unique_everseen(members)) - - # We had a little issue during Code Jam 4 here, the greedy converter did it's job - # and ignored anything which wasn't a valid argument which left us with teams of - # two members or at some times even 1 member. This fixes that by checking that there - # are always 3 members in the members list. - if len(members) < 3: - await ctx.send( - ":no_entry_sign: One of your arguments was invalid\n" - f"There must be a minimum of 3 valid members in your team. Found: {len(members)}" - " members" - ) - return + async with ctx.typing(): + if csv_file: + async with self.bot.http_session.get(csv_file) as response: + if response.status != 200: + await ctx.send(f"Got a bad response from the URL: {response.status}") + return - team_channel = await self.create_channels(ctx.guild, team_name, members) - await self.add_roles(ctx.guild, members) + csv_file = await response.text() - await ctx.send( - f":ok_hand: Team created: {team_channel}\n" - f"**Team Leader:** {members[0].mention}\n" - f"**Team Members:** {' '.join(member.mention for member in members[1:])}" - ) + elif ctx.message.attachments: + csv_file = (await ctx.message.attachments[0].read()).decode("utf8") + else: + raise commands.BadArgument("You must include either a CSV file or a link to one.") + + teams = defaultdict(list) + reader = csv.DictReader(csv_file.splitlines()) + + for row in reader: + member = ctx.guild.get_member(int(row["Team Member Discord ID"])) + + if member is None: + log.trace(f"Got an invalid member ID: {row['Team Member Discord ID']}") + continue + + teams[row["Team Name"]].append((member, row["Team Leader"].upper() == "Y")) + + team_leaders = await ctx.guild.create_role(name="Team Leaders", colour=TEAM_LEADERS_COLOUR) + + for team_name, members in teams.items(): + await self.create_team_channel(ctx.guild, team_name, members, team_leaders) - async def get_category(self, guild: Guild) -> CategoryChannel: + await self.create_team_leader_channel(ctx.guild, team_leaders) + await ctx.send(f"{Emojis.check_mark} Created Code Jam with {len(teams)} teams.") + + async def get_category(self, guild: discord.Guild) -> discord.CategoryChannel: """ Return a code jam category. @@ -60,84 +80,97 @@ class CodeJams(commands.Cog): """ for category in guild.categories: # Need 2 available spaces: one for the text channel and one for voice. - if category.name == CATEGORY_NAME and MAX_CHANNELS - len(category.channels) >= 2: + if category.name == CATEGORY_NAME and len(category.channels) < MAX_CHANNELS: return category return await self.create_category(guild) - @staticmethod - async def create_category(guild: Guild) -> CategoryChannel: + async def create_category(self, guild: discord.Guild) -> discord.CategoryChannel: """Create a new code jam category and return it.""" log.info("Creating a new code jam category.") category_overwrites = { - guild.default_role: PermissionOverwrite(read_messages=False), - guild.me: PermissionOverwrite(read_messages=True) + guild.default_role: discord.PermissionOverwrite(read_messages=False), + guild.me: discord.PermissionOverwrite(read_messages=True) } - return await guild.create_category_channel( + category = await guild.create_category_channel( CATEGORY_NAME, overwrites=category_overwrites, reason="It's code jam time!" ) + await self.send_status_update( + guild, f"Created a new category with the ID {category.id} for this Code Jam's team channels." + ) + + return category + @staticmethod - def get_overwrites(members: t.List[Member], guild: Guild) -> t.Dict[t.Union[Member, Role], PermissionOverwrite]: + def get_overwrites( + members: list[tuple[discord.Member, bool]], + guild: discord.Guild, + ) -> dict[t.Union[discord.Member, discord.Role], discord.PermissionOverwrite]: """Get code jam team channels permission overwrites.""" - # First member is always the team leader team_channel_overwrites = { - members[0]: PermissionOverwrite( - manage_messages=True, - read_messages=True, - manage_webhooks=True, - connect=True - ), - guild.default_role: PermissionOverwrite(read_messages=False, connect=False), + guild.default_role: discord.PermissionOverwrite(read_messages=False), + guild.get_role(Roles.moderators): discord.PermissionOverwrite(read_messages=True), + guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite(read_messages=True) } - # Rest of members should just have read_messages - for member in members[1:]: - team_channel_overwrites[member] = PermissionOverwrite( - read_messages=True, - connect=True + for member, _ in members: + team_channel_overwrites[member] = discord.PermissionOverwrite( + read_messages=True ) return team_channel_overwrites - async def create_channels(self, guild: Guild, team_name: str, members: t.List[Member]) -> str: - """Create team text and voice channels. Return the mention for the text channel.""" + async def create_team_channel( + self, + guild: discord.Guild, + team_name: str, + members: list[tuple[discord.Member, bool]], + team_leaders: discord.Role + ) -> None: + """Create the team's text channel.""" + await self.add_team_leader_roles(members, team_leaders) + # Get permission overwrites and category team_channel_overwrites = self.get_overwrites(members, guild) code_jam_category = await self.get_category(guild) # Create a text channel for the team - team_channel = await guild.create_text_channel( + await code_jam_category.create_text_channel( team_name, overwrites=team_channel_overwrites, - category=code_jam_category ) - # Create a voice channel for the team - team_voice_name = " ".join(team_name.split("-")).title() + async def create_team_leader_channel(self, guild: discord.Guild, team_leaders: discord.Role) -> None: + """Create the Team Leader Chat channel for the Code Jam team leaders.""" + category: discord.CategoryChannel = guild.get_channel(Categories.summer_code_jam) - await guild.create_voice_channel( - team_voice_name, - overwrites=team_channel_overwrites, - category=code_jam_category + team_leaders_chat = await category.create_text_channel( + name="team-leaders-chat", + overwrites={ + guild.default_role: discord.PermissionOverwrite(read_messages=False), + team_leaders: discord.PermissionOverwrite(read_messages=True) + } ) - return team_channel.mention + await self.send_status_update(guild, f"Created {team_leaders_chat.mention} in the {category} category.") + + async def send_status_update(self, guild: discord.Guild, message: str) -> None: + """Inform the events lead with a status update when the command is ran.""" + channel: discord.TextChannel = guild.get_channel(Channels.code_jam_planning) + + await channel.send(f"<@&{Roles.events_lead}>\n\n{message}") @staticmethod - async def add_roles(guild: Guild, members: t.List[Member]) -> None: - """Assign team leader and jammer roles.""" - # Assign team leader role - await members[0].add_roles(guild.get_role(Roles.team_leaders)) - - # Assign rest of roles - jammer_role = guild.get_role(Roles.jammers) - for member in members: - await member.add_roles(jammer_role) + async def add_team_leader_roles(members: list[tuple[discord.Member, bool]], team_leaders: discord.Role) -> None: + """Assign team leader role, the jammer role and their team role.""" + for member, is_leader in members: + if is_leader: + await member.add_roles(team_leaders) def setup(bot: Bot) -> None: diff --git a/config-default.yml b/config-default.yml index 394c51c26..8c30ecf69 100644 --- a/config-default.yml +++ b/config-default.yml @@ -142,6 +142,7 @@ guild: moderators: &MODS_CATEGORY 749736277464842262 modmail: &MODMAIL 714494672835444826 voice: 356013253765234688 + summer_code_jam: 861692638540857384 channels: # Public announcement and news channels @@ -185,6 +186,7 @@ guild: bot_commands: &BOT_CMD 267659945086812160 esoteric: 470884583684964352 voice_gate: 764802555427029012 + code_jam_planning: 490217981872177157 # Staff admins: &ADMINS 365960823622991872 @@ -258,8 +260,10 @@ guild: # Staff admins: &ADMINS_ROLE 267628507062992896 core_developers: 587606783669829632 + code_jam_event_team: 787816728474288181 devops: 409416496733880320 domain_leads: 807415650778742785 + events_lead: 778361735739998228 helpers: &HELPERS_ROLE 267630620367257601 moderators: &MODS_ROLE 831776746206265384 mod_team: &MOD_TEAM_ROLE 267629731250176001 @@ -268,7 +272,6 @@ guild: # Code Jam jammers: 737249140966162473 - team_leaders: 737250302834638889 # Streaming video: 764245844798079016 -- cgit v1.2.3 From 089ef6219d98cbfcf9eed8bced6cb3ca43f0c55b Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Tue, 6 Jul 2021 02:33:57 +0300 Subject: Adds Documentation For Running A Single Test (#1669) Adds a portion to the testing README explaining how and when to run an individual test file when working with tests. Additionally adds a table of contents as the document has become quite long. Signed-off-by: Hassan Abouelela --- tests/README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/README.md b/tests/README.md index 0192f916e..b7fddfaa2 100644 --- a/tests/README.md +++ b/tests/README.md @@ -4,6 +4,14 @@ Our bot is one of the most important tools we have for running our community. As _**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you can take a look at the [Additional resources](#additional-resources) section at the bottom of this page._ +### Table of contents: +- [Tools](#tools) +- [Running tests](#running-tests) +- [Writing tests](#writing-tests) +- [Mocking](#mocking) +- [Some considerations](#some-considerations) +- [Additional resources](#additional-resources) + ## Tools We are using the following modules and packages for our unit tests: @@ -25,6 +33,29 @@ To ensure the results you obtain on your personal machine are comparable to thos If you want a coverage report, make sure to run the tests with `poetry run task test` *first*. +## Running tests +There are multiple ways to run the tests, which one you use will be determined by your goal, and stage in development. + +When actively developing, you'll most likely be working on one portion of the codebase, and as a result, won't need to run the entire test suite. +To run just one file, and save time, you can use the following command: +```shell +poetry run task test-nocov +``` + +For example: +```shell +poetry run task test-nocov tests/bot/exts/test_cogs.py +``` +will run the test suite in the `test_cogs` file. + +If you'd like to collect coverage as well, you can append `--cov` to the command above. + + +If you're done and are preparing to commit and push your code, it's a good idea to run the entire test suite as a sanity check: +```shell +poetry run task test +``` + ## Writing tests Since consistency is an important consideration for collaborative projects, we have written some guidelines on writing tests for the bot. In addition to these guidelines, it's a good idea to look at the existing code base for examples (e.g., [`test_converters.py`](/tests/bot/test_converters.py)). -- cgit v1.2.3 From 2a5a15f69d8ea3079f60e0e5d44387bc59061de5 Mon Sep 17 00:00:00 2001 From: ToxicKidz Date: Mon, 5 Jul 2021 19:48:29 -0400 Subject: chore: Update tests for the new codejam create command --- bot/exts/utils/jams.py | 1 - tests/bot/exts/utils/test_jams.py | 137 +++++++++++++++++++------------------- tests/helpers.py | 22 ++++++ 3 files changed, 92 insertions(+), 68 deletions(-) diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py index d45f9b57f..0fc84c2eb 100644 --- a/bot/exts/utils/jams.py +++ b/bot/exts/utils/jams.py @@ -79,7 +79,6 @@ class CodeJams(commands.Cog): If all categories are full or none exist, create a new category. """ for category in guild.categories: - # Need 2 available spaces: one for the text channel and one for voice. if category.name == CATEGORY_NAME and len(category.channels) < MAX_CHANNELS: return category diff --git a/tests/bot/exts/utils/test_jams.py b/tests/bot/exts/utils/test_jams.py index 85d6a1173..368a15476 100644 --- a/tests/bot/exts/utils/test_jams.py +++ b/tests/bot/exts/utils/test_jams.py @@ -2,10 +2,24 @@ import unittest from unittest.mock import AsyncMock, MagicMock, create_autospec from discord import CategoryChannel +from discord.ext.commands import BadArgument from bot.constants import Roles from bot.exts.utils import jams -from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel +from tests.helpers import ( + MockAttachment, MockBot, MockCategoryChannel, MockContext, + MockGuild, MockMember, MockRole, MockTextChannel +) + +TEST_CSV = b"""\ +Team Name,Team Member Discord ID,Team Leader +Annoyed Alligators,12345,Y +Annoyed Alligators,54321,N +Oscillating Otters,12358,Y +Oscillating Otters,74832,N +Oscillating Otters,19903,N +Annoyed Alligators,11111,N +""" def get_mock_category(channel_count: int, name: str) -> CategoryChannel: @@ -17,8 +31,8 @@ def get_mock_category(channel_count: int, name: str) -> CategoryChannel: return category -class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): - """Tests for `createteam` command.""" +class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): + """Tests for `codejam create` command.""" def setUp(self): self.bot = MockBot() @@ -28,60 +42,64 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) self.cog = jams.CodeJams(self.bot) - async def test_too_small_amount_of_team_members_passed(self): - """Should `ctx.send` and exit early when too small amount of members.""" - for case in (1, 2): - with self.subTest(amount_of_members=case): - self.cog.create_channels = AsyncMock() - self.cog.add_roles = AsyncMock() + async def test_message_without_attachments(self): + """If no link or attachments are provided, commands.BadArgument should be raised.""" + self.ctx.message.attachments = [] - self.ctx.reset_mock() - members = (MockMember() for _ in range(case)) - await self.cog.createteam(self.cog, self.ctx, "foo", members) + with self.assertRaises(BadArgument): + await self.cog.create(self.cog, self.ctx, None) - self.ctx.send.assert_awaited_once() - self.cog.create_channels.assert_not_awaited() - self.cog.add_roles.assert_not_awaited() + async def test_result_sending(self): + """Should call `ctx.send` when everything goes right.""" + self.ctx.message.attachments = [MockAttachment()] + self.ctx.message.attachments[0].read = AsyncMock() + self.ctx.message.attachments[0].read.return_value = TEST_CSV + + team_leaders = MockRole() + + self.guild.get_member.return_value = MockMember() - async def test_duplicate_members_provided(self): - """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member.""" - self.cog.create_channels = AsyncMock() + self.ctx.guild.create_role = AsyncMock() + self.ctx.guild.create_role.return_value = team_leaders + self.cog.create_team_channel = AsyncMock() + self.cog.create_team_leader_channel = AsyncMock() self.cog.add_roles = AsyncMock() - member = MockMember() - await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5))) + await self.cog.create(self.cog, self.ctx, None) + self.cog.create_team_channel.assert_awaited() + self.cog.create_team_leader_channel.assert_awaited_once_with( + self.ctx.guild, team_leaders + ) self.ctx.send.assert_awaited_once() - self.cog.create_channels.assert_not_awaited() - self.cog.add_roles.assert_not_awaited() - - async def test_result_sending(self): - """Should call `ctx.send` when everything goes right.""" - self.cog.create_channels = AsyncMock() - self.cog.add_roles = AsyncMock() - members = [MockMember() for _ in range(5)] - await self.cog.createteam(self.cog, self.ctx, "foo", members) + async def test_link_returning_non_200_status(self): + """When the URL passed returns a non 200 status, it should send a message informing them.""" + self.bot.http_session.get.return_value = mock = MagicMock() + mock.status = 404 + await self.cog.create(self.cog, self.ctx, "https://not-a-real-link.com") - self.cog.create_channels.assert_awaited_once() - self.cog.add_roles.assert_awaited_once() self.ctx.send.assert_awaited_once() async def test_category_doesnt_exist(self): """Should create a new code jam category.""" subtests = ( [], - [get_mock_category(jams.MAX_CHANNELS - 1, jams.CATEGORY_NAME)], + [get_mock_category(jams.MAX_CHANNELS, jams.CATEGORY_NAME)], [get_mock_category(jams.MAX_CHANNELS - 2, "other")], ) + self.cog.send_status_update = AsyncMock() + for categories in subtests: + self.cog.send_status_update.reset_mock() self.guild.reset_mock() self.guild.categories = categories with self.subTest(categories=categories): actual_category = await self.cog.get_category(self.guild) + self.cog.send_status_update.assert_called_once() self.guild.create_category_channel.assert_awaited_once() category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] @@ -103,62 +121,47 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): async def test_channel_overwrites(self): """Should have correct permission overwrites for users and roles.""" - leader = MockMember() - members = [leader] + [MockMember() for _ in range(4)] + leader = (MockMember(), True) + members = [leader] + [(MockMember(), False) for _ in range(4)] overwrites = self.cog.get_overwrites(members, self.guild) - # Leader permission overwrites - self.assertTrue(overwrites[leader].manage_messages) - self.assertTrue(overwrites[leader].read_messages) - self.assertTrue(overwrites[leader].manage_webhooks) - self.assertTrue(overwrites[leader].connect) - - # Other members permission overwrites - for member in members[1:]: + for member, _ in members: self.assertTrue(overwrites[member].read_messages) - self.assertTrue(overwrites[member].connect) - - # Everyone role overwrite - self.assertFalse(overwrites[self.guild.default_role].read_messages) - self.assertFalse(overwrites[self.guild.default_role].connect) async def test_team_channels_creation(self): - """Should create new voice and text channel for team.""" - members = [MockMember() for _ in range(5)] + """Should create a text channel for a team.""" + team_leaders = MockRole() + members = [(MockMember(), True)] + [(MockMember(), False) for _ in range(5)] + category = MockCategoryChannel() + category.create_text_channel = AsyncMock() self.cog.get_overwrites = MagicMock() self.cog.get_category = AsyncMock() - self.ctx.guild.create_text_channel.return_value = MockTextChannel(mention="foobar-channel") - actual = await self.cog.create_channels(self.guild, "my-team", members) + self.cog.get_category.return_value = category + self.cog.add_team_leader_roles = AsyncMock() - self.assertEqual("foobar-channel", actual) + await self.cog.create_team_channel(self.guild, "my-team", members, team_leaders) + self.cog.add_team_leader_roles.assert_awaited_once_with(members, team_leaders) self.cog.get_overwrites.assert_called_once_with(members, self.guild) self.cog.get_category.assert_awaited_once_with(self.guild) - self.guild.create_text_channel.assert_awaited_once_with( + category.create_text_channel.assert_awaited_once_with( "my-team", - overwrites=self.cog.get_overwrites.return_value, - category=self.cog.get_category.return_value - ) - self.guild.create_voice_channel.assert_awaited_once_with( - "My Team", - overwrites=self.cog.get_overwrites.return_value, - category=self.cog.get_category.return_value + overwrites=self.cog.get_overwrites.return_value ) async def test_jam_roles_adding(self): """Should add team leader role to leader and jam role to every team member.""" leader_role = MockRole(name="Team Leader") - jam_role = MockRole(name="Jammer") - self.guild.get_role.side_effect = [leader_role, jam_role] leader = MockMember() - members = [leader] + [MockMember() for _ in range(4)] - await self.cog.add_roles(self.guild, members) + members = [(leader, True)] + [(MockMember(), False) for _ in range(4)] + await self.cog.add_team_leader_roles(members, leader_role) - leader.add_roles.assert_any_await(leader_role) - for member in members: - member.add_roles.assert_any_await(jam_role) + leader.add_roles.assert_awaited_once_with(leader_role) + for member, is_leader in members: + if not is_leader: + member.add_roles.assert_not_awaited() class CodeJamSetup(unittest.TestCase): diff --git a/tests/helpers.py b/tests/helpers.py index e3dc5fe5b..eedd7a601 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -361,6 +361,27 @@ class MockDMChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): super().__init__(**collections.ChainMap(kwargs, default_kwargs)) +# Create CategoryChannel instance to get a realistic MagicMock of `discord.CategoryChannel` +category_channel_data = { + 'id': 1, + 'type': discord.ChannelType.category, + 'name': 'category', + 'position': 1, +} + +state = unittest.mock.MagicMock() +guild = unittest.mock.MagicMock() +category_channel_instance = discord.CategoryChannel( + state=state, guild=guild, data=category_channel_data +) + + +class MockCategoryChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): + def __init__(self, **kwargs) -> None: + default_kwargs = {'id': next(self.discord_id)} + super().__init__(**collections.ChainMap(default_kwargs, kwargs)) + + # Create a Message instance to get a realistic MagicMock of `discord.Message` message_data = { 'id': 1, @@ -403,6 +424,7 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock): self.guild = kwargs.get('guild', MockGuild()) self.author = kwargs.get('author', MockMember()) self.channel = kwargs.get('channel', MockTextChannel()) + self.message = kwargs.get('message', MockMessage()) self.invoked_from_error_handler = kwargs.get('invoked_from_error_handler', False) -- cgit v1.2.3 From 37f8637955350c6c5b437d66807f42d8f26adfcd Mon Sep 17 00:00:00 2001 From: ToxicKidz Date: Mon, 5 Jul 2021 19:58:04 -0400 Subject: chore: Remove the moderators role from the team channels' overwrites --- bot/exts/utils/jams.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py index 0fc84c2eb..b2f7dab04 100644 --- a/bot/exts/utils/jams.py +++ b/bot/exts/utils/jams.py @@ -113,7 +113,6 @@ class CodeJams(commands.Cog): """Get code jam team channels permission overwrites.""" team_channel_overwrites = { guild.default_role: discord.PermissionOverwrite(read_messages=False), - guild.get_role(Roles.moderators): discord.PermissionOverwrite(read_messages=True), guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite(read_messages=True) } -- cgit v1.2.3 From 1905f7cf62370f98ca4e484b54cf912353856a35 Mon Sep 17 00:00:00 2001 From: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> Date: Mon, 5 Jul 2021 20:00:59 -0400 Subject: chore: Change the `Code Jam Team Leader` role's name Co-authored-by: Boris Muratov <8bee278@gmail.com> --- bot/exts/utils/jams.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py index b2f7dab04..87ae847f6 100644 --- a/bot/exts/utils/jams.py +++ b/bot/exts/utils/jams.py @@ -64,7 +64,7 @@ class CodeJams(commands.Cog): teams[row["Team Name"]].append((member, row["Team Leader"].upper() == "Y")) - team_leaders = await ctx.guild.create_role(name="Team Leaders", colour=TEAM_LEADERS_COLOUR) + team_leaders = await ctx.guild.create_role(name="Code Jam Team Leaders", colour=TEAM_LEADERS_COLOUR) for team_name, members in teams.items(): await self.create_team_channel(ctx.guild, team_name, members, team_leaders) -- cgit v1.2.3 From 48e989fe07019401eca6caa6d0eb3981f003eddd Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 30 Jun 2021 21:20:31 +0200 Subject: Move tag listing to new design and move it outside of tag display method The display method was renamed to get_tag_embed and now exclusively handles embed for a tag/suggestions instead of holding the logic of the whole command fixup! Move tag listing to new design and move it outside of tag display method --- bot/exts/info/tags.py | 170 ++++++++++++++++++++++++++++---------------------- 1 file changed, 97 insertions(+), 73 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 1665275b9..4aa590430 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -1,14 +1,15 @@ from __future__ import annotations +import enum import logging import re import time from pathlib import Path -from typing import Callable, Iterable, List, NamedTuple, Optional +from typing import Callable, Iterable, List, Literal, NamedTuple, Optional, Union import discord import frontmatter -from discord import Colour, Embed, Member +from discord import Embed, Member from discord.ext.commands import Cog, Context, group from bot import constants @@ -28,6 +29,12 @@ REGEX_NON_ALPHABET = re.compile(r"[^a-z]", re.MULTILINE & re.IGNORECASE) FOOTER_TEXT = f"To show a tag, type {constants.Bot.prefix}tags ." +class COOLDOWN(enum.Enum): + """Sentinel value to signal that a tag is on cooldown.""" + + obj = object() + + class TagIdentifier(NamedTuple): """Stores the group and name used as an identifier for a tag.""" @@ -237,81 +244,80 @@ class Tags(Cog): matching_tags = self._get_tags_via_content(any, keywords or 'any', ctx.author) await self._send_matching_tags(ctx, keywords, matching_tags) - async def display_tag(self, ctx: Context, tag_identifier: TagIdentifier) -> bool: + async def get_tag_embed( + self, + ctx: Context, + tag_identifier: TagIdentifier, + ) -> Optional[Union[Embed, Literal[COOLDOWN.obj]]]: """ - If a tag is not found, display similar tag names as suggestions. - - If a tag is not specified, display a paginated embed of all tags. + Generate an embed of the requested tag or of suggestions if the tag doesn't exist/isn't accessible by the user. - Tags are on cooldowns on a per-tag, per-channel basis. If a tag is on cooldown, display - nothing and return True. + If the requested tag is on cooldown or no suggestions were found, return None. """ - if tag_identifier.name is not None: + if (tag := self._tags.get(tag_identifier)) is not None and tag.accessible_by(ctx.author): - if (tag := self._tags.get(tag_identifier)) is not None and tag.accessible_by(ctx.author): + if tag.on_cooldown_in(ctx.channel): + log.debug(f"Tag {str(tag_identifier)!r} is on cooldown.") + return COOLDOWN.obj + tag.set_cooldown_for(ctx.channel) - if tag.on_cooldown_in(ctx.channel): - log.debug(f"Tag {str(tag_identifier)!r} is on cooldown.") - return True - tag.set_cooldown_for(ctx.channel) - - self.bot.stats.incr( - f"tags.usages" - f"{'.' + tag_identifier.group.replace('-', '_') if tag_identifier.group else ''}" - f".{tag_identifier.name.replace('-', '_')}" - ) - - await wait_for_deletion( - await ctx.send(embed=tag.embed), - [ctx.author.id], - ) - return True - - elif len(tag_identifier.name) >= 3: - suggested_tags = self.get_fuzzy_matches(tag_identifier)[:10] - if not suggested_tags: - return False - suggested_tags_text = "\n".join( - str(identifier) - for identifier, tag in suggested_tags - if tag.accessible_by(ctx.author) - and not tag.on_cooldown_in(ctx.channel) - ) - await wait_for_deletion( - await ctx.send( - embed=Embed( - title="Did you mean ...", - description=suggested_tags_text - ) - ), - [ctx.author.id], - ) - return True + self.bot.stats.incr( + f"tags.usages" + f"{'.' + tag_identifier.group.replace('-', '_') if tag_identifier.group else ''}" + f".{tag_identifier.name.replace('-', '_')}" + ) + return tag.embed + + elif len(tag_identifier.name) >= 3: + suggested_tags = self.get_fuzzy_matches(tag_identifier)[:10] + if not suggested_tags: + return None + suggested_tags_text = "\n".join( + str(identifier) + for identifier, tag in suggested_tags + if tag.accessible_by(ctx.author) + and not tag.on_cooldown_in(ctx.channel) + ) + return Embed( + title="Did you mean ...", + description=suggested_tags_text + ) - else: - tags = self._cache.values() - if not tags: - await ctx.send(embed=Embed( - description="**There are no tags in the database!**", - colour=Colour.red() - )) - return True + async def list_all_tags(self, ctx: Context) -> None: + """Send a paginator with all loaded tags accessible by `ctx.author`, groups first, and alphabetically sorted.""" + def tag_sort_key(tag_item: tuple[TagIdentifier, tag]) -> str: + ident = tag_item[0] + if ident.group is None: + # Max codepoint character to force tags without a group to the end + group = chr(0x10ffff) else: - embed: Embed = Embed(title="**Current tags**") - await LinePaginator.paginate( - sorted( - f"**»** {tag['title']}" for tag in tags - if self.check_accessibility(ctx.author, tag) - ), - ctx, - embed, - footer_text=FOOTER_TEXT, - empty=False, - max_lines=15 - ) - return True - - return False + group = ident.group + return group+ident.name + + result_lines = [] + current_group = object() + group_accessible = True + + for identifier, tag in sorted(self._tags.items(), key=tag_sort_key): + + if identifier.group != current_group: + if not group_accessible: + # Remove group separator line if no tags in the previous group were accessible by the user. + result_lines.pop() + # A new group began, add a separator with the group name. + if identifier.group is not None: + group_accessible = False + result_lines.append(f"\n\N{BULLET} **{identifier.group}**") + else: + result_lines.append("\n\N{BULLET}") + current_group = identifier.group + + if tag.accessible_by(ctx.author): + result_lines.append(f"**\N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK}** {identifier.name}") + group_accessible = True + + embed = Embed(title="Current tags") + await LinePaginator.paginate(result_lines, ctx, embed=embed, max_lines=15, empty=False, footer_text=FOOTER_TEXT) @tags_group.command(name='get', aliases=('show', 'g')) async def get_command( @@ -322,15 +328,33 @@ class Tags(Cog): """ Get a specified tag, or a list of all tags if no tag is specified. - Returns True if something can be sent, or if the tag is on cooldown. - Returns False if no matches are found. + Returns True if something was sent, or if the tag is on cooldown. + Returns False if no message was sent. """ + if tag_name_or_group is None and tag_name is None: + if self._tags: + await self.list_all_tags(ctx) + return True + else: + await ctx.send(embed=Embed(description="**There are no tags!**")) + return True + if tag_name is None: tag_name = tag_name_or_group tag_group = None else: tag_group = tag_name_or_group - return await self.display_tag(ctx, TagIdentifier(tag_group, tag_name)) + + embed = await self.get_tag_embed(ctx, TagIdentifier(tag_group, tag_name)) + if embed is not None: + if embed is not COOLDOWN.obj: + await wait_for_deletion( + await ctx.send(embed=embed), + (ctx.author.id,) + ) + return True + else: + return False def setup(bot: Bot) -> None: -- cgit v1.2.3 From 7cc8925c681386e20094a918713b5e5b53cea3dd Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 30 Jun 2021 21:23:09 +0200 Subject: Add option to list all tags in a group --- bot/exts/info/tags.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 4aa590430..2c6dbd29d 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -319,6 +319,16 @@ class Tags(Cog): embed = Embed(title="Current tags") await LinePaginator.paginate(result_lines, ctx, embed=embed, max_lines=15, empty=False, footer_text=FOOTER_TEXT) + async def list_tags_in_group(self, ctx: Context, group: str) -> None: + """Send a paginator with all tags under `group`, that are accessible by `ctx.author`.""" + embed = Embed(title=f"**Tags under *{group}***") + tag_lines = sorted( + f"**\N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK}** {identifier}" + for identifier, tag in self._tags.items() + if identifier.group == group and tag.accessible_by(ctx.author) + ) + await LinePaginator.paginate(tag_lines, ctx, embed, footer_text=FOOTER_TEXT, empty=False, max_lines=15) + @tags_group.command(name='get', aliases=('show', 'g')) async def get_command( self, ctx: Context, @@ -340,8 +350,15 @@ class Tags(Cog): return True if tag_name is None: - tag_name = tag_name_or_group - tag_group = None + if any( + tag_name_or_group == identifier.group and tag.accessible_by(ctx.author) + for identifier, tag in self._tags.items() + ): + await self.list_tags_in_group(ctx, tag_name_or_group) + return True + else: + tag_name = tag_name_or_group + tag_group = None else: tag_group = tag_name_or_group -- cgit v1.2.3 From 82160274aa63a3820f5424eeb54fef4722248293 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 30 Jun 2021 22:13:17 +0200 Subject: Move tag search to new design --- bot/exts/info/tags.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 2c6dbd29d..854db5c5c 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -166,7 +166,12 @@ class Tags(Cog): suggestions += self._get_suggestions(tag_identifier) return suggestions - def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str, user: Member) -> list: + def _get_tags_via_content( + self, + check: Callable[[Iterable], bool], + keywords: str, + user: Member, + ) -> list[tuple[TagIdentifier, Tag]]: """ Search for tags via contents. @@ -186,27 +191,29 @@ class Tags(Cog): keywords_processed = [keywords] matching_tags = [] - for tag in self._cache.values(): - matches = (query in tag['embed']['description'].casefold() for query in keywords_processed) - if self.check_accessibility(user, tag) and check(matches): - matching_tags.append(tag) + for identifier, tag in self._tags.items(): + matches = (query in tag.content.casefold() for query in keywords_processed) + if tag.accessible_by(user) and check(matches): + matching_tags.append((identifier, tag)) return matching_tags - async def _send_matching_tags(self, ctx: Context, keywords: str, matching_tags: list) -> None: + async def _send_matching_tags( + self, + ctx: Context, + keywords: str, + matching_tags: list[tuple[TagIdentifier, Tag]], + ) -> None: """Send the result of matching tags to user.""" - if not matching_tags: - pass - elif len(matching_tags) == 1: - await ctx.send(embed=Embed().from_dict(matching_tags[0]['embed'])) - else: + if len(matching_tags) == 1: + await ctx.send(embed=matching_tags[0][1].embed) + elif matching_tags: is_plural = keywords.strip().count(' ') > 0 or keywords.strip().count(',') > 0 embed = Embed( title=f"Here are the tags containing the given keyword{'s' * is_plural}:", - description='\n'.join(tag['title'] for tag in matching_tags[:10]) ) await LinePaginator.paginate( - sorted(f"**»** {tag['title']}" for tag in matching_tags), + sorted(f"**»** {identifier.name}" for identifier, _ in matching_tags), ctx, embed, footer_text=FOOTER_TEXT, -- cgit v1.2.3 From 2478b2125918e04d82d18fef78fd8c0e719776ee Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 30 Jun 2021 22:13:58 +0200 Subject: remove unused thresholds parameter --- bot/exts/info/tags.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 854db5c5c..bbec5b86d 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -137,15 +137,9 @@ class Tags(Cog): self._tags[TagIdentifier(tag_group, tag_name)] = Tag(file.read_text("utf-8")) - def _get_suggestions( - self, - tag_identifier: TagIdentifier, - thresholds: Optional[list[int]] = None - ) -> list[tuple[TagIdentifier, Tag]]: + def _get_suggestions(self, tag_identifier: TagIdentifier) -> list[tuple[TagIdentifier, Tag]]: """Return a list of suggested tags for `tag_identifier`.""" - thresholds = thresholds or [100, 90, 80, 70, 60] - - for threshold in thresholds: + for threshold in [100, 90, 80, 70, 60]: suggestions = [ (identifier, tag) for identifier, tag in self._tags.items() -- cgit v1.2.3 From 2e944b673d2c744c6768814d8d7e073e5d2fe100 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 30 Jun 2021 22:18:18 +0200 Subject: Update strings to use double quotes --- bot/exts/info/tags.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index bbec5b86d..9b477d7cc 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -172,7 +172,7 @@ class Tags(Cog): `predicate` will be the built-in any, all, or a custom callable. Must return a bool. """ keywords_processed: List[str] = [] - for keyword in keywords.split(','): + for keyword in keywords.split(","): keyword_sanitized = keyword.strip().casefold() if not keyword_sanitized: # this happens when there are leading / trailing / consecutive comma. @@ -180,7 +180,7 @@ class Tags(Cog): keywords_processed.append(keyword_sanitized) if not keywords_processed: - # after sanitizing, we can end up with an empty list, for example when keywords is ',' + # after sanitizing, we can end up with an empty list, for example when keywords is "," # in that case, we simply want to search for such keywords directly instead. keywords_processed = [keywords] @@ -202,7 +202,7 @@ class Tags(Cog): if len(matching_tags) == 1: await ctx.send(embed=matching_tags[0][1].embed) elif matching_tags: - is_plural = keywords.strip().count(' ') > 0 or keywords.strip().count(',') > 0 + is_plural = keywords.strip().count(" ") > 0 or keywords.strip().count(",") > 0 embed = Embed( title=f"Here are the tags containing the given keyword{'s' * is_plural}:", ) @@ -215,7 +215,7 @@ class Tags(Cog): max_lines=15 ) - @group(name='tags', aliases=('tag', 't'), invoke_without_command=True) + @group(name="tags", aliases=("tag", "t"), invoke_without_command=True) async def tags_group( self, ctx: Context, @@ -225,7 +225,7 @@ class Tags(Cog): """Show all known tags, a single tag, or run a subcommand.""" await self.get_command(ctx, tag_name_or_group=tag_name_or_group, tag_name=tag_name) - @tags_group.group(name='search', invoke_without_command=True) + @tags_group.group(name="search", invoke_without_command=True) async def search_tag_content(self, ctx: Context, *, keywords: str) -> None: """ Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma. @@ -235,14 +235,14 @@ class Tags(Cog): matching_tags = self._get_tags_via_content(all, keywords, ctx.author) await self._send_matching_tags(ctx, keywords, matching_tags) - @search_tag_content.command(name='any') - async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = 'any') -> None: + @search_tag_content.command(name="any") + async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = "any") -> None: """ Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma. Search for tags that has ANY of the keywords. """ - matching_tags = self._get_tags_via_content(any, keywords or 'any', ctx.author) + matching_tags = self._get_tags_via_content(any, keywords or "any", ctx.author) await self._send_matching_tags(ctx, keywords, matching_tags) async def get_tag_embed( @@ -330,7 +330,7 @@ class Tags(Cog): ) await LinePaginator.paginate(tag_lines, ctx, embed, footer_text=FOOTER_TEXT, empty=False, max_lines=15) - @tags_group.command(name='get', aliases=('show', 'g')) + @tags_group.command(name="get", aliases=("show", "g")) async def get_command( self, ctx: Context, tag_name_or_group: TagNameConverter = None, -- cgit v1.2.3 From 12dc173cea1d2846c405743dde7c3df36c75659e Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 30 Jun 2021 22:18:56 +0200 Subject: Remove unnecessary typehint --- bot/exts/info/tags.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 9b477d7cc..594a3e409 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -5,7 +5,7 @@ import logging import re import time from pathlib import Path -from typing import Callable, Iterable, List, Literal, NamedTuple, Optional, Union +from typing import Callable, Iterable, Literal, NamedTuple, Optional, Union import discord import frontmatter @@ -171,7 +171,7 @@ class Tags(Cog): `predicate` will be the built-in any, all, or a custom callable. Must return a bool. """ - keywords_processed: List[str] = [] + keywords_processed = [] for keyword in keywords.split(","): keyword_sanitized = keyword.strip().casefold() if not keyword_sanitized: -- cgit v1.2.3 From 2e10a303aa7d62339fea5b8c489fe6c7d9dd4f24 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 30 Jun 2021 22:33:33 +0200 Subject: Add leading » when listing tag suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/exts/info/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 594a3e409..01d0b97fa 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -274,7 +274,7 @@ class Tags(Cog): if not suggested_tags: return None suggested_tags_text = "\n".join( - str(identifier) + f"**\N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK}** {identifier}" for identifier, tag in suggested_tags if tag.accessible_by(ctx.author) and not tag.on_cooldown_in(ctx.channel) -- cgit v1.2.3 From 46e62b877d1db210a7788249eea3f205b9021d68 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 30 Jun 2021 22:52:22 +0200 Subject: Fix tests Unnecessary invoked with mocks were removed and some more checks added for the new behaviour --- tests/bot/exts/backend/test_error_handler.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index bd4fb5942..4a466c22e 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -273,14 +273,12 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase): async def test_try_get_tag_get_command(self): """Should call `Bot.get_command` with `tags get` argument.""" self.bot.get_command.reset_mock() - self.ctx.invoked_with = "foo" await self.cog.try_get_tag(self.ctx) self.bot.get_command.assert_called_once_with("tags get") async def test_try_get_tag_invoked_from_error_handler(self): """`self.ctx` should have `invoked_from_error_handler` `True`.""" self.ctx.invoked_from_error_handler = False - self.ctx.invoked_with = "foo" await self.cog.try_get_tag(self.ctx) self.assertTrue(self.ctx.invoked_from_error_handler) @@ -295,38 +293,48 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase): err = errors.CommandError() self.tag.get_command.can_run = AsyncMock(side_effect=err) self.cog.on_command_error = AsyncMock() - self.ctx.invoked_with = "foo" self.assertIsNone(await self.cog.try_get_tag(self.ctx)) self.cog.on_command_error.assert_awaited_once_with(self.ctx, err) @patch("bot.exts.backend.error_handler.TagNameConverter") async def test_try_get_tag_convert_success(self, tag_converter): """Converting tag should successful.""" - self.ctx.invoked_with = "foo" + self.ctx.message = MagicMock(content="foo") tag_converter.convert = AsyncMock(return_value="foo") self.assertIsNone(await self.cog.try_get_tag(self.ctx)) tag_converter.convert.assert_awaited_once_with(self.ctx, "foo") self.ctx.invoke.assert_awaited_once() + self.ctx.reset_mock() + self.ctx.message = MagicMock(content="foo bar") + tag_converter.convert = AsyncMock(return_value="foo bar") + self.assertIsNone(await self.cog.try_get_tag(self.ctx)) + self.assertEqual(tag_converter.convert.call_count, 2) + self.ctx.invoke.assert_awaited_once() + @patch("bot.exts.backend.error_handler.TagNameConverter") async def test_try_get_tag_convert_fail(self, tag_converter): """Converting tag should raise `BadArgument`.""" self.ctx.reset_mock() - self.ctx.invoked_with = "bar" tag_converter.convert = AsyncMock(side_effect=errors.BadArgument()) self.assertIsNone(await self.cog.try_get_tag(self.ctx)) self.ctx.invoke.assert_not_awaited() async def test_try_get_tag_ctx_invoke(self): """Should call `ctx.invoke` with proper args/kwargs.""" - self.ctx.reset_mock() - self.ctx.invoked_with = "foo" - self.assertIsNone(await self.cog.try_get_tag(self.ctx)) - self.ctx.invoke.assert_awaited_once_with(self.tag.get_command, tag_name="foo") + test_cases = ( + ("foo", ("foo", None)), + ("foo bar", ("foo", "bar")), + ) + for message_content, args in test_cases: + self.ctx.reset_mock() + self.ctx.message = MagicMock(content=message_content) + self.assertIsNone(await self.cog.try_get_tag(self.ctx)) + self.ctx.invoke.assert_awaited_once_with(self.tag.get_command, *args) async def test_dont_call_suggestion_tag_sent(self): """Should never call command suggestion if tag is already sent.""" - self.ctx.invoked_with = "foo" + self.ctx.message = MagicMock(content="foo") self.ctx.invoke = AsyncMock(return_value=True) self.cog.send_command_suggestion = AsyncMock() -- cgit v1.2.3 From 08c068ef8f8ccc43c576ec653f250a67d7458438 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 30 Jun 2021 22:55:25 +0200 Subject: Update outdated docstring The saving functionality has not been present on the bot for a while --- bot/exts/info/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 01d0b97fa..1847fa240 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -117,7 +117,7 @@ def _fuzzy_search(search: str, target: str) -> float: class Tags(Cog): - """Save new tags and fetch existing tags.""" + """Fetch tags by name or content.""" def __init__(self, bot: Bot): self.bot = bot -- cgit v1.2.3 From 14d6fca825bf87c35f82215da3e9561bd1db1ab8 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 1 Jul 2021 00:08:39 +0200 Subject: Do not add suggestion for tags with short names if a group is specified --- bot/exts/info/tags.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 1847fa240..798be6543 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -153,12 +153,20 @@ class Tags(Cog): def get_fuzzy_matches(self, tag_identifier: TagIdentifier) -> list[tuple[TagIdentifier, Tag]]: """Get tags with identifiers similar to `tag_identifier`.""" if tag_identifier.group is None: - suggestions = self._get_suggestions(tag_identifier) + if len(tag_identifier.name) < 3: + return [] + else: + return self._get_suggestions(tag_identifier) else: - # Try fuzzy matching with only a name first - suggestions = self._get_suggestions(TagIdentifier(None, tag_identifier.group)) - suggestions += self._get_suggestions(tag_identifier) - return suggestions + if len(tag_identifier.group) < 3: + suggestions = [] + else: + # Try fuzzy matching with only a name first + suggestions = self._get_suggestions(TagIdentifier(None, tag_identifier.group)) + + if not len(tag_identifier.name) < 3: + suggestions += self._get_suggestions(tag_identifier) + return suggestions def _get_tags_via_content( self, @@ -269,7 +277,7 @@ class Tags(Cog): ) return tag.embed - elif len(tag_identifier.name) >= 3: + else: suggested_tags = self.get_fuzzy_matches(tag_identifier)[:10] if not suggested_tags: return None -- cgit v1.2.3 From aafd0e614512c7ffdfaa90ad384168033549ad8a Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 1 Jul 2021 00:31:56 +0200 Subject: Emit tag if only one fuzzy match is found This feature was accidentally removed when restructuring the code --- bot/exts/info/tags.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 798be6543..6971397d4 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -263,8 +263,18 @@ class Tags(Cog): If the requested tag is on cooldown or no suggestions were found, return None. """ - if (tag := self._tags.get(tag_identifier)) is not None and tag.accessible_by(ctx.author): - + filtered_tags = [ + (ident, tag) for ident, tag in + self.get_fuzzy_matches(tag_identifier)[:10] + if tag.accessible_by(ctx.author) + ] + + if (tag := self._tags.get(tag_identifier)) is None: + if len(filtered_tags) == 1: + tag_identifier = filtered_tags[0][0] + tag = filtered_tags[0][1] + + if tag is not None: if tag.on_cooldown_in(ctx.channel): log.debug(f"Tag {str(tag_identifier)!r} is on cooldown.") return COOLDOWN.obj @@ -278,14 +288,12 @@ class Tags(Cog): return tag.embed else: - suggested_tags = self.get_fuzzy_matches(tag_identifier)[:10] - if not suggested_tags: + if not filtered_tags: return None suggested_tags_text = "\n".join( f"**\N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK}** {identifier}" - for identifier, tag in suggested_tags - if tag.accessible_by(ctx.author) - and not tag.on_cooldown_in(ctx.channel) + for identifier, tag in filtered_tags + if not tag.on_cooldown_in(ctx.channel) ) return Embed( title="Did you mean ...", -- cgit v1.2.3 From 839e5250f1ba15db59e2e0d9b4f289391b7b87a0 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Tue, 6 Jul 2021 11:56:19 +0100 Subject: Disable filter in codejam team channels (#1670) * Disable filter_invites in codejam team channels * Fix incorrect comment Co-authored-by: ChrisJL Co-authored-by: ChrisJL --- bot/exts/filters/filtering.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 661d6c9a2..5d5f59590 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -20,6 +20,7 @@ from bot.constants import ( Guild, Icons, URLs ) from bot.exts.moderation.modlog import ModLog +from bot.exts.utils.jams import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.utils.messages import format_user from bot.utils.regex import INVITE_RE from bot.utils.scheduling import Scheduler @@ -281,6 +282,12 @@ class Filtering(Cog): if delta is not None and delta < 100: continue + if filter_name == "filter_invites": + # Disable invites filter in codejam team channels + category = msg.channel.category + if category and category.name == JAM_CATEGORY_NAME: + continue + # Does the filter only need the message content or the full message? if _filter["content_only"]: payload = msg.content -- cgit v1.2.3 From 3996c2f17f25b59e65461a1195537a52f0a64a7e Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 6 Jul 2021 12:12:45 +0100 Subject: Use getattr with a default, to protect against DM channels --- bot/exts/filters/filtering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 5d5f59590..77fb324a5 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -284,7 +284,7 @@ class Filtering(Cog): if filter_name == "filter_invites": # Disable invites filter in codejam team channels - category = msg.channel.category + category = getattr(msg.channel, "category", None) if category and category.name == JAM_CATEGORY_NAME: continue -- cgit v1.2.3 From 4500628e21e4a98c56d667d56818947adf34c404 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Tue, 6 Jul 2021 17:03:39 +0300 Subject: Disable more filters in jam channels (#1675) * Disable everyone ping filter in jam channels * Disable anti-spam in jam channels * Disable antimalware in jam channels --- bot/exts/filters/antimalware.py | 5 +++++ bot/exts/filters/antispam.py | 2 ++ bot/exts/filters/filtering.py | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index 89e539e7b..4c4836c88 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/antimalware.py @@ -7,6 +7,7 @@ from discord.ext.commands import Cog from bot.bot import Bot from bot.constants import Channels, Filter, URLs +from bot.exts.utils.jams import CATEGORY_NAME as JAM_CATEGORY_NAME log = logging.getLogger(__name__) @@ -61,6 +62,10 @@ class AntiMalware(Cog): if message.webhook_id or message.author.bot: return + # Ignore code jam channels + if hasattr(message.channel, "category") and message.channel.category.name == JAM_CATEGORY_NAME: + return + # Check if user is staff, if is, return # Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance if hasattr(message.author, "roles") and any(role.id in Filter.role_whitelist for role in message.author.roles): diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 7555e25a2..48c3aa5a6 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -18,6 +18,7 @@ from bot.constants import ( ) from bot.converters import Duration from bot.exts.moderation.modlog import ModLog +from bot.exts.utils.jams import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.utils import lock, scheduling from bot.utils.messages import format_user, send_attachments @@ -148,6 +149,7 @@ class AntiSpam(Cog): not message.guild or message.guild.id != GuildConfig.id or message.author.bot + or (hasattr(message.channel, "category") and message.channel.category.name == JAM_CATEGORY_NAME) or (message.channel.id in Filter.channel_whitelist and not DEBUG_MODE) or (any(role.id in Filter.role_whitelist for role in message.author.roles) and not DEBUG_MODE) ): diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 77fb324a5..16aaf11cf 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -282,7 +282,7 @@ class Filtering(Cog): if delta is not None and delta < 100: continue - if filter_name == "filter_invites": + if filter_name in ("filter_invites", "filter_everyone_ping"): # Disable invites filter in codejam team channels category = getattr(msg.channel, "category", None) if category and category.name == JAM_CATEGORY_NAME: -- cgit v1.2.3 From 9f5dba163a9ca57e09e9a391a09745c032b4c6be Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Wed, 7 Jul 2021 17:17:38 +0300 Subject: Replaces Fuzzywuzzy Replaces Fuzzywuzzy with RapidFuzz, as fuzzywuzzy is licensed under a GPL2 license. Signed-off-by: Hassan Abouelela --- bot/exts/info/help.py | 13 +---- bot/exts/info/information.py | 6 +- poetry.lock | 127 ++++++++++++++++++++++++++++++++----------- pyproject.toml | 2 +- 4 files changed, 102 insertions(+), 46 deletions(-) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 3a05b2c8a..bf9ea5986 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -6,8 +6,8 @@ from typing import List, Union from discord import Colour, Embed from discord.ext.commands import Bot, Cog, Command, CommandError, Context, DisabledCommand, Group, HelpCommand -from fuzzywuzzy import fuzz, process -from fuzzywuzzy.utils import full_process +from rapidfuzz import fuzz, process +from rapidfuzz.utils import default_process from bot import constants from bot.constants import Channels, STAFF_ROLES @@ -126,14 +126,7 @@ class CustomHelpCommand(HelpCommand): Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches. """ choices = await self.get_all_help_choices() - - # Run fuzzywuzzy's processor beforehand, and avoid matching if processed string is empty - # This avoids fuzzywuzzy from raising a warning on inputs with only non-alphanumeric characters - if (processed := full_process(string)): - result = process.extractBests(processed, choices, scorer=fuzz.ratio, score_cutoff=60, processor=None) - else: - result = [] - + result = process.extract(default_process(string), choices, scorer=fuzz.ratio, score_cutoff=60, processor=None) return HelpQueryNotFound(f'Query "{string}" not found.', dict(result)) async def subcommand_not_found(self, command: Command, string: str) -> "HelpQueryNotFound": diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 1b1243118..fc3c2c61e 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -5,7 +5,7 @@ import textwrap from collections import defaultdict from typing import Any, DefaultDict, Dict, Mapping, Optional, Tuple, Union -import fuzzywuzzy +import rapidfuzz from discord import AllowedMentions, Colour, Embed, Guild, Message, Role from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role @@ -117,9 +117,9 @@ class Information(Cog): parsed_roles.add(role_name) continue - match = fuzzywuzzy.process.extractOne( + match = rapidfuzz.process.extractOne( role_name, all_roles, score_cutoff=80, - scorer=fuzzywuzzy.fuzz.ratio + scorer=rapidfuzz.fuzz.ratio ) if not match: diff --git a/poetry.lock b/poetry.lock index 2041824e2..9c9379ebb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -454,17 +454,6 @@ python-versions = "*" [package.dependencies] pycodestyle = ">=2.0.0,<3.0.0" -[[package]] -name = "fuzzywuzzy" -version = "0.18.0" -description = "Fuzzy string matching in python" -category = "main" -optional = false -python-versions = "*" - -[package.extras] -speedup = ["python-levenshtein (>=0.12)"] - [[package]] name = "hiredis" version = "2.0.0" @@ -497,11 +486,11 @@ license = ["editdistance-s"] [[package]] name = "idna" -version = "3.2" +version = "2.10" description = "Internationalized Domain Names in Applications (IDNA)" category = "main" optional = false -python-versions = ">=3.5" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "iniconfig" @@ -587,11 +576,11 @@ python-versions = ">=3.5" [[package]] name = "packaging" -version = "20.9" +version = "21.0" description = "Core utilities for Python packages" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" [package.dependencies] pyparsing = ">=2.0.2" @@ -609,13 +598,14 @@ codegen = ["lxml"] [[package]] name = "pep8-naming" -version = "0.11.1" +version = "0.12.0" description = "Check PEP-8 naming conventions, plugin for flake8" category = "dev" optional = false python-versions = "*" [package.dependencies] +flake8 = ">=3.9.1" flake8-polyfill = ">=1.0.2,<2" [[package]] @@ -844,6 +834,14 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +[[package]] +name = "rapidfuzz" +version = "1.4.1" +description = "rapid fuzzy string matching" +category = "main" +optional = false +python-versions = ">=3.5" + [[package]] name = "redis" version = "3.5.3" @@ -865,14 +863,20 @@ python-versions = "*" [[package]] name = "requests" -version = "2.15.1" +version = "2.25.1" description = "Python HTTP for Humans." category = "dev" optional = false -python-versions = "*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +certifi = ">=2017.4.17" +chardet = ">=3.0.2,<5" +idna = ">=2.5,<3" +urllib3 = ">=1.21.1,<1.27" [package.extras] -security = ["cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)"] +security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] [[package]] @@ -1026,7 +1030,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "feec7372374cc4025f407b64b2e5b45c2c9c8d49c4538b91dc372f2bad89a624" +content-hash = "c7ea9fa5c2dc62eebba817dc0c98c58cfed4cf298b12a1b86a157e63d0882ef9" [metadata.files] aio-pika = [ @@ -1303,10 +1307,6 @@ flake8-tidy-imports = [ flake8-todo = [ {file = "flake8-todo-0.7.tar.gz", hash = "sha256:6e4c5491ff838c06fe5a771b0e95ee15fc005ca57196011011280fc834a85915"}, ] -fuzzywuzzy = [ - {file = "fuzzywuzzy-0.18.0-py2.py3-none-any.whl", hash = "sha256:928244b28db720d1e0ee7587acf660ea49d7e4c632569cad4f1cd7e68a5f0993"}, - {file = "fuzzywuzzy-0.18.0.tar.gz", hash = "sha256:45016e92264780e58972dca1b3d939ac864b78437422beecebb3095f8efd00e8"}, -] hiredis = [ {file = "hiredis-2.0.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b4c8b0bc5841e578d5fb32a16e0c305359b987b850a06964bd5a62739d688048"}, {file = "hiredis-2.0.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0adea425b764a08270820531ec2218d0508f8ae15a448568109ffcae050fee26"}, @@ -1359,8 +1359,8 @@ identify = [ {file = "identify-2.2.10.tar.gz", hash = "sha256:5b41f71471bc738e7b586308c3fca172f78940195cb3bf6734c1e66fdac49306"}, ] idna = [ - {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, - {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, + {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, + {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -1477,16 +1477,16 @@ ordered-set = [ {file = "ordered-set-4.0.2.tar.gz", hash = "sha256:ba93b2df055bca202116ec44b9bead3df33ea63a7d5827ff8e16738b97f33a95"}, ] packaging = [ - {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, - {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, + {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"}, + {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"}, ] pamqp = [ {file = "pamqp-2.3.0-py2.py3-none-any.whl", hash = "sha256:2f81b5c186f668a67f165193925b6bfd83db4363a6222f599517f29ecee60b02"}, {file = "pamqp-2.3.0.tar.gz", hash = "sha256:5cd0f5a85e89f20d5f8e19285a1507788031cfca4a9ea6f067e3cf18f5e294e8"}, ] pep8-naming = [ - {file = "pep8-naming-0.11.1.tar.gz", hash = "sha256:a1dd47dd243adfe8a83616e27cf03164960b507530f155db94e10b36a6cd6724"}, - {file = "pep8_naming-0.11.1-py2.py3-none-any.whl", hash = "sha256:f43bfe3eea7e0d73e8b5d07d6407ab47f2476ccaeff6937c84275cd30b016738"}, + {file = "pep8-naming-0.12.0.tar.gz", hash = "sha256:1f9a3ecb2f3fd83240fd40afdd70acc89695c49c333413e49788f93b61827e12"}, + {file = "pep8_naming-0.12.0-py2.py3-none-any.whl", hash = "sha256:2321ac2b7bf55383dd19a6a9c8ae2ebf05679699927a3af33e60dd7d337099d3"}, ] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, @@ -1641,6 +1641,69 @@ pyyaml = [ {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] +rapidfuzz = [ + {file = "rapidfuzz-1.4.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:72878878d6744883605b5453c382361716887e9e552f677922f76d93d622d8cb"}, + {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:56a67a5b3f783e9af73940f6945366408b3a2060fc6ab18466e5a2894fd85617"}, + {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f5d396b64f8ae3a793633911a1fb5d634ac25bf8f13d440139fa729131be42d8"}, + {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:4990698233e7eda7face7c09f5874a09760c7524686045cbb10317e3a7f3225f"}, + {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:a87e212855b18a951e79ec71d71dbd856d98cd2019d0c2bd46ec30688a8aa68a"}, + {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1897d2ef03f5b51bc19bdb2d0398ae968766750fa319843733f0a8f12ddde986"}, + {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2014_ppc64le.whl", hash = "sha256:e1fc4fd219057f5f1fa40bb9bc5e880f8ef45bf19350d4f5f15ca2ce7f61c99b"}, + {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2014_s390x.whl", hash = "sha256:21300c4d048798985c271a8bf1ed1611902ebd4479fcacda1a3eaaebbad2f744"}, + {file = "rapidfuzz-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:d2659967c6ac74211a87a1109e79253e4bc179641057c64800ef4e2dc0534fdb"}, + {file = "rapidfuzz-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:26ac4bfe564c516e053fc055f1543d2b2433338806738c7582e1f75ed0485f7e"}, + {file = "rapidfuzz-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3b485c98ad1ce3c04556f65aaab5d6d6d72121cde656d43505169c71ae956476"}, + {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:59db06356eaf22c83f44b0dded964736cbb137291cdf2cf7b4974c0983b94932"}, + {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fef95249af9a535854b617a68788c38cd96308d97ee14d44bc598cc73e986167"}, + {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:7d8c186e8270e103d339b26ef498581cf3178470ccf238dfd5fd0e47d80e4c7d"}, + {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:9246b9c5c8992a83a08ac7813c8bbff2e674ad0b681f9b3fb1ec7641eff6c21f"}, + {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:f58c17f7a82b1bcc2ce304942cae14287223e6b6eead7071241273da7d9b9770"}, + {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:ed708620b23a09ac52eaaec0761943c1bbc9a62d19ecd2feb4da8c3f79ef9d37"}, + {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:bdec9ae5fd8a8d4d8813b4aac3505c027b922b4033a32a7aab66a9b2f03a7b47"}, + {file = "rapidfuzz-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:fc668fd706ad1162ce14f26ca2957b4690d47770d23609756536c918a855ced0"}, + {file = "rapidfuzz-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f9f35df5dd9b02669ff6b1d4a386607ff56982c86a7e57d95eb08c6afbab4ddd"}, + {file = "rapidfuzz-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8427310ea29ce2968e1c6f6779ae5a458b3a4984f9150fc4d16f92b96456f848"}, + {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1430dc745476e3798742ad835f61f6e6bf5d3e9a22cf9cd0288b28b7440a9872"}, + {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1d20311da611c8f4638a09e2bc5e04b327bae010cb265ef9628d9c13c6d5da7b"}, + {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7881965e428cf6fe248d6e702e6d5857da02278ab9b21313bee717c080e443e"}, + {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:f76c965f15861ec4d39e904bd65b84a39121334439ac17bfb8b900d1e6779a93"}, + {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:61167f989415e701ac379de247e6b0a21ea62afc86c54d8a79f485b4f0173c02"}, + {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:645cfb9456229f0bd5752b3eda69f221d825fbb8cbb8855433516bc185111506"}, + {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:c28be57c9bc47b3d7f484340fab1bec8ed4393dee1090892c2774a4584435eb8"}, + {file = "rapidfuzz-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:3c94b6d3513c693f253ff762112cc4580d3bd377e4abacb96af31a3d606fbe14"}, + {file = "rapidfuzz-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:506d50a066451502ee2f8bf016bc3ba3e3b04eede7a4059d7956248e2dd96179"}, + {file = "rapidfuzz-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:80b375098658bb3db14215a975d354f6573d3943ac2ae0c4627c7760d57ce075"}, + {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ba8f7cbd8fdbd3ae115f4484888f3cb94bc2ac7cbd4eb1ca95a3d4f874261ff8"}, + {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:5fa8570720b0fdfc52f24f5663d66c52ea88ba19cb8b1ff6a39a8bc0b925b33b"}, + {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:f35c8a4c690447fd335bfd77df4da42dfea37cfa06a8ecbf22543d86dc720e12"}, + {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:27f9eef48e212d73e78f0f5ceedc62180b68f6a25fa0752d2ccfaedc3a840bec"}, + {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:31e99216e2a04aec4f281d472b28a683921f1f669a429cf605d11526623eaeed"}, + {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:f22bf7ba6eddd59764457f74c637ab5c3ed976c5fcfaf827e1d320cc0478e12b"}, + {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:c43ddb354abd00e56f024ce80affb3023fa23206239bb81916d5877cba7f2d1e"}, + {file = "rapidfuzz-1.4.1-cp38-cp38-win32.whl", hash = "sha256:62c1f4ac20c8019ce8d481fb27235306ef3912a8d0b9a60b17905699f43ff072"}, + {file = "rapidfuzz-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:2963f356c70b710dc6337b012ec976ce2fc2b81c2a9918a686838fead6eb4e1d"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c07f301fd549b266410654850c6918318d7dcde8201350e9ac0819f0542cf147"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa4c8b6fc7e93e3a3fb9be9566f1fe7ef920735eadcee248a0d70f3ca8941341"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c200bd813bbd3b146ba0fd284a9ad314bbad9d95ed542813273bdb9d0ee4e796"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2cccc84e1f0c6217747c09cafe93164e57d3644e18a334845a2dfbdd2073cd2c"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f2033e3d61d1e498f618123b54dc7436d50510b0d18fd678d867720e8d7b2f23"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:26b7f48b3ddd9d97cf8482a88f0f6cba47ac13ff16e63386ea7ce06178174770"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:bf18614f87fe3bfff783f0a3d0fad0eb59c92391e52555976e55570a651d2330"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:8cb5c2502ff06028a1468bdf61323b53cc3a37f54b5d62d62c5371795b81086a"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:f37f80c1541d6e0a30547261900086b8c0bac519ebc12c9cd6b61a9a43a7e195"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:c13cd1e840aa93639ac1d131fbfa740a609fd20dfc2a462d5cd7bce747a2398d"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-win32.whl", hash = "sha256:0ec346f271e96c485716c091c8b0b78ba52da33f7c6ebb52a349d64094566c2d"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:5208ce1b1989a10e6fc5b5ef5d0bb7d1ffe5408838f3106abde241aff4dab08c"}, + {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4fa195ea9ca35bacfa2a4319c6d4ab03aa6a283ad2089b70d2dfa0f6a7d9c1bc"}, + {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:6e336cfd8103b0b38e107e01502e9d6bf7c7f04e49b970fb11a4bf6c7a932b94"}, + {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:c798c5b87efe8a7e63f408e07ff3bc03ba8b94f4498a89b48eaab3a9f439d52c"}, + {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-win32.whl", hash = "sha256:bb16a10b40f5bd3c645f7748fbd36f49699a03f550c010a2c665905cc8937de8"}, + {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2278001924031d9d75f821bff2c5fef565c8376f252562e04d8eec8857475c36"}, + {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-manylinux1_x86_64.whl", hash = "sha256:a89d11f3b5da35fdf3e839186203b9367d56e2be792e8dccb098f47634ec6eb9"}, + {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:f8c79cd11b4778d387366a59aa747f5268433f9d68be37b00d16f4fb08fdf850"}, + {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-win32.whl", hash = "sha256:4364db793ed4b439f9dd28a335bee14e2a828283d3b93c2d2686cc645eeafdd5"}, + {file = "rapidfuzz-1.4.1.tar.gz", hash = "sha256:de20550178376d21bfe1b34a7dc42ab107bb282ef82069cf6dfe2805a0029e26"}, +] redis = [ {file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"}, {file = "redis-3.5.3.tar.gz", hash = "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2"}, @@ -1689,8 +1752,8 @@ regex = [ {file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"}, ] requests = [ - {file = "requests-2.15.1-py2.py3-none-any.whl", hash = "sha256:ff753b2196cd18b1bbeddc9dcd5c864056599f7a7d9a4fb5677e723efa2b7fb9"}, - {file = "requests-2.15.1.tar.gz", hash = "sha256:e5659b9315a0610505e050bb7190bf6fa2ccee1ac295f2b760ef9d8a03ebbb2e"}, + {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, + {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, ] sentry-sdk = [ {file = "sentry-sdk-0.20.3.tar.gz", hash = "sha256:4ae8d1ced6c67f1c8ea51d82a16721c166c489b76876c9f2c202b8a50334b237"}, diff --git a/pyproject.toml b/pyproject.toml index c76bb47d6..36db20366 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ deepdiff = "~=4.0" "discord.py" = "~=1.7.3" emoji = "~=0.6" feedparser = "~=6.0.2" -fuzzywuzzy = "~=0.17" +rapidfuzz = "~=1.4" lxml = "~=4.4" markdownify = "==0.6.1" more_itertools = "~=8.2" -- cgit v1.2.3 From 3a0ea4e9543e80f39e7db81c1ec3ac949660bb2f Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Wed, 7 Jul 2021 17:37:04 +0300 Subject: Drops AIOPing Dependency Drops aioping as a dependency for the ping command since it's licenced under GPL2. Substitutes the site ping with a health-check and status to compensate. Signed-off-by: Hassan Abouelela --- bot/exts/utils/ping.py | 28 +++++++++++++--------------- poetry.lock | 18 +----------------- pyproject.toml | 1 - 3 files changed, 14 insertions(+), 33 deletions(-) diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index 750ff46d2..58485fc34 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -1,18 +1,16 @@ -import socket -import urllib.parse from datetime import datetime -import aioping +from aiohttp import client_exceptions from discord import Embed from discord.ext import commands from bot.bot import Bot -from bot.constants import Channels, Emojis, STAFF_ROLES, URLs +from bot.constants import Channels, STAFF_ROLES, URLs from bot.decorators import in_whitelist DESCRIPTIONS = ( "Command processing time", - "Python Discord website latency", + "Python Discord website status", "Discord API latency" ) ROUND_LATENCY = 3 @@ -41,23 +39,23 @@ class Latency(commands.Cog): bot_ping = f"{bot_ping:.{ROUND_LATENCY}f} ms" try: - url = urllib.parse.urlparse(URLs.site_schema + URLs.site).hostname - try: - delay = await aioping.ping(url, family=socket.AddressFamily.AF_INET) * 1000 - site_ping = f"{delay:.{ROUND_LATENCY}f} ms" - except OSError: - # Some machines do not have permission to run ping - site_ping = "Permission denied, could not ping." + request = await self.bot.http_session.get(f"{URLs.site_api_schema}{URLs.site_api}/healthcheck") + request.raise_for_status() + site_status = "Healthy" - except TimeoutError: - site_ping = f"{Emojis.cross_mark} Connection timed out." + except client_exceptions.ClientResponseError as e: + """The site returned an unexpected response.""" + site_status = f"The site returned an error in the response: ({e.status}) {e}" + except client_exceptions.ClientConnectionError: + """Something went wrong with the connection.""" + site_status = "Could not establish connection with the site." # Discord Protocol latency return value is in seconds, must be multiplied by 1000 to get milliseconds. discord_ping = f"{self.bot.latency * 1000:.{ROUND_LATENCY}f} ms" embed = Embed(title="Pong!") - for desc, latency in zip(DESCRIPTIONS, [bot_ping, site_ping, discord_ping]): + for desc, latency in zip(DESCRIPTIONS, [bot_ping, site_status, discord_ping]): embed.add_field(name=desc, value=latency, inline=False) await ctx.send(embed=embed) diff --git a/poetry.lock b/poetry.lock index 9c9379ebb..dac277ed8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -43,18 +43,6 @@ yarl = ">=1.0,<2.0" [package.extras] speedups = ["aiodns", "brotlipy", "cchardet"] -[[package]] -name = "aioping" -version = "0.3.1" -description = "Asyncio ping implementation" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -aiodns = "*" -async-timeout = "*" - [[package]] name = "aioredis" version = "1.3.1" @@ -1030,7 +1018,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "c7ea9fa5c2dc62eebba817dc0c98c58cfed4cf298b12a1b86a157e63d0882ef9" +content-hash = "85160036e3b07c9d5d24a32302462591e82cc3bf3d5490b87550d9c26bc5648d" [metadata.files] aio-pika = [ @@ -1080,10 +1068,6 @@ aiohttp = [ {file = "aiohttp-3.7.4.post0-cp39-cp39-win_amd64.whl", hash = "sha256:02f46fc0e3c5ac58b80d4d56eb0a7c7d97fcef69ace9326289fb9f1955e65cfe"}, {file = "aiohttp-3.7.4.post0.tar.gz", hash = "sha256:493d3299ebe5f5a7c66b9819eacdcfbbaaf1a8e84911ddffcdc48888497afecf"}, ] -aioping = [ - {file = "aioping-0.3.1-py3-none-any.whl", hash = "sha256:8900ef2f5a589ba0c12aaa9c2d586f5371820d468d21b374ddb47ef5fc8f297c"}, - {file = "aioping-0.3.1.tar.gz", hash = "sha256:f983d86acab3a04c322731ce88d42c55d04d2842565fc8532fe10c838abfd275"}, -] aioredis = [ {file = "aioredis-1.3.1-py3-none-any.whl", hash = "sha256:b61808d7e97b7cd5a92ed574937a079c9387fdadd22bfbfa7ad2fd319ecc26e3"}, {file = "aioredis-1.3.1.tar.gz", hash = "sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a"}, diff --git a/pyproject.toml b/pyproject.toml index 36db20366..8eac504c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,6 @@ python = "3.9.*" aio-pika = "~=6.1" aiodns = "~=2.0" aiohttp = "~=3.7" -aioping = "~=0.3.1" aioredis = "~=1.3.1" arrow = "~=1.0.3" async-rediscache = { version = "~=0.1.2", extras = ["fakeredis"] } -- cgit v1.2.3 From 2ff595670b0603fe9e97d16ffcc2c04457fea1c5 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Wed, 7 Jul 2021 18:21:06 +0300 Subject: Fixes N818 Compliance Renames a couple exceptions to include the error suffix, as enforced by N818. Signed-off-by: Hassan Abouelela --- bot/errors.py | 2 +- bot/exts/backend/error_handler.py | 4 ++-- bot/exts/moderation/infraction/_utils.py | 4 ++-- bot/pagination.py | 4 ++-- tests/bot/exts/backend/test_error_handler.py | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/bot/errors.py b/bot/errors.py index 3544c6320..46efb6d4f 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -22,7 +22,7 @@ class LockedResourceError(RuntimeError): ) -class InvalidInfractedUser(Exception): +class InvalidInfractedUserError(Exception): """ Exception raised upon attempt of infracting an invalid user. diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index d8de177f5..7ef55af45 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -10,7 +10,7 @@ from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Colours, Icons, MODERATION_ROLES from bot.converters import TagNameConverter -from bot.errors import InvalidInfractedUser, LockedResourceError +from bot.errors import InvalidInfractedUserError, LockedResourceError from bot.utils.checks import ContextCheckFailure log = logging.getLogger(__name__) @@ -76,7 +76,7 @@ class ErrorHandler(Cog): 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.") - elif isinstance(e.original, InvalidInfractedUser): + elif isinstance(e.original, InvalidInfractedUserError): await ctx.send(f"Cannot infract that user. {e.original.reason}") else: await self.handle_unexpected_error(ctx, e.original) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index e4eb7f79c..adbc641fa 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -7,7 +7,7 @@ from discord.ext.commands import Context from bot.api import ResponseCodeError from bot.constants import Colours, Icons -from bot.errors import InvalidInfractedUser +from bot.errors import InvalidInfractedUserError log = logging.getLogger(__name__) @@ -85,7 +85,7 @@ async def post_infraction( """Posts an infraction to the API.""" if isinstance(user, (discord.Member, discord.User)) and user.bot: log.trace(f"Posting of {infr_type} infraction for {user} to the API aborted. User is a bot.") - raise InvalidInfractedUser(user) + raise InvalidInfractedUserError(user) log.trace(f"Posting {infr_type} infraction for {user} to the API.") diff --git a/bot/pagination.py b/bot/pagination.py index 1c5b94b07..fbab74021 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -22,7 +22,7 @@ PAGINATION_EMOJI = (FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMO log = logging.getLogger(__name__) -class EmptyPaginatorEmbed(Exception): +class EmptyPaginatorEmbedError(Exception): """Raised when attempting to paginate with empty contents.""" pass @@ -233,7 +233,7 @@ class LinePaginator(Paginator): if not lines: if exception_on_empty_embed: log.exception("Pagination asked for empty lines iterable") - raise EmptyPaginatorEmbed("No lines to paginate") + raise EmptyPaginatorEmbedError("No lines to paginate") log.debug("No lines to add to paginator, adding '(nothing to display)' message") lines.append("(nothing to display)") diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index bd4fb5942..944cef6ca 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, call, patch from discord.ext.commands import errors from bot.api import ResponseCodeError -from bot.errors import InvalidInfractedUser, LockedResourceError +from bot.errors import InvalidInfractedUserError, LockedResourceError from bot.exts.backend.error_handler import ErrorHandler, setup from bot.exts.info.tags import Tags from bot.exts.moderation.silence import Silence @@ -130,7 +130,7 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): "expect_mock_call": "send" }, { - "args": (self.ctx, errors.CommandInvokeError(InvalidInfractedUser(self.ctx.author))), + "args": (self.ctx, errors.CommandInvokeError(InvalidInfractedUserError(self.ctx.author))), "expect_mock_call": "send" } ) -- cgit v1.2.3 From e6fed40da875a3866d8f2156d1a381faabab5ef7 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Wed, 7 Jul 2021 19:58:15 +0300 Subject: Prevents Blocking In Ping Command Makes the network fetch asynchronous to prevent blocking the program. Signed-off-by: Hassan Abouelela --- bot/exts/utils/ping.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index 58485fc34..c6d7bd900 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -39,9 +39,9 @@ class Latency(commands.Cog): bot_ping = f"{bot_ping:.{ROUND_LATENCY}f} ms" try: - request = await self.bot.http_session.get(f"{URLs.site_api_schema}{URLs.site_api}/healthcheck") - request.raise_for_status() - site_status = "Healthy" + async with self.bot.http_session.get(f"{URLs.site_api_schema}{URLs.site_api}/healthcheck") as request: + request.raise_for_status() + site_status = "Healthy" except client_exceptions.ClientResponseError as e: """The site returned an unexpected response.""" -- cgit v1.2.3 From fb9cbe434fc4531d117e6b8bdbd778dc4e9803a5 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Wed, 7 Jul 2021 22:24:21 +0300 Subject: Create events ext, prepare jams cog for file split --- bot/exts/events/__init__.py | 0 bot/exts/events/code_jams/__init__.py | 8 ++ bot/exts/events/code_jams/_cog.py | 176 ++++++++++++++++++++++++++++++++ bot/exts/filters/antimalware.py | 2 +- bot/exts/filters/antispam.py | 2 +- bot/exts/filters/filtering.py | 2 +- bot/exts/utils/jams.py | 176 -------------------------------- tests/bot/exts/events/__init__.py | 0 tests/bot/exts/events/test_code_jams.py | 174 +++++++++++++++++++++++++++++++ tests/bot/exts/utils/test_jams.py | 174 ------------------------------- 10 files changed, 361 insertions(+), 353 deletions(-) create mode 100644 bot/exts/events/__init__.py create mode 100644 bot/exts/events/code_jams/__init__.py create mode 100644 bot/exts/events/code_jams/_cog.py delete mode 100644 bot/exts/utils/jams.py create mode 100644 tests/bot/exts/events/__init__.py create mode 100644 tests/bot/exts/events/test_code_jams.py delete mode 100644 tests/bot/exts/utils/test_jams.py diff --git a/bot/exts/events/__init__.py b/bot/exts/events/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/exts/events/code_jams/__init__.py b/bot/exts/events/code_jams/__init__.py new file mode 100644 index 000000000..16e81e365 --- /dev/null +++ b/bot/exts/events/code_jams/__init__.py @@ -0,0 +1,8 @@ +from bot.bot import Bot + + +def setup(bot: Bot) -> None: + """Load the CodeJams cog.""" + from bot.exts.events.code_jams._cog import CodeJams + + bot.add_cog(CodeJams(bot)) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py new file mode 100644 index 000000000..87ae847f6 --- /dev/null +++ b/bot/exts/events/code_jams/_cog.py @@ -0,0 +1,176 @@ +import csv +import logging +import typing as t +from collections import defaultdict + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Categories, Channels, Emojis, Roles + +log = logging.getLogger(__name__) + +MAX_CHANNELS = 50 +CATEGORY_NAME = "Code Jam" +TEAM_LEADERS_COLOUR = 0x11806a + + +class CodeJams(commands.Cog): + """Manages the code-jam related parts of our server.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.group() + @commands.has_any_role(Roles.admins) + async def codejam(self, ctx: commands.Context) -> None: + """A Group of commands for managing Code Jams.""" + if ctx.invoked_subcommand is None: + await ctx.send_help(ctx.command) + + @codejam.command() + async def create(self, ctx: commands.Context, csv_file: t.Optional[str]) -> None: + """ + Create code-jam teams from a CSV file or a link to one, specifying the team names, leaders and members. + + The CSV file must have 3 columns: 'Team Name', 'Team Member Discord ID', and 'Team Leader'. + + This will create the text channels for the teams, and give the team leaders their roles. + """ + async with ctx.typing(): + if csv_file: + async with self.bot.http_session.get(csv_file) as response: + if response.status != 200: + await ctx.send(f"Got a bad response from the URL: {response.status}") + return + + csv_file = await response.text() + + elif ctx.message.attachments: + csv_file = (await ctx.message.attachments[0].read()).decode("utf8") + else: + raise commands.BadArgument("You must include either a CSV file or a link to one.") + + teams = defaultdict(list) + reader = csv.DictReader(csv_file.splitlines()) + + for row in reader: + member = ctx.guild.get_member(int(row["Team Member Discord ID"])) + + if member is None: + log.trace(f"Got an invalid member ID: {row['Team Member Discord ID']}") + continue + + teams[row["Team Name"]].append((member, row["Team Leader"].upper() == "Y")) + + team_leaders = await ctx.guild.create_role(name="Code Jam Team Leaders", colour=TEAM_LEADERS_COLOUR) + + for team_name, members in teams.items(): + await self.create_team_channel(ctx.guild, team_name, members, team_leaders) + + await self.create_team_leader_channel(ctx.guild, team_leaders) + await ctx.send(f"{Emojis.check_mark} Created Code Jam with {len(teams)} teams.") + + async def get_category(self, guild: discord.Guild) -> discord.CategoryChannel: + """ + Return a code jam category. + + If all categories are full or none exist, create a new category. + """ + for category in guild.categories: + if category.name == CATEGORY_NAME and len(category.channels) < MAX_CHANNELS: + return category + + return await self.create_category(guild) + + async def create_category(self, guild: discord.Guild) -> discord.CategoryChannel: + """Create a new code jam category and return it.""" + log.info("Creating a new code jam category.") + + category_overwrites = { + guild.default_role: discord.PermissionOverwrite(read_messages=False), + guild.me: discord.PermissionOverwrite(read_messages=True) + } + + category = await guild.create_category_channel( + CATEGORY_NAME, + overwrites=category_overwrites, + reason="It's code jam time!" + ) + + await self.send_status_update( + guild, f"Created a new category with the ID {category.id} for this Code Jam's team channels." + ) + + return category + + @staticmethod + def get_overwrites( + members: list[tuple[discord.Member, bool]], + guild: discord.Guild, + ) -> dict[t.Union[discord.Member, discord.Role], discord.PermissionOverwrite]: + """Get code jam team channels permission overwrites.""" + team_channel_overwrites = { + guild.default_role: discord.PermissionOverwrite(read_messages=False), + guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite(read_messages=True) + } + + for member, _ in members: + team_channel_overwrites[member] = discord.PermissionOverwrite( + read_messages=True + ) + + return team_channel_overwrites + + async def create_team_channel( + self, + guild: discord.Guild, + team_name: str, + members: list[tuple[discord.Member, bool]], + team_leaders: discord.Role + ) -> None: + """Create the team's text channel.""" + await self.add_team_leader_roles(members, team_leaders) + + # Get permission overwrites and category + team_channel_overwrites = self.get_overwrites(members, guild) + code_jam_category = await self.get_category(guild) + + # Create a text channel for the team + await code_jam_category.create_text_channel( + team_name, + overwrites=team_channel_overwrites, + ) + + async def create_team_leader_channel(self, guild: discord.Guild, team_leaders: discord.Role) -> None: + """Create the Team Leader Chat channel for the Code Jam team leaders.""" + category: discord.CategoryChannel = guild.get_channel(Categories.summer_code_jam) + + team_leaders_chat = await category.create_text_channel( + name="team-leaders-chat", + overwrites={ + guild.default_role: discord.PermissionOverwrite(read_messages=False), + team_leaders: discord.PermissionOverwrite(read_messages=True) + } + ) + + await self.send_status_update(guild, f"Created {team_leaders_chat.mention} in the {category} category.") + + async def send_status_update(self, guild: discord.Guild, message: str) -> None: + """Inform the events lead with a status update when the command is ran.""" + channel: discord.TextChannel = guild.get_channel(Channels.code_jam_planning) + + await channel.send(f"<@&{Roles.events_lead}>\n\n{message}") + + @staticmethod + async def add_team_leader_roles(members: list[tuple[discord.Member, bool]], team_leaders: discord.Role) -> None: + """Assign team leader role, the jammer role and their team role.""" + for member, is_leader in members: + if is_leader: + await member.add_roles(team_leaders) + + +def setup(bot: Bot) -> None: + """Load the CodeJams cog.""" + bot.add_cog(CodeJams(bot)) diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index 4c4836c88..3f6213db3 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/antimalware.py @@ -7,7 +7,7 @@ from discord.ext.commands import Cog from bot.bot import Bot from bot.constants import Channels, Filter, URLs -from bot.exts.utils.jams import CATEGORY_NAME as JAM_CATEGORY_NAME +from bot.exts.events.code_jams._cog import CATEGORY_NAME as JAM_CATEGORY_NAME log = logging.getLogger(__name__) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 48c3aa5a6..124905cb4 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -17,8 +17,8 @@ from bot.constants import ( Guild as GuildConfig, Icons, ) from bot.converters import Duration +from bot.exts.events.code_jams._cog import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.exts.moderation.modlog import ModLog -from bot.exts.utils.jams import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.utils import lock, scheduling from bot.utils.messages import format_user, send_attachments diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 16aaf11cf..0810425e2 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -19,8 +19,8 @@ from bot.constants import ( Channels, Colours, Filter, Guild, Icons, URLs ) +from bot.exts.events.code_jams._cog import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.exts.moderation.modlog import ModLog -from bot.exts.utils.jams import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.utils.messages import format_user from bot.utils.regex import INVITE_RE from bot.utils.scheduling import Scheduler diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py deleted file mode 100644 index 87ae847f6..000000000 --- a/bot/exts/utils/jams.py +++ /dev/null @@ -1,176 +0,0 @@ -import csv -import logging -import typing as t -from collections import defaultdict - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Categories, Channels, Emojis, Roles - -log = logging.getLogger(__name__) - -MAX_CHANNELS = 50 -CATEGORY_NAME = "Code Jam" -TEAM_LEADERS_COLOUR = 0x11806a - - -class CodeJams(commands.Cog): - """Manages the code-jam related parts of our server.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @commands.group() - @commands.has_any_role(Roles.admins) - async def codejam(self, ctx: commands.Context) -> None: - """A Group of commands for managing Code Jams.""" - if ctx.invoked_subcommand is None: - await ctx.send_help(ctx.command) - - @codejam.command() - async def create(self, ctx: commands.Context, csv_file: t.Optional[str]) -> None: - """ - Create code-jam teams from a CSV file or a link to one, specifying the team names, leaders and members. - - The CSV file must have 3 columns: 'Team Name', 'Team Member Discord ID', and 'Team Leader'. - - This will create the text channels for the teams, and give the team leaders their roles. - """ - async with ctx.typing(): - if csv_file: - async with self.bot.http_session.get(csv_file) as response: - if response.status != 200: - await ctx.send(f"Got a bad response from the URL: {response.status}") - return - - csv_file = await response.text() - - elif ctx.message.attachments: - csv_file = (await ctx.message.attachments[0].read()).decode("utf8") - else: - raise commands.BadArgument("You must include either a CSV file or a link to one.") - - teams = defaultdict(list) - reader = csv.DictReader(csv_file.splitlines()) - - for row in reader: - member = ctx.guild.get_member(int(row["Team Member Discord ID"])) - - if member is None: - log.trace(f"Got an invalid member ID: {row['Team Member Discord ID']}") - continue - - teams[row["Team Name"]].append((member, row["Team Leader"].upper() == "Y")) - - team_leaders = await ctx.guild.create_role(name="Code Jam Team Leaders", colour=TEAM_LEADERS_COLOUR) - - for team_name, members in teams.items(): - await self.create_team_channel(ctx.guild, team_name, members, team_leaders) - - await self.create_team_leader_channel(ctx.guild, team_leaders) - await ctx.send(f"{Emojis.check_mark} Created Code Jam with {len(teams)} teams.") - - async def get_category(self, guild: discord.Guild) -> discord.CategoryChannel: - """ - Return a code jam category. - - If all categories are full or none exist, create a new category. - """ - for category in guild.categories: - if category.name == CATEGORY_NAME and len(category.channels) < MAX_CHANNELS: - return category - - return await self.create_category(guild) - - async def create_category(self, guild: discord.Guild) -> discord.CategoryChannel: - """Create a new code jam category and return it.""" - log.info("Creating a new code jam category.") - - category_overwrites = { - guild.default_role: discord.PermissionOverwrite(read_messages=False), - guild.me: discord.PermissionOverwrite(read_messages=True) - } - - category = await guild.create_category_channel( - CATEGORY_NAME, - overwrites=category_overwrites, - reason="It's code jam time!" - ) - - await self.send_status_update( - guild, f"Created a new category with the ID {category.id} for this Code Jam's team channels." - ) - - return category - - @staticmethod - def get_overwrites( - members: list[tuple[discord.Member, bool]], - guild: discord.Guild, - ) -> dict[t.Union[discord.Member, discord.Role], discord.PermissionOverwrite]: - """Get code jam team channels permission overwrites.""" - team_channel_overwrites = { - guild.default_role: discord.PermissionOverwrite(read_messages=False), - guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite(read_messages=True) - } - - for member, _ in members: - team_channel_overwrites[member] = discord.PermissionOverwrite( - read_messages=True - ) - - return team_channel_overwrites - - async def create_team_channel( - self, - guild: discord.Guild, - team_name: str, - members: list[tuple[discord.Member, bool]], - team_leaders: discord.Role - ) -> None: - """Create the team's text channel.""" - await self.add_team_leader_roles(members, team_leaders) - - # Get permission overwrites and category - team_channel_overwrites = self.get_overwrites(members, guild) - code_jam_category = await self.get_category(guild) - - # Create a text channel for the team - await code_jam_category.create_text_channel( - team_name, - overwrites=team_channel_overwrites, - ) - - async def create_team_leader_channel(self, guild: discord.Guild, team_leaders: discord.Role) -> None: - """Create the Team Leader Chat channel for the Code Jam team leaders.""" - category: discord.CategoryChannel = guild.get_channel(Categories.summer_code_jam) - - team_leaders_chat = await category.create_text_channel( - name="team-leaders-chat", - overwrites={ - guild.default_role: discord.PermissionOverwrite(read_messages=False), - team_leaders: discord.PermissionOverwrite(read_messages=True) - } - ) - - await self.send_status_update(guild, f"Created {team_leaders_chat.mention} in the {category} category.") - - async def send_status_update(self, guild: discord.Guild, message: str) -> None: - """Inform the events lead with a status update when the command is ran.""" - channel: discord.TextChannel = guild.get_channel(Channels.code_jam_planning) - - await channel.send(f"<@&{Roles.events_lead}>\n\n{message}") - - @staticmethod - async def add_team_leader_roles(members: list[tuple[discord.Member, bool]], team_leaders: discord.Role) -> None: - """Assign team leader role, the jammer role and their team role.""" - for member, is_leader in members: - if is_leader: - await member.add_roles(team_leaders) - - -def setup(bot: Bot) -> None: - """Load the CodeJams cog.""" - bot.add_cog(CodeJams(bot)) diff --git a/tests/bot/exts/events/__init__.py b/tests/bot/exts/events/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/exts/events/test_code_jams.py b/tests/bot/exts/events/test_code_jams.py new file mode 100644 index 000000000..d7b8aa4d2 --- /dev/null +++ b/tests/bot/exts/events/test_code_jams.py @@ -0,0 +1,174 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, create_autospec + +from discord import CategoryChannel +from discord.ext.commands import BadArgument + +from bot.constants import Roles +from bot.exts.events.code_jams import _cog +from tests.helpers import ( + MockAttachment, MockBot, MockCategoryChannel, MockContext, + MockGuild, MockMember, MockRole, MockTextChannel +) + +TEST_CSV = b"""\ +Team Name,Team Member Discord ID,Team Leader +Annoyed Alligators,12345,Y +Annoyed Alligators,54321,N +Oscillating Otters,12358,Y +Oscillating Otters,74832,N +Oscillating Otters,19903,N +Annoyed Alligators,11111,N +""" + + +def get_mock_category(channel_count: int, name: str) -> CategoryChannel: + """Return a mocked code jam category.""" + category = create_autospec(CategoryChannel, spec_set=True, instance=True) + category.name = name + category.channels = [MockTextChannel() for _ in range(channel_count)] + + return category + + +class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): + """Tests for `codejam create` command.""" + + def setUp(self): + self.bot = MockBot() + self.admin_role = MockRole(name="Admins", id=Roles.admins) + self.command_user = MockMember([self.admin_role]) + self.guild = MockGuild([self.admin_role]) + self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) + self.cog = _cog.CodeJams(self.bot) + + async def test_message_without_attachments(self): + """If no link or attachments are provided, commands.BadArgument should be raised.""" + self.ctx.message.attachments = [] + + with self.assertRaises(BadArgument): + await self.cog.create(self.cog, self.ctx, None) + + async def test_result_sending(self): + """Should call `ctx.send` when everything goes right.""" + self.ctx.message.attachments = [MockAttachment()] + self.ctx.message.attachments[0].read = AsyncMock() + self.ctx.message.attachments[0].read.return_value = TEST_CSV + + team_leaders = MockRole() + + self.guild.get_member.return_value = MockMember() + + self.ctx.guild.create_role = AsyncMock() + self.ctx.guild.create_role.return_value = team_leaders + self.cog.create_team_channel = AsyncMock() + self.cog.create_team_leader_channel = AsyncMock() + self.cog.add_roles = AsyncMock() + + await self.cog.create(self.cog, self.ctx, None) + + self.cog.create_team_channel.assert_awaited() + self.cog.create_team_leader_channel.assert_awaited_once_with( + self.ctx.guild, team_leaders + ) + self.ctx.send.assert_awaited_once() + + async def test_link_returning_non_200_status(self): + """When the URL passed returns a non 200 status, it should send a message informing them.""" + self.bot.http_session.get.return_value = mock = MagicMock() + mock.status = 404 + await self.cog.create(self.cog, self.ctx, "https://not-a-real-link.com") + + self.ctx.send.assert_awaited_once() + + async def test_category_doesnt_exist(self): + """Should create a new code jam category.""" + subtests = ( + [], + [get_mock_category(_cog.MAX_CHANNELS, _cog.CATEGORY_NAME)], + [get_mock_category(_cog.MAX_CHANNELS - 2, "other")], + ) + + self.cog.send_status_update = AsyncMock() + + for categories in subtests: + self.cog.send_status_update.reset_mock() + self.guild.reset_mock() + self.guild.categories = categories + + with self.subTest(categories=categories): + actual_category = await self.cog.get_category(self.guild) + + self.cog.send_status_update.assert_called_once() + self.guild.create_category_channel.assert_awaited_once() + category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] + + self.assertFalse(category_overwrites[self.guild.default_role].read_messages) + self.assertTrue(category_overwrites[self.guild.me].read_messages) + self.assertEqual(self.guild.create_category_channel.return_value, actual_category) + + async def test_category_channel_exist(self): + """Should not try to create category channel.""" + expected_category = get_mock_category(_cog.MAX_CHANNELS - 2, _cog.CATEGORY_NAME) + self.guild.categories = [ + get_mock_category(_cog.MAX_CHANNELS - 2, "other"), + expected_category, + get_mock_category(0, _cog.CATEGORY_NAME), + ] + + actual_category = await self.cog.get_category(self.guild) + self.assertEqual(expected_category, actual_category) + + async def test_channel_overwrites(self): + """Should have correct permission overwrites for users and roles.""" + leader = (MockMember(), True) + members = [leader] + [(MockMember(), False) for _ in range(4)] + overwrites = self.cog.get_overwrites(members, self.guild) + + for member, _ in members: + self.assertTrue(overwrites[member].read_messages) + + async def test_team_channels_creation(self): + """Should create a text channel for a team.""" + team_leaders = MockRole() + members = [(MockMember(), True)] + [(MockMember(), False) for _ in range(5)] + category = MockCategoryChannel() + category.create_text_channel = AsyncMock() + + self.cog.get_overwrites = MagicMock() + self.cog.get_category = AsyncMock() + self.cog.get_category.return_value = category + self.cog.add_team_leader_roles = AsyncMock() + + await self.cog.create_team_channel(self.guild, "my-team", members, team_leaders) + self.cog.add_team_leader_roles.assert_awaited_once_with(members, team_leaders) + self.cog.get_overwrites.assert_called_once_with(members, self.guild) + self.cog.get_category.assert_awaited_once_with(self.guild) + + category.create_text_channel.assert_awaited_once_with( + "my-team", + overwrites=self.cog.get_overwrites.return_value + ) + + async def test_jam_roles_adding(self): + """Should add team leader role to leader and jam role to every team member.""" + leader_role = MockRole(name="Team Leader") + + leader = MockMember() + members = [(leader, True)] + [(MockMember(), False) for _ in range(4)] + await self.cog.add_team_leader_roles(members, leader_role) + + leader.add_roles.assert_awaited_once_with(leader_role) + for member, is_leader in members: + if not is_leader: + member.add_roles.assert_not_awaited() + + +class CodeJamSetup(unittest.TestCase): + """Test for `setup` function of `CodeJam` cog.""" + + def test_setup(self): + """Should call `bot.add_cog`.""" + bot = MockBot() + _cog.setup(bot) + bot.add_cog.assert_called_once() diff --git a/tests/bot/exts/utils/test_jams.py b/tests/bot/exts/utils/test_jams.py deleted file mode 100644 index 368a15476..000000000 --- a/tests/bot/exts/utils/test_jams.py +++ /dev/null @@ -1,174 +0,0 @@ -import unittest -from unittest.mock import AsyncMock, MagicMock, create_autospec - -from discord import CategoryChannel -from discord.ext.commands import BadArgument - -from bot.constants import Roles -from bot.exts.utils import jams -from tests.helpers import ( - MockAttachment, MockBot, MockCategoryChannel, MockContext, - MockGuild, MockMember, MockRole, MockTextChannel -) - -TEST_CSV = b"""\ -Team Name,Team Member Discord ID,Team Leader -Annoyed Alligators,12345,Y -Annoyed Alligators,54321,N -Oscillating Otters,12358,Y -Oscillating Otters,74832,N -Oscillating Otters,19903,N -Annoyed Alligators,11111,N -""" - - -def get_mock_category(channel_count: int, name: str) -> CategoryChannel: - """Return a mocked code jam category.""" - category = create_autospec(CategoryChannel, spec_set=True, instance=True) - category.name = name - category.channels = [MockTextChannel() for _ in range(channel_count)] - - return category - - -class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): - """Tests for `codejam create` command.""" - - def setUp(self): - self.bot = MockBot() - self.admin_role = MockRole(name="Admins", id=Roles.admins) - self.command_user = MockMember([self.admin_role]) - self.guild = MockGuild([self.admin_role]) - self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) - self.cog = jams.CodeJams(self.bot) - - async def test_message_without_attachments(self): - """If no link or attachments are provided, commands.BadArgument should be raised.""" - self.ctx.message.attachments = [] - - with self.assertRaises(BadArgument): - await self.cog.create(self.cog, self.ctx, None) - - async def test_result_sending(self): - """Should call `ctx.send` when everything goes right.""" - self.ctx.message.attachments = [MockAttachment()] - self.ctx.message.attachments[0].read = AsyncMock() - self.ctx.message.attachments[0].read.return_value = TEST_CSV - - team_leaders = MockRole() - - self.guild.get_member.return_value = MockMember() - - self.ctx.guild.create_role = AsyncMock() - self.ctx.guild.create_role.return_value = team_leaders - self.cog.create_team_channel = AsyncMock() - self.cog.create_team_leader_channel = AsyncMock() - self.cog.add_roles = AsyncMock() - - await self.cog.create(self.cog, self.ctx, None) - - self.cog.create_team_channel.assert_awaited() - self.cog.create_team_leader_channel.assert_awaited_once_with( - self.ctx.guild, team_leaders - ) - self.ctx.send.assert_awaited_once() - - async def test_link_returning_non_200_status(self): - """When the URL passed returns a non 200 status, it should send a message informing them.""" - self.bot.http_session.get.return_value = mock = MagicMock() - mock.status = 404 - await self.cog.create(self.cog, self.ctx, "https://not-a-real-link.com") - - self.ctx.send.assert_awaited_once() - - async def test_category_doesnt_exist(self): - """Should create a new code jam category.""" - subtests = ( - [], - [get_mock_category(jams.MAX_CHANNELS, jams.CATEGORY_NAME)], - [get_mock_category(jams.MAX_CHANNELS - 2, "other")], - ) - - self.cog.send_status_update = AsyncMock() - - for categories in subtests: - self.cog.send_status_update.reset_mock() - self.guild.reset_mock() - self.guild.categories = categories - - with self.subTest(categories=categories): - actual_category = await self.cog.get_category(self.guild) - - self.cog.send_status_update.assert_called_once() - self.guild.create_category_channel.assert_awaited_once() - category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] - - self.assertFalse(category_overwrites[self.guild.default_role].read_messages) - self.assertTrue(category_overwrites[self.guild.me].read_messages) - self.assertEqual(self.guild.create_category_channel.return_value, actual_category) - - async def test_category_channel_exist(self): - """Should not try to create category channel.""" - expected_category = get_mock_category(jams.MAX_CHANNELS - 2, jams.CATEGORY_NAME) - self.guild.categories = [ - get_mock_category(jams.MAX_CHANNELS - 2, "other"), - expected_category, - get_mock_category(0, jams.CATEGORY_NAME), - ] - - actual_category = await self.cog.get_category(self.guild) - self.assertEqual(expected_category, actual_category) - - async def test_channel_overwrites(self): - """Should have correct permission overwrites for users and roles.""" - leader = (MockMember(), True) - members = [leader] + [(MockMember(), False) for _ in range(4)] - overwrites = self.cog.get_overwrites(members, self.guild) - - for member, _ in members: - self.assertTrue(overwrites[member].read_messages) - - async def test_team_channels_creation(self): - """Should create a text channel for a team.""" - team_leaders = MockRole() - members = [(MockMember(), True)] + [(MockMember(), False) for _ in range(5)] - category = MockCategoryChannel() - category.create_text_channel = AsyncMock() - - self.cog.get_overwrites = MagicMock() - self.cog.get_category = AsyncMock() - self.cog.get_category.return_value = category - self.cog.add_team_leader_roles = AsyncMock() - - await self.cog.create_team_channel(self.guild, "my-team", members, team_leaders) - self.cog.add_team_leader_roles.assert_awaited_once_with(members, team_leaders) - self.cog.get_overwrites.assert_called_once_with(members, self.guild) - self.cog.get_category.assert_awaited_once_with(self.guild) - - category.create_text_channel.assert_awaited_once_with( - "my-team", - overwrites=self.cog.get_overwrites.return_value - ) - - async def test_jam_roles_adding(self): - """Should add team leader role to leader and jam role to every team member.""" - leader_role = MockRole(name="Team Leader") - - leader = MockMember() - members = [(leader, True)] + [(MockMember(), False) for _ in range(4)] - await self.cog.add_team_leader_roles(members, leader_role) - - leader.add_roles.assert_awaited_once_with(leader_role) - for member, is_leader in members: - if not is_leader: - member.add_roles.assert_not_awaited() - - -class CodeJamSetup(unittest.TestCase): - """Test for `setup` function of `CodeJam` cog.""" - - def test_setup(self): - """Should call `bot.add_cog`.""" - bot = MockBot() - jams.setup(bot) - bot.add_cog.assert_called_once() -- cgit v1.2.3 From cce90faf3ea394104dede886ef4bf5747573a612 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 7 Jul 2021 22:53:44 +0200 Subject: Fix leading space in str of identifiers without a group This issue doesn't show on discord as whitespace is collapsed in embeds, but could be seen in logs --- bot/exts/info/tags.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 6971397d4..0bedd6e10 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -63,7 +63,10 @@ class TagIdentifier(NamedTuple): return fuzzy_score def __str__(self) -> str: - return f"{self.group or ''} {self.name}" + if self.group is not None: + return f"{self.group} {self.name}" + else: + return self.name class Tag: -- cgit v1.2.3 From 698660004b13273371baefa1f41ce2f908a3431f Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 8 Jul 2021 02:16:33 +0300 Subject: Move jam channels creation to separate file The channel creations are static and clutter the cog class. We want to add more commands to the cog, so we move the static functions away to a separate file first. --- bot/exts/events/code_jams/_channels.py | 113 ++++++++++++++++++++++++++++++++ bot/exts/events/code_jams/_cog.py | 113 ++------------------------------ bot/exts/filters/antimalware.py | 2 +- bot/exts/filters/antispam.py | 2 +- bot/exts/filters/filtering.py | 2 +- tests/bot/exts/events/test_code_jams.py | 64 +++++++++--------- 6 files changed, 150 insertions(+), 146 deletions(-) create mode 100644 bot/exts/events/code_jams/_channels.py diff --git a/bot/exts/events/code_jams/_channels.py b/bot/exts/events/code_jams/_channels.py new file mode 100644 index 000000000..8b199a3c2 --- /dev/null +++ b/bot/exts/events/code_jams/_channels.py @@ -0,0 +1,113 @@ +import logging +import typing as t + +import discord + +from bot.constants import Categories, Channels, Roles + +log = logging.getLogger(__name__) + +MAX_CHANNELS = 50 +CATEGORY_NAME = "Code Jam" + + +async def _get_category(guild: discord.Guild) -> discord.CategoryChannel: + """ + Return a code jam category. + + If all categories are full or none exist, create a new category. + """ + for category in guild.categories: + if category.name == CATEGORY_NAME and len(category.channels) < MAX_CHANNELS: + return category + + return await _create_category(guild) + + +async def _create_category(guild: discord.Guild) -> discord.CategoryChannel: + """Create a new code jam category and return it.""" + log.info("Creating a new code jam category.") + + category_overwrites = { + guild.default_role: discord.PermissionOverwrite(read_messages=False), + guild.me: discord.PermissionOverwrite(read_messages=True) + } + + category = await guild.create_category_channel( + CATEGORY_NAME, + overwrites=category_overwrites, + reason="It's code jam time!" + ) + + await _send_status_update( + guild, f"Created a new category with the ID {category.id} for this Code Jam's team channels." + ) + + return category + + +def _get_overwrites( + members: list[tuple[discord.Member, bool]], + guild: discord.Guild, +) -> dict[t.Union[discord.Member, discord.Role], discord.PermissionOverwrite]: + """Get code jam team channels permission overwrites.""" + team_channel_overwrites = { + guild.default_role: discord.PermissionOverwrite(read_messages=False), + guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite(read_messages=True) + } + + for member, _ in members: + team_channel_overwrites[member] = discord.PermissionOverwrite( + read_messages=True + ) + + return team_channel_overwrites + + +async def create_team_channel( + guild: discord.Guild, + team_name: str, + members: list[tuple[discord.Member, bool]], + team_leaders: discord.Role +) -> None: + """Create the team's text channel.""" + await _add_team_leader_roles(members, team_leaders) + + # Get permission overwrites and category + team_channel_overwrites = _get_overwrites(members, guild) + code_jam_category = await _get_category(guild) + + # Create a text channel for the team + await code_jam_category.create_text_channel( + team_name, + overwrites=team_channel_overwrites, + ) + + +async def create_team_leader_channel(guild: discord.Guild, team_leaders: discord.Role) -> None: + """Create the Team Leader Chat channel for the Code Jam team leaders.""" + category: discord.CategoryChannel = guild.get_channel(Categories.summer_code_jam) + + team_leaders_chat = await category.create_text_channel( + name="team-leaders-chat", + overwrites={ + guild.default_role: discord.PermissionOverwrite(read_messages=False), + team_leaders: discord.PermissionOverwrite(read_messages=True) + } + ) + + await _send_status_update(guild, f"Created {team_leaders_chat.mention} in the {category} category.") + + +async def _send_status_update(guild: discord.Guild, message: str) -> None: + """Inform the events lead with a status update when the command is ran.""" + channel: discord.TextChannel = guild.get_channel(Channels.code_jam_planning) + + await channel.send(f"<@&{Roles.events_lead}>\n\n{message}") + + +async def _add_team_leader_roles(members: list[tuple[discord.Member, bool]], team_leaders: discord.Role) -> None: + """Assign team leader role, the jammer role and their team role.""" + for member, is_leader in members: + if is_leader: + await member.add_roles(team_leaders) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index 87ae847f6..2d0873de7 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -3,16 +3,14 @@ import logging import typing as t from collections import defaultdict -import discord from discord.ext import commands from bot.bot import Bot -from bot.constants import Categories, Channels, Emojis, Roles +from bot.constants import Emojis, Roles +from bot.exts.events.code_jams import _channels log = logging.getLogger(__name__) -MAX_CHANNELS = 50 -CATEGORY_NAME = "Code Jam" TEAM_LEADERS_COLOUR = 0x11806a @@ -67,110 +65,7 @@ class CodeJams(commands.Cog): team_leaders = await ctx.guild.create_role(name="Code Jam Team Leaders", colour=TEAM_LEADERS_COLOUR) for team_name, members in teams.items(): - await self.create_team_channel(ctx.guild, team_name, members, team_leaders) + await _channels.create_team_channel(ctx.guild, team_name, members, team_leaders) - await self.create_team_leader_channel(ctx.guild, team_leaders) + await _channels.create_team_leader_channel(ctx.guild, team_leaders) await ctx.send(f"{Emojis.check_mark} Created Code Jam with {len(teams)} teams.") - - async def get_category(self, guild: discord.Guild) -> discord.CategoryChannel: - """ - Return a code jam category. - - If all categories are full or none exist, create a new category. - """ - for category in guild.categories: - if category.name == CATEGORY_NAME and len(category.channels) < MAX_CHANNELS: - return category - - return await self.create_category(guild) - - async def create_category(self, guild: discord.Guild) -> discord.CategoryChannel: - """Create a new code jam category and return it.""" - log.info("Creating a new code jam category.") - - category_overwrites = { - guild.default_role: discord.PermissionOverwrite(read_messages=False), - guild.me: discord.PermissionOverwrite(read_messages=True) - } - - category = await guild.create_category_channel( - CATEGORY_NAME, - overwrites=category_overwrites, - reason="It's code jam time!" - ) - - await self.send_status_update( - guild, f"Created a new category with the ID {category.id} for this Code Jam's team channels." - ) - - return category - - @staticmethod - def get_overwrites( - members: list[tuple[discord.Member, bool]], - guild: discord.Guild, - ) -> dict[t.Union[discord.Member, discord.Role], discord.PermissionOverwrite]: - """Get code jam team channels permission overwrites.""" - team_channel_overwrites = { - guild.default_role: discord.PermissionOverwrite(read_messages=False), - guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite(read_messages=True) - } - - for member, _ in members: - team_channel_overwrites[member] = discord.PermissionOverwrite( - read_messages=True - ) - - return team_channel_overwrites - - async def create_team_channel( - self, - guild: discord.Guild, - team_name: str, - members: list[tuple[discord.Member, bool]], - team_leaders: discord.Role - ) -> None: - """Create the team's text channel.""" - await self.add_team_leader_roles(members, team_leaders) - - # Get permission overwrites and category - team_channel_overwrites = self.get_overwrites(members, guild) - code_jam_category = await self.get_category(guild) - - # Create a text channel for the team - await code_jam_category.create_text_channel( - team_name, - overwrites=team_channel_overwrites, - ) - - async def create_team_leader_channel(self, guild: discord.Guild, team_leaders: discord.Role) -> None: - """Create the Team Leader Chat channel for the Code Jam team leaders.""" - category: discord.CategoryChannel = guild.get_channel(Categories.summer_code_jam) - - team_leaders_chat = await category.create_text_channel( - name="team-leaders-chat", - overwrites={ - guild.default_role: discord.PermissionOverwrite(read_messages=False), - team_leaders: discord.PermissionOverwrite(read_messages=True) - } - ) - - await self.send_status_update(guild, f"Created {team_leaders_chat.mention} in the {category} category.") - - async def send_status_update(self, guild: discord.Guild, message: str) -> None: - """Inform the events lead with a status update when the command is ran.""" - channel: discord.TextChannel = guild.get_channel(Channels.code_jam_planning) - - await channel.send(f"<@&{Roles.events_lead}>\n\n{message}") - - @staticmethod - async def add_team_leader_roles(members: list[tuple[discord.Member, bool]], team_leaders: discord.Role) -> None: - """Assign team leader role, the jammer role and their team role.""" - for member, is_leader in members: - if is_leader: - await member.add_roles(team_leaders) - - -def setup(bot: Bot) -> None: - """Load the CodeJams cog.""" - bot.add_cog(CodeJams(bot)) diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index 3f6213db3..0eedeb0fb 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/antimalware.py @@ -7,7 +7,7 @@ from discord.ext.commands import Cog from bot.bot import Bot from bot.constants import Channels, Filter, URLs -from bot.exts.events.code_jams._cog import CATEGORY_NAME as JAM_CATEGORY_NAME +from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME log = logging.getLogger(__name__) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 124905cb4..1830e23b8 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -17,7 +17,7 @@ from bot.constants import ( Guild as GuildConfig, Icons, ) from bot.converters import Duration -from bot.exts.events.code_jams._cog import CATEGORY_NAME as JAM_CATEGORY_NAME +from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.exts.moderation.modlog import ModLog from bot.utils import lock, scheduling from bot.utils.messages import format_user, send_attachments diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 0810425e2..10cc7885d 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -19,7 +19,7 @@ from bot.constants import ( Channels, Colours, Filter, Guild, Icons, URLs ) -from bot.exts.events.code_jams._cog import CATEGORY_NAME as JAM_CATEGORY_NAME +from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.exts.moderation.modlog import ModLog from bot.utils.messages import format_user from bot.utils.regex import INVITE_RE diff --git a/tests/bot/exts/events/test_code_jams.py b/tests/bot/exts/events/test_code_jams.py index d7b8aa4d2..b9ee1e363 100644 --- a/tests/bot/exts/events/test_code_jams.py +++ b/tests/bot/exts/events/test_code_jams.py @@ -1,14 +1,15 @@ import unittest -from unittest.mock import AsyncMock, MagicMock, create_autospec +from unittest.mock import AsyncMock, MagicMock, create_autospec, patch from discord import CategoryChannel from discord.ext.commands import BadArgument from bot.constants import Roles -from bot.exts.events.code_jams import _cog +from bot.exts.events import code_jams +from bot.exts.events.code_jams import _channels, _cog from tests.helpers import ( MockAttachment, MockBot, MockCategoryChannel, MockContext, - MockGuild, MockMember, MockRole, MockTextChannel + MockGuild, MockMember, MockRole, MockTextChannel, autospec ) TEST_CSV = b"""\ @@ -49,7 +50,9 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): with self.assertRaises(BadArgument): await self.cog.create(self.cog, self.ctx, None) - async def test_result_sending(self): + @patch.object(_channels, "create_team_channel") + @patch.object(_channels, "create_team_leader_channel") + async def test_result_sending(self, create_leader_channel, create_team_channel): """Should call `ctx.send` when everything goes right.""" self.ctx.message.attachments = [MockAttachment()] self.ctx.message.attachments[0].read = AsyncMock() @@ -61,14 +64,12 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): self.ctx.guild.create_role = AsyncMock() self.ctx.guild.create_role.return_value = team_leaders - self.cog.create_team_channel = AsyncMock() - self.cog.create_team_leader_channel = AsyncMock() self.cog.add_roles = AsyncMock() await self.cog.create(self.cog, self.ctx, None) - self.cog.create_team_channel.assert_awaited() - self.cog.create_team_leader_channel.assert_awaited_once_with( + create_team_channel.assert_awaited() + create_leader_channel.assert_awaited_once_with( self.ctx.guild, team_leaders ) self.ctx.send.assert_awaited_once() @@ -81,25 +82,24 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): self.ctx.send.assert_awaited_once() - async def test_category_doesnt_exist(self): + @patch.object(_channels, "_send_status_update") + async def test_category_doesnt_exist(self, update): """Should create a new code jam category.""" subtests = ( [], - [get_mock_category(_cog.MAX_CHANNELS, _cog.CATEGORY_NAME)], - [get_mock_category(_cog.MAX_CHANNELS - 2, "other")], + [get_mock_category(_channels.MAX_CHANNELS, _channels.CATEGORY_NAME)], + [get_mock_category(_channels.MAX_CHANNELS - 2, "other")], ) - self.cog.send_status_update = AsyncMock() - for categories in subtests: - self.cog.send_status_update.reset_mock() + update.reset_mock() self.guild.reset_mock() self.guild.categories = categories with self.subTest(categories=categories): - actual_category = await self.cog.get_category(self.guild) + actual_category = await _channels._get_category(self.guild) - self.cog.send_status_update.assert_called_once() + update.assert_called_once() self.guild.create_category_channel.assert_awaited_once() category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] @@ -109,45 +109,41 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): async def test_category_channel_exist(self): """Should not try to create category channel.""" - expected_category = get_mock_category(_cog.MAX_CHANNELS - 2, _cog.CATEGORY_NAME) + expected_category = get_mock_category(_channels.MAX_CHANNELS - 2, _channels.CATEGORY_NAME) self.guild.categories = [ - get_mock_category(_cog.MAX_CHANNELS - 2, "other"), + get_mock_category(_channels.MAX_CHANNELS - 2, "other"), expected_category, - get_mock_category(0, _cog.CATEGORY_NAME), + get_mock_category(0, _channels.CATEGORY_NAME), ] - actual_category = await self.cog.get_category(self.guild) + actual_category = await _channels._get_category(self.guild) self.assertEqual(expected_category, actual_category) async def test_channel_overwrites(self): """Should have correct permission overwrites for users and roles.""" leader = (MockMember(), True) members = [leader] + [(MockMember(), False) for _ in range(4)] - overwrites = self.cog.get_overwrites(members, self.guild) + overwrites = _channels._get_overwrites(members, self.guild) for member, _ in members: self.assertTrue(overwrites[member].read_messages) - async def test_team_channels_creation(self): + @patch.object(_channels, "_get_overwrites") + @patch.object(_channels, "_get_category") + @autospec(_channels, "_add_team_leader_roles", pass_mocks=False) + async def test_team_channels_creation(self, get_category, get_overwrites): """Should create a text channel for a team.""" team_leaders = MockRole() members = [(MockMember(), True)] + [(MockMember(), False) for _ in range(5)] category = MockCategoryChannel() category.create_text_channel = AsyncMock() - self.cog.get_overwrites = MagicMock() - self.cog.get_category = AsyncMock() - self.cog.get_category.return_value = category - self.cog.add_team_leader_roles = AsyncMock() - - await self.cog.create_team_channel(self.guild, "my-team", members, team_leaders) - self.cog.add_team_leader_roles.assert_awaited_once_with(members, team_leaders) - self.cog.get_overwrites.assert_called_once_with(members, self.guild) - self.cog.get_category.assert_awaited_once_with(self.guild) + get_category.return_value = category + await _channels.create_team_channel(self.guild, "my-team", members, team_leaders) category.create_text_channel.assert_awaited_once_with( "my-team", - overwrites=self.cog.get_overwrites.return_value + overwrites=get_overwrites.return_value ) async def test_jam_roles_adding(self): @@ -156,7 +152,7 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): leader = MockMember() members = [(leader, True)] + [(MockMember(), False) for _ in range(4)] - await self.cog.add_team_leader_roles(members, leader_role) + await _channels._add_team_leader_roles(members, leader_role) leader.add_roles.assert_awaited_once_with(leader_role) for member, is_leader in members: @@ -170,5 +166,5 @@ class CodeJamSetup(unittest.TestCase): def test_setup(self): """Should call `bot.add_cog`.""" bot = MockBot() - _cog.setup(bot) + code_jams.setup(bot) bot.add_cog.assert_called_once() -- cgit v1.2.3 From d4271b0c1df1e7acbbf694c9f585bacc25edaecd Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Thu, 8 Jul 2021 22:09:12 +0300 Subject: More code jam functionality - An info embed with team the member is in. The team is decided by finding in which channel the member has overwrites. - Command to move a member from one team to another by changing the permissions of the appropriate team channels. - A command to end the code jam and delete all the team channels and categories. --- bot/exts/events/code_jams/_cog.py | 105 +++++++++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index 2d0873de7..39577a5c3 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -3,6 +3,8 @@ import logging import typing as t from collections import defaultdict +import discord +from discord import Colour, Embed, Guild, Member from discord.ext import commands from bot.bot import Bot @@ -20,7 +22,9 @@ class CodeJams(commands.Cog): def __init__(self, bot: Bot): self.bot = bot - @commands.group() + self.end_counter = 0 + + @commands.group(aliases=("cj", "jam")) @commands.has_any_role(Roles.admins) async def codejam(self, ctx: commands.Context) -> None: """A Group of commands for managing Code Jams.""" @@ -69,3 +73,102 @@ class CodeJams(commands.Cog): await _channels.create_team_leader_channel(ctx.guild, team_leaders) await ctx.send(f"{Emojis.check_mark} Created Code Jam with {len(teams)} teams.") + + @codejam.command() + @commands.has_any_role(Roles.admins) + async def end(self, ctx: commands.Context) -> None: + """ + Call it three times while spinning around for it all to end. + + Deletes all code jam channels and wipes the cache. + """ + self.end_counter += 1 + if self.end_counter == 1: + await ctx.send("Are you sure about that?") + return + if self.end_counter == 2: + await ctx.send("Are you *really really* sure about that?") + return + + self.end_counter = 0 + + for category in self.jam_categories(ctx.guild): + for channel in category.channels: + await channel.delete(reason="Code jam ended.") + await category.delete(reason="Code jam ended.") + + await ctx.message.add_reaction(Emojis.check_mark) + + @codejam.command() + @commands.has_any_role(Roles.admins, Roles.code_jam_event_team) + async def info(self, ctx: commands.Context, member: Member) -> None: + """ + Send an info embed about the member with the team they're in. + + The team is found by searching the permissions of the team channels. + """ + channel = self.team_channel(ctx.guild, member) + if not channel: + await ctx.send(":x: I can't find the team channel for this member.") + return + + embed = Embed( + title=str(member), + colour=Colour.blurple() + ) + embed.add_field(name="Team", value=self.team_name(channel), inline=True) + + await ctx.send(embed=embed) + + @codejam.command() + @commands.has_any_role(Roles.admins) + async def move(self, ctx: commands.Context, member: Member, new_team_name: str) -> None: + """Move participant from one team to another by changing the user's permissions for the relevant channels.""" + old_team_channel = self.team_channel(ctx.guild, member) + if not old_team_channel: + await ctx.send(":x: I can't find the team channel for this member.") + return + + if old_team_channel.name == new_team_name or self.team_name(old_team_channel) == new_team_name: + await ctx.send(f"`{member}` is already in `{new_team_name}`.") + return + + new_team_channel = self.team_channel(ctx.guild, new_team_name) + if not new_team_channel: + await ctx.send(f":x: I can't find a team channel named `{new_team_name}`.") + return + + await old_team_channel.set_permissions(member, overwrite=None, reason=f"Participant moved to {new_team_name}") + await new_team_channel.set_permissions( + member, + overwrite=discord.PermissionOverwrite(read_messages=True), + reason=f"Participant moved from {old_team_channel.name}" + ) + + await ctx.send( + f"Participant moved from `{self.team_name(old_team_channel)}` to `{self.team_name(new_team_channel)}`." + ) + + @staticmethod + def jam_categories(guild: Guild) -> list[discord.CategoryChannel]: + """Get all the code jam team categories.""" + return [category for category in guild.categories if category.name == _channels.CATEGORY_NAME] + + @staticmethod + def team_channel(guild: Guild, criterion: t.Union[str, Member]) -> t.Optional[discord.TextChannel]: + """Get a team channel through either a participant or the team name.""" + for category in CodeJams.jam_categories(guild): + for channel in category.channels: + if isinstance(channel, discord.TextChannel): + if ( + # If it's a string. + criterion == channel.name or criterion == CodeJams.team_name(channel) + # If it's a member. + or criterion in channel.overwrites + ): + return channel + + @staticmethod + def team_name(channel: discord.TextChannel) -> str: + """Retrieves the team name from the given channel.""" + return channel.name.replace("-", " ").title() -- cgit v1.2.3 From bc390bcb66d060aeba29c835d6c5cceb3d366626 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 8 Jul 2021 23:14:47 +0300 Subject: Added command to remove from team --- bot/exts/events/code_jams/_cog.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index 39577a5c3..862e53a13 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -149,6 +149,18 @@ class CodeJams(commands.Cog): f"Participant moved from `{self.team_name(old_team_channel)}` to `{self.team_name(new_team_channel)}`." ) + @codejam.command() + @commands.has_any_role(Roles.admins) + async def remove(self, ctx: commands.Context, member: Member) -> None: + """Removes the participant from their team. Does not remove the participants or leader roles.""" + channel = self.team_channel(ctx.guild, member) + if not channel: + await ctx.send(":x: I can't find the team channel for this member.") + return + + await channel.set_permissions(member, overwrite=None, reason="Participant removed from the team.") + await ctx.send(f"Removed the participant from `{self.team_name(channel)}`.") + @staticmethod def jam_categories(guild: Guild) -> list[discord.CategoryChannel]: """Get all the code jam team categories.""" -- cgit v1.2.3 From fdc6f2387ee9894c21f84fb41d9a909a52e42971 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 8 Jul 2021 23:23:34 +0300 Subject: Fix end command docstring --- bot/exts/events/code_jams/_cog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index 862e53a13..83e2e18ce 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -78,9 +78,9 @@ class CodeJams(commands.Cog): @commands.has_any_role(Roles.admins) async def end(self, ctx: commands.Context) -> None: """ - Call it three times while spinning around for it all to end. + Deletes all code jam channels. - Deletes all code jam channels and wipes the cache. + Call it three times while spinning around for it all to end. """ self.end_counter += 1 if self.end_counter == 1: -- cgit v1.2.3 From 7049a0052b375d8fa28288ab8ca63f7b99c069ed Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Thu, 8 Jul 2021 13:52:28 +0200 Subject: Create `join_role_stats` function in helpers Add `join_role_stats` function that joins the relevant information (number of members) of the given roles into one group under a pre-specified `name` --- bot/utils/helpers.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py index 3501a3933..1da5da5a3 100644 --- a/bot/utils/helpers.py +++ b/bot/utils/helpers.py @@ -1,6 +1,8 @@ from abc import ABCMeta -from typing import Optional +from types import List +from typing import Dict, Optional +from discord import Guild from discord.ext.commands import CogMeta @@ -30,3 +32,11 @@ def has_lines(string: str, count: int) -> bool: def pad_base64(data: str) -> str: """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" return data + "=" * (-len(data) % 4) + + +def join_role_stats(role_ids: List[int], name: str, guild: Guild) -> Dict[str, int]: + """Return a dict object with the number of `members` of each role given, and the `name` for this joined group.""" + members = [] + for role_id in role_ids: + members += guild.get_role(role_id).members + return {name: len(set(members))} -- cgit v1.2.3 From a8e30f6c67cd64a5cf5a1ab0b3668a06a9621485 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Thu, 8 Jul 2021 13:53:48 +0200 Subject: Add `Leads` role(s) to the server information embed --- bot/exts/info/information.py | 7 ++++++- bot/utils/helpers.py | 3 +-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 1b1243118..677b5d1f7 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -17,8 +17,10 @@ from bot.decorators import in_whitelist from bot.pagination import LinePaginator from bot.utils.channel import is_mod_channel, is_staff_channel from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check +from bot.utils.helpers import join_role_stats from bot.utils.time import humanize_delta, time_since + log = logging.getLogger(__name__) @@ -50,7 +52,10 @@ class Information(Cog): constants.Roles.owners, constants.Roles.contributors, ) ) - return {role.name.title(): len(role.members) for role in roles} + role_stats = {role.name.title(): len(role.members) for role in roles} + role_stats.update( + **join_role_stats([constants.Roles.project_leads, constants.Roles.domain_leads], "Leads", guild)) + return role_stats def get_extended_server_info(self, ctx: Context) -> str: """Return additional server info only visible in moderation channels.""" diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py index 1da5da5a3..b0d17c3b8 100644 --- a/bot/utils/helpers.py +++ b/bot/utils/helpers.py @@ -1,6 +1,5 @@ from abc import ABCMeta -from types import List -from typing import Dict, Optional +from typing import Dict, List, Optional from discord import Guild from discord.ext.commands import CogMeta -- cgit v1.2.3 From 5bbd29c8957c320ef94beacd191239032eb959ec Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Fri, 9 Jul 2021 03:32:51 +0300 Subject: Properly Handle Fuzzy Matching Help Fixes a bug where calling help with an invalid command would crash out during fuzzy matching. Signed-off-by: Hassan Abouelela --- bot/exts/info/help.py | 4 ++-- tests/bot/exts/info/test_help.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 tests/bot/exts/info/test_help.py diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index bf9ea5986..0235bbaf3 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -125,9 +125,9 @@ class CustomHelpCommand(HelpCommand): Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches. """ - choices = await self.get_all_help_choices() + choices = list(await self.get_all_help_choices()) result = process.extract(default_process(string), choices, scorer=fuzz.ratio, score_cutoff=60, processor=None) - return HelpQueryNotFound(f'Query "{string}" not found.', dict(result)) + return HelpQueryNotFound(f'Query "{string}" not found.', {choice[0]: choice[1] for choice in result}) async def subcommand_not_found(self, command: Command, string: str) -> "HelpQueryNotFound": """ diff --git a/tests/bot/exts/info/test_help.py b/tests/bot/exts/info/test_help.py new file mode 100644 index 000000000..604c69671 --- /dev/null +++ b/tests/bot/exts/info/test_help.py @@ -0,0 +1,23 @@ +import unittest + +import rapidfuzz + +from bot.exts.info import help +from tests.helpers import MockBot, MockContext, autospec + + +class HelpCogTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + """Attach an instance of the cog to the class for tests.""" + self.bot = MockBot() + self.cog = help.Help(self.bot) + self.ctx = MockContext(bot=self.bot) + self.bot.help_command.context = self.ctx + + @autospec(help.CustomHelpCommand, "get_all_help_choices", return_value={"help"}, pass_mocks=False) + async def test_help_fuzzy_matching(self): + """Test fuzzy matching of commands when called from help.""" + result = await self.bot.help_command.command_not_found("holp") + + match = {"help": rapidfuzz.fuzz.ratio("help", "holp")} + self.assertEqual(match, result.possible_matches) -- cgit v1.2.3 From 7e3b7cae852fcb8d2a13d648fd06ea74d863981e Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 12 Jul 2021 16:49:46 +0530 Subject: Fix bugs when scheduling from cache 1. Dict was missing .items() method, causing TypeError. 2. Timestamp wasn't converted to float before passing to dt.fromtimestamp(), was stored as a joined string with work_time. --- bot/exts/moderation/modpings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index cf45a2182..1f6b7984a 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -71,9 +71,9 @@ class ModPings(Cog): schedule_cache = await self.modpings_schedule.to_dict() log.info("Scheduling modpings schedule for applicable moderators found in cache.") - for mod_id, schedule in schedule_cache: + for mod_id, schedule in schedule_cache.items(): start_timestamp, work_time = schedule.split("|") - start = datetime.datetime.fromtimestamp(start_timestamp) + start = datetime.datetime.fromtimestamp(float(start_timestamp)) mod = self.bot.fetch_user(mod_id) self._modpings_scheduler.schedule_at( -- cgit v1.2.3 From 133a1349ee52cc774c3c991a0753fd12f478a010 Mon Sep 17 00:00:00 2001 From: vcokltfre Date: Tue, 13 Jul 2021 09:03:02 +0100 Subject: feat: add for-else tag (#1643) * feat: add for-else tag Co-authored-by: Joe Banks Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/resources/tags/for-else.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 bot/resources/tags/for-else.md diff --git a/bot/resources/tags/for-else.md b/bot/resources/tags/for-else.md new file mode 100644 index 000000000..e102e4e75 --- /dev/null +++ b/bot/resources/tags/for-else.md @@ -0,0 +1,17 @@ +**for-else** + +In Python it's possible to attach an `else` clause to a for loop. The code under the `else` block will be run when the iterable is exhausted (there are no more items to iterate over). Code within the else block will **not** run if the loop is broken out using `break`. + +Here's an example of its usage: +```py +numbers = [1, 3, 5, 7, 9, 11] + +for number in numbers: + if number % 2 == 0: + print(f"Found an even number: {number}") + break + print(f"{number} is odd.") +else: + print("All numbers are odd. How odd.") +``` +Try running this example but with an even number in the list, see how the output changes as you do so. -- cgit v1.2.3 From 66ec8673a16ec6a2afe7d72d045600d18e882fc8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 15 Jul 2021 22:07:25 -0700 Subject: Allow manual mutes to override auto mutes If a moderator mutes a user, allow them to override any active mute that was automatically applied by the bot (e.g. from antispam). Resolve #1665 --- bot/exts/moderation/infraction/_utils.py | 13 +++++++++---- bot/exts/moderation/infraction/infractions.py | 9 +++++++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index adbc641fa..dd427e413 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -143,15 +143,20 @@ async def get_active_infraction( # Checks to see if the moderator should be told there is an active infraction if send_msg: log.trace(f"{user} has active infractions of type {infr_type}.") - await ctx.send( - f":x: According to my records, this user already has a {infr_type} infraction. " - f"See infraction **#{active_infractions[0]['id']}**." - ) + await send_active_infraction_message(ctx, active_infractions[0]) return active_infractions[0] else: log.trace(f"{user} does not have active infractions of type {infr_type}.") +async def send_active_infraction_message(ctx: Context, infraction: Infraction) -> None: + """Send a message stating that the given infraction is active.""" + await ctx.send( + f":x: According to my records, this user already has a {infraction['type']} infraction. " + f"See infraction **#{infraction['id']}**." + ) + + async def notify_infraction( user: UserObject, infr_type: str, diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index f19323c7c..1b1414ec7 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -280,8 +280,13 @@ class Infractions(InfractionScheduler, commands.Cog): async def apply_mute(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: """Apply a mute infraction with kwargs passed to `post_infraction`.""" - if await _utils.get_active_infraction(ctx, user, "mute"): - return + if active := await _utils.get_active_infraction(ctx, user, "mute", send_msg=False): + if active["actor"] != self.bot.user.id: + await _utils.send_active_infraction_message(ctx, active) + return + + # Let the current mute attempt override an automatically triggered mute. + await self.deactivate_infraction(active) infraction = await _utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs) if infraction is None: -- cgit v1.2.3 From 786b690fab9812e0bda5296d5ea64e2c3cb446d3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 15 Jul 2021 22:25:16 -0700 Subject: Display error if overriding mute fails --- bot/exts/moderation/infraction/infractions.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 1b1414ec7..eaf718af4 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -286,7 +286,13 @@ class Infractions(InfractionScheduler, commands.Cog): return # Let the current mute attempt override an automatically triggered mute. - await self.deactivate_infraction(active) + log_text = await self.deactivate_infraction(active) + if "Failure" in log_text: + await ctx.send( + f":x: can't override infraction **mute** for {user.mention}: " + f"failed to deactivate. {log_text['Failure']}" + ) + return infraction = await _utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs) if infraction is None: -- cgit v1.2.3 From 8ec6f0880be0ca876b9a255a0fd5e0090695d5b8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 15 Jul 2021 22:31:01 -0700 Subject: Fix get_active_infraction test --- tests/bot/exts/moderation/infraction/test_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index 50a717bb5..f3af7bea9 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -94,8 +94,8 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): test_case = namedtuple("test_case", ["get_return_value", "expected_output", "infraction_nr", "send_msg"]) test_cases = [ test_case([], None, None, True), - test_case([{"id": 123987}], {"id": 123987}, "123987", False), - test_case([{"id": 123987}], {"id": 123987}, "123987", True) + test_case([{"id": 123987, "type": "ban"}], {"id": 123987, "type": "ban"}, "123987", False), + test_case([{"id": 123987, "type": "ban"}], {"id": 123987, "type": "ban"}, "123987", True) ] for case in test_cases: -- cgit v1.2.3 From 24e4a9d2f4c037f7652b7300772ec0c6c6ab8d3c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 15 Jul 2021 22:44:42 -0700 Subject: Remove redundant parameter from pardon_voice_ban --- bot/exts/moderation/infraction/infractions.py | 4 ++-- tests/bot/exts/moderation/infraction/test_infractions.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index eaf718af4..dbf56d6bb 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -455,7 +455,7 @@ class Infractions(InfractionScheduler, commands.Cog): return log_text - async def pardon_voice_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: + async def pardon_voice_ban(self, user_id: int, guild: discord.Guild) -> t.Dict[str, str]: """Add Voice Verified role back to user, DM them a notification, and return a log dict.""" user = guild.get_member(user_id) log_text = {} @@ -491,7 +491,7 @@ class Infractions(InfractionScheduler, commands.Cog): elif infraction["type"] == "ban": return await self.pardon_ban(user_id, guild, reason) elif infraction["type"] == "voice_ban": - return await self.pardon_voice_ban(user_id, guild, reason) + return await self.pardon_voice_ban(user_id, guild) # endregion diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index b9d527770..f844a9181 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -195,7 +195,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): async def test_voice_unban_user_not_found(self): """Should include info to return dict when user was not found from guild.""" self.guild.get_member.return_value = None - result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + result = await self.cog.pardon_voice_ban(self.user.id, self.guild) self.assertEqual(result, {"Info": "User was not found in the guild."}) @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") @@ -206,7 +206,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): notify_pardon_mock.return_value = True format_user_mock.return_value = "my-user" - result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + result = await self.cog.pardon_voice_ban(self.user.id, self.guild) self.assertEqual(result, { "Member": "my-user", "DM": "Sent" @@ -221,7 +221,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): notify_pardon_mock.return_value = False format_user_mock.return_value = "my-user" - result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + result = await self.cog.pardon_voice_ban(self.user.id, self.guild) self.assertEqual(result, { "Member": "my-user", "DM": "**Failed**" -- cgit v1.2.3 From c84ed0fa6d647116b333327a29525719000f4c29 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 15 Jul 2021 23:02:30 -0700 Subject: Avoid sending pardon DM when overriding a mute --- bot/exts/moderation/infraction/_scheduler.py | 25 ++++++++--- bot/exts/moderation/infraction/infractions.py | 64 +++++++++++++++++---------- 2 files changed, 59 insertions(+), 30 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 8286d3635..c2fd959f7 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -265,13 +265,17 @@ class InfractionScheduler: ctx: Context, infr_type: str, user: UserSnowflake, - send_msg: bool = True + *, + send_msg: bool = True, + notify: bool = True ) -> None: """ Prematurely end an infraction for a user and log the action in the mod log. If `send_msg` is True, then a pardoning confirmation message will be sent to - the context channel. Otherwise, no such message will be sent. + the context channel. Otherwise, no such message will be sent. + + If `notify` is True, notify the user of the pardon via DM where applicable. """ log.trace(f"Pardoning {infr_type} infraction for {user}.") @@ -292,7 +296,7 @@ class InfractionScheduler: return # Deactivate the infraction and cancel its scheduled expiration task. - log_text = await self.deactivate_infraction(response[0], send_log=False) + log_text = await self.deactivate_infraction(response[0], send_log=False, notify=notify) log_text["Member"] = messages.format_user(user) log_text["Actor"] = ctx.author.mention @@ -345,7 +349,9 @@ class InfractionScheduler: async def deactivate_infraction( self, infraction: _utils.Infraction, - send_log: bool = True + *, + send_log: bool = True, + notify: bool = True ) -> t.Dict[str, str]: """ Deactivate an active infraction and return a dictionary of lines to send in a mod log. @@ -354,6 +360,8 @@ class InfractionScheduler: expiration task cancelled. If `send_log` is True, a mod log is sent for the deactivation of the infraction. + If `notify` is True, notify the user of the pardon via DM where applicable. + Infractions of unsupported types will raise a ValueError. """ guild = self.bot.get_guild(constants.Guild.id) @@ -380,7 +388,7 @@ class InfractionScheduler: try: log.trace("Awaiting the pardon action coroutine.") - returned_log = await self._pardon_action(infraction) + returned_log = await self._pardon_action(infraction, notify) if returned_log is not None: log_text = {**log_text, **returned_log} # Merge the logs together @@ -468,10 +476,15 @@ class InfractionScheduler: return log_text @abstractmethod - async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: + async def _pardon_action( + self, + infraction: _utils.Infraction, + notify: bool + ) -> t.Optional[t.Dict[str, str]]: """ Execute deactivation steps specific to the infraction's type and return a log dict. + If `notify` is True, notify the user of the pardon via DM where applicable. If an infraction type is unsupported, return None instead. """ raise NotImplementedError diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index dbf56d6bb..9d5b049e1 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -286,7 +286,7 @@ class Infractions(InfractionScheduler, commands.Cog): return # Let the current mute attempt override an automatically triggered mute. - log_text = await self.deactivate_infraction(active) + log_text = await self.deactivate_infraction(active, notify=False) if "Failure" in log_text: await ctx.send( f":x: can't override infraction **mute** for {user.mention}: " @@ -414,8 +414,15 @@ class Infractions(InfractionScheduler, commands.Cog): # endregion # region: Base pardon functions - async def pardon_mute(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: - """Remove a user's muted role, DM them a notification, and return a log dict.""" + async def pardon_mute( + self, + user_id: int, + guild: discord.Guild, + reason: t.Optional[str], + *, + notify: bool = True + ) -> t.Dict[str, str]: + """Remove a user's muted role, optionally DM them a notification, and return a log dict.""" user = guild.get_member(user_id) log_text = {} @@ -424,16 +431,17 @@ class Infractions(InfractionScheduler, commands.Cog): self.mod_log.ignore(Event.member_update, user.id) await user.remove_roles(self._muted_role, reason=reason) - # DM the user about the expiration. - notified = await _utils.notify_pardon( - user=user, - title="You have been unmuted", - content="You may now send messages in the server.", - icon_url=_utils.INFRACTION_ICONS["mute"][1] - ) + if notify: + # DM the user about the expiration. + notified = await _utils.notify_pardon( + user=user, + title="You have been unmuted", + content="You may now send messages in the server.", + icon_url=_utils.INFRACTION_ICONS["mute"][1] + ) + log_text["DM"] = "Sent" if notified else "**Failed**" log_text["Member"] = format_user(user) - log_text["DM"] = "Sent" if notified else "**Failed**" else: log.info(f"Failed to unmute user {user_id}: user not found") log_text["Failure"] = "User was not found in the guild." @@ -455,31 +463,39 @@ class Infractions(InfractionScheduler, commands.Cog): return log_text - async def pardon_voice_ban(self, user_id: int, guild: discord.Guild) -> t.Dict[str, str]: - """Add Voice Verified role back to user, DM them a notification, and return a log dict.""" + async def pardon_voice_ban( + self, + user_id: int, + guild: discord.Guild, + *, + notify: bool = True + ) -> t.Dict[str, str]: + """Optionally DM the user a pardon notification and return a log dict.""" user = guild.get_member(user_id) log_text = {} if user: - # DM user about infraction expiration - notified = await _utils.notify_pardon( - user=user, - title="Voice ban ended", - content="You have been unbanned and can verify yourself again in the server.", - icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] - ) + if notify: + # DM user about infraction expiration + notified = await _utils.notify_pardon( + user=user, + title="Voice ban ended", + content="You have been unbanned and can verify yourself again in the server.", + icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] + ) + log_text["DM"] = "Sent" if notified else "**Failed**" log_text["Member"] = format_user(user) - log_text["DM"] = "Sent" if notified else "**Failed**" else: log_text["Info"] = "User was not found in the guild." return log_text - async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: + async def _pardon_action(self, infraction: _utils.Infraction, notify: bool) -> t.Optional[t.Dict[str, str]]: """ Execute deactivation steps specific to the infraction's type and return a log dict. + If `notify` is True, notify the user of the pardon via DM where applicable. If an infraction type is unsupported, return None instead. """ guild = self.bot.get_guild(constants.Guild.id) @@ -487,11 +503,11 @@ class Infractions(InfractionScheduler, commands.Cog): reason = f"Infraction #{infraction['id']} expired or was pardoned." if infraction["type"] == "mute": - return await self.pardon_mute(user_id, guild, reason) + return await self.pardon_mute(user_id, guild, reason, notify=notify) elif infraction["type"] == "ban": return await self.pardon_ban(user_id, guild, reason) elif infraction["type"] == "voice_ban": - return await self.pardon_voice_ban(user_id, guild) + return await self.pardon_voice_ban(user_id, guild, notify=notify) # endregion -- cgit v1.2.3 From f848dafef496e75e73160d47983a9c78ac3f7cdf Mon Sep 17 00:00:00 2001 From: NIRDERIi <78727420+NIRDERIi@users.noreply.github.com> Date: Tue, 20 Jul 2021 09:44:23 +0300 Subject: Enabled charinfo for discord_py channel. --- 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 4c39a7c2a..fce767d84 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -49,7 +49,7 @@ class Utils(Cog): self.bot = bot @command() - @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) + @in_whitelist(channels=(Channels.bot_commands, Channels.discord_py), roles=STAFF_ROLES) async def charinfo(self, ctx: Context, *, characters: str) -> None: """Shows you information on up to 50 unicode characters.""" match = re.match(r"<(a?):(\w+):(\d+)>", characters) -- cgit v1.2.3 From d6c237cc6d2b1efa21d917df1afa5b5c425c0cc6 Mon Sep 17 00:00:00 2001 From: NotFlameDev <78838310+NotFlameDev@users.noreply.github.com> Date: Wed, 21 Jul 2021 10:14:02 +0700 Subject: Added docstring tag --- bot/resources/tags/docstring.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 bot/resources/tags/docstring.md diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md new file mode 100644 index 000000000..88f6b3a1d --- /dev/null +++ b/bot/resources/tags/docstring.md @@ -0,0 +1,16 @@ +A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that often used in file, classes, functions, etc. Docstrings usually has clear explanation, parameter(s) and return type. + +Here's an example of usage of a docstring: +```py +def greet(name, age) -> str: + """ + :param name: The name to greet. + :type name: str + :param age: The age to display. + :type age: int + :return: String of the greeting. + """ + return_string = f"Hello, {name} you are {age} years old!" + return return_string +``` +You can get the docstring by using `.__doc__` attribute. For the last example you can get it through: `print(greet.__doc__)`. -- cgit v1.2.3 From 40bee31b0db3d528aca442e6b6493fd13bca2f75 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 21 Jul 2021 10:34:15 +0200 Subject: Reminder: remove footer Now that we have Discord timestamps, the timestamp in the footer isn't useful anymore since it can be hovered to have a localised timestamp. --- bot/exts/utils/reminders.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index c7ce8b9e9..7420f1489 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -96,11 +96,6 @@ class Reminders(Cog): footer_str = f"ID: {reminder_id}" - if delivery_dt: - # Reminder deletion will have a `None` `delivery_dt` - footer_str += ', Due' - embed.timestamp = delivery_dt - embed.set_footer(text=footer_str) await ctx.send(embed=embed) -- cgit v1.2.3 From a591154ce4246ac06998325bfe282c4dc85b9ed9 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 21 Jul 2021 18:22:16 +0200 Subject: Information: make !server use moderation_team We use the ping role instead of the team role here, leading to inaccuracies. --- bot/exts/info/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index b9fcb6b40..089ce4eb2 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -46,7 +46,7 @@ class Information(Cog): """Return the total number of members for certain roles in `guild`.""" roles = ( guild.get_role(role_id) for role_id in ( - constants.Roles.helpers, constants.Roles.moderators, constants.Roles.admins, + constants.Roles.helpers, constants.Roles.moderation_team, constants.Roles.admins, constants.Roles.owners, constants.Roles.contributors, ) ) -- cgit v1.2.3 From 114ffade88f6fa4daa6bf846546cd7f070efde7f Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 21 Jul 2021 20:51:05 +0100 Subject: fix reference role constant in !server --- bot/exts/info/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 089ce4eb2..bb713eef1 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -46,7 +46,7 @@ class Information(Cog): """Return the total number of members for certain roles in `guild`.""" roles = ( guild.get_role(role_id) for role_id in ( - constants.Roles.helpers, constants.Roles.moderation_team, constants.Roles.admins, + constants.Roles.helpers, constants.Roles.mod_team, constants.Roles.admins, constants.Roles.owners, constants.Roles.contributors, ) ) -- cgit v1.2.3 From c5b76c93b0b4f6690b927f5bb53a4491a9455b62 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 21 Jul 2021 23:28:08 +0200 Subject: Talentpool: join_at -> joined_at --- bot/exts/recruitment/talentpool/_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index aebb401e0..3a1e66970 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -254,7 +254,7 @@ class Reviewer: last_channel = user_activity["top_channel_activity"][-1] channels += f", and {last_channel[1]} in {last_channel[0]}" - joined_at_formatted = time_since(member.join_at) + joined_at_formatted = time_since(member.joined_at) review = ( f"{member.name} joined the server **{joined_at_formatted}**" f" and has **{messages} messages**{channels}." -- cgit v1.2.3 From d462ad0541acbd0f3282190a0648c28232563767 Mon Sep 17 00:00:00 2001 From: NotFlameDev <78838310+NotFlameDev@users.noreply.github.com> Date: Thu, 22 Jul 2021 08:01:35 +0700 Subject: Changed the documentation as intended --- bot/resources/tags/docstring.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index 88f6b3a1d..cf5413f4b 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -1,9 +1,11 @@ -A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that often used in file, classes, functions, etc. Docstrings usually has clear explanation, parameter(s) and return type. +A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that often used in file, classes, functions, etc. A docstring usually has clear explanation (such as what the function do, purposes of the function, and other details of the function), parameter(s) and a return type. -Here's an example of usage of a docstring: +Here's an example of a docstring: ```py def greet(name, age) -> str: """ + Greet someone with their name and age. + :param name: The name to greet. :type name: str :param age: The age to display. @@ -14,3 +16,5 @@ def greet(name, age) -> str: return return_string ``` You can get the docstring by using `.__doc__` attribute. For the last example you can get it through: `print(greet.__doc__)`. + +For more details about what docstring is and it's usage check out [https://realpython.com/documenting-python-code/#docstrings-background](realpython) or [https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring](official PEP docs). -- cgit v1.2.3 From 9a7a6433de5adfcb973aa69a237242b0bc07d173 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 22 Jul 2021 11:10:45 +0200 Subject: Reminder: remove unused delivery_dt parameter --- bot/exts/utils/reminders.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 7420f1489..7b8c5c4b3 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -84,8 +84,7 @@ class Reminders(Cog): async def _send_confirmation( ctx: Context, on_success: str, - reminder_id: t.Union[str, int], - delivery_dt: t.Optional[datetime], + reminder_id: t.Union[str, int] ) -> None: """Send an embed confirming the reminder change was made successfully.""" embed = discord.Embed( @@ -274,8 +273,7 @@ class Reminders(Cog): await self._send_confirmation( ctx, on_success=mention_string, - reminder_id=reminder["id"], - delivery_dt=expiration, + reminder_id=reminder["id"] ) self.schedule_reminder(reminder) @@ -378,15 +376,11 @@ class Reminders(Cog): return reminder = await self._edit_reminder(id_, payload) - # Parse the reminder expiration back into a datetime - expiration = isoparse(reminder["expiration"]).replace(tzinfo=None) - # Send a confirmation message to the channel await self._send_confirmation( ctx, on_success="That reminder has been edited successfully!", reminder_id=id_, - delivery_dt=expiration, ) await self._reschedule_reminder(reminder) @@ -403,8 +397,7 @@ class Reminders(Cog): await self._send_confirmation( ctx, on_success="That reminder has been deleted successfully!", - reminder_id=id_, - delivery_dt=None, + reminder_id=id_ ) async def _can_modify(self, ctx: Context, reminder_id: t.Union[str, int]) -> bool: -- cgit v1.2.3 From 4fff8ff555162c3a8c99fbbb2f6c62a6366704d9 Mon Sep 17 00:00:00 2001 From: NotFlameDev <78838310+NotFlameDev@users.noreply.github.com> Date: Fri, 23 Jul 2021 08:06:06 +0700 Subject: Hyperlink fix Co-authored-by: ChrisJL --- bot/resources/tags/docstring.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index cf5413f4b..31d9b239a 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -17,4 +17,4 @@ def greet(name, age) -> str: ``` You can get the docstring by using `.__doc__` attribute. For the last example you can get it through: `print(greet.__doc__)`. -For more details about what docstring is and it's usage check out [https://realpython.com/documenting-python-code/#docstrings-background](realpython) or [https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring](official PEP docs). +For more details about what docstring is and it's usage check out this guide by [Real Python](https://realpython.com/documenting-python-code/#docstrings-background), or the [PEP-257 docs](https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring). -- cgit v1.2.3 From ed142e5251000fe12a39844feec31a308e391d96 Mon Sep 17 00:00:00 2001 From: NotFlameDev <78838310+NotFlameDev@users.noreply.github.com> Date: Fri, 23 Jul 2021 08:06:26 +0700 Subject: Update docstring explanation. Co-authored-by: ChrisJL --- bot/resources/tags/docstring.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index 31d9b239a..feafecc41 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -4,7 +4,7 @@ Here's an example of a docstring: ```py def greet(name, age) -> str: """ - Greet someone with their name and age. + Return a string that greets the given person, including their name and age. :param name: The name to greet. :type name: str -- cgit v1.2.3 From b6d339e46b2fb445d00a8c78e796ee97296baaa2 Mon Sep 17 00:00:00 2001 From: NotFlameDev <78838310+NotFlameDev@users.noreply.github.com> Date: Fri, 23 Jul 2021 08:13:19 +0700 Subject: Update docstring's explanation --- bot/resources/tags/docstring.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index feafecc41..9457b629c 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -1,6 +1,4 @@ -A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that often used in file, classes, functions, etc. A docstring usually has clear explanation (such as what the function do, purposes of the function, and other details of the function), parameter(s) and a return type. - -Here's an example of a docstring: +A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that often used in file, classes, functions, etc. A docstring should have a clear explanation of exactly what the function does. You can also include descriptions of the function's parameter(s) and its return type, as shown below. ```py def greet(name, age) -> str: """ -- cgit v1.2.3 From a2592d0b205e43c676106258a059df13a99fd191 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 23 Jul 2021 10:40:52 +0100 Subject: update docstring command to use 4 space indents. --- bot/resources/tags/docstring.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index 9457b629c..33452b998 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -1,17 +1,18 @@ A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that often used in file, classes, functions, etc. A docstring should have a clear explanation of exactly what the function does. You can also include descriptions of the function's parameter(s) and its return type, as shown below. ```py def greet(name, age) -> str: - """ - Return a string that greets the given person, including their name and age. + """ + Return a string that greets the given person, including their name and age. - :param name: The name to greet. - :type name: str - :param age: The age to display. - :type age: int - :return: String of the greeting. - """ - return_string = f"Hello, {name} you are {age} years old!" - return return_string + :param name: The name to greet. + :type name: str + :param age: The age to display. + :type age: int + + :return: String of the greeting. + """ + return_string = f"Hello, {name} you are {age} years old!" + return return_string ``` You can get the docstring by using `.__doc__` attribute. For the last example you can get it through: `print(greet.__doc__)`. -- cgit v1.2.3 From 1abbb06bcb330be672ef4c979f5d83d8b3bbafad Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 23 Jul 2021 10:41:30 +0100 Subject: Fix grammar issues in docstring tag --- bot/resources/tags/docstring.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index 33452b998..2918281c3 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -1,4 +1,4 @@ -A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that often used in file, classes, functions, etc. A docstring should have a clear explanation of exactly what the function does. You can also include descriptions of the function's parameter(s) and its return type, as shown below. +A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that's placed at the top of files, classes and functions. A docstring should contain a clear explanation of what it's describing. You can also include descriptions of the subject's parameter(s) and its return type, as shown below: ```py def greet(name, age) -> str: """ @@ -9,11 +9,10 @@ def greet(name, age) -> str: :param age: The age to display. :type age: int - :return: String of the greeting. + :return: String representation of the greeting. """ - return_string = f"Hello, {name} you are {age} years old!" - return return_string + return f"Hello {name}, you are {age} years old!" ``` -You can get the docstring by using `.__doc__` attribute. For the last example you can get it through: `print(greet.__doc__)`. +You can get the docstring by using the `.__doc__` attribute. For the last example, you can print it by doing this: `print(greet.__doc__)`. -For more details about what docstring is and it's usage check out this guide by [Real Python](https://realpython.com/documenting-python-code/#docstrings-background), or the [PEP-257 docs](https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring). +For more details about what a docstring is and its usage, check out this guide by [Real Python](https://realpython.com/documenting-python-code/#docstrings-background), or the [PEP-257 docs](https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring). -- cgit v1.2.3 From 1dec12a1b024283cc3464f0f81bb78375280e82c Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 23 Jul 2021 11:02:57 +0100 Subject: Move type docs to type hints --- bot/resources/tags/docstring.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index 2918281c3..7144d3702 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -1,13 +1,11 @@ A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that's placed at the top of files, classes and functions. A docstring should contain a clear explanation of what it's describing. You can also include descriptions of the subject's parameter(s) and its return type, as shown below: ```py -def greet(name, age) -> str: +def greet(name: str, age: int) -> str: """ Return a string that greets the given person, including their name and age. :param name: The name to greet. - :type name: str :param age: The age to display. - :type age: int :return: String representation of the greeting. """ -- cgit v1.2.3 From fb7ec43edc0581175cb86fed7df48ef7efb6e743 Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Fri, 23 Jul 2021 11:17:43 +0100 Subject: Refer to PEP-257 as the official spec This is for users who may not know what a PEP is. Co-authored-by: Bluenix --- bot/resources/tags/docstring.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index 7144d3702..8d6fd3615 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -13,4 +13,4 @@ def greet(name: str, age: int) -> str: ``` You can get the docstring by using the `.__doc__` attribute. For the last example, you can print it by doing this: `print(greet.__doc__)`. -For more details about what a docstring is and its usage, check out this guide by [Real Python](https://realpython.com/documenting-python-code/#docstrings-background), or the [PEP-257 docs](https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring). +For more details about what a docstring is and its usage, check out this guide by [Real Python](https://realpython.com/documenting-python-code/#docstrings-background), or the [official docstring specification](https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring). -- cgit v1.2.3 From 4b1cf360f0f7b3bcc400184a127ce74b8c98148b Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 23 Jul 2021 11:19:30 +0100 Subject: Further describe the funciton in docstring tag --- bot/resources/tags/docstring.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index 8d6fd3615..95ad13b35 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -1,11 +1,11 @@ -A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that's placed at the top of files, classes and functions. A docstring should contain a clear explanation of what it's describing. You can also include descriptions of the subject's parameter(s) and its return type, as shown below: +A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that's placed at the top of files, classes and functions. A docstring should contain a clear explanation of what it's describing. You can also include descriptions of the subject's parameter(s) and what it returns, as shown below: ```py def greet(name: str, age: int) -> str: """ - Return a string that greets the given person, including their name and age. + Return a string that greets the given person, using their name and age. - :param name: The name to greet. - :param age: The age to display. + :param name: The name of the person to greet. + :param age: The age of the person to greet. :return: String representation of the greeting. """ -- cgit v1.2.3 From e5ebccc410c38ba84eca555ecef72c4a91cf8779 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 23 Jul 2021 11:28:58 +0100 Subject: Update grammer in docstring tag Co-authored-by: Bluenix --- bot/resources/tags/docstring.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index 95ad13b35..0160d5ff3 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -1,4 +1,4 @@ -A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that's placed at the top of files, classes and functions. A docstring should contain a clear explanation of what it's describing. You can also include descriptions of the subject's parameter(s) and what it returns, as shown below: +A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string - always using triple quotes - that's placed at the top of files, classes and functions. A docstring should contain a clear explanation of what it's describing. You can also include descriptions of the subject's parameter(s) and what it returns, as shown below: ```py def greet(name: str, age: int) -> str: """ @@ -7,7 +7,7 @@ def greet(name: str, age: int) -> str: :param name: The name of the person to greet. :param age: The age of the person to greet. - :return: String representation of the greeting. + :return: The greeting. """ return f"Hello {name}, you are {age} years old!" ``` -- cgit v1.2.3 From 39500b40ac2892cb469366103022bd854f7734b2 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 23 Jul 2021 12:14:28 +0100 Subject: Suggest inspect.getdoc in docstring tag Co-authored-by: Numerlor <25886452+Numerlor@users.noreply.github.com> --- bot/resources/tags/docstring.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index 0160d5ff3..20043131e 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -11,6 +11,8 @@ def greet(name: str, age: int) -> str: """ return f"Hello {name}, you are {age} years old!" ``` -You can get the docstring by using the `.__doc__` attribute. For the last example, you can print it by doing this: `print(greet.__doc__)`. +You can get the docstring by using the [`inspect.getdoc`](https://docs.python.org/3/library/inspect.html#inspect.getdoc) function, from the built-in [`inspect`](https://docs.python.org/3/library/inspect.html) module, or by accessing the `.__doc__` attribute. `inspect.getdoc` is often preferred, as it clears indents from the docstring. + +For the last example, you can print it by doing this: `print(inspect.getdoc(greet))`. For more details about what a docstring is and its usage, check out this guide by [Real Python](https://realpython.com/documenting-python-code/#docstrings-background), or the [official docstring specification](https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring). -- cgit v1.2.3 From 10041a4651676cd02c6282b0cec09b1dcea973f1 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 13:23:08 +0100 Subject: Prevent ghost-pings in docs get command Won't delete the invoking message when the giving symbol is invalid if the message contains user/role mentions. If it has mentions, allows deletions of error message through reactions. --- bot/exts/info/doc/_cog.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index c54a3ee1c..b83c3c47e 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -10,6 +10,7 @@ from types import SimpleNamespace from typing import Dict, NamedTuple, Optional, Tuple, Union import aiohttp +import asyncio import discord from discord.ext import commands @@ -34,6 +35,7 @@ FORCE_PREFIX_GROUPS = ( "pdbcommand", "2to3fixer", ) +DELETE_ERROR_MESSAGE_REACTION = '\u274c' # :x: NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay # Delay to wait before trying to reach a rescheduled inventory again, in minutes FETCH_RESCHEDULE_DELAY = SimpleNamespace(first=2, repeated=5) @@ -340,11 +342,29 @@ class DocCog(commands.Cog): if doc_embed is None: error_message = await send_denial(ctx, "No documentation found for the requested symbol.") - await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) - with suppress(discord.NotFound): - await ctx.message.delete() - with suppress(discord.NotFound): - await error_message.delete() + + if ctx.message.mentions or ctx.message.role_mentions: + await error_message.add_reaction(DELETE_ERROR_MESSAGE_REACTION) + + try: + await self.bot.wait_for( + 'reaction_add', + check=lambda reaction, user: reaction.message == error_message and user == ctx.author and str(reaction) == DELETE_ERROR_MESSAGE_REACTION, + timeout=NOT_FOUND_DELETE_DELAY + ) + + with suppress(discord.HTTPException): + await error_message.delete() + + except asyncio.TimeoutError: + await error_message.clear_reaction(DELETE_ERROR_MESSAGE_REACTION) + + else: + await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) + with suppress(discord.NotFound): + await ctx.message.delete() + with suppress(discord.NotFound): + await error_message.delete() else: msg = await ctx.send(embed=doc_embed) await wait_for_deletion(msg, (ctx.author.id,)) -- cgit v1.2.3 From 4b7962a1d128f29af4caccbf9ae610087a59a142 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 13:37:39 +0100 Subject: Remove duplicate asyncio import --- bot/exts/info/doc/_cog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index b83c3c47e..e7ca634b2 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -10,7 +10,6 @@ from types import SimpleNamespace from typing import Dict, NamedTuple, Optional, Tuple, Union import aiohttp -import asyncio import discord from discord.ext import commands -- cgit v1.2.3 From 155422cdaaaaf7e53892c0f9cd3dde4bb94797a4 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 13:58:24 +0100 Subject: Revamped imports --- bot/exts/info/doc/_cog.py | 53 +++++++++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index e7ca634b2..92a92d201 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -11,7 +11,9 @@ from typing import Dict, NamedTuple, Optional, Tuple, Union import aiohttp import discord -from discord.ext import commands +from discord import Colour, Embed, Message, NotFound, Reaction, User + +from discord.ext.commands import BadArgument, Cog, Context, group, has_any_role from bot.bot import Bot from bot.constants import MODERATION_ROLES, RedirectOutput @@ -57,7 +59,7 @@ class DocItem(NamedTuple): return self.base_url + self.relative_url_path -class DocCog(commands.Cog): +class DocCog(Cog): """A set of commands for querying & displaying documentation.""" def __init__(self, bot: Bot): @@ -265,7 +267,7 @@ class DocCog(commands.Cog): return "Unable to parse the requested symbol." return markdown - async def create_symbol_embed(self, symbol_name: str) -> Optional[discord.Embed]: + async def create_symbol_embed(self, symbol_name: str) -> Optional[Embed]: """ Attempt to scrape and fetch the data for the given `symbol_name`, and build an embed from its contents. @@ -294,7 +296,7 @@ class DocCog(commands.Cog): else: footer_text = "" - embed = discord.Embed( + embed = Embed( title=discord.utils.escape_markdown(symbol_name), url=f"{doc_item.url}#{doc_item.symbol_id}", description=await self.get_symbol_markdown(doc_item) @@ -302,13 +304,13 @@ class DocCog(commands.Cog): embed.set_footer(text=footer_text) return embed - @commands.group(name="docs", aliases=("doc", "d"), invoke_without_command=True) - async def docs_group(self, ctx: commands.Context, *, symbol_name: Optional[str]) -> None: + @group(name="docs", aliases=("doc", "d"), invoke_without_command=True) + async def docs_group(self, ctx: Context, *, symbol_name: Optional[str]) -> None: """Look up documentation for Python symbols.""" await self.get_command(ctx, symbol_name=symbol_name) @docs_group.command(name="getdoc", aliases=("g",)) - async def get_command(self, ctx: commands.Context, *, symbol_name: Optional[str]) -> None: + async def get_command(self, ctx: Context, *, symbol_name: Optional[str]) -> None: """ Return a documentation embed for a given symbol. @@ -321,9 +323,9 @@ class DocCog(commands.Cog): !docs getdoc aiohttp.ClientSession """ if not symbol_name: - inventory_embed = discord.Embed( + inventory_embed = Embed( title=f"All inventories (`{len(self.base_urls)}` total)", - colour=discord.Colour.blue() + colour=Colour.blue() ) lines = sorted(f"• [`{name}`]({url})" for name, url in self.base_urls.items()) @@ -345,14 +347,15 @@ class DocCog(commands.Cog): if ctx.message.mentions or ctx.message.role_mentions: await error_message.add_reaction(DELETE_ERROR_MESSAGE_REACTION) + _predicate_emoji_reaction = partial(predicate_emoji_reaction, ctx, error_message) try: await self.bot.wait_for( 'reaction_add', - check=lambda reaction, user: reaction.message == error_message and user == ctx.author and str(reaction) == DELETE_ERROR_MESSAGE_REACTION, + check=_predicate_emoji_reaction timeout=NOT_FOUND_DELETE_DELAY ) - with suppress(discord.HTTPException): + with suppress(NotFound): await error_message.delete() except asyncio.TimeoutError: @@ -360,20 +363,20 @@ class DocCog(commands.Cog): else: await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) - with suppress(discord.NotFound): + with suppress(NotFound): await ctx.message.delete() - with suppress(discord.NotFound): + with suppress(NotFound): await error_message.delete() else: msg = await ctx.send(embed=doc_embed) await wait_for_deletion(msg, (ctx.author.id,)) @docs_group.command(name="setdoc", aliases=("s",)) - @commands.has_any_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) async def set_command( self, - ctx: commands.Context, + ctx: Context, package_name: PackageName, base_url: ValidURL, inventory: Inventory, @@ -390,7 +393,7 @@ class DocCog(commands.Cog): https://docs.python.org/3/objects.inv """ if not base_url.endswith("/"): - raise commands.BadArgument("The base url must end with a slash.") + raise BadArgument("The base url must end with a slash.") inventory_url, inventory_dict = inventory body = { "package": package_name, @@ -408,9 +411,9 @@ class DocCog(commands.Cog): await ctx.send(f"Added the package `{package_name}` to the database and updated the inventories.") @docs_group.command(name="deletedoc", aliases=("removedoc", "rm", "d")) - @commands.has_any_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) - async def delete_command(self, ctx: commands.Context, package_name: PackageName) -> None: + async def delete_command(self, ctx: Context, package_name: PackageName) -> None: """ Removes the specified package from the database. @@ -425,9 +428,9 @@ class DocCog(commands.Cog): await ctx.send(f"Successfully deleted `{package_name}` and refreshed the inventories.") @docs_group.command(name="refreshdoc", aliases=("rfsh", "r")) - @commands.has_any_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) - async def refresh_command(self, ctx: commands.Context) -> None: + async def refresh_command(self, ctx: Context) -> None: """Refresh inventories and show the difference.""" old_inventories = set(self.base_urls) with ctx.typing(): @@ -440,17 +443,17 @@ class DocCog(commands.Cog): if removed := ", ".join(old_inventories - new_inventories): removed = "- " + removed - embed = discord.Embed( + embed = Embed( title="Inventories refreshed", description=f"```diff\n{added}\n{removed}```" if added or removed else "" ) await ctx.send(embed=embed) @docs_group.command(name="cleardoccache", aliases=("deletedoccache",)) - @commands.has_any_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def clear_cache_command( self, - ctx: commands.Context, + ctx: Context, package_name: Union[PackageName, allowed_strings("*")] # noqa: F722 ) -> None: """Clear the persistent redis cache for `package`.""" @@ -464,3 +467,7 @@ class DocCog(commands.Cog): self.inventory_scheduler.cancel_all() self.init_refresh_task.cancel() asyncio.create_task(self.item_fetcher.clear(), name="DocCog.item_fetcher unload clear") + + +def predicate_emoji_reaction(ctx: Context, error_message: Message, reaction: Reaction, user: User): + return reaction.message == error_message and user == ctx.author and str(reaction) == DELETE_ERROR_MESSAGE_REACTION -- cgit v1.2.3 From cb4098d816e069e622202c11f2a6fbaea7475c0f Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 14:02:26 +0100 Subject: Add missing functools.partial import --- bot/exts/info/doc/_cog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 92a92d201..01358c453 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -6,6 +6,7 @@ import sys import textwrap from collections import defaultdict from contextlib import suppress +from functools import partial from types import SimpleNamespace from typing import Dict, NamedTuple, Optional, Tuple, Union -- cgit v1.2.3 From 8f66672728e8f1d9adcc07882bcfe037bd1b9d2b Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 14:06:07 +0100 Subject: Add missing comma --- bot/exts/info/doc/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 01358c453..54960ce11 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -352,7 +352,7 @@ class DocCog(Cog): try: await self.bot.wait_for( 'reaction_add', - check=_predicate_emoji_reaction + check=_predicate_emoji_reaction, timeout=NOT_FOUND_DELETE_DELAY ) -- cgit v1.2.3 From b9203197f891932d2aa8d0066cfb495393e6b5ce Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 14:51:29 +0100 Subject: Remove trailing whitespace --- bot/exts/info/doc/_cog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 54960ce11..8faff926c 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -468,7 +468,7 @@ class DocCog(Cog): self.inventory_scheduler.cancel_all() self.init_refresh_task.cancel() asyncio.create_task(self.item_fetcher.clear(), name="DocCog.item_fetcher unload clear") - - + + def predicate_emoji_reaction(ctx: Context, error_message: Message, reaction: Reaction, user: User): return reaction.message == error_message and user == ctx.author and str(reaction) == DELETE_ERROR_MESSAGE_REACTION -- cgit v1.2.3 From 17234954f2ca61c543225d110edbc22edcb55f9b Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 14:57:08 +0100 Subject: Add return type-hint and docstring --- bot/exts/info/doc/_cog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 8faff926c..a768d6af7 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -470,5 +470,6 @@ class DocCog(Cog): asyncio.create_task(self.item_fetcher.clear(), name="DocCog.item_fetcher unload clear") -def predicate_emoji_reaction(ctx: Context, error_message: Message, reaction: Reaction, user: User): +def predicate_emoji_reaction(ctx: Context, error_message: Message, reaction: Reaction, user: User) -> bool: + """Return whether command author added the `:x:` emote to the `error_message`.""" return reaction.message == error_message and user == ctx.author and str(reaction) == DELETE_ERROR_MESSAGE_REACTION -- cgit v1.2.3 From c37cbe534d124f35cb9a654d078dca5d7fdb7b42 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 14:59:31 +0100 Subject: Remove blankline that flake8 didn't like --- bot/exts/info/doc/_cog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index a768d6af7..cd9b69818 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -13,7 +13,6 @@ from typing import Dict, NamedTuple, Optional, Tuple, Union import aiohttp import discord from discord import Colour, Embed, Message, NotFound, Reaction, User - from discord.ext.commands import BadArgument, Cog, Context, group, has_any_role from bot.bot import Bot -- cgit v1.2.3 From 24aa14f5856994ebb2df85284c3cd7140c52aa96 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 15:04:03 +0100 Subject: Fix shadowed name --- bot/exts/info/doc/_cog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index cd9b69818..0b30526a4 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -101,11 +101,11 @@ class DocCog(Cog): """ self.base_urls[package_name] = base_url - for group, items in inventory.items(): + for _group, items in inventory.items(): for symbol_name, relative_doc_url in items: # e.g. get 'class' from 'py:class' - group_name = group.split(":")[1] + group_name = _group.split(":")[1] symbol_name = self.ensure_unique_symbol_name( package_name, group_name, -- cgit v1.2.3 From a4fcca34bdf5ddd67e91e2cbc66506fffac3fe77 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 15:20:00 +0100 Subject: Undo change in import style --- bot/exts/info/doc/_cog.py | 61 +++++++++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 0b30526a4..f6fd92302 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -12,8 +12,8 @@ from typing import Dict, NamedTuple, Optional, Tuple, Union import aiohttp import discord -from discord import Colour, Embed, Message, NotFound, Reaction, User -from discord.ext.commands import BadArgument, Cog, Context, group, has_any_role + +from discord.ext import commands from bot.bot import Bot from bot.constants import MODERATION_ROLES, RedirectOutput @@ -59,7 +59,7 @@ class DocItem(NamedTuple): return self.base_url + self.relative_url_path -class DocCog(Cog): +class DocCog(commands.Cog): """A set of commands for querying & displaying documentation.""" def __init__(self, bot: Bot): @@ -101,11 +101,11 @@ class DocCog(Cog): """ self.base_urls[package_name] = base_url - for _group, items in inventory.items(): + for group, items in inventory.items(): for symbol_name, relative_doc_url in items: # e.g. get 'class' from 'py:class' - group_name = _group.split(":")[1] + group_name = group.split(":")[1] symbol_name = self.ensure_unique_symbol_name( package_name, group_name, @@ -267,7 +267,7 @@ class DocCog(Cog): return "Unable to parse the requested symbol." return markdown - async def create_symbol_embed(self, symbol_name: str) -> Optional[Embed]: + async def create_symbol_embed(self, symbol_name: str) -> Optional[discord.Embed]: """ Attempt to scrape and fetch the data for the given `symbol_name`, and build an embed from its contents. @@ -296,7 +296,7 @@ class DocCog(Cog): else: footer_text = "" - embed = Embed( + embed = discord.Embed( title=discord.utils.escape_markdown(symbol_name), url=f"{doc_item.url}#{doc_item.symbol_id}", description=await self.get_symbol_markdown(doc_item) @@ -304,13 +304,13 @@ class DocCog(Cog): embed.set_footer(text=footer_text) return embed - @group(name="docs", aliases=("doc", "d"), invoke_without_command=True) - async def docs_group(self, ctx: Context, *, symbol_name: Optional[str]) -> None: + @commands.group(name="docs", aliases=("doc", "d"), invoke_without_command=True) + async def docs_group(self, ctx: commands.Context, *, symbol_name: Optional[str]) -> None: """Look up documentation for Python symbols.""" await self.get_command(ctx, symbol_name=symbol_name) @docs_group.command(name="getdoc", aliases=("g",)) - async def get_command(self, ctx: Context, *, symbol_name: Optional[str]) -> None: + async def get_command(self, ctx: commands.Context, *, symbol_name: Optional[str]) -> None: """ Return a documentation embed for a given symbol. @@ -323,9 +323,9 @@ class DocCog(Cog): !docs getdoc aiohttp.ClientSession """ if not symbol_name: - inventory_embed = Embed( + inventory_embed = discord.Embed( title=f"All inventories (`{len(self.base_urls)}` total)", - colour=Colour.blue() + colour=discord.Colour.blue() ) lines = sorted(f"• [`{name}`]({url})" for name, url in self.base_urls.items()) @@ -355,7 +355,7 @@ class DocCog(Cog): timeout=NOT_FOUND_DELETE_DELAY ) - with suppress(NotFound): + with suppress(discord.NotFound): await error_message.delete() except asyncio.TimeoutError: @@ -363,20 +363,20 @@ class DocCog(Cog): else: await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) - with suppress(NotFound): + with suppress(discord.NotFound): await ctx.message.delete() - with suppress(NotFound): + with suppress(discord.NotFound): await error_message.delete() else: msg = await ctx.send(embed=doc_embed) await wait_for_deletion(msg, (ctx.author.id,)) @docs_group.command(name="setdoc", aliases=("s",)) - @has_any_role(*MODERATION_ROLES) + @commands.has_any_role(*MODERATION_ROLES) @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) async def set_command( self, - ctx: Context, + ctx: commands.Context, package_name: PackageName, base_url: ValidURL, inventory: Inventory, @@ -393,7 +393,7 @@ class DocCog(Cog): https://docs.python.org/3/objects.inv """ if not base_url.endswith("/"): - raise BadArgument("The base url must end with a slash.") + raise commands.BadArgument("The base url must end with a slash.") inventory_url, inventory_dict = inventory body = { "package": package_name, @@ -411,9 +411,9 @@ class DocCog(Cog): await ctx.send(f"Added the package `{package_name}` to the database and updated the inventories.") @docs_group.command(name="deletedoc", aliases=("removedoc", "rm", "d")) - @has_any_role(*MODERATION_ROLES) + @commands.has_any_role(*MODERATION_ROLES) @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) - async def delete_command(self, ctx: Context, package_name: PackageName) -> None: + async def delete_command(self, ctx: commands.Context, package_name: PackageName) -> None: """ Removes the specified package from the database. @@ -428,9 +428,9 @@ class DocCog(Cog): await ctx.send(f"Successfully deleted `{package_name}` and refreshed the inventories.") @docs_group.command(name="refreshdoc", aliases=("rfsh", "r")) - @has_any_role(*MODERATION_ROLES) + @commands.has_any_role(*MODERATION_ROLES) @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) - async def refresh_command(self, ctx: Context) -> None: + async def refresh_command(self, ctx: commands.Context) -> None: """Refresh inventories and show the difference.""" old_inventories = set(self.base_urls) with ctx.typing(): @@ -443,17 +443,17 @@ class DocCog(Cog): if removed := ", ".join(old_inventories - new_inventories): removed = "- " + removed - embed = Embed( + embed = discord.Embed( title="Inventories refreshed", description=f"```diff\n{added}\n{removed}```" if added or removed else "" ) await ctx.send(embed=embed) @docs_group.command(name="cleardoccache", aliases=("deletedoccache",)) - @has_any_role(*MODERATION_ROLES) + @commands.has_any_role(*MODERATION_ROLES) async def clear_cache_command( self, - ctx: Context, + ctx: commands.Context, package_name: Union[PackageName, allowed_strings("*")] # noqa: F722 ) -> None: """Clear the persistent redis cache for `package`.""" @@ -469,6 +469,11 @@ class DocCog(Cog): asyncio.create_task(self.item_fetcher.clear(), name="DocCog.item_fetcher unload clear") -def predicate_emoji_reaction(ctx: Context, error_message: Message, reaction: Reaction, user: User) -> bool: - """Return whether command author added the `:x:` emote to the `error_message`.""" - return reaction.message == error_message and user == ctx.author and str(reaction) == DELETE_ERROR_MESSAGE_REACTION +def predicate_emoji_reaction( + ctx: commands.Context, + message: discord.Message, + reaction: discord.Reaction, + user: discord.User +) -> bool: + """Return whether command author added the `:x:` emote to the `message`.""" + return reaction.message == message and user == ctx.author and str(reaction) == DELETE_ERROR_MESSAGE_REACTION -- cgit v1.2.3 From 66f7492a028256f66a20b9255ebd695af67f507a Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 15:21:05 +0100 Subject: Remove blankline that flake8 doesn't like --- bot/exts/info/doc/_cog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index f6fd92302..c9daa3680 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -12,7 +12,6 @@ from typing import Dict, NamedTuple, Optional, Tuple, Union import aiohttp import discord - from discord.ext import commands from bot.bot import Bot -- cgit v1.2.3 From e76057e63452d91b48dbbba70c287cdf3d18423a Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 16:27:27 +0100 Subject: Update code to use `utils.messages.wait_for_deletion` --- bot/exts/info/doc/_cog.py | 25 +++---------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index c9daa3680..e00c64150 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -6,7 +6,6 @@ import sys import textwrap from collections import defaultdict from contextlib import suppress -from functools import partial from types import SimpleNamespace from typing import Dict, NamedTuple, Optional, Tuple, Union @@ -35,7 +34,6 @@ FORCE_PREFIX_GROUPS = ( "pdbcommand", "2to3fixer", ) -DELETE_ERROR_MESSAGE_REACTION = '\u274c' # :x: NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay # Delay to wait before trying to reach a rescheduled inventory again, in minutes FETCH_RESCHEDULE_DELAY = SimpleNamespace(first=2, repeated=5) @@ -343,29 +341,12 @@ class DocCog(commands.Cog): if doc_embed is None: error_message = await send_denial(ctx, "No documentation found for the requested symbol.") - if ctx.message.mentions or ctx.message.role_mentions: - await error_message.add_reaction(DELETE_ERROR_MESSAGE_REACTION) + await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) - _predicate_emoji_reaction = partial(predicate_emoji_reaction, ctx, error_message) - try: - await self.bot.wait_for( - 'reaction_add', - check=_predicate_emoji_reaction, - timeout=NOT_FOUND_DELETE_DELAY - ) - - with suppress(discord.NotFound): - await error_message.delete() - - except asyncio.TimeoutError: - await error_message.clear_reaction(DELETE_ERROR_MESSAGE_REACTION) - - else: - await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) + if not (ctx.message.mentions or ctx.message.role_mentions): with suppress(discord.NotFound): await ctx.message.delete() - with suppress(discord.NotFound): - await error_message.delete() + else: msg = await ctx.send(embed=doc_embed) await wait_for_deletion(msg, (ctx.author.id,)) -- cgit v1.2.3 From b2061e4f5afab8d8d714ca7a1f969d84c6f1e213 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 16:29:56 +0100 Subject: Remove deprecated function --- bot/exts/info/doc/_cog.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index e00c64150..ddf8e65e3 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -447,13 +447,3 @@ class DocCog(commands.Cog): self.inventory_scheduler.cancel_all() self.init_refresh_task.cancel() asyncio.create_task(self.item_fetcher.clear(), name="DocCog.item_fetcher unload clear") - - -def predicate_emoji_reaction( - ctx: commands.Context, - message: discord.Message, - reaction: discord.Reaction, - user: discord.User -) -> bool: - """Return whether command author added the `:x:` emote to the `message`.""" - return reaction.message == message and user == ctx.author and str(reaction) == DELETE_ERROR_MESSAGE_REACTION -- cgit v1.2.3 From c5ab6af7770745d69e0d1d04fc07d7b8c601f98a Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 16:33:52 +0100 Subject: Remove extra lines --- bot/exts/info/doc/_cog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index ddf8e65e3..2cac7c10e 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -340,7 +340,6 @@ class DocCog(commands.Cog): if doc_embed is None: error_message = await send_denial(ctx, "No documentation found for the requested symbol.") - await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) if not (ctx.message.mentions or ctx.message.role_mentions): -- cgit v1.2.3 From d5b6f3d934215eceefdb9905e1b39f7c5c2a8a61 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 16:59:39 +0100 Subject: Delete reaction if error_message not deleted. --- bot/exts/info/doc/_cog.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 2cac7c10e..c0cb4db29 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -14,7 +14,7 @@ import discord from discord.ext import commands from bot.bot import Bot -from bot.constants import MODERATION_ROLES, RedirectOutput +from bot.constants import Emojis, MODERATION_ROLES, RedirectOutput from bot.converters import Inventory, PackageName, ValidURL, allowed_strings from bot.pagination import LinePaginator from bot.utils.lock import SharedEvent, lock @@ -342,6 +342,9 @@ class DocCog(commands.Cog): error_message = await send_denial(ctx, "No documentation found for the requested symbol.") await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) + with suppress(discord.NotFound): + await message.clear_reaction(Emojis.trashcan) + if not (ctx.message.mentions or ctx.message.role_mentions): with suppress(discord.NotFound): await ctx.message.delete() -- cgit v1.2.3 From 75a77e1e85ee9e4a7ea337564dcc25e327d94b8a Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 17:01:27 +0100 Subject: Fix typo causing NameError --- bot/exts/info/doc/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index c0cb4db29..25d69cbed 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -343,7 +343,7 @@ class DocCog(commands.Cog): await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) with suppress(discord.NotFound): - await message.clear_reaction(Emojis.trashcan) + await error_message.clear_reaction(Emojis.trashcan) if not (ctx.message.mentions or ctx.message.role_mentions): with suppress(discord.NotFound): -- cgit v1.2.3 From 47abb8d17c531e75d977ab395752690115f05cab Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 17:17:11 +0100 Subject: Remove extra line Co-authored-by: Bluenix --- bot/exts/info/doc/_cog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 25d69cbed..eebd39451 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -341,7 +341,6 @@ class DocCog(commands.Cog): if doc_embed is None: error_message = await send_denial(ctx, "No documentation found for the requested symbol.") await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) - with suppress(discord.NotFound): await error_message.clear_reaction(Emojis.trashcan) -- cgit v1.2.3 From 9078afd4465838406fc5db8bba527f1b18f6d175 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 18:13:16 +0100 Subject: Add comment Co-authored-by: Bluenix --- bot/exts/info/doc/_cog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index eebd39451..704884fd1 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -344,6 +344,7 @@ class DocCog(commands.Cog): with suppress(discord.NotFound): await error_message.clear_reaction(Emojis.trashcan) + # Make sure that we won't cause a ghost-ping by deleting the message if not (ctx.message.mentions or ctx.message.role_mentions): with suppress(discord.NotFound): await ctx.message.delete() -- cgit v1.2.3 From a0c013c95b664eef9df7dd10c987ed51865c378a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 23 Jul 2021 16:42:52 -0700 Subject: Add missing notify param to _pardon_action for superstarify --- bot/exts/moderation/infraction/superstarify.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 07e79b9fe..05a2bbe10 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -192,8 +192,8 @@ class Superstarify(InfractionScheduler, Cog): """Remove the superstarify infraction and allow the user to change their nickname.""" await self.pardon_infraction(ctx, "superstar", member) - async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: - """Pardon a superstar infraction and return a log dict.""" + async def _pardon_action(self, infraction: _utils.Infraction, notify: bool) -> t.Optional[t.Dict[str, str]]: + """Pardon a superstar infraction, optionally notify the user via DM, and return a log dict.""" if infraction["type"] != "superstar": return @@ -208,18 +208,19 @@ class Superstarify(InfractionScheduler, Cog): ) return {} + log_text = {"Member": format_user(user)} + # DM the user about the expiration. - notified = await _utils.notify_pardon( - user=user, - title="You are no longer superstarified", - content="You may now change your nickname on the server.", - icon_url=_utils.INFRACTION_ICONS["superstar"][1] - ) + if notify: + notified = await _utils.notify_pardon( + user=user, + title="You are no longer superstarified", + content="You may now change your nickname on the server.", + icon_url=_utils.INFRACTION_ICONS["superstar"][1] + ) + log_text["DM"] = "Sent" if notified else "**Failed**" - return { - "Member": format_user(user), - "DM": "Sent" if notified else "**Failed**" - } + return log_text @staticmethod def get_nick(infraction_id: int, member_id: int) -> str: -- cgit v1.2.3 From 400f821d203222e4bc2de91bf70207b9401c017f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 23 Jul 2021 16:45:30 -0700 Subject: Fix pardon_infraction call for permanent bans --- bot/exts/moderation/infraction/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 9d5b049e1..ce19bcfdc 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -356,7 +356,7 @@ class Infractions(InfractionScheduler, commands.Cog): return log.trace("Old tempban is being replaced by new permaban.") - await self.pardon_infraction(ctx, "ban", user, is_temporary) + await self.pardon_infraction(ctx, "ban", user, send_msg=is_temporary) infraction = await _utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs) if infraction is None: -- cgit v1.2.3 From 693573412dcf726426271c66f832e626fa77a5d4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 23 Jul 2021 19:26:03 -0700 Subject: Clarify a comment --- bot/exts/moderation/infraction/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index ce19bcfdc..0df5fb60b 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -285,7 +285,7 @@ class Infractions(InfractionScheduler, commands.Cog): await _utils.send_active_infraction_message(ctx, active) return - # Let the current mute attempt override an automatically triggered mute. + # Allow the current mute attempt to override an automatically triggered mute. log_text = await self.deactivate_infraction(active, notify=False) if "Failure" in log_text: await ctx.send( -- cgit v1.2.3 From 19cd66fa1615ed8f220cef8dbff5f826c7d4670c Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 25 Jul 2021 00:39:22 +0300 Subject: Improved codejam end confirmation The command now sends the details of all channels about to be deleted to the pasting service, and confirmation is done through a reaction by the invoker of the command within a limited time (10 seconds). --- bot/exts/events/code_jams/_cog.py | 74 +++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 15 deletions(-) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index 83e2e18ce..d0c206b5e 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -1,3 +1,4 @@ +import asyncio import csv import logging import typing as t @@ -10,10 +11,12 @@ from discord.ext import commands from bot.bot import Bot from bot.constants import Emojis, Roles from bot.exts.events.code_jams import _channels +from bot.utils.services import send_to_paste_service log = logging.getLogger(__name__) TEAM_LEADERS_COLOUR = 0x11806a +DELETION_REACTION = "\U0001f4a5" class CodeJams(commands.Cog): @@ -22,8 +25,6 @@ class CodeJams(commands.Cog): def __init__(self, bot: Bot): self.bot = bot - self.end_counter = 0 - @commands.group(aliases=("cj", "jam")) @commands.has_any_role(Roles.admins) async def codejam(self, ctx: commands.Context) -> None: @@ -80,24 +81,67 @@ class CodeJams(commands.Cog): """ Deletes all code jam channels. - Call it three times while spinning around for it all to end. + Displays a confirmation message with the categories and channels to be deleted. Pressing the added reaction + deletes those channels. """ - self.end_counter += 1 - if self.end_counter == 1: - await ctx.send("Are you sure about that?") - return - if self.end_counter == 2: - await ctx.send("Are you *really really* sure about that?") + def predicate_deletion_emoji_reaction(reaction: discord.Reaction, user: discord.User) -> bool: + """Return True if the reaction :boom: was added by the context message author on this message.""" + return ( + reaction.message.id == message.id + and user.id == ctx.author.id + and str(reaction) == DELETION_REACTION + ) + + # A copy of the list of channels is stored. This is to make sure that we delete precisely the channels displayed + # in the confirmation message. + categories = self.jam_categories(ctx.guild) + category_channels = {category: category.channels.copy() for category in categories} + + confirmation_message = await self._build_confirmation_message(category_channels) + message = await ctx.send(confirmation_message) + await message.add_reaction(DELETION_REACTION) + try: + await self.bot.wait_for( + 'reaction_add', + check=predicate_deletion_emoji_reaction, + timeout=10 + ) + + except asyncio.TimeoutError: + await message.clear_reaction(DELETION_REACTION) return - self.end_counter = 0 + else: + await message.clear_reaction(DELETION_REACTION) + for category, channels in category_channels.items(): + for channel in channels: + await channel.delete(reason="Code jam ended.") + await category.delete(reason="Code jam ended.") - for category in self.jam_categories(ctx.guild): - for channel in category.channels: - await channel.delete(reason="Code jam ended.") - await category.delete(reason="Code jam ended.") + await message.add_reaction(Emojis.check_mark) + + @staticmethod + async def _build_confirmation_message( + categories: dict[discord.CategoryChannel, list[discord.abc.GuildChannel]] + ) -> str: + """Sends details of the channels to be deleted to the pasting service, and formats the confirmation message.""" + def channel_repr(channel: discord.abc.GuildChannel) -> str: + """Formats the channel name and ID and a readable format.""" + return f"{channel.name} ({channel.id})" + + def format_category_info(category: discord.CategoryChannel, channels: list[discord.abc.GuildChannel]) -> str: + """Displays the category and the channels within it in a readable format.""" + return f"{channel_repr(category)}:" + "".join(f"\n - {channel_repr(channel)}" for channel in channels) + + deletion_details = "\n\n".join( + format_category_info(category, channels) for category, channels in categories.items() + ) + + url = await send_to_paste_service(deletion_details) + if url is None: + url = "**Unable to send deletion details to the pasting service.**" - await ctx.message.add_reaction(Emojis.check_mark) + return f"Are you sure you want to delete all code jam channels?\n\nThe channels to be deleted: {url}" @codejam.command() @commands.has_any_role(Roles.admins, Roles.code_jam_event_team) -- cgit v1.2.3 From 02cd0ebec94286237832e4ea8a57bb17c1e2adfb Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Sun, 25 Jul 2021 10:10:43 +0100 Subject: Prevent ghost-pings in pypi command (#1696) * Update `utils.messages.wait_for_deletion` Will now clear reactions after the timeout ends to indicate it's no longer possible to delete the message through reactions. * Update pypi command to not ghost-ping users Will no longer ghost-ping users when an invalid packaged is search containing a ping and reaction is pressed to delete message. * Update local file * Remove redundant code No longer try to clear reactions after calling `utils.messages.wait_for_deletion()` since the util now does it. * Remove trailing whitespace * Remove redundant import * Fix NameErrors * Remove redundant import * Reword comment * Update `contextlib.suppress` import to be consistent * Update docstring to reflect earlier changes * Update docstring to be more informative * Update to delete error message if invocation doesn't ping * Update to delete error message if invocation doesn't ping --- bot/exts/info/doc/_cog.py | 5 ++--- bot/exts/info/pypi.py | 15 ++++++++++++--- bot/utils/messages.py | 10 +++++++--- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 704884fd1..fb9b2584a 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -14,7 +14,7 @@ import discord from discord.ext import commands from bot.bot import Bot -from bot.constants import Emojis, MODERATION_ROLES, RedirectOutput +from bot.constants import MODERATION_ROLES, RedirectOutput from bot.converters import Inventory, PackageName, ValidURL, allowed_strings from bot.pagination import LinePaginator from bot.utils.lock import SharedEvent, lock @@ -341,13 +341,12 @@ class DocCog(commands.Cog): if doc_embed is None: error_message = await send_denial(ctx, "No documentation found for the requested symbol.") await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) - with suppress(discord.NotFound): - await error_message.clear_reaction(Emojis.trashcan) # Make sure that we won't cause a ghost-ping by deleting the message if not (ctx.message.mentions or ctx.message.role_mentions): with suppress(discord.NotFound): await ctx.message.delete() + await error_message.delete() else: msg = await ctx.send(embed=doc_embed) diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py index 2e42e7d6b..62498ce0b 100644 --- a/bot/exts/info/pypi.py +++ b/bot/exts/info/pypi.py @@ -2,13 +2,15 @@ import itertools import logging import random import re +from contextlib import suppress -from discord import Embed +from discord import Embed, NotFound from discord.ext.commands import Cog, Context, command from discord.utils import escape_markdown from bot.bot import Bot from bot.constants import Colours, NEGATIVE_REPLIES, RedirectOutput +from bot.utils.messages import wait_for_deletion URL = "https://pypi.org/pypi/{package}/json" PYPI_ICON = "https://cdn.discordapp.com/emojis/766274397257334814.png" @@ -67,8 +69,15 @@ class PyPi(Cog): log.trace(f"Error when fetching PyPi package: {response.status}.") if error: - await ctx.send(embed=embed, delete_after=INVALID_INPUT_DELETE_DELAY) - await ctx.message.delete(delay=INVALID_INPUT_DELETE_DELAY) + error_message = await ctx.send(embed=embed) + await wait_for_deletion(error_message, (ctx.author.id,), timeout=INVALID_INPUT_DELETE_DELAY) + + # Make sure that we won't cause a ghost-ping by deleting the message + if not (ctx.message.mentions or ctx.message.role_mentions): + with suppress(NotFound): + await ctx.message.delete() + await error_message.delete() + else: await ctx.send(embed=embed) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index d4a921161..90672fba2 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -1,5 +1,4 @@ import asyncio -import contextlib import logging import random import re @@ -69,7 +68,9 @@ async def wait_for_deletion( allow_mods: bool = True ) -> None: """ - Wait for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message. + Wait for any of `user_ids` to react with one of the `deletion_emojis` within `timeout` seconds to delete `message`. + + If `timeout` expires then reactions are cleared to indicate the option to delete has expired. An `attach_emojis` bool may be specified to determine whether to attach the given `deletion_emojis` to the message in the given `context`. @@ -95,8 +96,11 @@ async def wait_for_deletion( allow_mods=allow_mods, ) - with contextlib.suppress(asyncio.TimeoutError): + try: await bot.instance.wait_for('reaction_add', check=check, timeout=timeout) + except asyncio.TimeoutError: + await message.clear_reactions() + else: await message.delete() -- cgit v1.2.3 From 12f3c40954db931300ea606fd03d329f16395f19 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 26 Jul 2021 15:27:44 +0100 Subject: Update _get_messages_from_channels return type --- bot/exts/utils/clean.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index e08be79fe..529dd9ee6 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -3,7 +3,7 @@ import random import re import time from collections import defaultdict -from typing import Callable, DefaultDict, Iterable, List, Optional, Tuple +from typing import Any, Callable, DefaultDict, Iterable, List, Optional, Tuple from discord import Colour, Embed, Message, NotFound, TextChannel, User, errors from discord.ext import commands @@ -79,7 +79,7 @@ class Clean(Cog): channels: Iterable[TextChannel], to_delete: Predicate, until_message: Optional[Message] = None - ) -> DefaultDict: + ) -> tuple[defaultdict[Any, list], list]: message_mappings = defaultdict(list) message_ids = [] -- cgit v1.2.3 From 02b3c8af0268239050d52db0becd856bc8ab9863 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 26 Jul 2021 15:31:55 +0100 Subject: Make is_older_than_14d a static method --- bot/exts/utils/clean.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index 529dd9ee6..a1a9eafe4 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -42,6 +42,18 @@ class Clean(Cog): """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") + @staticmethod + def is_older_than_14d(message: Message) -> bool: + """ + Precisely checks if message is older than 14 days, bulk deletion limit. + + Inspired by how purge works internally. + Comparison on message age could possibly be less accurate which in turn would resort in problems + with message deletion if said messages are very close to the 14d mark. + """ + two_weeks_old_snowflake = int((time.time() - 14 * 24 * 60 * 60) * 1000.0 - 1420070400000) << 22 + return message.id < two_weeks_old_snowflake + async def _delete_messages_individually(self, messages: List[Message]) -> None: for message in messages: # Ensure that deletion was not canceled @@ -105,17 +117,6 @@ class Clean(Cog): return message_mappings, message_ids - def is_older_than_14d(self, message: Message) -> bool: - """ - Precisely checks if message is older than 14 days, bulk deletion limit. - - Inspired by how purge works internally. - Comparison on message age could possibly be less accurate which in turn would resort in problems - with message deletion if said messages are very close to the 14d mark. - """ - two_weeks_old_snowflake = int((time.time() - 14 * 24 * 60 * 60) * 1000.0 - 1420070400000) << 22 - return message.id < two_weeks_old_snowflake - async def _clean_messages( self, amount: int, -- cgit v1.2.3 From 741c0dc44e2d8c519564d1477f8d168d82eb572e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 27 Jul 2021 13:52:36 -0700 Subject: Specify DEBUG_MODE via the config file The `SITE_URL` environment variable hasn't been used by anything else for a long time. It wouldn't have been reliable to look for "local" anyway since the Docker hostname may be used. --- bot/constants.py | 2 +- config-default.yml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index 500803f33..b7c6ffa70 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -687,7 +687,7 @@ class VideoPermission(metaclass=YAMLGetter): # Debug mode -DEBUG_MODE = 'local' in os.environ.get("SITE_URL", "local") +DEBUG_MODE: bool = _CONFIG_YAML["debug"] # Paths BOT_DIR = os.path.dirname(__file__) diff --git a/config-default.yml b/config-default.yml index 811640034..40c6d691e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -1,3 +1,6 @@ +debug: false + + bot: prefix: "!" sentry_dsn: !ENV "BOT_SENTRY_DSN" -- cgit v1.2.3 From 14056b3ede96401d2a3364ca9a0e8f3b3ec72277 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 27 Jul 2021 13:58:17 -0700 Subject: Restrict internal eval to bot owner when in debug mode It's a security risk to let anyone in a test server have essentially full access to the host machine. Resolve #1683 --- bot/exts/utils/internal.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index 6f2da3131..5d2cd7611 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -11,10 +11,10 @@ from io import StringIO from typing import Any, Optional, Tuple import discord -from discord.ext.commands import Cog, Context, group, has_any_role +from discord.ext.commands import Cog, Context, group, has_any_role, is_owner from bot.bot import Bot -from bot.constants import Roles +from bot.constants import DEBUG_MODE, Roles from bot.utils import find_nth_occurrence, send_to_paste_service log = logging.getLogger(__name__) @@ -33,6 +33,9 @@ class Internal(Cog): self.socket_event_total = 0 self.socket_events = Counter() + if DEBUG_MODE: + self.eval.add_check(is_owner().predicate) + @Cog.listener() async def on_socket_response(self, msg: dict) -> None: """When a websocket event is received, increase our counters.""" -- cgit v1.2.3 From b2b9d23469960bcf9c8a0e17af7a6c0d51dda58a Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Wed, 28 Jul 2021 14:45:21 +0200 Subject: Handle non-existent roles in `join_role_stats` --- bot/utils/helpers.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py index b0d17c3b8..cb1d46411 100644 --- a/bot/utils/helpers.py +++ b/bot/utils/helpers.py @@ -2,7 +2,7 @@ from abc import ABCMeta from typing import Dict, List, Optional from discord import Guild -from discord.ext.commands import CogMeta +from discord.ext.commands import BadArgument, CogMeta class CogABCMeta(CogMeta, ABCMeta): @@ -37,5 +37,8 @@ def join_role_stats(role_ids: List[int], name: str, guild: Guild) -> Dict[str, i """Return a dict object with the number of `members` of each role given, and the `name` for this joined group.""" members = [] for role_id in role_ids: - members += guild.get_role(role_id).members + if (role := guild.get_role(role_id)) is None: + raise BadArgument("Unable to fetch role data, the specified role does not exist.") + else: + members += role.members return {name: len(set(members))} -- cgit v1.2.3 From 0cc135de21c8fe8a85b2c42b95b04779b3af7baa Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 28 Jul 2021 17:21:36 +0100 Subject: Return empty containers if clean is cancelled --- bot/exts/utils/clean.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index a1a9eafe4..3aabe42f7 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -100,8 +100,8 @@ class Clean(Cog): async for message in channel.history(limit=amount): if not self.cleaning: - # Cleaning was canceled - return (message_mappings, message_ids) + # Cleaning was canceled, return empty containers + return defaultdict(list), [] if until_message: -- cgit v1.2.3 From 770528c70ff38b739c963c88b89ec6401d687d16 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 28 Jul 2021 17:22:24 +0100 Subject: simplify range predicate for clean command --- bot/exts/utils/clean.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index 3aabe42f7..847ac5c86 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -164,7 +164,7 @@ class Clean(Cog): def predicate_range(message: Message) -> bool: """Check if message is older than message provided in after_message but younger than until_message.""" - return message.created_at >= after_message.created_at and message.created_at <= until_message.created_at + return after_message.created_at <= message.created_at <= until_message.created_at # Is this an acceptable amount of messages to clean? if amount > CleanMessages.message_limit: -- cgit v1.2.3 From ed352272a67224182178bbd5583746053ec912a6 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 28 Jul 2021 17:59:04 +0100 Subject: Rely on error handler for sending input errors to user --- bot/exts/utils/clean.py | 54 +++++++++---------------------------------------- 1 file changed, 9 insertions(+), 45 deletions(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index 847ac5c86..7514c7a64 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -1,5 +1,4 @@ import logging -import random import re import time from collections import defaultdict @@ -8,10 +7,11 @@ from typing import Any, Callable, DefaultDict, Iterable, List, Optional, Tuple from discord import Colour, Embed, Message, NotFound, TextChannel, User, errors from discord.ext import commands from discord.ext.commands import Cog, Context, group, has_any_role +from discord.ext.commands.errors import BadArgument, MaxConcurrencyReached, MissingRequiredArgument from bot.bot import Bot from bot.constants import ( - Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES + Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES ) from bot.exts.moderation.modlog import ModLog from bot.utils.channel import is_mod_channel @@ -168,55 +168,24 @@ class Clean(Cog): # Is this an acceptable amount of messages to clean? if amount > CleanMessages.message_limit: - embed = Embed( - color=Colour(Colours.soft_red), - title=random.choice(NEGATIVE_REPLIES), - description=f"You cannot clean more than {CleanMessages.message_limit} messages." - ) - await ctx.send(embed=embed) - return + raise BadArgument(f"You cannot clean more than {CleanMessages.message_limit} messages.") if after_message: - # Ensure that until_message is specified. if not until_message: - embed = Embed( - color=Colour(Colours.soft_red), - title=random.choice(NEGATIVE_REPLIES), - description="`until_message` must be specified if `after_message` is specified." - ) - await ctx.send(embed=embed) - return + raise MissingRequiredArgument("`until_message` must be specified if `after_message` is specified.") - # Check if the messages are not in same channel + # Messages are not in same channel if after_message.channel != until_message.channel: - embed = Embed( - color=Colour(Colours.soft_red), - title=random.choice(NEGATIVE_REPLIES), - description="You cannot do range clean across several channel." - ) - await ctx.send(embed=embed) - return + raise BadArgument("You cannot do range clean across several channel.") # Ensure that after_message is younger than until_message if after_message.created_at >= until_message.created_at: - embed = Embed( - color=Colour(Colours.soft_red), - title=random.choice(NEGATIVE_REPLIES), - description="`after` message must be younger than `until` message" - ) - await ctx.send(embed=embed) - return + raise BadArgument("`after` message must be younger than `until` message") # Are we already performing a clean? if self.cleaning: - embed = Embed( - color=Colour(Colours.soft_red), - title=random.choice(NEGATIVE_REPLIES), - description="Please wait for the currently ongoing clean operation to complete." - ) - await ctx.send(embed=embed) - return + raise MaxConcurrencyReached("Please wait for the currently ongoing clean operation to complete.") # Set up the correct predicate if bots_only: @@ -305,12 +274,7 @@ class Clean(Cog): log_url = await self.mod_log.upload_log(log_messages, ctx.author.id) else: # Can't build an embed, nothing to clean! - embed = Embed( - color=Colour(Colours.soft_red), - description="No matching messages could be found." - ) - await ctx.send(embed=embed, delete_after=10) - return + raise BadArgument("No matching messages could be found.") # Build the embed and send it target_channels = ", ".join(channel.mention for channel in channels) -- cgit v1.2.3 From b2e76ddc6f4d3ccd327f48d9333eb977ddfb72d2 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 28 Jul 2021 18:04:13 +0100 Subject: Fix references to kwarg after renaming in clean command --- bot/exts/utils/clean.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index 7514c7a64..25582165a 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -216,12 +216,12 @@ class Clean(Cog): self.cleaning = True if use_cache: - message_mappings, message_ids = self._get_messages_from_cache(amount=amount, predicate=predicate) + message_mappings, message_ids = self._get_messages_from_cache(amount=amount, to_delete=predicate) else: message_mappings, message_ids = await self._get_messages_from_channels( amount=amount, channels=channels, - predicate=predicate, + to_delete=predicate, until_message=until_message ) -- cgit v1.2.3 From ed00aad9245769137a47e3e536bfa0da93b63917 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Thu, 29 Jul 2021 11:12:56 +0200 Subject: Modify error handling in join_role_stats and move it to the Information Cog --- bot/exts/info/information.py | 17 ++++++++++++++--- bot/utils/helpers.py | 16 ++-------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index eef18298c..54616a1c6 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -3,7 +3,7 @@ import logging import pprint import textwrap from collections import defaultdict -from typing import Any, DefaultDict, Dict, Mapping, Optional, Tuple, Union +from typing import Any, DefaultDict, Dict, List, Mapping, Optional, Tuple, Union import rapidfuzz from discord import AllowedMentions, Colour, Embed, Guild, Message, Role @@ -17,7 +17,6 @@ from bot.decorators import in_whitelist from bot.pagination import LinePaginator from bot.utils.channel import is_mod_channel, is_staff_channel from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check -from bot.utils.helpers import join_role_stats from bot.utils.time import TimestampFormats, discord_timestamp, humanize_delta @@ -43,6 +42,17 @@ class Information(Cog): return channel_counter + @staticmethod + def join_role_stats(role_ids: List[int], name: str, guild: Guild) -> Dict[str, int]: + """Return a dictionary with the number of `members` of each role given, and the `name` for this joined group.""" + members = [] + for role_id in role_ids: + if (role := guild.get_role(role_id)) is None: + raise ValueError(f"Could not fetch data for role {role} for server embed.") + else: + members += role.members + return {name: len(set(members))} + @staticmethod def get_member_counts(guild: Guild) -> Dict[str, int]: """Return the total number of members for certain roles in `guild`.""" @@ -54,7 +64,8 @@ class Information(Cog): ) role_stats = {role.name.title(): len(role.members) for role in roles} role_stats.update( - **join_role_stats([constants.Roles.project_leads, constants.Roles.domain_leads], "Leads", guild)) + **Information.join_role_stats([constants.Roles.project_leads, constants.Roles.domain_leads], "Leads", guild) + ) return role_stats def get_extended_server_info(self, ctx: Context) -> str: diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py index cb1d46411..3501a3933 100644 --- a/bot/utils/helpers.py +++ b/bot/utils/helpers.py @@ -1,8 +1,7 @@ from abc import ABCMeta -from typing import Dict, List, Optional +from typing import Optional -from discord import Guild -from discord.ext.commands import BadArgument, CogMeta +from discord.ext.commands import CogMeta class CogABCMeta(CogMeta, ABCMeta): @@ -31,14 +30,3 @@ def has_lines(string: str, count: int) -> bool: def pad_base64(data: str) -> str: """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" return data + "=" * (-len(data) % 4) - - -def join_role_stats(role_ids: List[int], name: str, guild: Guild) -> Dict[str, int]: - """Return a dict object with the number of `members` of each role given, and the `name` for this joined group.""" - members = [] - for role_id in role_ids: - if (role := guild.get_role(role_id)) is None: - raise BadArgument("Unable to fetch role data, the specified role does not exist.") - else: - members += role.members - return {name: len(set(members))} -- cgit v1.2.3 From 43240643642fae3fe92413189e99d772fdebf562 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Fri, 30 Jul 2021 03:25:25 +0200 Subject: Update join_role_stats, add new custom error - Add new custom error to handle non-existent roles in the Information cog - Update join_role_stats to use built in generics for typing --- bot/errors.py | 12 ++++++++++++ bot/exts/info/information.py | 24 +++++++++++++----------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/bot/errors.py b/bot/errors.py index 46efb6d4f..ce2371f56 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -41,3 +41,15 @@ class BrandingMisconfiguration(RuntimeError): """Raised by the Branding cog when a misconfigured event is encountered.""" pass + + +class NonExistentRoleError(ValueError): + """ + Raised by the Information Cog when encountering a role that does not exist. + + Attributes: + `role_id` -- the ID of the role that does not exist + """ + + def __init__(self, role_id: int): + super().__init__(f"Could not fetch data for role {role_id}") diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 54616a1c6..78a9b2122 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -3,7 +3,7 @@ import logging import pprint import textwrap from collections import defaultdict -from typing import Any, DefaultDict, Dict, List, Mapping, Optional, Tuple, Union +from typing import Any, DefaultDict, Mapping, Optional, Tuple, Union import rapidfuzz from discord import AllowedMentions, Colour, Embed, Guild, Message, Role @@ -14,12 +14,12 @@ from bot.api import ResponseCodeError from bot.bot import Bot from bot.converters import FetchedMember from bot.decorators import in_whitelist +from bot.errors import NonExistentRoleError from bot.pagination import LinePaginator from bot.utils.channel import is_mod_channel, is_staff_channel from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check from bot.utils.time import TimestampFormats, discord_timestamp, humanize_delta - log = logging.getLogger(__name__) @@ -43,25 +43,27 @@ class Information(Cog): return channel_counter @staticmethod - def join_role_stats(role_ids: List[int], name: str, guild: Guild) -> Dict[str, int]: + def join_role_stats(role_ids: list[int], name: str, guild: Guild) -> dict[str, int]: """Return a dictionary with the number of `members` of each role given, and the `name` for this joined group.""" members = [] for role_id in role_ids: if (role := guild.get_role(role_id)) is None: - raise ValueError(f"Could not fetch data for role {role} for server embed.") + raise NonExistentRoleError(role_id) else: members += role.members return {name: len(set(members))} @staticmethod - def get_member_counts(guild: Guild) -> Dict[str, int]: + def get_member_counts(guild: Guild) -> dict[str, int]: """Return the total number of members for certain roles in `guild`.""" - roles = ( - guild.get_role(role_id) for role_id in ( - constants.Roles.helpers, constants.Roles.mod_team, constants.Roles.admins, - constants.Roles.owners, constants.Roles.contributors, - ) - ) + role_ids = [constants.Roles.helpers, constants.Roles.mod_team, constants.Roles.admins, + constants.Roles.owners, constants.Roles.contributors] + roles = [] + for role_id in role_ids: + if (role := guild.get_role(role_id)) is None: + raise NonExistentRoleError(role_id) + else: + roles.append(role) role_stats = {role.name.title(): len(role.members) for role in roles} role_stats.update( **Information.join_role_stats([constants.Roles.project_leads, constants.Roles.domain_leads], "Leads", guild) -- cgit v1.2.3 From a5cbe723435fba4a4db82aa518cef68ab2836f29 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sat, 31 Jul 2021 21:15:18 +0100 Subject: Add partners to the filtering whitelist A partner being filtered is highly unlikely to be correct --- config-default.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 811640034..881a7df76 100644 --- a/config-default.yml +++ b/config-default.yml @@ -260,7 +260,7 @@ guild: contributors: 295488872404484098 help_cooldown: 699189276025421825 muted: &MUTED_ROLE 277914926603829249 - partners: 323426753857191936 + partners: &PY_PARTNER_ROLE 323426753857191936 python_community: &PY_COMMUNITY_ROLE 458226413825294336 sprinters: &SPRINTERS 758422482289426471 voice_verified: 764802720779337729 @@ -342,6 +342,7 @@ filter: - *OWNERS_ROLE - *PY_COMMUNITY_ROLE - *SPRINTERS + - *PY_PARTNER_ROLE keys: -- cgit v1.2.3 From 3c91cfb852de5e4d01e8e1778e366ddc59e57f0f Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Sun, 1 Aug 2021 18:53:04 +0200 Subject: Update join_role_stats and NonExistentError to be clear --- bot/errors.py | 3 ++- bot/exts/info/information.py | 13 +++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/bot/errors.py b/bot/errors.py index ce2371f56..69c588f4a 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -52,4 +52,5 @@ class NonExistentRoleError(ValueError): """ def __init__(self, role_id: int): - super().__init__(f"Could not fetch data for role {role_id}") + self.role_id = role_id + super().__init__(f"Could not fetch data for role {self.role_id}") diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 78a9b2122..62fde5473 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -47,10 +47,10 @@ class Information(Cog): """Return a dictionary with the number of `members` of each role given, and the `name` for this joined group.""" members = [] for role_id in role_ids: - if (role := guild.get_role(role_id)) is None: - raise NonExistentRoleError(role_id) + if (role := guild.get_role(role_id)) is not None: + members.append(role.members) else: - members += role.members + raise NonExistentRoleError(role_id) return {name: len(set(members))} @staticmethod @@ -60,10 +60,11 @@ class Information(Cog): constants.Roles.owners, constants.Roles.contributors] roles = [] for role_id in role_ids: - if (role := guild.get_role(role_id)) is None: - raise NonExistentRoleError(role_id) - else: + if (role := guild.get_role(role_id)) is not None: roles.append(role) + else: + raise NonExistentRoleError(role_id) + role_stats = {role.name.title(): len(role.members) for role in roles} role_stats.update( **Information.join_role_stats([constants.Roles.project_leads, constants.Roles.domain_leads], "Leads", guild) -- cgit v1.2.3 From 587e52e1bd05eaf66035b6089cb37149399c8851 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sun, 1 Aug 2021 19:58:46 +0100 Subject: Add commands to turn automatic review posting on or off --- bot/exts/recruitment/talentpool/_cog.py | 59 ++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 03326cab2..5d90701bf 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -5,12 +5,13 @@ from io import StringIO from typing import Union import discord +from async_rediscache import RedisCache from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent, User from discord.ext.commands import Cog, Context, group, has_any_role from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks +from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES, Webhooks from bot.converters import FetchedMember from bot.exts.moderation.watchchannels._watchchannel import WatchChannel from bot.exts.recruitment.talentpool._review import Reviewer @@ -25,6 +26,8 @@ log = logging.getLogger(__name__) class TalentPool(WatchChannel, Cog, name="Talentpool"): """Relays messages of helper candidates to a watch channel to observe them.""" + talentpool_settings = RedisCache() + def __init__(self, bot: Bot) -> None: super().__init__( bot, @@ -37,7 +40,18 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): ) self.reviewer = Reviewer(self.__class__.__name__, bot, self) - self.bot.loop.create_task(self.reviewer.reschedule_reviews()) + self.bot.loop.create_task(self.schedule_autoreviews()) + + async def schedule_autoreviews(self) -> None: + """Reschedule reviews for active nominations if autoreview is enabled.""" + if await self.autoreview_enabled(): + await self.reviewer.reschedule_reviews() + else: + self.log.trace('Not scheduling reviews as autoreview is disabled.') + + async def autoreview_enabled(self) -> bool: + """Return whether automatic posting of nomination reviews is enabled.""" + return await self.talentpool_settings.get('autoreview_enabled', True) @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) @has_any_role(*MODERATION_ROLES) @@ -45,6 +59,42 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" await ctx.send_help(ctx.command) + @nomination_group.group(name='autoreview', invoke_without_command=True) + @has_any_role(*MODERATION_ROLES) + async def nomination_autoreview_group(self, ctx: Context) -> None: + """Commands for enabling or disabling autoreview.""" + await ctx.send_help(ctx.command) + + @nomination_autoreview_group.command(name='on') + @has_any_role(Roles.admins) + async def autoreview_on(self, ctx: Context) -> None: + """ + Turn on automatic posting of reviews. + + This will post reviews up to one day overdue, older nominations can be + manually reviewed with `!tp post_review `. + """ + await self.talentpool_settings.set('autoreview_enabled', True) + await self.reviewer.reschedule_reviews() + await ctx.send(':white_check_mark: Autoreview turned on') + + @nomination_autoreview_group.command(name='off') + @has_any_role(Roles.admins) + async def autoreview_off(self, ctx: Context) -> None: + """Turn off automatic posting of reviews.""" + await self.talentpool_settings.set('autoreview_enabled', False) + self.reviewer.cancel_all() + await ctx.send(':white_check_mark: Autoreview turned off') + + @has_any_role(*MODERATION_ROLES) + @nomination_autoreview_group.command(name='status') + async def autoreview_status(self, ctx: Context) -> None: + """Show whether automatic posting of reviews is enabled or disabled.""" + if await self.autoreview_enabled(): + await ctx.send('Autoreview is currently enabled') + else: + await ctx.send('Autoreview is currently disabled') + @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",)) @has_any_role(*MODERATION_ROLES) async def watched_command( @@ -190,7 +240,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): self.watched_users[user.id] = response_data - if user.id not in self.reviewer: + if await self.autoreview_enabled() and user.id not in self.reviewer: self.reviewer.schedule_review(user.id) history = await self.bot.api_client.get( @@ -403,7 +453,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): ) self._remove_user(user_id) - self.reviewer.cancel(user_id) + if await self.autoreview_enabled(): + self.reviewer.cancel(user_id) return True -- cgit v1.2.3 From 3bdec8eaddfc8bfc6644f61a3aa7e5842757149c Mon Sep 17 00:00:00 2001 From: wookie184 Date: Mon, 2 Aug 2021 08:45:10 +0100 Subject: Small code improvements and added 'ar' alias --- bot/exts/recruitment/talentpool/_cog.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 5d90701bf..d0558f1f3 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -18,6 +18,7 @@ from bot.exts.recruitment.talentpool._review import Reviewer from bot.pagination import LinePaginator from bot.utils import time +AUTOREVIEW_ENABLED_KEY = 'autoreview_enabled' REASON_MAX_CHARS = 1000 log = logging.getLogger(__name__) @@ -51,7 +52,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): async def autoreview_enabled(self) -> bool: """Return whether automatic posting of nomination reviews is enabled.""" - return await self.talentpool_settings.get('autoreview_enabled', True) + return await self.talentpool_settings.get(AUTOREVIEW_ENABLED_KEY, True) @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) @has_any_role(*MODERATION_ROLES) @@ -59,7 +60,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" await ctx.send_help(ctx.command) - @nomination_group.group(name='autoreview', invoke_without_command=True) + @nomination_group.group(name='autoreview', aliases=('ar',), invoke_without_command=True) @has_any_role(*MODERATION_ROLES) async def nomination_autoreview_group(self, ctx: Context) -> None: """Commands for enabling or disabling autoreview.""" @@ -71,10 +72,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """ Turn on automatic posting of reviews. - This will post reviews up to one day overdue, older nominations can be + This will post reviews up to one day overdue. Older nominations can be manually reviewed with `!tp post_review `. """ - await self.talentpool_settings.set('autoreview_enabled', True) + await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, True) await self.reviewer.reschedule_reviews() await ctx.send(':white_check_mark: Autoreview turned on') @@ -82,12 +83,12 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @has_any_role(Roles.admins) async def autoreview_off(self, ctx: Context) -> None: """Turn off automatic posting of reviews.""" - await self.talentpool_settings.set('autoreview_enabled', False) + await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, False) self.reviewer.cancel_all() await ctx.send(':white_check_mark: Autoreview turned off') - @has_any_role(*MODERATION_ROLES) @nomination_autoreview_group.command(name='status') + @has_any_role(*MODERATION_ROLES) async def autoreview_status(self, ctx: Context) -> None: """Show whether automatic posting of reviews is enabled or disabled.""" if await self.autoreview_enabled(): -- cgit v1.2.3 From 567c911e4b3a15559d8203da13174ef1e43b1312 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Aug 2021 14:26:11 +0200 Subject: Correct the documented return objects --- bot/exts/info/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 0bedd6e10..b6a91269a 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -264,7 +264,7 @@ class Tags(Cog): """ Generate an embed of the requested tag or of suggestions if the tag doesn't exist/isn't accessible by the user. - If the requested tag is on cooldown or no suggestions were found, return None. + If the requested tag is on cooldown return `COOLDOWN.obj`, otherwise if no suggestions were found return None. """ filtered_tags = [ (ident, tag) for ident, tag in -- cgit v1.2.3 From b5d8e5ac9f094973ac73f1998fec844586244d8b Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Aug 2021 14:29:18 +0200 Subject: Add missing "the" Co-authored-by: Bluenix --- bot/exts/info/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index b6a91269a..5e3dd400d 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -400,7 +400,7 @@ def setup(bot: Bot) -> None: def extract_tag_identifier(string: str) -> TagIdentifier: - """Create a `TagIdentifier` instance from beginning of `string`.""" + """Create a `TagIdentifier` instance from the beginning of `string`.""" split_string = string.removeprefix(constants.Bot.prefix).split(" ", maxsplit=2) if len(split_string) == 1: return TagIdentifier(None, split_string[0]) -- cgit v1.2.3 From e40cd9840c188210a7cfde6bffbf08ce563c2a11 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Aug 2021 14:41:46 +0200 Subject: Improve help output of get command --- bot/exts/info/tags.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 5e3dd400d..655ec2dd7 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -356,11 +356,15 @@ class Tags(Cog): tag_name: TagNameConverter = None, ) -> bool: """ - Get a specified tag, or a list of all tags if no tag is specified. + When arguments are passed in: + If a single argument is given and it matches a group name, list accessible all tags from that group. + Otherwise display the tag if one was found for the given arguments, or try to display suggestions for that name + + With no arguments, list all accessible tags Returns True if something was sent, or if the tag is on cooldown. Returns False if no message was sent. - """ + """ # noqa: D205, D415 if tag_name_or_group is None and tag_name is None: if self._tags: await self.list_all_tags(ctx) -- cgit v1.2.3 From 4d8b06e99ed9cc7e1c2e60aae64c7c945f697a97 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Aug 2021 14:46:45 +0200 Subject: Use \N escape --- bot/exts/info/tags.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 655ec2dd7..6e9f1cf90 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -218,7 +218,10 @@ class Tags(Cog): title=f"Here are the tags containing the given keyword{'s' * is_plural}:", ) await LinePaginator.paginate( - sorted(f"**»** {identifier.name}" for identifier, _ in matching_tags), + sorted( + f"**\N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK}** {identifier.name}" + for identifier, _ in matching_tags + ), ctx, embed, footer_text=FOOTER_TEXT, -- cgit v1.2.3 From 96a8390290f379a90fa0f966b74993dd1ddf9f44 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Aug 2021 14:52:34 +0200 Subject: Remove embed title bolding for group listing The other embeds don't have a bold title --- bot/exts/info/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 6e9f1cf90..2b610a5fb 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -344,7 +344,7 @@ class Tags(Cog): async def list_tags_in_group(self, ctx: Context, group: str) -> None: """Send a paginator with all tags under `group`, that are accessible by `ctx.author`.""" - embed = Embed(title=f"**Tags under *{group}***") + embed = Embed(title=f"Tags under *{group}*") tag_lines = sorted( f"**\N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK}** {identifier}" for identifier, tag in self._tags.items() -- cgit v1.2.3 From e3b9924920a32e19adfa1274cfdbc4cf9ddb874a Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Aug 2021 14:57:10 +0200 Subject: Do not pass in embed as a kwarg Using a doesn't add anything to the readability of the line and makes it inconsistent with other uses --- bot/exts/info/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 2b610a5fb..34592adde 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -340,7 +340,7 @@ class Tags(Cog): group_accessible = True embed = Embed(title="Current tags") - await LinePaginator.paginate(result_lines, ctx, embed=embed, max_lines=15, empty=False, footer_text=FOOTER_TEXT) + await LinePaginator.paginate(result_lines, ctx, embed, max_lines=15, empty=False, footer_text=FOOTER_TEXT) async def list_tags_in_group(self, ctx: Context, group: str) -> None: """Send a paginator with all tags under `group`, that are accessible by `ctx.author`.""" -- cgit v1.2.3 From dcf2e4ab3690d96e3788a0204ff2ba5f45834482 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Aug 2021 15:02:27 +0200 Subject: Move assignment to its own line instead of using an assignment expr --- bot/exts/info/tags.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 34592adde..884e8e10f 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -275,7 +275,8 @@ class Tags(Cog): if tag.accessible_by(ctx.author) ] - if (tag := self._tags.get(tag_identifier)) is None: + tag = self._tags.get(tag_identifier) + if tag is None: if len(filtered_tags) == 1: tag_identifier = filtered_tags[0][0] tag = filtered_tags[0][1] -- cgit v1.2.3 From a37c390618a436fe01e18ce52a5598de1b11cdf4 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Aug 2021 15:03:59 +0200 Subject: Use "message" in docstring for consistency --- bot/exts/info/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 884e8e10f..8d4073342 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -366,7 +366,7 @@ class Tags(Cog): With no arguments, list all accessible tags - Returns True if something was sent, or if the tag is on cooldown. + Returns True if a message was sent, or if the tag is on cooldown. Returns False if no message was sent. """ # noqa: D205, D415 if tag_name_or_group is None and tag_name is None: -- cgit v1.2.3 From 5cbb7d20713c0a7027c2e0909ddcdb6e02dbddc2 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Aug 2021 15:13:46 +0200 Subject: Move `current_group` assignment and use it instead of `identifier.group` --- bot/exts/info/tags.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 8d4073342..50e63b479 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -329,12 +329,12 @@ class Tags(Cog): # Remove group separator line if no tags in the previous group were accessible by the user. result_lines.pop() # A new group began, add a separator with the group name. - if identifier.group is not None: + current_group = identifier.group + if current_group is not None: group_accessible = False - result_lines.append(f"\n\N{BULLET} **{identifier.group}**") + result_lines.append(f"\n\N{BULLET} **{current_group}**") else: result_lines.append("\n\N{BULLET}") - current_group = identifier.group if tag.accessible_by(ctx.author): result_lines.append(f"**\N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK}** {identifier.name}") -- cgit v1.2.3 From 792c5a474b5d5f0a4ad58c536f86cad5a5bf3e18 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Mon, 2 Aug 2021 17:25:59 +0100 Subject: Rename commands from on/off to enable/disable Added on/off as aliases --- bot/exts/recruitment/talentpool/_cog.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index d0558f1f3..aff2aa023 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -66,26 +66,26 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """Commands for enabling or disabling autoreview.""" await ctx.send_help(ctx.command) - @nomination_autoreview_group.command(name='on') + @nomination_autoreview_group.command(name='enable', aliases=('on',)) @has_any_role(Roles.admins) - async def autoreview_on(self, ctx: Context) -> None: + async def autoreview_enable(self, ctx: Context) -> None: """ - Turn on automatic posting of reviews. + Enable automatic posting of reviews. This will post reviews up to one day overdue. Older nominations can be manually reviewed with `!tp post_review `. """ await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, True) await self.reviewer.reschedule_reviews() - await ctx.send(':white_check_mark: Autoreview turned on') + await ctx.send(':white_check_mark: Autoreview enabled') - @nomination_autoreview_group.command(name='off') + @nomination_autoreview_group.command(name='disable', aliases=('off',)) @has_any_role(Roles.admins) - async def autoreview_off(self, ctx: Context) -> None: - """Turn off automatic posting of reviews.""" + async def autoreview_disable(self, ctx: Context) -> None: + """Disable automatic posting of reviews.""" await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, False) self.reviewer.cancel_all() - await ctx.send(':white_check_mark: Autoreview turned off') + await ctx.send(':white_check_mark: Autoreview disabled') @nomination_autoreview_group.command(name='status') @has_any_role(*MODERATION_ROLES) -- cgit v1.2.3 From 20997b425168890636a04b270e7433fa82c60fa6 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 2 Aug 2021 18:28:05 +0200 Subject: Optimize Information Cog's join_role_stats and get_member counts --- bot/errors.py | 3 ++- bot/exts/info/information.py | 20 ++++++++------------ 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/bot/errors.py b/bot/errors.py index 69c588f4a..5209997f9 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -52,5 +52,6 @@ class NonExistentRoleError(ValueError): """ def __init__(self, role_id: int): + super().__init__(f"Could not fetch data for role {role_id}") + self.role_id = role_id - super().__init__(f"Could not fetch data for role {self.role_id}") diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 62fde5473..2621e15b1 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -43,31 +43,27 @@ class Information(Cog): return channel_counter @staticmethod - def join_role_stats(role_ids: list[int], name: str, guild: Guild) -> dict[str, int]: + def join_role_stats(role_ids: list[int], guild: Guild, name: Optional[str] = None) -> dict[str, int]: """Return a dictionary with the number of `members` of each role given, and the `name` for this joined group.""" - members = [] + members = int() for role_id in role_ids: if (role := guild.get_role(role_id)) is not None: - members.append(role.members) + members += len(role.members) else: raise NonExistentRoleError(role_id) - return {name: len(set(members))} + return {name or role.name.title(): members} @staticmethod def get_member_counts(guild: Guild) -> dict[str, int]: """Return the total number of members for certain roles in `guild`.""" role_ids = [constants.Roles.helpers, constants.Roles.mod_team, constants.Roles.admins, constants.Roles.owners, constants.Roles.contributors] - roles = [] - for role_id in role_ids: - if (role := guild.get_role(role_id)) is not None: - roles.append(role) - else: - raise NonExistentRoleError(role_id) - role_stats = {role.name.title(): len(role.members) for role in roles} + role_stats = {} + for role_id in role_ids: + role_stats.update(Information.join_role_stats([role_id], guild)) role_stats.update( - **Information.join_role_stats([constants.Roles.project_leads, constants.Roles.domain_leads], "Leads", guild) + Information.join_role_stats([constants.Roles.project_leads, constants.Roles.domain_leads], guild, "Leads") ) return role_stats -- cgit v1.2.3 From ab14da21715c549b7cb5508fc6339b6ee9a31490 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Aug 2021 20:33:51 +0200 Subject: Use an and instead of nested ifs --- bot/exts/info/tags.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 50e63b479..7efaae3c3 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -276,10 +276,9 @@ class Tags(Cog): ] tag = self._tags.get(tag_identifier) - if tag is None: - if len(filtered_tags) == 1: - tag_identifier = filtered_tags[0][0] - tag = filtered_tags[0][1] + if tag is None and len(filtered_tags) == 1: + tag_identifier = filtered_tags[0][0] + tag = filtered_tags[0][1] if tag is not None: if tag.on_cooldown_in(ctx.channel): -- cgit v1.2.3 From 6421ebed42c87ccc6a271ef3baf5345797b3b42e Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Aug 2021 20:35:14 +0200 Subject: Change if to elif to indicate it's exclusive with the above if --- bot/exts/info/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 7efaae3c3..f2b5c0823 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -376,7 +376,7 @@ class Tags(Cog): await ctx.send(embed=Embed(description="**There are no tags!**")) return True - if tag_name is None: + elif tag_name is None: if any( tag_name_or_group == identifier.group and tag.accessible_by(ctx.author) for identifier, tag in self._tags.items() -- cgit v1.2.3 From 359a17c00bb036f9a90fe7681592dee29f84c806 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Aug 2021 20:38:09 +0200 Subject: Move tag identifier creation method to a TagIdentifier constructor --- bot/exts/backend/error_handler.py | 2 +- bot/exts/info/tags.py | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 78822aece..51b6bc660 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -155,7 +155,7 @@ class ErrorHandler(Cog): return try: - tag_identifier = tags.extract_tag_identifier(ctx.message.content) + tag_identifier = tags.TagIdentifier.from_string(ctx.message.content) if tag_identifier.group is not None: tag_name = await TagNameConverter.convert(ctx, tag_identifier.name) tag_name_or_group = await TagNameConverter.convert(ctx, tag_identifier.group) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index f2b5c0823..c05528daf 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -68,6 +68,15 @@ class TagIdentifier(NamedTuple): else: return self.name + @classmethod + def from_string(cls, string: str) -> TagIdentifier: + """Create a `TagIdentifier` instance from the beginning of `string`.""" + split_string = string.removeprefix(constants.Bot.prefix).split(" ", maxsplit=2) + if len(split_string) == 1: + return cls(None, split_string[0]) + else: + return cls(split_string[0], split_string[1]) + class Tag: """Provide an interface to a tag from resources with `file_content`.""" @@ -404,12 +413,3 @@ class Tags(Cog): def setup(bot: Bot) -> None: """Load the Tags cog.""" bot.add_cog(Tags(bot)) - - -def extract_tag_identifier(string: str) -> TagIdentifier: - """Create a `TagIdentifier` instance from the beginning of `string`.""" - split_string = string.removeprefix(constants.Bot.prefix).split(" ", maxsplit=2) - if len(split_string) == 1: - return TagIdentifier(None, split_string[0]) - else: - return TagIdentifier(split_string[0], split_string[1]) -- cgit v1.2.3 From 8bb2ffc4f7bb324c7940fb88ae1b698f56f8ce67 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 2 Aug 2021 20:42:56 +0200 Subject: Improve code consistency of join_role_stats and NonExistentRoleError --- bot/errors.py | 2 +- bot/exts/info/information.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/errors.py b/bot/errors.py index 5209997f9..5785faa44 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -45,7 +45,7 @@ class BrandingMisconfiguration(RuntimeError): class NonExistentRoleError(ValueError): """ - Raised by the Information Cog when encountering a role that does not exist. + Raised by the Information Cog when encountering a Role that does not exist. Attributes: `role_id` -- the ID of the role that does not exist diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 2621e15b1..b879e1330 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -45,7 +45,7 @@ class Information(Cog): @staticmethod def join_role_stats(role_ids: list[int], guild: Guild, name: Optional[str] = None) -> dict[str, int]: """Return a dictionary with the number of `members` of each role given, and the `name` for this joined group.""" - members = int() + members = 0 for role_id in role_ids: if (role := guild.get_role(role_id)) is not None: members += len(role.members) -- cgit v1.2.3 From 116c4af36990df5f2c8413101748f68a612e07f4 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Aug 2021 20:51:44 +0200 Subject: Use opposite comparison operator instead of negating condition --- bot/exts/info/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index c05528daf..50a88ed19 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -176,7 +176,7 @@ class Tags(Cog): # Try fuzzy matching with only a name first suggestions = self._get_suggestions(TagIdentifier(None, tag_identifier.group)) - if not len(tag_identifier.name) < 3: + if len(tag_identifier.name) >= 3: suggestions += self._get_suggestions(tag_identifier) return suggestions -- cgit v1.2.3 From 18c6c5b1a0e7dd29a7c9b142401a44d4e1971633 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Aug 2021 20:54:28 +0200 Subject: Simplify condition by assigning group and name before it --- bot/exts/info/tags.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 50a88ed19..fb7f60aa7 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -318,13 +318,12 @@ class Tags(Cog): async def list_all_tags(self, ctx: Context) -> None: """Send a paginator with all loaded tags accessible by `ctx.author`, groups first, and alphabetically sorted.""" def tag_sort_key(tag_item: tuple[TagIdentifier, tag]) -> str: - ident = tag_item[0] - if ident.group is None: + group, name = tag_item[0] + if group is None: # Max codepoint character to force tags without a group to the end group = chr(0x10ffff) - else: - group = ident.group - return group+ident.name + + return group + name result_lines = [] current_group = object() -- cgit v1.2.3 From e2157f90975cc37e9970d300bb8d2abf21b0a09b Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 2 Aug 2021 21:04:57 +0200 Subject: Make return condition clearer --- bot/exts/info/tags.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index fb7f60aa7..beabade58 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -398,16 +398,17 @@ class Tags(Cog): tag_group = tag_name_or_group embed = await self.get_tag_embed(ctx, TagIdentifier(tag_group, tag_name)) - if embed is not None: - if embed is not COOLDOWN.obj: - await wait_for_deletion( - await ctx.send(embed=embed), - (ctx.author.id,) - ) - return True - else: + if embed is None: return False + if embed is not COOLDOWN.obj: + await wait_for_deletion( + await ctx.send(embed=embed), + (ctx.author.id,) + ) + # A valid tag was found and was either sent, or is on cooldown + return True + def setup(bot: Bot) -> None: """Load the Tags cog.""" -- cgit v1.2.3 From c1fcab3ba4767cac481fa32ebaf2db31b108faa4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 2 Aug 2021 19:41:30 -0700 Subject: Fix TypeError when infraction append is not given a reason Fix #1706 --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 4b0cb78a5..3094159cd 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -81,7 +81,7 @@ class ModManagement(commands.Cog): """ old_reason = infraction["reason"] - if old_reason is not None: + if old_reason is not None and reason is not None: add_period = not old_reason.endswith((".", "!", "?")) reason = old_reason + (". " if add_period else " ") + reason -- cgit v1.2.3 From 53f8ea442994a8f5a465d49031894be0ae17748a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 2 Aug 2021 20:11:17 -0700 Subject: Catch 404 error when waiting to delete message Sometimes the message is deleted before the function gets around to it. Fixes BOT-1JD Fixes BOT-1K3 Fixes BOT-1JE --- bot/utils/messages.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 90672fba2..a82096b1c 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -97,11 +97,14 @@ async def wait_for_deletion( ) try: - await bot.instance.wait_for('reaction_add', check=check, timeout=timeout) - except asyncio.TimeoutError: - await message.clear_reactions() - else: - await message.delete() + try: + await bot.instance.wait_for('reaction_add', check=check, timeout=timeout) + except asyncio.TimeoutError: + await message.clear_reactions() + else: + await message.delete() + except discord.NotFound: + log.trace(f"wait_for_deletion: message {message.id} deleted prematurely.") async def send_attachments( -- cgit v1.2.3 From 8bfc0d7a35b0c49a787e498280cffb4841ffa14e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 2 Aug 2021 20:12:48 -0700 Subject: Reduce imports in utils/messages by qualifying names --- bot/utils/messages.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index a82096b1c..abeb04021 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -7,8 +7,6 @@ from io import BytesIO from typing import Callable, List, Optional, Sequence, Union import discord -from discord import Message, MessageType, Reaction, User -from discord.errors import HTTPException from discord.ext.commands import Context import bot @@ -53,7 +51,7 @@ def reaction_check( log.trace(f"Removing reaction {reaction} by {user} on {reaction.message.id}: disallowed user.") scheduling.create_task( reaction.message.remove_reaction(reaction.emoji, user), - suppressed_exceptions=(HTTPException,), + suppressed_exceptions=(discord.HTTPException,), name=f"remove_reaction-{reaction}-{reaction.message.id}-{user}" ) return False @@ -153,7 +151,7 @@ async def send_attachments( large.append(attachment) else: log.info(f"{failure_msg} because it's too large.") - except HTTPException as e: + except discord.HTTPException as e: if link_large and e.status == 413: large.append(attachment) else: @@ -174,8 +172,8 @@ async def send_attachments( async def count_unique_users_reaction( message: discord.Message, - reaction_predicate: Callable[[Reaction], bool] = lambda _: True, - user_predicate: Callable[[User], bool] = lambda _: True, + reaction_predicate: Callable[[discord.Reaction], bool] = lambda _: True, + user_predicate: Callable[[discord.User], bool] = lambda _: True, count_bots: bool = True ) -> int: """ @@ -195,7 +193,7 @@ async def count_unique_users_reaction( return len(unique_users) -async def pin_no_system_message(message: Message) -> bool: +async def pin_no_system_message(message: discord.Message) -> bool: """Pin the given message, wait a couple of seconds and try to delete the system message.""" await message.pin() @@ -203,7 +201,7 @@ async def pin_no_system_message(message: Message) -> bool: await asyncio.sleep(2) # Search for the system message in the last 10 messages async for historical_message in message.channel.history(limit=10): - if historical_message.type == MessageType.pins_add: + if historical_message.type == discord.MessageType.pins_add: await historical_message.delete() return True -- cgit v1.2.3 From 80861fff60b2622f42f91c8821808e3cbb13fd1b Mon Sep 17 00:00:00 2001 From: Zack Didcott <66186954+Zedeldi@users.noreply.github.com> Date: Tue, 3 Aug 2021 03:43:25 +0000 Subject: Add virtual environment (venv) tag (#1702) * Add virtual environment (venv) tag Co-authored-by: bast0006 Co-authored-by: wookie184 Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/resources/tags/venv.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 bot/resources/tags/venv.md diff --git a/bot/resources/tags/venv.md b/bot/resources/tags/venv.md new file mode 100644 index 000000000..a4fc62151 --- /dev/null +++ b/bot/resources/tags/venv.md @@ -0,0 +1,20 @@ +**Virtual Environments** + +Virtual environments are isolated Python environments, which make it easier to keep your system clean and manage dependencies. By default, when activated, only libraries and scripts installed in the virtual environment are accessible, preventing cross-project dependency conflicts, and allowing easy isolation of requirements. + +To create a new virtual environment, you can use the standard library `venv` module: `python3 -m venv .venv` (replace `python3` with `python` or `py` on Windows) + +Then, to activate the new virtual environment: + +**Windows** (PowerShell): `.venv\Scripts\Activate.ps1` +or (Command Prompt): `.venv\Scripts\activate.bat` +**MacOS / Linux** (Bash): `source .venv/bin/activate` + +Packages can then be installed to the virtual environment using `pip`, as normal. + +For more information, take a read of the [documentation](https://docs.python.org/3/library/venv.html). If you run code through your editor, check its documentation on how to make it use your virtual environment. For example, see the [VSCode](https://code.visualstudio.com/docs/python/environments#_select-and-activate-an-environment) or [PyCharm](https://www.jetbrains.com/help/pycharm/creating-virtual-environment.html) docs. + +Tools such as [poetry](https://python-poetry.org/docs/basic-usage/) and [pipenv](https://pipenv.pypa.io/en/latest/) can manage the creation of virtual environments as well as project dependencies, making packaging and installing your project easier. + +**Note:** When using Windows PowerShell, you may need to change the [execution policy](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies) first. This is only required once: +`Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser` -- cgit v1.2.3 From 5aa0022692eefb64a4e69da24e73745d949ba562 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Tue, 3 Aug 2021 12:16:39 +0100 Subject: Remove bot prefix from docstring and change single quotes to double --- bot/exts/recruitment/talentpool/_cog.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index aff2aa023..d2b1d7c02 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -18,7 +18,7 @@ from bot.exts.recruitment.talentpool._review import Reviewer from bot.pagination import LinePaginator from bot.utils import time -AUTOREVIEW_ENABLED_KEY = 'autoreview_enabled' +AUTOREVIEW_ENABLED_KEY = "autoreview_enabled" REASON_MAX_CHARS = 1000 log = logging.getLogger(__name__) @@ -48,7 +48,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): if await self.autoreview_enabled(): await self.reviewer.reschedule_reviews() else: - self.log.trace('Not scheduling reviews as autoreview is disabled.') + self.log.trace("Not scheduling reviews as autoreview is disabled.") async def autoreview_enabled(self) -> bool: """Return whether automatic posting of nomination reviews is enabled.""" @@ -60,41 +60,41 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" await ctx.send_help(ctx.command) - @nomination_group.group(name='autoreview', aliases=('ar',), invoke_without_command=True) + @nomination_group.group(name="autoreview", aliases=("ar",), invoke_without_command=True) @has_any_role(*MODERATION_ROLES) async def nomination_autoreview_group(self, ctx: Context) -> None: """Commands for enabling or disabling autoreview.""" await ctx.send_help(ctx.command) - @nomination_autoreview_group.command(name='enable', aliases=('on',)) + @nomination_autoreview_group.command(name="enable", aliases=("on",)) @has_any_role(Roles.admins) async def autoreview_enable(self, ctx: Context) -> None: """ Enable automatic posting of reviews. This will post reviews up to one day overdue. Older nominations can be - manually reviewed with `!tp post_review `. + manually reviewed with the `tp post_review ` command. """ await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, True) await self.reviewer.reschedule_reviews() - await ctx.send(':white_check_mark: Autoreview enabled') + await ctx.send(":white_check_mark: Autoreview enabled") - @nomination_autoreview_group.command(name='disable', aliases=('off',)) + @nomination_autoreview_group.command(name="disable", aliases=("off",)) @has_any_role(Roles.admins) async def autoreview_disable(self, ctx: Context) -> None: """Disable automatic posting of reviews.""" await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, False) self.reviewer.cancel_all() - await ctx.send(':white_check_mark: Autoreview disabled') + await ctx.send(":white_check_mark: Autoreview disabled") - @nomination_autoreview_group.command(name='status') + @nomination_autoreview_group.command(name="status") @has_any_role(*MODERATION_ROLES) async def autoreview_status(self, ctx: Context) -> None: """Show whether automatic posting of reviews is enabled or disabled.""" if await self.autoreview_enabled(): - await ctx.send('Autoreview is currently enabled') + await ctx.send("Autoreview is currently enabled") else: - await ctx.send('Autoreview is currently disabled') + await ctx.send("Autoreview is currently disabled") @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",)) @has_any_role(*MODERATION_ROLES) -- cgit v1.2.3 From def62f6129e5b24a52f1cc0cafc616edfa44f3b2 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Tue, 3 Aug 2021 12:23:57 +0100 Subject: Add check for redundant change --- bot/exts/recruitment/talentpool/_cog.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index d2b1d7c02..e7ad314e8 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -75,6 +75,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): This will post reviews up to one day overdue. Older nominations can be manually reviewed with the `tp post_review ` command. """ + if await self.autoreview_enabled(): + await ctx.send(":x: Autoreview is already enabled") + return + await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, True) await self.reviewer.reschedule_reviews() await ctx.send(":white_check_mark: Autoreview enabled") @@ -83,6 +87,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @has_any_role(Roles.admins) async def autoreview_disable(self, ctx: Context) -> None: """Disable automatic posting of reviews.""" + if not await self.autoreview_enabled(): + await ctx.send(":x: Autoreview is already disabled") + return + await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, False) self.reviewer.cancel_all() await ctx.send(":white_check_mark: Autoreview disabled") -- cgit v1.2.3 From 62ab2fc8d98e6ff28af8e9ca2944ce705de4ab48 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Tue, 3 Aug 2021 19:25:04 +0300 Subject: Correctly delete from cache * Fixed wrong condition in rescheduler which made the eventual consistency not work. * Mods now correctly removed from cache on role reapplies. --- bot/exts/moderation/modpings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 1ad5005de..29a5c1c8e 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -44,7 +44,7 @@ class ModPings(Cog): log.trace("Applying the moderators role to the mod team where necessary.") for mod in mod_team.members: if mod in pings_on: # Make sure that on-duty mods aren't in the cache. - if mod in pings_off: + if mod.id in pings_off: await self.pings_off_mods.delete(mod.id) continue @@ -59,6 +59,7 @@ class ModPings(Cog): """Reapply the moderator's role to the given moderator.""" log.trace(f"Re-applying role to mod with ID {mod.id}.") await mod.add_roles(self.moderators_role, reason="Pings off period expired.") + await self.pings_off_mods.delete(mod.id) @group(name='modpings', aliases=('modping',), invoke_without_command=True) @has_any_role(*MODERATION_ROLES) -- cgit v1.2.3 From 1e3d8fa4d8e6ba4bdabc96e31bb7c0c4ab82b1d5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 2 Aug 2021 20:38:55 -0700 Subject: Update Sentry SDK to 1.3 --- poetry.lock | 207 ++++++++++++++++++++++++++++++++++++--------------------- pyproject.toml | 2 +- 2 files changed, 131 insertions(+), 78 deletions(-) diff --git a/poetry.lock b/poetry.lock index dac277ed8..a4ce5d1a9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -70,14 +70,6 @@ yarl = "*" [package.extras] develop = ["aiomisc (>=11.0,<12.0)", "async-generator", "coverage (!=4.3)", "coveralls", "pylava", "pytest", "pytest-cov", "tox (>=2.4)"] -[[package]] -name = "appdirs" -version = "1.4.4" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" -optional = false -python-versions = "*" - [[package]] name = "arrow" version = "1.0.3" @@ -134,6 +126,18 @@ docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] +[[package]] +name = "backports.entry-points-selectable" +version = "1.1.0" +description = "Compatibility shim providing selectable entry points for older implementations" +category = "dev" +optional = false +python-versions = ">=2.7" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=4.6)", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] + [[package]] name = "beautifulsoup4" version = "4.9.3" @@ -159,7 +163,7 @@ python-versions = "*" [[package]] name = "cffi" -version = "1.14.5" +version = "1.14.6" description = "Foreign Function Interface for Python calling C code." category = "main" optional = false @@ -184,6 +188,17 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "charset-normalizer" +version = "2.0.4" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "dev" +optional = false +python-versions = ">=3.5.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + [[package]] name = "colorama" version = "0.4.4" @@ -463,7 +478,7 @@ pyreadline = {version = "*", markers = "sys_platform == \"win32\""} [[package]] name = "identify" -version = "2.2.10" +version = "2.2.11" description = "File identification library for Python" category = "dev" optional = false @@ -474,11 +489,11 @@ license = ["editdistance-s"] [[package]] name = "idna" -version = "2.10" +version = "3.2" description = "Internationalized Domain Names in Applications (IDNA)" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.5" [[package]] name = "iniconfig" @@ -596,6 +611,18 @@ python-versions = "*" flake8 = ">=3.9.1" flake8-polyfill = ">=1.0.2,<2" +[[package]] +name = "platformdirs" +version = "2.2.0" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] +test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] + [[package]] name = "pluggy" version = "0.13.1" @@ -779,7 +806,7 @@ testing = ["filelock"] [[package]] name = "python-dateutil" -version = "2.8.1" +version = "2.8.2" description = "Extensions to the standard Python datetime module" category = "main" optional = false @@ -851,25 +878,25 @@ python-versions = "*" [[package]] name = "requests" -version = "2.25.1" +version = "2.26.0" description = "Python HTTP for Humans." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [package.dependencies] certifi = ">=2017.4.17" -chardet = ">=3.0.2,<5" -idna = ">=2.5,<3" +charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} +idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} urllib3 = ">=1.21.1,<1.27" [package.extras] -security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] [[package]] name = "sentry-sdk" -version = "0.20.3" +version = "1.3.1" description = "Python client for Sentry (https://sentry.io)" category = "main" optional = false @@ -888,6 +915,7 @@ chalice = ["chalice (>=1.16.0)"] django = ["django (>=1.8)"] falcon = ["falcon (>=1.4)"] flask = ["flask (>=0.11)", "blinker (>=1.1)"] +httpx = ["httpx (>=0.16.0)"] pure_eval = ["pure-eval", "executing", "asttokens"] pyspark = ["pyspark (>=2.4.4)"] rq = ["rq (>=0.6)"] @@ -987,21 +1015,22 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.4.7" +version = "20.7.0" description = "Virtual Python Environment builder" category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] -appdirs = ">=1.4.3,<2" +"backports.entry-points-selectable" = ">=1.0.4" distlib = ">=0.3.1,<1" filelock = ">=3.0.0,<4" +platformdirs = ">=2,<3" six = ">=1.9.0,<2" [package.extras] docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] -testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] +testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] [[package]] name = "yarl" @@ -1018,7 +1047,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "85160036e3b07c9d5d24a32302462591e82cc3bf3d5490b87550d9c26bc5648d" +content-hash = "f46fe1d2d9e0621e4e06d4c2ba5f6190ec4574ac6ca809abe8bf542a3b55204e" [metadata.files] aio-pika = [ @@ -1076,10 +1105,6 @@ aiormq = [ {file = "aiormq-3.3.1-py3-none-any.whl", hash = "sha256:e584dac13a242589aaf42470fd3006cb0dc5aed6506cbd20357c7ec8bbe4a89e"}, {file = "aiormq-3.3.1.tar.gz", hash = "sha256:8218dd9f7198d6e7935855468326bbacf0089f926c70baa8dd92944cb2496573"}, ] -appdirs = [ - {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, - {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, -] arrow = [ {file = "arrow-1.0.3-py3-none-any.whl", hash = "sha256:3515630f11a15c61dcb4cdd245883270dd334c83f3e639824e65a4b79cc48543"}, {file = "arrow-1.0.3.tar.gz", hash = "sha256:399c9c8ae732270e1aa58ead835a79a40d7be8aa109c579898eb41029b5a231d"}, @@ -1100,6 +1125,10 @@ attrs = [ {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, ] +"backports.entry-points-selectable" = [ + {file = "backports.entry_points_selectable-1.1.0-py2.py3-none-any.whl", hash = "sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc"}, + {file = "backports.entry_points_selectable-1.1.0.tar.gz", hash = "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a"}, +] beautifulsoup4 = [ {file = "beautifulsoup4-4.9.3-py2-none-any.whl", hash = "sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35"}, {file = "beautifulsoup4-4.9.3-py3-none-any.whl", hash = "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666"}, @@ -1110,43 +1139,51 @@ certifi = [ {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, ] cffi = [ - {file = "cffi-1.14.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991"}, - {file = "cffi-1.14.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1"}, - {file = "cffi-1.14.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa"}, - {file = "cffi-1.14.5-cp27-cp27m-win32.whl", hash = "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3"}, - {file = "cffi-1.14.5-cp27-cp27m-win_amd64.whl", hash = "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5"}, - {file = "cffi-1.14.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482"}, - {file = "cffi-1.14.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6"}, - {file = "cffi-1.14.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045"}, - {file = "cffi-1.14.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa"}, - {file = "cffi-1.14.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406"}, - {file = "cffi-1.14.5-cp35-cp35m-win32.whl", hash = "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369"}, - {file = "cffi-1.14.5-cp35-cp35m-win_amd64.whl", hash = "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315"}, - {file = "cffi-1.14.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892"}, - {file = "cffi-1.14.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058"}, - {file = "cffi-1.14.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5"}, - {file = "cffi-1.14.5-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132"}, - {file = "cffi-1.14.5-cp36-cp36m-win32.whl", hash = "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53"}, - {file = "cffi-1.14.5-cp36-cp36m-win_amd64.whl", hash = "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813"}, - {file = "cffi-1.14.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73"}, - {file = "cffi-1.14.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06"}, - {file = "cffi-1.14.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1"}, - {file = "cffi-1.14.5-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49"}, - {file = "cffi-1.14.5-cp37-cp37m-win32.whl", hash = "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62"}, - {file = "cffi-1.14.5-cp37-cp37m-win_amd64.whl", hash = "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4"}, - {file = "cffi-1.14.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053"}, - {file = "cffi-1.14.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0"}, - {file = "cffi-1.14.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e"}, - {file = "cffi-1.14.5-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827"}, - {file = "cffi-1.14.5-cp38-cp38-win32.whl", hash = "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e"}, - {file = "cffi-1.14.5-cp38-cp38-win_amd64.whl", hash = "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396"}, - {file = "cffi-1.14.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea"}, - {file = "cffi-1.14.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322"}, - {file = "cffi-1.14.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c"}, - {file = "cffi-1.14.5-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee"}, - {file = "cffi-1.14.5-cp39-cp39-win32.whl", hash = "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396"}, - {file = "cffi-1.14.5-cp39-cp39-win_amd64.whl", hash = "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d"}, - {file = "cffi-1.14.5.tar.gz", hash = "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c"}, + {file = "cffi-1.14.6-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:22b9c3c320171c108e903d61a3723b51e37aaa8c81255b5e7ce102775bd01e2c"}, + {file = "cffi-1.14.6-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:f0c5d1acbfca6ebdd6b1e3eded8d261affb6ddcf2186205518f1428b8569bb99"}, + {file = "cffi-1.14.6-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:99f27fefe34c37ba9875f224a8f36e31d744d8083e00f520f133cab79ad5e819"}, + {file = "cffi-1.14.6-cp27-cp27m-win32.whl", hash = "sha256:55af55e32ae468e9946f741a5d51f9896da6b9bf0bbdd326843fec05c730eb20"}, + {file = "cffi-1.14.6-cp27-cp27m-win_amd64.whl", hash = "sha256:7bcac9a2b4fdbed2c16fa5681356d7121ecabf041f18d97ed5b8e0dd38a80224"}, + {file = "cffi-1.14.6-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ed38b924ce794e505647f7c331b22a693bee1538fdf46b0222c4717b42f744e7"}, + {file = "cffi-1.14.6-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e22dcb48709fc51a7b58a927391b23ab37eb3737a98ac4338e2448bef8559b33"}, + {file = "cffi-1.14.6-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:aedb15f0a5a5949ecb129a82b72b19df97bbbca024081ed2ef88bd5c0a610534"}, + {file = "cffi-1.14.6-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:48916e459c54c4a70e52745639f1db524542140433599e13911b2f329834276a"}, + {file = "cffi-1.14.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f627688813d0a4140153ff532537fbe4afea5a3dffce1f9deb7f91f848a832b5"}, + {file = "cffi-1.14.6-cp35-cp35m-win32.whl", hash = "sha256:f0010c6f9d1a4011e429109fda55a225921e3206e7f62a0c22a35344bfd13cca"}, + {file = "cffi-1.14.6-cp35-cp35m-win_amd64.whl", hash = "sha256:57e555a9feb4a8460415f1aac331a2dc833b1115284f7ded7278b54afc5bd218"}, + {file = "cffi-1.14.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e8c6a99be100371dbb046880e7a282152aa5d6127ae01783e37662ef73850d8f"}, + {file = "cffi-1.14.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:19ca0dbdeda3b2615421d54bef8985f72af6e0c47082a8d26122adac81a95872"}, + {file = "cffi-1.14.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d950695ae4381ecd856bcaf2b1e866720e4ab9a1498cba61c602e56630ca7195"}, + {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9dc245e3ac69c92ee4c167fbdd7428ec1956d4e754223124991ef29eb57a09d"}, + {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8661b2ce9694ca01c529bfa204dbb144b275a31685a075ce123f12331be790b"}, + {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b315d709717a99f4b27b59b021e6207c64620790ca3e0bde636a6c7f14618abb"}, + {file = "cffi-1.14.6-cp36-cp36m-win32.whl", hash = "sha256:80b06212075346b5546b0417b9f2bf467fea3bfe7352f781ffc05a8ab24ba14a"}, + {file = "cffi-1.14.6-cp36-cp36m-win_amd64.whl", hash = "sha256:a9da7010cec5a12193d1af9872a00888f396aba3dc79186604a09ea3ee7c029e"}, + {file = "cffi-1.14.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4373612d59c404baeb7cbd788a18b2b2a8331abcc84c3ba40051fcd18b17a4d5"}, + {file = "cffi-1.14.6-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:f10afb1004f102c7868ebfe91c28f4a712227fe4cb24974350ace1f90e1febbf"}, + {file = "cffi-1.14.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fd4305f86f53dfd8cd3522269ed7fc34856a8ee3709a5e28b2836b2db9d4cd69"}, + {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d6169cb3c6c2ad50db5b868db6491a790300ade1ed5d1da29289d73bbe40b56"}, + {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d4b68e216fc65e9fe4f524c177b54964af043dde734807586cf5435af84045c"}, + {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33791e8a2dc2953f28b8d8d300dde42dd929ac28f974c4b4c6272cb2955cb762"}, + {file = "cffi-1.14.6-cp37-cp37m-win32.whl", hash = "sha256:0c0591bee64e438883b0c92a7bed78f6290d40bf02e54c5bf0978eaf36061771"}, + {file = "cffi-1.14.6-cp37-cp37m-win_amd64.whl", hash = "sha256:8eb687582ed7cd8c4bdbff3df6c0da443eb89c3c72e6e5dcdd9c81729712791a"}, + {file = "cffi-1.14.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ba6f2b3f452e150945d58f4badd92310449876c4c954836cfb1803bdd7b422f0"}, + {file = "cffi-1.14.6-cp38-cp38-manylinux1_i686.whl", hash = "sha256:64fda793737bc4037521d4899be780534b9aea552eb673b9833b01f945904c2e"}, + {file = "cffi-1.14.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9f3e33c28cd39d1b655ed1ba7247133b6f7fc16fa16887b120c0c670e35ce346"}, + {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26bb2549b72708c833f5abe62b756176022a7b9a7f689b571e74c8478ead51dc"}, + {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb687a11f0a7a1839719edd80f41e459cc5366857ecbed383ff376c4e3cc6afd"}, + {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2ad4d668a5c0645d281dcd17aff2be3212bc109b33814bbb15c4939f44181cc"}, + {file = "cffi-1.14.6-cp38-cp38-win32.whl", hash = "sha256:487d63e1454627c8e47dd230025780e91869cfba4c753a74fda196a1f6ad6548"}, + {file = "cffi-1.14.6-cp38-cp38-win_amd64.whl", hash = "sha256:c33d18eb6e6bc36f09d793c0dc58b0211fccc6ae5149b808da4a62660678b156"}, + {file = "cffi-1.14.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:06c54a68935738d206570b20da5ef2b6b6d92b38ef3ec45c5422c0ebaf338d4d"}, + {file = "cffi-1.14.6-cp39-cp39-manylinux1_i686.whl", hash = "sha256:f174135f5609428cc6e1b9090f9268f5c8935fddb1b25ccb8255a2d50de6789e"}, + {file = "cffi-1.14.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f3ebe6e73c319340830a9b2825d32eb6d8475c1dac020b4f0aa774ee3b898d1c"}, + {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c8d896becff2fa653dc4438b54a5a25a971d1f4110b32bd3068db3722c80202"}, + {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4922cd707b25e623b902c86188aca466d3620892db76c0bdd7b99a3d5e61d35f"}, + {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c9e005e9bd57bc987764c32a1bee4364c44fdc11a3cc20a40b93b444984f2b87"}, + {file = "cffi-1.14.6-cp39-cp39-win32.whl", hash = "sha256:eb9e2a346c5238a30a746893f23a9535e700f8192a68c07c0258e7ece6ff3728"}, + {file = "cffi-1.14.6-cp39-cp39-win_amd64.whl", hash = "sha256:818014c754cd3dba7229c0f5884396264d51ffb87ec86e927ef0be140bfdb0d2"}, + {file = "cffi-1.14.6.tar.gz", hash = "sha256:c9a875ce9d7fe32887784274dd533c57909b7b1dcadcc128a2ac21331a9765dd"}, ] cfgv = [ {file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"}, @@ -1156,6 +1193,10 @@ chardet = [ {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, ] +charset-normalizer = [ + {file = "charset-normalizer-2.0.4.tar.gz", hash = "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3"}, + {file = "charset_normalizer-2.0.4-py3-none-any.whl", hash = "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b"}, +] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, @@ -1339,12 +1380,12 @@ humanfriendly = [ {file = "humanfriendly-9.2.tar.gz", hash = "sha256:f7dba53ac7935fd0b4a2fc9a29e316ddd9ea135fb3052d3d0279d10c18ff9c48"}, ] identify = [ - {file = "identify-2.2.10-py2.py3-none-any.whl", hash = "sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421"}, - {file = "identify-2.2.10.tar.gz", hash = "sha256:5b41f71471bc738e7b586308c3fca172f78940195cb3bf6734c1e66fdac49306"}, + {file = "identify-2.2.11-py2.py3-none-any.whl", hash = "sha256:7abaecbb414e385752e8ce02d8c494f4fbc780c975074b46172598a28f1ab839"}, + {file = "identify-2.2.11.tar.gz", hash = "sha256:a0e700637abcbd1caae58e0463861250095dfe330a8371733a471af706a4a29a"}, ] idna = [ - {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, - {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, + {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, + {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -1472,6 +1513,10 @@ pep8-naming = [ {file = "pep8-naming-0.12.0.tar.gz", hash = "sha256:1f9a3ecb2f3fd83240fd40afdd70acc89695c49c333413e49788f93b61827e12"}, {file = "pep8_naming-0.12.0-py2.py3-none-any.whl", hash = "sha256:2321ac2b7bf55383dd19a6a9c8ae2ebf05679699927a3af33e60dd7d337099d3"}, ] +platformdirs = [ + {file = "platformdirs-2.2.0-py3-none-any.whl", hash = "sha256:4666d822218db6a262bdfdc9c39d21f23b4cfdb08af331a81e92751daf6c866c"}, + {file = "platformdirs-2.2.0.tar.gz", hash = "sha256:632daad3ab546bd8e6af0537d09805cec458dce201bccfe23012df73332e181e"}, +] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, @@ -1591,8 +1636,8 @@ pytest-xdist = [ {file = "pytest_xdist-2.3.0-py3-none-any.whl", hash = "sha256:ed3d7da961070fce2a01818b51f6888327fb88df4379edeb6b9d990e789d9c8d"}, ] python-dateutil = [ - {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, - {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, ] python-dotenv = [ {file = "python-dotenv-0.17.1.tar.gz", hash = "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f"}, @@ -1609,18 +1654,26 @@ pyyaml = [ {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"}, {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"}, {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"}, + {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"}, + {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"}, {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"}, {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"}, {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"}, {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"}, + {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"}, + {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"}, {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"}, {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"}, {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"}, {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"}, + {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"}, + {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"}, {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"}, {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"}, {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, + {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"}, + {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"}, {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, @@ -1736,12 +1789,12 @@ regex = [ {file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"}, ] requests = [ - {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, - {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, + {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, + {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"}, ] sentry-sdk = [ - {file = "sentry-sdk-0.20.3.tar.gz", hash = "sha256:4ae8d1ced6c67f1c8ea51d82a16721c166c489b76876c9f2c202b8a50334b237"}, - {file = "sentry_sdk-0.20.3-py2.py3-none-any.whl", hash = "sha256:e75c8c58932bda8cd293ea8e4b242527129e1caaec91433d21b8b2f20fee030b"}, + {file = "sentry-sdk-1.3.1.tar.gz", hash = "sha256:ebe99144fa9618d4b0e7617e7929b75acd905d258c3c779edcd34c0adfffe26c"}, + {file = "sentry_sdk-1.3.1-py2.py3-none-any.whl", hash = "sha256:f33d34c886d0ba24c75ea8885a8b3a172358853c7cbde05979fc99c29ef7bc52"}, ] sgmllib3k = [ {file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"}, @@ -1784,8 +1837,8 @@ urllib3 = [ {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"}, ] virtualenv = [ - {file = "virtualenv-20.4.7-py2.py3-none-any.whl", hash = "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76"}, - {file = "virtualenv-20.4.7.tar.gz", hash = "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467"}, + {file = "virtualenv-20.7.0-py2.py3-none-any.whl", hash = "sha256:fdfdaaf0979ac03ae7f76d5224a05b58165f3c804f8aa633f3dd6f22fbd435d5"}, + {file = "virtualenv-20.7.0.tar.gz", hash = "sha256:97066a978431ec096d163e72771df5357c5c898ffdd587048f45e0aecc228094"}, ] yarl = [ {file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"}, diff --git a/pyproject.toml b/pyproject.toml index 8eac504c5..2ae79f9e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ python-dateutil = "~=2.8" python-frontmatter = "~=1.0.0" pyyaml = "~=5.1" regex = "==2021.4.4" -sentry-sdk = "~=0.19" +sentry-sdk = "~=1.3" statsd = "~=3.3" [tool.poetry.dev-dependencies] -- cgit v1.2.3 From 5057ff6058c6cdaf4c1ac91544779f615d0e4d39 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Tue, 3 Aug 2021 18:29:57 +0100 Subject: Add comment on RedisCache --- bot/exts/recruitment/talentpool/_cog.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index e7ad314e8..80bd48534 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -27,6 +27,8 @@ log = logging.getLogger(__name__) class TalentPool(WatchChannel, Cog, name="Talentpool"): """Relays messages of helper candidates to a watch channel to observe them.""" + # RedisCache[str, bool] + # Can contain a single key, "autoreview_enabled", with the value a bool indicating if autoreview is enabled. talentpool_settings = RedisCache() def __init__(self, bot: Bot) -> None: -- cgit v1.2.3 From 35047316636fa1da6405954c3a199b4b3d4e0e44 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Aug 2021 12:54:13 -0700 Subject: CodeSnippets: refactor on_message Reduce nesting and code duplication. --- bot/exts/info/code_snippets.py | 73 ++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 35 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 24a9ae28a..9cef16b83 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -222,43 +222,46 @@ class CodeSnippets(Cog): @Cog.listener() async def on_message(self, message: Message) -> None: """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" - if not message.author.bot: - all_snippets = [] - - for pattern, handler in self.pattern_handlers: - for match in pattern.finditer(message.content): - try: - snippet = await handler(**match.groupdict()) - all_snippets.append((match.start(), snippet)) - except ClientResponseError as error: - error_message = error.message # noqa: B306 - log.log( - logging.DEBUG if error.status == 404 else logging.ERROR, - f'Failed to fetch code snippet from {match[0]!r}: {error.status} ' - f'{error_message} for GET {error.request_info.real_url.human_repr()}' - ) - - # Sorts the list of snippets by their match index and joins them into a single message - message_to_send = '\n'.join(map(lambda x: x[1], sorted(all_snippets))) - - if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 15: - await message.edit(suppress=True) - if len(message_to_send) > 1000 and message.channel.id != Channels.bot_commands: - # Redirects to #bot-commands if the snippet contents are too long - await self.bot.wait_until_guild_available() - await message.channel.send(('The snippet you tried to send was too long. Please ' - f'see <#{Channels.bot_commands}> for the full snippet.')) - bot_commands_channel = self.bot.get_channel(Channels.bot_commands) - await wait_for_deletion( - await bot_commands_channel.send(message_to_send), - (message.author.id,) - ) - else: - await wait_for_deletion( - await message.channel.send(message_to_send), - (message.author.id,) + if message.author.bot: + return + + all_snippets = [] + + for pattern, handler in self.pattern_handlers: + for match in pattern.finditer(message.content): + try: + snippet = await handler(**match.groupdict()) + all_snippets.append((match.start(), snippet)) + except ClientResponseError as error: + error_message = error.message # noqa: B306 + log.log( + logging.DEBUG if error.status == 404 else logging.ERROR, + f'Failed to fetch code snippet from {match[0]!r}: {error.status} ' + f'{error_message} for GET {error.request_info.real_url.human_repr()}' ) + # Sorts the list of snippets by their match index and joins them into a single message + message_to_send = '\n'.join(map(lambda x: x[1], sorted(all_snippets))) + destination = message.channel + + if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 15: + await message.edit(suppress=True) + + if len(message_to_send) > 1000 and message.channel.id != Channels.bot_commands: + # Redirects to #bot-commands if the snippet contents are too long + await self.bot.wait_until_guild_available() + destination = self.bot.get_channel(Channels.bot_commands) + + await message.channel.send( + 'The snippet you tried to send was too long. ' + f'Please see {destination.mention} for the full snippet.' + ) + + await wait_for_deletion( + await destination.send(message_to_send), + (message.author.id,) + ) + def setup(bot: Bot) -> None: """Load the CodeSnippets cog.""" -- cgit v1.2.3 From cfbeabb6a18b3ea6c19a81881139b3b389dd50ed Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Aug 2021 12:56:25 -0700 Subject: CodeSnippets: move __init__ to the top of the class definition --- bot/exts/info/code_snippets.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 9cef16b83..cb231350d 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -45,6 +45,17 @@ class CodeSnippets(Cog): Matches each message against a regex and prints the contents of all matched snippets. """ + def __init__(self, bot: Bot): + """Initializes the cog's bot.""" + self.bot = bot + + self.pattern_handlers = [ + (GITHUB_RE, self._fetch_github_snippet), + (GITHUB_GIST_RE, self._fetch_github_gist_snippet), + (GITLAB_RE, self._fetch_gitlab_snippet), + (BITBUCKET_RE, self._fetch_bitbucket_snippet) + ] + async def _fetch_response(self, url: str, response_format: str, **kwargs) -> Any: """Makes http requests using aiohttp.""" async with self.bot.http_session.get(url, raise_for_status=True, **kwargs) as response: @@ -208,17 +219,6 @@ class CodeSnippets(Cog): # Returns an empty codeblock if the snippet is empty return f'{ret}``` ```' - def __init__(self, bot: Bot): - """Initializes the cog's bot.""" - self.bot = bot - - self.pattern_handlers = [ - (GITHUB_RE, self._fetch_github_snippet), - (GITHUB_GIST_RE, self._fetch_github_gist_snippet), - (GITLAB_RE, self._fetch_gitlab_snippet), - (BITBUCKET_RE, self._fetch_bitbucket_snippet) - ] - @Cog.listener() async def on_message(self, message: Message) -> None: """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" -- cgit v1.2.3 From 47532644f3ebeb07fe9a47b7a51e6d4413ecd58c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Aug 2021 13:02:42 -0700 Subject: CodeSnippets: refactor snippet parsing into a separate function --- bot/exts/info/code_snippets.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index cb231350d..8c86b9a93 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -219,16 +219,12 @@ class CodeSnippets(Cog): # Returns an empty codeblock if the snippet is empty return f'{ret}``` ```' - @Cog.listener() - async def on_message(self, message: Message) -> None: - """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" - if message.author.bot: - return - + async def _parse_snippets(self, content: str) -> str: + """Parse message content and return a string with a code block for each URL found.""" all_snippets = [] for pattern, handler in self.pattern_handlers: - for match in pattern.finditer(message.content): + for match in pattern.finditer(content): try: snippet = await handler(**match.groupdict()) all_snippets.append((match.start(), snippet)) @@ -241,7 +237,15 @@ class CodeSnippets(Cog): ) # Sorts the list of snippets by their match index and joins them into a single message - message_to_send = '\n'.join(map(lambda x: x[1], sorted(all_snippets))) + return '\n'.join(map(lambda x: x[1], sorted(all_snippets))) + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" + if message.author.bot: + return + + message_to_send = await self._parse_snippets(message.content) destination = message.channel if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 15: -- cgit v1.2.3 From 900bf69a3178e1cdacfd7492a359d53f3b7db72a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Aug 2021 13:10:05 -0700 Subject: CodeSnippets: don't send snippets if the original message was deleted Fixes BOT-13B --- bot/exts/info/code_snippets.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 8c86b9a93..4a90a0668 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -4,8 +4,8 @@ import textwrap from typing import Any from urllib.parse import quote_plus +import discord from aiohttp import ClientResponseError -from discord import Message from discord.ext.commands import Cog from bot.bot import Bot @@ -240,7 +240,7 @@ class CodeSnippets(Cog): return '\n'.join(map(lambda x: x[1], sorted(all_snippets))) @Cog.listener() - async def on_message(self, message: Message) -> None: + async def on_message(self, message: discord.Message) -> None: """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" if message.author.bot: return @@ -249,7 +249,11 @@ class CodeSnippets(Cog): destination = message.channel if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 15: - await message.edit(suppress=True) + try: + await message.edit(suppress=True) + except discord.NotFound: + # Don't send snippets if the original message was deleted. + return if len(message_to_send) > 1000 and message.channel.id != Channels.bot_commands: # Redirects to #bot-commands if the snippet contents are too long -- cgit v1.2.3 From a8869b4d60512b173871c886321b261cbc4acca9 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 3 Aug 2021 20:37:00 +0100 Subject: Force utf-8 decoding when querying metabase Some discord usernames contain unicode characters, which causes an decoding error as chardet isn't 100% and can't falsely detect the response text as Windows-1254. --- bot/exts/moderation/metabase.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py index db5f04d83..e9faf7240 100644 --- a/bot/exts/moderation/metabase.py +++ b/bot/exts/moderation/metabase.py @@ -115,12 +115,12 @@ class Metabase(Cog): try: async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp: if extension == "csv": - out = await resp.text() + out = await resp.text(encoding="utf-8") # Save the output for use with int e self.exports[question_id] = list(csv.DictReader(StringIO(out))) elif extension == "json": - out = await resp.json() + out = await resp.json(encoding="utf-8") # Save the output for use with int e self.exports[question_id] = out -- cgit v1.2.3 From c390ca427422c5532848eaf43cd23029ff38f0f6 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 4 Aug 2021 08:48:23 -0700 Subject: Suppress 403 error when sending DEFCON reject DM (#1711) 403 occurs if the user has DMs disabled. Fixes BOT-137 --- bot/exts/moderation/defcon.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 9801d45ad..6ac077b93 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -9,7 +9,7 @@ from typing import Optional, Union from aioredis import RedisError from async_rediscache import RedisCache from dateutil.relativedelta import relativedelta -from discord import Colour, Embed, Member, User +from discord import Colour, Embed, Forbidden, Member, User from discord.ext import tasks from discord.ext.commands import Cog, Context, group, has_any_role @@ -118,10 +118,12 @@ class Defcon(Cog): try: await member.send(REJECTION_MESSAGE.format(user=member.mention)) - message_sent = True + except Forbidden: + log.debug(f"Cannot send DEFCON rejection DM to {member}: DMs disabled") except Exception: - log.exception(f"Unable to send rejection message to user: {member}") + # Broadly catch exceptions because DM isn't critical, but it's imperative to kick them. + log.exception(f"Error sending DEFCON rejection message to {member}") await member.kick(reason="DEFCON active, user is too new") self.bot.stats.incr("defcon.leaves") -- cgit v1.2.3 From 75862768ebd4b182e713add91cf705c6fcfc70f8 Mon Sep 17 00:00:00 2001 From: Objectivitix <79152594+Objectivitix@users.noreply.github.com> Date: Wed, 4 Aug 2021 13:40:43 -0400 Subject: Reorder user roles in !user command from highest to lowest (#1719) Reorder user roles in !user command from highest to lowest --- bot/exts/info/information.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index bb713eef1..800a68821 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -243,7 +243,9 @@ class Information(Cog): if on_server: joined = discord_timestamp(user.joined_at, TimestampFormats.RELATIVE) - roles = ", ".join(role.mention for role in user.roles[1:]) + # The 0 is for excluding the default @everyone role, + # and the -1 is for reversing the order of the roles to highest to lowest in hierarchy. + roles = ", ".join(role.mention for role in user.roles[:0:-1]) membership = {"Joined": joined, "Verified": not user.pending, "Roles": roles or None} if not is_mod_channel(ctx.channel): membership.pop("Verified") -- cgit v1.2.3 From 1a952752de4b2d9d472f385d3598eb357be0abcd Mon Sep 17 00:00:00 2001 From: Ryu18 Date: Sat, 7 Aug 2021 00:52:52 +0200 Subject: added escape markdown in PythonNews --- bot/exts/info/python_news.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index a7837c93a..651a33d02 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -1,6 +1,7 @@ import logging import typing as t from datetime import date, datetime +import re import discord import feedparser @@ -72,6 +73,12 @@ class PythonNews(Cog): if mail["name"].split("@")[0] in constants.PythonNews.mail_lists: self.webhook_names[mail["name"].split("@")[0]] = mail["display_name"] + @staticmethod + def escape_markdown(content: str) -> str: + """Escape the markdown underlines""" + # taken from discord.utils.escape_markdown + return re.sub(r'[_\\~|]', lambda match: '\\' + match[0], content, 0, re.MULTILINE) + async def post_pep_news(self) -> None: """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" # Wait until everything is ready and http_session available @@ -103,7 +110,7 @@ class PythonNews(Cog): # Build an embed and send a webhook embed = discord.Embed( title=new["title"], - description=new["summary"], + description=self.escape_markdown(new["summary"]), timestamp=new_datetime, url=new["link"], colour=constants.Colours.soft_green @@ -167,7 +174,7 @@ class PythonNews(Cog): ): continue - content = email_information["content"] + content = self.escape_markdown(email_information["content"]) link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist) # Build an embed and send a message to the webhook -- cgit v1.2.3 From 93456475548ff883bd534ab5e0de62a29d9dc936 Mon Sep 17 00:00:00 2001 From: Ryu18 Date: Sat, 7 Aug 2021 01:00:29 +0200 Subject: fix linting issues --- bot/exts/info/python_news.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index 651a33d02..0b6e230b4 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -1,7 +1,7 @@ import logging +import re import typing as t from datetime import date, datetime -import re import discord import feedparser @@ -75,7 +75,7 @@ class PythonNews(Cog): @staticmethod def escape_markdown(content: str) -> str: - """Escape the markdown underlines""" + """Escape the markdown underlines.""" # taken from discord.utils.escape_markdown return re.sub(r'[_\\~|]', lambda match: '\\' + match[0], content, 0, re.MULTILINE) -- cgit v1.2.3 From 357c7181d8b0b11ab38ac63c96bf6667b55b0524 Mon Sep 17 00:00:00 2001 From: Ryu1845 <77058942+Ryu1845@users.noreply.github.com> Date: Sun, 8 Aug 2021 00:47:49 +0200 Subject: Removed comment The code is now almost completely different from discord.py. --- bot/exts/info/python_news.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index 0b6e230b4..8d7ffec88 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -76,7 +76,6 @@ class PythonNews(Cog): @staticmethod def escape_markdown(content: str) -> str: """Escape the markdown underlines.""" - # taken from discord.utils.escape_markdown return re.sub(r'[_\\~|]', lambda match: '\\' + match[0], content, 0, re.MULTILINE) async def post_pep_news(self) -> None: -- cgit v1.2.3 From 5fc4b67323a241193b262765d1e3503bc72f6ced Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 7 Aug 2021 15:54:59 -0700 Subject: Incidents: catch 404s Fixes BOT-ZN --- bot/exts/moderation/incidents.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 0e479d33f..561e0251e 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -143,7 +143,14 @@ async def add_signals(incident: discord.Message) -> None: log.trace(f"Skipping emoji as it's already been placed: {signal_emoji}") else: log.trace(f"Adding reaction: {signal_emoji}") - await incident.add_reaction(signal_emoji.value) + try: + await incident.add_reaction(signal_emoji.value) + except discord.NotFound as e: + if e.code != 10008: + raise + + log.trace(f"Couldn't react with signal because message {incident.id} was deleted; skipping incident") + return class Incidents(Cog): @@ -288,14 +295,20 @@ class Incidents(Cog): members_roles: t.Set[int] = {role.id for role in member.roles} if not members_roles & ALLOWED_ROLES: # Intersection is truthy on at least 1 common element log.debug(f"Removing invalid reaction: user {member} is not permitted to send signals") - await incident.remove_reaction(reaction, member) + try: + await incident.remove_reaction(reaction, member) + except discord.NotFound: + log.trace("Couldn't remove reaction because the reaction or its message was deleted") return try: signal = Signal(reaction) except ValueError: log.debug(f"Removing invalid reaction: emoji {reaction} is not a valid signal") - await incident.remove_reaction(reaction, member) + try: + await incident.remove_reaction(reaction, member) + except discord.NotFound: + log.trace("Couldn't remove reaction because the reaction or its message was deleted") return log.trace(f"Received signal: {signal}") @@ -313,7 +326,10 @@ class Incidents(Cog): confirmation_task = self.make_confirmation_task(incident, timeout) log.trace("Deleting original message") - await incident.delete() + try: + await incident.delete() + except discord.NotFound: + log.trace("Couldn't delete message because it was already deleted") log.trace(f"Awaiting deletion confirmation: {timeout=} seconds") try: -- cgit v1.2.3 From b8d959c4ec2b07a49334a6fafb5f203495ec610d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 7 Aug 2021 15:59:54 -0700 Subject: Code block: catch 404s when editing or deleting the message Fixes BOT-J2 --- bot/exts/info/codeblock/_cog.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/bot/exts/info/codeblock/_cog.py b/bot/exts/info/codeblock/_cog.py index 9094d9d15..9a0705d2b 100644 --- a/bot/exts/info/codeblock/_cog.py +++ b/bot/exts/info/codeblock/_cog.py @@ -177,10 +177,13 @@ class CodeBlockCog(Cog, name="Code Block"): if not bot_message: return - if not instructions: - log.info("User's incorrect code block has been fixed. Removing instructions message.") - await bot_message.delete() - del self.codeblock_message_ids[payload.message_id] - else: - log.info("Message edited but still has invalid code blocks; editing the instructions.") - await bot_message.edit(embed=self.create_embed(instructions)) + try: + if not instructions: + log.info("User's incorrect code block was fixed. Removing instructions message.") + await bot_message.delete() + del self.codeblock_message_ids[payload.message_id] + else: + log.info("Message edited but still has invalid code blocks; editing instructions.") + await bot_message.edit(embed=self.create_embed(instructions)) + except discord.NotFound: + log.debug("Could not find instructions message; it was probably deleted.") -- cgit v1.2.3 From dde617e19e570df2a6a57b36679b307cda496217 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 7 Aug 2021 16:03:41 -0700 Subject: Duck pond: abort if reaction's message or author can't be found Fixes BOT-1J7 --- bot/exts/fun/duck_pond.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index c78b9c141..d02912545 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -171,8 +171,14 @@ class DuckPond(Cog): if not self.is_helper_viewable(channel): return - message = await channel.fetch_message(payload.message_id) + try: + message = await channel.fetch_message(payload.message_id) + except discord.NotFound: + return # Message was deleted. + member = discord.utils.get(message.guild.members, id=payload.user_id) + if not member: + return # Member left or wasn't in the cache. # Was the message sent by a human staff member? if not self.is_staff(message.author) or message.author.bot: -- cgit v1.2.3 From c4e0faeb6f1e1430bc75578c49567d884072ce34 Mon Sep 17 00:00:00 2001 From: aru Date: Sun, 8 Aug 2021 09:27:29 -0400 Subject: change dockerfile python version to latest 3.9.x --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index c285898dc..4d8592590 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9.5-slim +FROM python:3.9-slim # Set pip to have no saved cache ENV PIP_NO_CACHE_DIR=false \ -- cgit v1.2.3 From da85add68b993136dbe1c3eb9da33a3a8ab1862b Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 9 Aug 2021 20:46:41 +0200 Subject: Move all converters to converters.py --- bot/converters.py | 78 +++++++++++++++++++++++++++++++++++++++++++- bot/exts/info/source.py | 35 ++------------------ bot/exts/utils/extensions.py | 41 ++--------------------- 3 files changed, 81 insertions(+), 73 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 595809517..23aa9eab8 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging import re import typing as t @@ -11,13 +13,17 @@ import discord from aiohttp import ClientConnectorError from dateutil.relativedelta import relativedelta from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, UserConverter -from discord.utils import DISCORD_EPOCH, snowflake_time +from discord.utils import DISCORD_EPOCH, escape_markdown, snowflake_time +from bot import exts from bot.api import ResponseCodeError from bot.constants import URLs from bot.exts.info.doc import _inventory_parser +from bot.utils.extensions import EXTENSIONS, unqualify from bot.utils.regex import INVITE_RE from bot.utils.time import parse_duration_string +if t.TYPE_CHECKING: + from bot.exts.info.source import SourceType log = logging.getLogger(__name__) @@ -128,6 +134,44 @@ class ValidFilterListType(Converter): return list_type +class Extension(Converter): + """ + Fully qualify the name of an extension and ensure it exists. + + The * and ** values bypass this when used with the reload command. + """ + + async def convert(self, ctx: Context, argument: str) -> str: + """Fully qualify the name of an extension and ensure it exists.""" + # Special values to reload all extensions + if argument == "*" or argument == "**": + return argument + + argument = argument.lower() + + if argument in EXTENSIONS: + return argument + elif (qualified_arg := f"{exts.__name__}.{argument}") in EXTENSIONS: + return qualified_arg + + matches = [] + for ext in EXTENSIONS: + if argument == unqualify(ext): + matches.append(ext) + + if len(matches) > 1: + matches.sort() + names = "\n".join(matches) + raise BadArgument( + f":x: `{argument}` is an ambiguous extension name. " + f"Please use one of the following fully-qualified names.```\n{names}```" + ) + elif matches: + return matches[0] + else: + raise BadArgument(f":x: Could not find the extension `{argument}`.") + + class PackageName(Converter): """ A converter that checks whether the given string is a valid package name. @@ -290,6 +334,38 @@ class TagContentConverter(Converter): return tag_content +class SourceConverter(Converter): + """Convert an argument into a help command, tag, command, or cog.""" + + @staticmethod + async def convert(ctx: Context, argument: str) -> SourceType: + """Convert argument into source object.""" + if argument.lower() == "help": + return ctx.bot.help_command + + cog = ctx.bot.get_cog(argument) + if cog: + return cog + + cmd = ctx.bot.get_command(argument) + if cmd: + return cmd + + tags_cog = ctx.bot.get_cog("Tags") + show_tag = True + + if not tags_cog: + show_tag = False + elif argument.lower() in tags_cog._cache: + return argument.lower() + + escaped_arg = escape_markdown(argument) + + raise BadArgument( + f"Unable to convert '{escaped_arg}' to valid command{', tag,' if show_tag else ''} or Cog." + ) + + class DurationDelta(Converter): """Convert duration strings into dateutil.relativedelta.relativedelta objects.""" diff --git a/bot/exts/info/source.py b/bot/exts/info/source.py index ef07c77a1..8ce25b4e8 100644 --- a/bot/exts/info/source.py +++ b/bot/exts/info/source.py @@ -2,47 +2,16 @@ import inspect from pathlib import Path from typing import Optional, Tuple, Union -from discord import Embed, utils +from discord import Embed from discord.ext import commands from bot.bot import Bot from bot.constants import URLs +from bot.converters import SourceConverter SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, str, commands.ExtensionNotLoaded] -class SourceConverter(commands.Converter): - """Convert an argument into a help command, tag, command, or cog.""" - - @staticmethod - async def convert(ctx: commands.Context, argument: str) -> SourceType: - """Convert argument into source object.""" - if argument.lower() == "help": - return ctx.bot.help_command - - cog = ctx.bot.get_cog(argument) - if cog: - return cog - - cmd = ctx.bot.get_command(argument) - if cmd: - return cmd - - tags_cog = ctx.bot.get_cog("Tags") - show_tag = True - - if not tags_cog: - show_tag = False - elif argument.lower() in tags_cog._cache: - return argument.lower() - - escaped_arg = utils.escape_markdown(argument) - - raise commands.BadArgument( - f"Unable to convert '{escaped_arg}' to valid command{', tag,' if show_tag else ''} or Cog." - ) - - class BotSource(commands.Cog): """Displays information about the bot's source code.""" diff --git a/bot/exts/utils/extensions.py b/bot/exts/utils/extensions.py index 8a1ed98f4..f78664527 100644 --- a/bot/exts/utils/extensions.py +++ b/bot/exts/utils/extensions.py @@ -10,8 +10,9 @@ from discord.ext.commands import Context, group from bot import exts from bot.bot import Bot from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs +from bot.converters import Extension from bot.pagination import LinePaginator -from bot.utils.extensions import EXTENSIONS, unqualify +from bot.utils.extensions import EXTENSIONS log = logging.getLogger(__name__) @@ -29,44 +30,6 @@ class Action(Enum): RELOAD = functools.partial(Bot.reload_extension) -class Extension(commands.Converter): - """ - Fully qualify the name of an extension and ensure it exists. - - The * and ** values bypass this when used with the reload command. - """ - - async def convert(self, ctx: Context, argument: str) -> str: - """Fully qualify the name of an extension and ensure it exists.""" - # Special values to reload all extensions - if argument == "*" or argument == "**": - return argument - - argument = argument.lower() - - if argument in EXTENSIONS: - return argument - elif (qualified_arg := f"{exts.__name__}.{argument}") in EXTENSIONS: - return qualified_arg - - matches = [] - for ext in EXTENSIONS: - if argument == unqualify(ext): - matches.append(ext) - - if len(matches) > 1: - matches.sort() - names = "\n".join(matches) - raise commands.BadArgument( - f":x: `{argument}` is an ambiguous extension name. " - f"Please use one of the following fully-qualified names.```\n{names}```" - ) - elif matches: - return matches[0] - else: - raise commands.BadArgument(f":x: Could not find the extension `{argument}`.") - - class Extensions(commands.Cog): """Extension management commands.""" -- cgit v1.2.3 From dd2c41a6e646c47e1740dcbdd5ada4d7143af856 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 9 Aug 2021 21:22:34 +0200 Subject: Assign converters to their return values during type checking --- bot/converters.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/bot/converters.py b/bot/converters.py index 23aa9eab8..3f6ed5128 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -643,6 +643,26 @@ class Infraction(Converter): return await ctx.bot.api_client.get(f"bot/infractions/{arg}") +if t.TYPE_CHECKING: + ValidDiscordServerInvite = dict # noqa: F811 + ValidFilterListType = str # noqa: F811 + Extension = str # noqa: F811 + PackageName = str # noqa: F811 + ValidURL = str # noqa: F811 + Inventory = t.Tuple[str, _inventory_parser.InventoryDict] # noqa: F811 + Snowflake = int # noqa: F811 + TagNameConverter = str # noqa: F811 + TagContentConverter = str # noqa: F811 + SourceConverter = SourceType # noqa: F811 + DurationDelta = relativedelta # noqa: F811 + Duration = datetime # noqa: F811 + OffTopicName = str # noqa: F811 + ISODateTime = datetime # noqa: F811 + HushDurationConverter = int # noqa: F811 + UserMentionOrID = discord.User # noqa: F811 + FetchedUser = t.Union[discord.User, discord.Object] # noqa: F811 + Infraction = t.Optional[dict] # noqa: F811 + Expiry = t.Union[Duration, ISODateTime] FetchedMember = t.Union[discord.Member, FetchedUser] UserMention = partial(_snowflake_from_regex, RE_USER_MENTION) -- cgit v1.2.3 From 8264fda1bbb28c91b811423684e58472aca1de55 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 9 Aug 2021 21:22:56 +0200 Subject: fix wrong name being interpolated --- bot/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index 3f6ed5128..566e56220 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -609,7 +609,7 @@ def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int: """ match = pattern.match(arg) if not match: - raise BadArgument(f"Mention {str!r} is invalid.") + raise BadArgument(f"Mention {arg!r} is invalid.") return int(match.group(1)) -- cgit v1.2.3 From 3d395b225c2bf0231273409244845be87e851d39 Mon Sep 17 00:00:00 2001 From: stalkerx Date: Mon, 9 Aug 2021 17:31:24 -0500 Subject: Fixed error message to match true value When the limit was raised, the error message was not edited to reflect the change. https://github.com/python-discord/bot/commit/6f45d6896adb3f05962733cec8e5db199def20bc --- bot/pagination.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/pagination.py b/bot/pagination.py index 90d7c84ee..26caa7db0 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -75,7 +75,7 @@ class LinePaginator(Paginator): raise ValueError(f"scale_to_size must be >= max_size. ({scale_to_size} < {max_size})") if scale_to_size > 4000: - raise ValueError(f"scale_to_size must be <= 2,000 characters. ({scale_to_size} > 4000)") + raise ValueError(f"scale_to_size must be <= 4,000 characters. ({scale_to_size} > 4000)") self.scale_to_size = scale_to_size - len(suffix) self.max_lines = max_lines -- cgit v1.2.3 From fa4b4694a6ec33cd1ee5cb2825aaf7c561dc3d90 Mon Sep 17 00:00:00 2001 From: vcokltfre Date: Tue, 10 Aug 2021 15:31:57 +0100 Subject: chore: remove some newlines in the blocking tag --- bot/resources/tags/blocking.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/bot/resources/tags/blocking.md b/bot/resources/tags/blocking.md index 31d91294c..5554d7eba 100644 --- a/bot/resources/tags/blocking.md +++ b/bot/resources/tags/blocking.md @@ -1,9 +1,7 @@ **Why do we need asynchronous programming?** - Imagine that you're coding a Discord bot and every time somebody uses a command, you need to get some information from a database. But there's a catch: the database servers are acting up today and take a whole 10 seconds to respond. If you do **not** use asynchronous methods, your whole bot will stop running until it gets a response from the database. How do you fix this? Asynchronous programming. **What is asynchronous programming?** - An asynchronous program utilises the `async` and `await` keywords. An asynchronous program pauses what it's doing and does something else whilst it waits for some third-party service to complete whatever it's supposed to do. Any code within an `async` context manager or function marked with the `await` keyword indicates to Python, that whilst this operation is being completed, it can do something else. For example: ```py @@ -14,13 +12,10 @@ import discord async def ping(ctx): await ctx.send("Pong!") ``` - **What does the term "blocking" mean?** - A blocking operation is wherever you do something without `await`ing it. This tells Python that this step must be completed before it can do anything else. Common examples of blocking operations, as simple as they may seem, include: outputting text, adding two numbers and appending an item onto a list. Most common Python libraries have an asynchronous version available to use in asynchronous contexts. **`async` libraries** - The standard async library - `asyncio` Asynchronous web requests - `aiohttp` Talking to PostgreSQL asynchronously - `asyncpg` -- cgit v1.2.3 From 10c3b34fb143247c1f40da3392e410ddd54ee818 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 11 Aug 2021 09:01:12 +0100 Subject: Update DORMANT_MSG to be compatible with str.format() --- bot/exts/help_channels/_message.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index befacd263..e4816044c 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -30,14 +30,14 @@ AVAILABLE_TITLE = "Available help channel" AVAILABLE_FOOTER = "Closes after a period of inactivity, or when you send !close." DORMANT_MSG = f""" -This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \ +This help channel has been marked as **dormant**, and has been moved into the **{}** \ category at the bottom of the channel list. It is no longer possible to send messages in this \ channel until it becomes available again. If your question wasn't answered yet, you can claim a new help channel from the \ -**Help: Available** category by simply asking your question again. Consider rephrasing the \ +**{}** category by simply asking your question again. Consider rephrasing the \ question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. +through our guide for **[asking a good question]({})**. """ -- cgit v1.2.3 From f038479a785a3ac2b737eba214a4366037bb9575 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 11 Aug 2021 09:04:20 +0100 Subject: Update DORMANT_MSG to allow kwargs in str.format() --- bot/exts/help_channels/_message.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index e4816044c..27b506b33 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -30,14 +30,14 @@ AVAILABLE_TITLE = "Available help channel" AVAILABLE_FOOTER = "Closes after a period of inactivity, or when you send !close." DORMANT_MSG = f""" -This help channel has been marked as **dormant**, and has been moved into the **{}** \ +This help channel has been marked as **dormant**, and has been moved into the **{dormant}** \ category at the bottom of the channel list. It is no longer possible to send messages in this \ channel until it becomes available again. If your question wasn't answered yet, you can claim a new help channel from the \ -**{}** category by simply asking your question again. Consider rephrasing the \ +**{available}** category by simply asking your question again. Consider rephrasing the \ question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for **[asking a good question]({})**. +through our guide for **[asking a good question]({asking_guide})**. """ -- cgit v1.2.3 From ae0d725be15c06ba66a6b3b557d6a09e454056ca Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 11 Aug 2021 09:10:37 +0100 Subject: Update embed sent when channel moves to dormant category --- bot/exts/help_channels/_cog.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 35658d117..6f8472069 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -387,7 +387,14 @@ class HelpChannels(commands.Cog): ) log.trace(f"Sending dormant message for #{channel} ({channel.id}).") - embed = discord.Embed(description=_message.DORMANT_MSG) + available_category = self.bot.get_channel(constants.Categories.help_available) + embed = discord.Embed( + description=_message.DORMANT_MSG.format( + dormant=channel.category.name, + available=available_category.name, + asking_guide=_message.ASKING_GUIDE_URL + ) + ) await channel.send(embed=embed) log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") -- cgit v1.2.3 From d6c92ada2af5cdfcd577ed4a14c4e66ba654842f Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 11 Aug 2021 09:20:49 +0100 Subject: Make DORMANT_MSG a string instead of f-string --- bot/exts/help_channels/_message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index 27b506b33..cf070be83 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -29,7 +29,7 @@ AVAILABLE_TITLE = "Available help channel" AVAILABLE_FOOTER = "Closes after a period of inactivity, or when you send !close." -DORMANT_MSG = f""" +DORMANT_MSG = """ This help channel has been marked as **dormant**, and has been moved into the **{dormant}** \ category at the bottom of the channel list. It is no longer possible to send messages in this \ channel until it becomes available again. -- cgit v1.2.3 From e77a0d91ec1623e5f0312e906e66077ad3890fbc Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 11 Aug 2021 09:57:02 +0100 Subject: Change bot.get_channel to utils.channels.try_get_channel --- bot/exts/help_channels/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 6f8472069..03e891271 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -387,7 +387,7 @@ class HelpChannels(commands.Cog): ) log.trace(f"Sending dormant message for #{channel} ({channel.id}).") - available_category = self.bot.get_channel(constants.Categories.help_available) + available_category = await channel_utils.try_get_channel(constants.Categories.help_available) embed = discord.Embed( description=_message.DORMANT_MSG.format( dormant=channel.category.name, -- cgit v1.2.3 From 99bfb15037ce7a6f88a403f8c1d2ab36d852874a Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 11 Aug 2021 12:34:14 +0100 Subject: Fetch dormant category rather than use channel.category channel.category doesn't get updated in cache so the category ends up still linking to "In Use", whereas we want the "Dormant". --- bot/exts/help_channels/_cog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 03e891271..afaf9b0bd 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -387,10 +387,11 @@ class HelpChannels(commands.Cog): ) log.trace(f"Sending dormant message for #{channel} ({channel.id}).") + dormant_category = await channel_utils.try_get_channel(constants.Categories.help_dormant) available_category = await channel_utils.try_get_channel(constants.Categories.help_available) embed = discord.Embed( description=_message.DORMANT_MSG.format( - dormant=channel.category.name, + dormant=dormant_category.name, available=available_category.name, asking_guide=_message.ASKING_GUIDE_URL ) -- cgit v1.2.3 From aef5584849c812a16a51475d544cb0d2abc9a1d4 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 11 Aug 2021 16:03:53 +0100 Subject: Remove added punctuation from reminder --- bot/exts/utils/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 7b8c5c4b3..441b0353f 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -181,7 +181,7 @@ class Reminders(Cog): ) # Let's not use a codeblock to keep emojis and mentions working. Embeds are safe anyway. - embed.description = f"Here's your reminder: {reminder['content']}." + embed.description = f"Here's your reminder: {reminder['content']}" if reminder.get("jump_url"): # keep backward compatibility embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})" -- cgit v1.2.3 From 939535d5c7c597741e5b1c5b6f7209aed152d4bc Mon Sep 17 00:00:00 2001 From: dawnofmidnight Date: Wed, 11 Aug 2021 17:34:15 -0400 Subject: fix: update urls in site cog --- bot/exts/info/site.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index fb5b99086..28eb558a6 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -9,7 +9,7 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) -PAGES_URL = f"{URLs.site_schema}{URLs.site}/pages" +BASE_URL = f"{URLs.site_schema}{URLs.site}" class Site(Cog): @@ -43,7 +43,7 @@ class Site(Cog): @site_group.command(name="resources", root_aliases=("resources", "resource")) async def site_resources(self, ctx: Context) -> None: """Info about the site's Resources page.""" - learning_url = f"{PAGES_URL}/resources" + learning_url = f"{BASE_URL}/resources" embed = Embed(title="Resources") embed.set_footer(text=f"{learning_url}") @@ -59,7 +59,7 @@ class Site(Cog): @site_group.command(name="tools", root_aliases=("tools",)) async def site_tools(self, ctx: Context) -> None: """Info about the site's Tools page.""" - tools_url = f"{PAGES_URL}/resources/tools" + tools_url = f"{BASE_URL}/resources/tools" embed = Embed(title="Tools") embed.set_footer(text=f"{tools_url}") @@ -74,7 +74,7 @@ class Site(Cog): @site_group.command(name="help") async def site_help(self, ctx: Context) -> None: """Info about the site's Getting Help page.""" - url = f"{PAGES_URL}/resources/guides/asking-good-questions" + url = f"{BASE_URL}/pages/guides/pydis-guides/asking-good-questions/" embed = Embed(title="Asking Good Questions") embed.set_footer(text=url) @@ -90,7 +90,7 @@ class Site(Cog): @site_group.command(name="faq", root_aliases=("faq",)) async def site_faq(self, ctx: Context) -> None: """Info about the site's FAQ page.""" - url = f"{PAGES_URL}/frequently-asked-questions" + url = f"{BASE_URL}/pages/frequently-asked-questions" embed = Embed(title="FAQ") embed.set_footer(text=url) @@ -107,13 +107,13 @@ class Site(Cog): @site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule")) async def site_rules(self, ctx: Context, rules: Greedy[int]) -> None: """Provides a link to all rules or, if specified, displays specific rule(s).""" - rules_embed = Embed(title='Rules', color=Colour.blurple(), url=f'{PAGES_URL}/rules') + rules_embed = Embed(title='Rules', color=Colour.blurple(), url=f'{BASE_URL}/pages/rules') if not rules: # Rules were not submitted. Return the default description. rules_embed.description = ( "The rules and guidelines that apply to this community can be found on" - f" our [rules page]({PAGES_URL}/rules). We expect" + f" our [rules page]({BASE_URL}/pages/rules). We expect" " all members of the community to have read and understood these." ) -- cgit v1.2.3 From 4d62dadc4d19f0c17d5cda8748c3fc520f2adccf Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Thu, 12 Aug 2021 00:30:39 +0100 Subject: Remove role pings when a helper vote is posted (#1744) --- bot/exts/recruitment/talentpool/_review.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 3a1e66970..74056dbf5 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -15,7 +15,7 @@ from discord.ext.commands import Context from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Colours, Emojis, Guild, Roles +from bot.constants import Channels, Colours, Emojis, Guild from bot.utils.messages import count_unique_users_reaction, pin_no_system_message from bot.utils.scheduling import Scheduler from bot.utils.time import get_time_delta, time_since @@ -118,7 +118,7 @@ class Reviewer: f"I tried to review the user with ID `{user_id}`, but they don't appear to be on the server :pensive:" ), None - opening = f"<@&{Roles.mod_team}> <@&{Roles.admins}>\n{member.mention} ({member}) for Helper!" + opening = f"{member.mention} ({member}) for Helper!" current_nominations = "\n\n".join( f"**<@{entry['actor']}>:** {entry['reason'] or '*no reason given*'}" -- cgit v1.2.3 From 4ecbb2e821f9f0452fddb49161989f68f98dbd38 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 12 Aug 2021 12:38:56 +0100 Subject: fix: Nomination message now checks historic and new style nominations Previously nomination messages had role pings in them, now they don't as we moved them into a thread. Due to this, we need to detect both in the interim of historic nominations existing. A 'proper' fix for this is to store the nomination message IDs when we post them against the nomination object in the site api. We are planing to work on this soon, this commit is a short term fix. --- bot/exts/recruitment/talentpool/_review.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 74056dbf5..4d496a1f7 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -33,10 +33,12 @@ MAX_MESSAGE_SIZE = 2000 # Maximum amount of characters allowed in an embed MAX_EMBED_SIZE = 4000 -# Regex finding the user ID of a user mention -MENTION_RE = re.compile(r"<@!?(\d+?)>") -# Regex matching role pings -ROLE_MENTION_RE = re.compile(r"<@&\d+>") +# Regex for finding the first message of a nomination, and extracting the nominee. +# Historic nominations will have 2 role mentions at the start, new ones won't, optionally match for this. +NOMINATION_MESSAGE_REGEX = re.compile( + r"(?:<@&\d+> <@&\d+>\n)*?<@!?(\d+?)> \(.+#\d{4}\) for Helper!\n\n\*\*Nominated by:\*\*", + re.MULTILINE +) class Reviewer: @@ -142,14 +144,14 @@ class Reviewer: """Archive this vote to #nomination-archive.""" message = await message.fetch() - # We consider the first message in the nomination to contain the two role pings + # We consider the first message in the nomination to contain the user ping, username#discrim, and fixed text messages = [message] - if not len(ROLE_MENTION_RE.findall(message.content)) >= 2: + if not NOMINATION_MESSAGE_REGEX.search(message.content): with contextlib.suppress(NoMoreItems): async for new_message in message.channel.history(before=message.created_at): messages.append(new_message) - if len(ROLE_MENTION_RE.findall(new_message.content)) >= 2: + if NOMINATION_MESSAGE_REGEX.search(new_message.content): break log.debug(f"Found {len(messages)} messages: {', '.join(str(m.id) for m in messages)}") @@ -161,7 +163,7 @@ class Reviewer: content = "".join(parts) # We assume that the first user mentioned is the user that we are voting on - user_id = int(MENTION_RE.search(content).group(1)) + user_id = int(NOMINATION_MESSAGE_REGEX.search(content).group(1)) # Get reaction counts reviewed = await count_unique_users_reaction( -- cgit v1.2.3 From 6b5431017c0c49deb689390ceccfe344662b8d30 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 12 Aug 2021 19:38:30 +0200 Subject: Store paths on Tags instead of only accepting the file contents --- bot/exts/info/tags.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index beabade58..ddd372fe7 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -81,8 +81,9 @@ class TagIdentifier(NamedTuple): class Tag: """Provide an interface to a tag from resources with `file_content`.""" - def __init__(self, file_content: str): - post = frontmatter.loads(file_content) + def __init__(self, content_path: Path): + post = frontmatter.loads(content_path.read_text("utf8")) + self.file_path = content_path self.content = post.content self.metadata = post.metadata self._restricted_to: set[int] = set(self.metadata.get("restricted_to", ())) @@ -147,7 +148,7 @@ class Tags(Cog): tag_name = file.stem tag_group = parent_dir.name if parent_dir.name else None - self._tags[TagIdentifier(tag_group, tag_name)] = Tag(file.read_text("utf-8")) + self._tags[TagIdentifier(tag_group, tag_name)] = Tag(file) def _get_suggestions(self, tag_identifier: TagIdentifier) -> list[tuple[TagIdentifier, Tag]]: """Return a list of suggested tags for `tag_identifier`.""" -- cgit v1.2.3 From f92e07104c7ac0a1abaed4fb49a7469872d726cb Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 12 Aug 2021 19:40:04 +0200 Subject: make the tags attribute public the tags need to be accessed by the source cog --- bot/exts/info/tags.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index ddd372fe7..8dcfa279e 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -134,11 +134,11 @@ class Tags(Cog): def __init__(self, bot: Bot): self.bot = bot - self._tags: dict[TagIdentifier, Tag] = {} + self.tags: dict[TagIdentifier, Tag] = {} self.initialize_tags() def initialize_tags(self) -> None: - """Load all tags from resources into `self._tags`.""" + """Load all tags from resources into `self.tags`.""" base_path = Path("bot", "resources", "tags") for file in base_path.glob("**/*"): @@ -148,14 +148,14 @@ class Tags(Cog): tag_name = file.stem tag_group = parent_dir.name if parent_dir.name else None - self._tags[TagIdentifier(tag_group, tag_name)] = Tag(file) + self.tags[TagIdentifier(tag_group, tag_name)] = Tag(file) def _get_suggestions(self, tag_identifier: TagIdentifier) -> list[tuple[TagIdentifier, Tag]]: """Return a list of suggested tags for `tag_identifier`.""" for threshold in [100, 90, 80, 70, 60]: suggestions = [ (identifier, tag) - for identifier, tag in self._tags.items() + for identifier, tag in self.tags.items() if identifier.get_fuzzy_score(tag_identifier) >= threshold ] if suggestions: @@ -206,7 +206,7 @@ class Tags(Cog): keywords_processed = [keywords] matching_tags = [] - for identifier, tag in self._tags.items(): + for identifier, tag in self.tags.items(): matches = (query in tag.content.casefold() for query in keywords_processed) if tag.accessible_by(user) and check(matches): matching_tags.append((identifier, tag)) @@ -285,7 +285,7 @@ class Tags(Cog): if tag.accessible_by(ctx.author) ] - tag = self._tags.get(tag_identifier) + tag = self.tags.get(tag_identifier) if tag is None and len(filtered_tags) == 1: tag_identifier = filtered_tags[0][0] tag = filtered_tags[0][1] @@ -330,7 +330,7 @@ class Tags(Cog): current_group = object() group_accessible = True - for identifier, tag in sorted(self._tags.items(), key=tag_sort_key): + for identifier, tag in sorted(self.tags.items(), key=tag_sort_key): if identifier.group != current_group: if not group_accessible: @@ -356,7 +356,7 @@ class Tags(Cog): embed = Embed(title=f"Tags under *{group}*") tag_lines = sorted( f"**\N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK}** {identifier}" - for identifier, tag in self._tags.items() + for identifier, tag in self.tags.items() if identifier.group == group and tag.accessible_by(ctx.author) ) await LinePaginator.paginate(tag_lines, ctx, embed, footer_text=FOOTER_TEXT, empty=False, max_lines=15) @@ -378,7 +378,7 @@ class Tags(Cog): Returns False if no message was sent. """ # noqa: D205, D415 if tag_name_or_group is None and tag_name is None: - if self._tags: + if self.tags: await self.list_all_tags(ctx) return True else: @@ -388,7 +388,7 @@ class Tags(Cog): elif tag_name is None: if any( tag_name_or_group == identifier.group and tag.accessible_by(ctx.author) - for identifier, tag in self._tags.items() + for identifier, tag in self.tags.items() ): await self.list_tags_in_group(ctx, tag_name_or_group) return True -- cgit v1.2.3 From f83dee308201ed6bddb4650f3a46e7b7d924ea54 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 12 Aug 2021 20:09:39 +0200 Subject: Use new Tags cog structure in source.py --- bot/exts/info/source.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/bot/exts/info/source.py b/bot/exts/info/source.py index ef07c77a1..723ae5aba 100644 --- a/bot/exts/info/source.py +++ b/bot/exts/info/source.py @@ -7,8 +7,9 @@ from discord.ext import commands from bot.bot import Bot from bot.constants import URLs +from bot.exts.info.tags import TagIdentifier -SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, str, commands.ExtensionNotLoaded] +SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, TagIdentifier, commands.ExtensionNotLoaded] class SourceConverter(commands.Converter): @@ -33,8 +34,10 @@ class SourceConverter(commands.Converter): if not tags_cog: show_tag = False - elif argument.lower() in tags_cog._cache: - return argument.lower() + else: + identifier = TagIdentifier.from_string(argument.lower()) + if identifier in tags_cog.tags: + return identifier escaped_arg = utils.escape_markdown(argument) @@ -72,9 +75,9 @@ class BotSource(commands.Cog): source_item = inspect.unwrap(source_item.callback) src = source_item.__code__ filename = src.co_filename - elif isinstance(source_item, str): + elif isinstance(source_item, TagIdentifier): tags_cog = self.bot.get_cog("Tags") - filename = tags_cog._cache[source_item]["location"] + filename = tags_cog.tags[source_item].file_path else: src = type(source_item) try: @@ -82,7 +85,7 @@ class BotSource(commands.Cog): except TypeError: raise commands.BadArgument("Cannot get source for a dynamically-created object.") - if not isinstance(source_item, str): + if not isinstance(source_item, TagIdentifier): try: lines, first_line_no = inspect.getsourcelines(src) except OSError: @@ -95,7 +98,7 @@ class BotSource(commands.Cog): # Handle tag file location differently than others to avoid errors in some cases if not first_line_no: - file_location = Path(filename).relative_to("/bot/") + file_location = Path(filename).relative_to("bot/") else: file_location = Path(filename).relative_to(Path.cwd()).as_posix() @@ -113,7 +116,7 @@ class BotSource(commands.Cog): elif isinstance(source_object, commands.Command): description = source_object.short_doc title = f"Command: {source_object.qualified_name}" - elif isinstance(source_object, str): + elif isinstance(source_object, TagIdentifier): title = f"Tag: {source_object}" description = "" else: -- cgit v1.2.3 From 2ea95a090253b7383f0d8be195ffe809dbfe4d53 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Fri, 13 Aug 2021 23:34:38 +0200 Subject: Update reminders to reply instead of using a jump url to the origin message --- bot/exts/utils/reminders.py | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 441b0353f..32e00bee1 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -8,7 +8,7 @@ from operator import itemgetter import discord from dateutil.parser import isoparse -from discord.ext.commands import Cog, Context, Greedy, group +from discord.ext.commands import Cog, Context, Greedy, HTTPException, group from bot.bot import Bot from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_ROLES @@ -172,32 +172,39 @@ class Reminders(Cog): if not is_valid: # No need to cancel the task too; it'll simply be done once this coroutine returns. return - embed = discord.Embed() - embed.colour = discord.Colour.blurple() - embed.set_author( - icon_url=Icons.remind_blurple, - name="It has arrived!" - ) - - # Let's not use a codeblock to keep emojis and mentions working. Embeds are safe anyway. - embed.description = f"Here's your reminder: {reminder['content']}" - - if reminder.get("jump_url"): # keep backward compatibility - embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})" - if expected_time: embed.colour = discord.Colour.red() embed.set_author( icon_url=Icons.remind_red, name=f"Sorry it should have arrived {time_since(expected_time)} !" ) + else: + embed.colour = discord.Colour.blurple() + embed.set_author( + icon_url=Icons.remind_blurple, + name="It has arrived!" + ) + + # Let's not use a codeblock to keep emojis and mentions working. Embeds are safe anyway. + embed.description = f"Here's your reminder: {reminder['content']}" + # Here the jump URL is in the format of base_url/guild_id/channel_id/message_id additional_mentions = ' '.join( mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"]) ) - await channel.send(content=f"{user.mention} {additional_mentions}", embed=embed) + jump_url = reminder.get("jump_url") + partial_message = channel.get_partial_message(int(jump_url.split("/")[-1])) + try: + await partial_message.reply(content=f"{additional_mentions}", embed=embed) + except HTTPException as e: + log.error( + f"There was an error when trying to reply to a reminder invocation message, {e}, " + "fall back to using jump_url" + ) + embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})" + 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']}") -- cgit v1.2.3 From d6817258a59cdb2d9d3ab31a6e7f394cb0793b93 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Fri, 13 Aug 2021 23:48:53 +0200 Subject: Improve code consitency in remainders --- bot/exts/utils/reminders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 32e00bee1..66a0e6e92 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -8,7 +8,7 @@ from operator import itemgetter import discord from dateutil.parser import isoparse -from discord.ext.commands import Cog, Context, Greedy, HTTPException, group +from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_ROLES @@ -198,7 +198,7 @@ class Reminders(Cog): partial_message = channel.get_partial_message(int(jump_url.split("/")[-1])) try: await partial_message.reply(content=f"{additional_mentions}", embed=embed) - except HTTPException as e: + except discord.HTTPException as e: log.error( f"There was an error when trying to reply to a reminder invocation message, {e}, " "fall back to using jump_url" -- cgit v1.2.3 From 7678982f4ece55b411ef0c155a040845f4c0c8d2 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sat, 14 Aug 2021 15:13:06 +0200 Subject: remove redundant typehints --- bot/exts/moderation/modpings.py | 1 - bot/exts/moderation/silence.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 29a5c1c8e..80c9f0c38 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -87,7 +87,6 @@ class ModPings(Cog): The duration cannot be longer than 30 days. """ - duration: datetime.datetime delta = duration - datetime.datetime.utcnow() if delta > datetime.timedelta(days=30): await ctx.send(":x: Cannot remove the role for longer than 30 days.") diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 8025f3df6..95e2792c3 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -202,8 +202,6 @@ class Silence(commands.Cog): duration: HushDurationConverter ) -> typing.Tuple[TextOrVoiceChannel, Optional[int]]: """Helper method to parse the arguments of the silence command.""" - duration: Optional[int] - if duration_or_channel: if isinstance(duration_or_channel, (TextChannel, VoiceChannel)): channel = duration_or_channel -- cgit v1.2.3 From c9e85dd5b7104bc652861c1d9623ce4da4122ae8 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sat, 14 Aug 2021 16:59:50 +0200 Subject: use an empty string as the initial group value --- bot/exts/info/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 8dcfa279e..68ab5f6bb 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -327,7 +327,7 @@ class Tags(Cog): return group + name result_lines = [] - current_group = object() + current_group = "" group_accessible = True for identifier, tag in sorted(self.tags.items(), key=tag_sort_key): -- cgit v1.2.3 From 0e2491dd7d9e3023284a9f7b3cc450bc9dbe9b25 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Aug 2021 21:29:13 +0200 Subject: Reword `tag get` help --- bot/exts/info/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 68ab5f6bb..d80ca448d 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -369,7 +369,7 @@ class Tags(Cog): ) -> bool: """ When arguments are passed in: - If a single argument is given and it matches a group name, list accessible all tags from that group. + If a single argument matching a group name is given, list all accessible tags from that group Otherwise display the tag if one was found for the given arguments, or try to display suggestions for that name With no arguments, list all accessible tags -- cgit v1.2.3 From 4724853d8155444ae1db50c24c5424c4d25f3dec Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Aug 2021 21:31:47 +0200 Subject: Fix incorrect annotation Co-authored-by: Bluenix --- bot/exts/info/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index d80ca448d..5632f2959 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -318,7 +318,7 @@ class Tags(Cog): async def list_all_tags(self, ctx: Context) -> None: """Send a paginator with all loaded tags accessible by `ctx.author`, groups first, and alphabetically sorted.""" - def tag_sort_key(tag_item: tuple[TagIdentifier, tag]) -> str: + def tag_sort_key(tag_item: tuple[TagIdentifier, Tag]) -> str: group, name = tag_item[0] if group is None: # Max codepoint character to force tags without a group to the end -- cgit v1.2.3 From 54cb20ea9af50c52fda40fe468470f4e7d351fed Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Aug 2021 22:08:26 +0200 Subject: refactor fuzzy_search to use conventional iteration Co-authored-by: Bluenix --- bot/exts/info/tags.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 5632f2959..d659be8c4 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -117,15 +117,13 @@ def _fuzzy_search(search: str, target: str) -> float: current, index = 0, 0 _search = REGEX_NON_ALPHABET.sub("", search.lower()) _targets = iter(REGEX_NON_ALPHABET.split(target.lower())) - _target = next(_targets) - try: - while True: - while index < len(_target) and _search[current] == _target[index]: - current += 1 - index += 1 - index, _target = 0, next(_targets) - except (StopIteration, IndexError): - pass + + for _target in _targets: + while index < len(_target) and _search[current] == _target[index]: + current += 1 + index += 1 + index = 0 + return current / len(_search) -- cgit v1.2.3 From 2b8a5b6f5b275c40af1139fab07461e6e96bdeb4 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 15 Aug 2021 22:09:32 +0200 Subject: Move definition of loop vars next to loop --- bot/exts/info/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index d659be8c4..8bb682366 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -114,10 +114,10 @@ class Tag: def _fuzzy_search(search: str, target: str) -> float: """A simple scoring algorithm based on how many letters are found / total, with order in mind.""" - current, index = 0, 0 _search = REGEX_NON_ALPHABET.sub("", search.lower()) _targets = iter(REGEX_NON_ALPHABET.split(target.lower())) + current, index = 0, 0 for _target in _targets: while index < len(_target) and _search[current] == _target[index]: current += 1 -- cgit v1.2.3 From 94f03bcc5bc6b7264d0fc32c403b60bf7ec9ac20 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Sun, 15 Aug 2021 23:10:59 +0300 Subject: Add default value to csv_file Co-authored-by: Bluenix --- bot/exts/events/code_jams/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index d0c206b5e..78f375416 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -33,7 +33,7 @@ class CodeJams(commands.Cog): await ctx.send_help(ctx.command) @codejam.command() - async def create(self, ctx: commands.Context, csv_file: t.Optional[str]) -> None: + async def create(self, ctx: commands.Context, csv_file: t.Optional[str] = None) -> None: """ Create code-jam teams from a CSV file or a link to one, specifying the team names, leaders and members. -- cgit v1.2.3 From be2c547586e80924bf416785bc354bbc7c392cfd Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 15 Aug 2021 23:45:41 +0300 Subject: Docstring corrections Co-authored-by: Bluenix --- bot/exts/events/code_jams/_channels.py | 2 +- bot/exts/events/code_jams/_cog.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/events/code_jams/_channels.py b/bot/exts/events/code_jams/_channels.py index 8b199a3c2..34ff0ad41 100644 --- a/bot/exts/events/code_jams/_channels.py +++ b/bot/exts/events/code_jams/_channels.py @@ -107,7 +107,7 @@ async def _send_status_update(guild: discord.Guild, message: str) -> None: async def _add_team_leader_roles(members: list[tuple[discord.Member, bool]], team_leaders: discord.Role) -> None: - """Assign team leader role, the jammer role and their team role.""" + """Assign the team leader role to the team leaders.""" for member, is_leader in members: if is_leader: await member.add_roles(team_leaders) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index d0c206b5e..b2ea97c38 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -79,9 +79,9 @@ class CodeJams(commands.Cog): @commands.has_any_role(Roles.admins) async def end(self, ctx: commands.Context) -> None: """ - Deletes all code jam channels. + Delete all code jam channels. - Displays a confirmation message with the categories and channels to be deleted. Pressing the added reaction + A confirmation message is displayed with the categories and channels to be deleted.. Pressing the added reaction deletes those channels. """ def predicate_deletion_emoji_reaction(reaction: discord.Reaction, user: discord.User) -> bool: @@ -196,7 +196,7 @@ class CodeJams(commands.Cog): @codejam.command() @commands.has_any_role(Roles.admins) async def remove(self, ctx: commands.Context, member: Member) -> None: - """Removes the participant from their team. Does not remove the participants or leader roles.""" + """Remove the participant from their team. Does not remove the participants or leader roles.""" channel = self.team_channel(ctx.guild, member) if not channel: await ctx.send(":x: I can't find the team channel for this member.") -- cgit v1.2.3 From a5b761791f1162f2854775d87b00c687bf397a0f Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 15 Aug 2021 23:51:02 +0300 Subject: Add team name to audit log reason Interestingly enough, the reason doesn't seem to be displayed for channel permission overrides. --- bot/exts/events/code_jams/_cog.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index b2ea97c38..64f886f0a 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -202,7 +202,11 @@ class CodeJams(commands.Cog): await ctx.send(":x: I can't find the team channel for this member.") return - await channel.set_permissions(member, overwrite=None, reason="Participant removed from the team.") + await channel.set_permissions( + member, + overwrite=None, + reason=f"Participant removed from the team {self.team_name(channel)}." + ) await ctx.send(f"Removed the participant from `{self.team_name(channel)}`.") @staticmethod -- cgit v1.2.3 From 73187a35c7349c661e50f4a429ae30560ee92ddd Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 15 Aug 2021 23:57:23 +0300 Subject: Add `cj end` timeout message, improve style Co-authored-by: Bluenix --- bot/exts/events/code_jams/_cog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index 64f886f0a..e385f6441 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -109,6 +109,7 @@ class CodeJams(commands.Cog): except asyncio.TimeoutError: await message.clear_reaction(DELETION_REACTION) + await ctx.send("Command timed out.", reference=message) return else: @@ -131,7 +132,7 @@ class CodeJams(commands.Cog): def format_category_info(category: discord.CategoryChannel, channels: list[discord.abc.GuildChannel]) -> str: """Displays the category and the channels within it in a readable format.""" - return f"{channel_repr(category)}:" + "".join(f"\n - {channel_repr(channel)}" for channel in channels) + return f"{channel_repr(category)}:\n" + "\n".join(" - " + channel_repr(channel) for channel in channels) deletion_details = "\n\n".join( format_category_info(category, channels) for category, channels in categories.items() -- cgit v1.2.3 From 054d5da2af3e75a8bb29f7088272f8222f2d0b33 Mon Sep 17 00:00:00 2001 From: Ryu18 Date: Mon, 16 Aug 2021 00:53:58 +0200 Subject: apply changes for review https://github.com/python-discord/bot/pull/1725\#pullrequestreview-730223166 --- bot/exts/info/python_news.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index 8d7ffec88..045f9e6f9 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -76,7 +76,7 @@ class PythonNews(Cog): @staticmethod def escape_markdown(content: str) -> str: """Escape the markdown underlines.""" - return re.sub(r'[_\\~|]', lambda match: '\\' + match[0], content, 0, re.MULTILINE) + return re.sub(r"[_|]", lambda match: "\\" + match[0], content) async def post_pep_news(self) -> None: """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" -- cgit v1.2.3 From c5d26a9358099660d18f9409e8d5a9c3fe9fd344 Mon Sep 17 00:00:00 2001 From: Ryu18 Date: Mon, 16 Aug 2021 00:56:31 +0200 Subject: change docstring in escape markdown to reflect actual behavior --- bot/exts/info/python_news.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index 045f9e6f9..63eb4ac17 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -75,7 +75,7 @@ class PythonNews(Cog): @staticmethod def escape_markdown(content: str) -> str: - """Escape the markdown underlines.""" + """Escape the markdown underlines and spoilers.""" return re.sub(r"[_|]", lambda match: "\\" + match[0], content) async def post_pep_news(self) -> None: -- cgit v1.2.3 From 5f0d7f739109e0b4e6ae7146a81de7a3ce173492 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 16 Aug 2021 01:40:19 +0200 Subject: Enhance security in the Webhook remover. From now on, webhooks that were posted in the chat will be also deleted from Discord in order to eliminate the risk. --- bot/exts/filters/webhook_remover.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index f11fc8912..dc1799f88 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -13,8 +13,8 @@ WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\ ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted a Discord webhook URL. Therefore, your " - "message has been removed. Your webhook may have been **compromised** so " - "please re-create the webhook **immediately**. If you believe this was a " + "message has been removed, alongside with your webhook" + "you can re-create it if you wish to. If you believe this was a " "mistake, please let us know." ) @@ -32,7 +32,7 @@ class WebhookRemover(Cog): """Get current instance of `ModLog`.""" return self.bot.get_cog("ModLog") - async def delete_and_respond(self, msg: Message, redacted_url: str) -> None: + async def delete_and_respond(self, msg: Message, redacted_url: str, webhook_deleted: bool) -> None: """Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`.""" # Don't log this, due internal delete, not by user. Will make different entry. self.mod_log.ignore(Event.message_delete, msg.id) @@ -44,9 +44,12 @@ class WebhookRemover(Cog): return await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention)) - + if webhook_deleted: + delete_state = "The webhook was successfully deleted." + else: + delete_state = "There was an error when deleting the webhook, it might have already been removed." message = ( - f"{format_user(msg.author)} posted a Discord webhook URL to {msg.channel.mention}. " + f"{format_user(msg.author)} posted a Discord webhook URL to {msg.channel.mention}.{delete_state} " f"Webhook URL was `{redacted_url}`" ) log.debug(message) @@ -72,7 +75,12 @@ class WebhookRemover(Cog): matches = WEBHOOK_URL_RE.search(msg.content) if matches: - await self.delete_and_respond(msg, matches[1] + "xxx") + async with self.bot.http_session.delete(msg.content) as resp: + # The Discord API Returns a 204 NO CONTENT repsonse on success. + if resp.status == 204: + await self.delete_and_respond(msg, matches[1] + "xxx", True) + else: + await self.delete_and_respond(msg, matches[1] + "xxx", False) @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: -- cgit v1.2.3 From 8731b19ef771b18bad6418ebb3699b6a3550c60b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 15 Aug 2021 18:41:44 -0700 Subject: HelpChannels: fix incomplete init of available channel set If the cog is reloaded while there are less than the maximum amount of available channels, it makes some channels available until the limit is reached. When a channel is made available, it updates the `available_help_channels` set. The `update_available_help_channels()` function would not update this set if it saw that the set already contains elements. This resulted in only the channels that were just made available being in the set; the set would not contain the channels that were already available when the bot started. Fix this by unconditionally populating the set, but moving it to `init_available()` so it only happens once. Fix BOT-Z1 Fix #1715 --- bot/exts/help_channels/_cog.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index afaf9b0bd..34fae7248 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -267,6 +267,10 @@ class HelpChannels(commands.Cog): for channel in channels[:abs(missing)]: await self.unclaim_channel(channel, closed_on=_channel.ClosingReason.CLEANUP) + self.available_help_channels = { + c for c in self.available_category.channels if not _channel.is_excluded_channel(c) + } + # Getting channels that need to be included in the dynamic message. await self.update_available_help_channels() log.trace("Dynamic available help message updated.") @@ -519,11 +523,6 @@ class HelpChannels(commands.Cog): async def update_available_help_channels(self) -> None: """Updates the dynamic message within #how-to-get-help for available help channels.""" - if not self.available_help_channels: - self.available_help_channels = set( - c for c in self.available_category.channels if not _channel.is_excluded_channel(c) - ) - available_channels = AVAILABLE_HELP_CHANNELS.format( available=", ".join( c.mention for c in sorted(self.available_help_channels, key=attrgetter("position")) -- cgit v1.2.3 From f6a1f00b637fd8e7922913d530cf0d7cc54ec225 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 15 Aug 2021 18:45:25 -0700 Subject: HelpChannels: simplify formatting of category names in dormant msg Remove redundant retrieval of category objects. Include the asking guide URL in the message via an f-string. --- bot/exts/help_channels/_cog.py | 7 ++----- bot/exts/help_channels/_message.py | 8 ++++---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 34fae7248..e71e58e7b 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -391,13 +391,10 @@ class HelpChannels(commands.Cog): ) log.trace(f"Sending dormant message for #{channel} ({channel.id}).") - dormant_category = await channel_utils.try_get_channel(constants.Categories.help_dormant) - available_category = await channel_utils.try_get_channel(constants.Categories.help_available) embed = discord.Embed( description=_message.DORMANT_MSG.format( - dormant=dormant_category.name, - available=available_category.name, - asking_guide=_message.ASKING_GUIDE_URL + dormant=self.dormant_category.name, + available=self.available_category.name, ) ) await channel.send(embed=embed) diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index cf070be83..077b20b47 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -29,15 +29,15 @@ AVAILABLE_TITLE = "Available help channel" AVAILABLE_FOOTER = "Closes after a period of inactivity, or when you send !close." -DORMANT_MSG = """ -This help channel has been marked as **dormant**, and has been moved into the **{dormant}** \ +DORMANT_MSG = f""" +This help channel has been marked as **dormant**, and has been moved into the **{{dormant}}** \ category at the bottom of the channel list. It is no longer possible to send messages in this \ channel until it becomes available again. If your question wasn't answered yet, you can claim a new help channel from the \ -**{available}** category by simply asking your question again. Consider rephrasing the \ +**{{available}}** category by simply asking your question again. Consider rephrasing the \ question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for **[asking a good question]({asking_guide})**. +through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. """ -- cgit v1.2.3 From d388d39487acbbf8071b9b5072955f54caba3bbf Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 16 Aug 2021 18:18:05 +0200 Subject: Improve code consistency in webhook_remover --- bot/exts/filters/webhook_remover.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index dc1799f88..040ae93b6 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -13,8 +13,8 @@ WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\ ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted a Discord webhook URL. Therefore, your " - "message has been removed, alongside with your webhook" - "you can re-create it if you wish to. If you believe this was a " + "message has been removed, alongside with your webhook." + "You can re-create it if you wish to. If you believe this was a " "mistake, please let us know." ) @@ -32,7 +32,7 @@ class WebhookRemover(Cog): """Get current instance of `ModLog`.""" return self.bot.get_cog("ModLog") - async def delete_and_respond(self, msg: Message, redacted_url: str, webhook_deleted: bool) -> None: + async def delete_and_respond(self, msg: Message, redacted_url: str, *, webhook_deleted: bool) -> None: """Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`.""" # Don't log this, due internal delete, not by user. Will make different entry. self.mod_log.ignore(Event.message_delete, msg.id) @@ -49,7 +49,7 @@ class WebhookRemover(Cog): else: delete_state = "There was an error when deleting the webhook, it might have already been removed." message = ( - f"{format_user(msg.author)} posted a Discord webhook URL to {msg.channel.mention}.{delete_state} " + f"{format_user(msg.author)} posted a Discord webhook URL to {msg.channel.mention}. {delete_state} " f"Webhook URL was `{redacted_url}`" ) log.debug(message) @@ -76,11 +76,9 @@ class WebhookRemover(Cog): matches = WEBHOOK_URL_RE.search(msg.content) if matches: async with self.bot.http_session.delete(msg.content) as resp: - # The Discord API Returns a 204 NO CONTENT repsonse on success. - if resp.status == 204: - await self.delete_and_respond(msg, matches[1] + "xxx", True) - else: - await self.delete_and_respond(msg, matches[1] + "xxx", False) + # The Discord API Returns a 204 NO CONTENT response on success. + deleted_successfully = resp.status == 204 + await self.delete_and_respond(msg, matches[1] + "xxx", webhook_deleted=deleted_successfully) @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: -- cgit v1.2.3 From 44adebc02aaedaf96634988019205d6b853d22db Mon Sep 17 00:00:00 2001 From: wookie184 Date: Mon, 16 Aug 2021 17:57:16 +0100 Subject: Remove the admins mention from the modmail tag --- bot/resources/tags/modmail.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/modmail.md b/bot/resources/tags/modmail.md index 412468174..8ac19c8a7 100644 --- a/bot/resources/tags/modmail.md +++ b/bot/resources/tags/modmail.md @@ -6,4 +6,4 @@ It supports attachments, codeblocks, and reactions. As communication happens ove **To use it, simply send a direct message to the bot.** -Should there be an urgent and immediate need for a moderator or admin to look at a channel, feel free to ping the <@&831776746206265384> or <@&267628507062992896> role instead. +Should there be an urgent and immediate need for a moderator to look at a channel, feel free to ping the <@&831776746206265384> role instead. -- cgit v1.2.3 From e8b22358c579cca3447a1db29c93c85f65bb6f56 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 16 Aug 2021 20:19:09 +0200 Subject: Fix up, and improve code consistency in webhook_remover --- bot/exts/filters/webhook_remover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index 040ae93b6..50bb4bef7 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -13,7 +13,7 @@ WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\ ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted a Discord webhook URL. Therefore, your " - "message has been removed, alongside with your webhook." + "message has been removed, and your webhook has been deleted." "You can re-create it if you wish to. If you believe this was a " "mistake, please let us know." ) @@ -75,7 +75,7 @@ class WebhookRemover(Cog): matches = WEBHOOK_URL_RE.search(msg.content) if matches: - async with self.bot.http_session.delete(msg.content) as resp: + async with self.bot.http_session.delete(matches[0]) as resp: # The Discord API Returns a 204 NO CONTENT response on success. deleted_successfully = resp.status == 204 await self.delete_and_respond(msg, matches[1] + "xxx", webhook_deleted=deleted_successfully) -- cgit v1.2.3 From 7d732153a218df95d2e7ade8f44d32a13ebe9be6 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 16 Aug 2021 22:08:05 +0200 Subject: Patch the regEx pattern in webhook_remover to match edge cases The regEx pattern did not match the 'https://' part in case of canary urls Thus, from now on the regEx pattern matches the full canary urls as well. --- bot/exts/filters/webhook_remover.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index 50bb4bef7..b1592ae07 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -9,7 +9,10 @@ from bot.constants import Channels, Colours, Event, Icons from bot.exts.moderation.modlog import ModLog from bot.utils.messages import format_user -WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.IGNORECASE) +WEBHOOK_URL_RE = re.compile( + r"((?:https?:\/\/)?(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/)\S+\/?", + re.IGNORECASE +) ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted a Discord webhook URL. Therefore, your " -- cgit v1.2.3 From 60b1d16785214be1d8c670d5b5822e9dd0f63719 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 16 Aug 2021 23:07:23 +0200 Subject: Improve output message consistency in webhook_remover Co-authored-by: Bluenix --- bot/exts/filters/webhook_remover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index b1592ae07..25e267426 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -16,7 +16,7 @@ WEBHOOK_URL_RE = re.compile( ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted a Discord webhook URL. Therefore, your " - "message has been removed, and your webhook has been deleted." + "message has been removed, and your webhook has been deleted. " "You can re-create it if you wish to. If you believe this was a " "mistake, please let us know." ) -- cgit v1.2.3 From 052ceadc25a3e45b00bf8203e65d03c6798d082c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 16 Aug 2021 14:28:40 -0700 Subject: HelpChannels: use utility method to get available channels --- bot/exts/help_channels/_cog.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index e71e58e7b..cfc9cf477 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -267,9 +267,7 @@ class HelpChannels(commands.Cog): for channel in channels[:abs(missing)]: await self.unclaim_channel(channel, closed_on=_channel.ClosingReason.CLEANUP) - self.available_help_channels = { - c for c in self.available_category.channels if not _channel.is_excluded_channel(c) - } + self.available_help_channels = set(_channel.get_category_channels(self.available_category)) # Getting channels that need to be included in the dynamic message. await self.update_available_help_channels() -- cgit v1.2.3 From d697860f22d13063a064ebc5982dea2dc127b0f0 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Tue, 17 Aug 2021 11:51:01 +0200 Subject: Provide jump_url in reminders even when the bot replies When the message is in the same channel and the bot replies, from now on it will also provide a jump_url for the sake of consistency --- bot/exts/utils/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 66a0e6e92..03c7d0323 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -195,6 +195,7 @@ class Reminders(Cog): ) jump_url = reminder.get("jump_url") + embed.description += f"\n[Jump back to when you created the reminder]({jump_url})" partial_message = channel.get_partial_message(int(jump_url.split("/")[-1])) try: await partial_message.reply(content=f"{additional_mentions}", embed=embed) @@ -203,7 +204,6 @@ class Reminders(Cog): f"There was an error when trying to reply to a reminder invocation message, {e}, " "fall back to using jump_url" ) - embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})" await channel.send(content=f"{user.mention} {additional_mentions}", embed=embed) log.debug(f"Deleting reminder #{reminder['id']} (the user has been reminded).") -- cgit v1.2.3 From 20aea4ff8cf0362d08108535cd8f837cfb74d82b Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Tue, 17 Aug 2021 17:58:13 +0300 Subject: Move max_interval to init The on_message event calculated the max interval value every time for no reason. The value is constant throughout the bot's up time. --- bot/exts/filters/antispam.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 226da2790..da4583e76 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -116,6 +116,13 @@ class AntiSpam(Cog): self.message_deletion_queue = dict() + # Fetch the rule configuration with the highest rule interval. + max_interval_config = max( + AntiSpamConfig.rules.values(), + key=itemgetter('interval') + ) + self.max_interval = max_interval_config['interval'] + self.bot.loop.create_task(self.alert_on_validation_error(), name="AntiSpam.alert_on_validation_error") @property @@ -155,15 +162,8 @@ class AntiSpam(Cog): ): return - # Fetch the rule configuration with the highest rule interval. - max_interval_config = max( - AntiSpamConfig.rules.values(), - key=itemgetter('interval') - ) - max_interval = max_interval_config['interval'] - # Store history messages since `interval` seconds ago in a list to prevent unnecessary API calls. - earliest_relevant_at = datetime.utcnow() - timedelta(seconds=max_interval) + earliest_relevant_at = datetime.utcnow() - timedelta(seconds=self.max_interval) relevant_messages = [ msg async for msg in message.channel.history(after=earliest_relevant_at, oldest_first=False) if not msg.author.bot -- cgit v1.2.3 From fe5c6a57222b5d5a6fab974dde41fc42c66f2173 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 19 Aug 2021 01:25:42 +0100 Subject: Fix linebreak formatting on server command The features part of the embed isn't included in some channels, this leads to there not being a linebreak between voice regions and roles in the embed. By changing it to this, rather than dedent, we specify exact where we want the linebreaks to be. --- bot/exts/info/information.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 167731e64..54c03e139 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -190,13 +190,13 @@ class Information(Cog): f"{constants.Emojis.status_offline} {offline_presences}" ) - embed.description = textwrap.dedent(f""" - Created: {created} - Voice region: {region}\ - {features} - Roles: {num_roles} - Member status: {member_status} - """) + embed.description = ( + f"Created: {created}" + f"\nVoice region: {region}" + f"{features}" + f"\nRoles: {num_roles}" + f"\nMember status: {member_status}" + ) embed.set_thumbnail(url=ctx.guild.icon_url) # Members -- cgit v1.2.3 From 480d26d0959f78d4990a83e46ee0c481e74ea62b Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 19 Aug 2021 01:26:10 +0100 Subject: Add comma separators to member counts in !server --- bot/exts/info/information.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 54c03e139..83ca59bea 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -186,8 +186,8 @@ class Information(Cog): online_presences = py_invite.approximate_presence_count offline_presences = py_invite.approximate_member_count - online_presences member_status = ( - f"{constants.Emojis.status_online} {online_presences} " - f"{constants.Emojis.status_offline} {offline_presences}" + f"{constants.Emojis.status_online} {online_presences:,} " + f"{constants.Emojis.status_offline} {offline_presences:,}" ) embed.description = ( @@ -200,7 +200,7 @@ class Information(Cog): embed.set_thumbnail(url=ctx.guild.icon_url) # Members - total_members = ctx.guild.member_count + total_members = f"{ctx.guild.member_count:,}" member_counts = self.get_member_counts(ctx.guild) member_info = "\n".join(f"{role}: {count}" for role, count in member_counts.items()) embed.add_field(name=f"Members: {total_members}", value=member_info) -- cgit v1.2.3 From 34d7f7cc61be8b76bbc32fce1d6c8cbfc97b4cb8 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 21 Aug 2021 01:28:07 +0300 Subject: AntiSpam modified to work with cache The anti-spam cog now uses a cache instead of reading channel history. The cache is for all channels in the guild, and does not remove deleted messages. That means that the anti-spam logic now works cross-channel and counts deleted messages. The size of the cache is determined via a new field in the config YAML file. The cache was implemented as a separate class, MessageCache, which uses circular buffer logic. This allows for constant time addition and removal form either side, and lookup. The cache does not support removal from the middle of the cache. The cache additionally stores a mapping from message ID's to the index of the message in the cache, to allow constant time lookup by message ID. The commit additionally adds accompanying tests, and renames `cache.py` to `caching.py` to better distinguish it from the new `message_cache.py` and convey that it's for general caching utilities. --- bot/constants.py | 2 + bot/exts/filters/antispam.py | 24 ++-- bot/exts/info/pep.py | 2 +- bot/utils/cache.py | 41 ------- bot/utils/caching.py | 41 +++++++ bot/utils/message_cache.py | 177 +++++++++++++++++++++++++++++ config-default.yml | 2 + tests/bot/utils/test_message_cache.py | 208 ++++++++++++++++++++++++++++++++++ 8 files changed, 447 insertions(+), 50 deletions(-) delete mode 100644 bot/utils/cache.py create mode 100644 bot/utils/caching.py create mode 100644 bot/utils/message_cache.py create mode 100644 tests/bot/utils/test_message_cache.py diff --git a/bot/constants.py b/bot/constants.py index 500803f33..34a814035 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -575,6 +575,8 @@ class Metabase(metaclass=YAMLGetter): class AntiSpam(metaclass=YAMLGetter): section = 'anti_spam' + cache_size: int + clean_offending: bool ping_everyone: bool diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index da4583e76..58cd8dec4 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -3,6 +3,7 @@ import logging from collections.abc import Mapping from dataclasses import dataclass, field from datetime import datetime, timedelta +from itertools import takewhile from operator import attrgetter, itemgetter from typing import Dict, Iterable, List, Set @@ -20,6 +21,7 @@ from bot.converters import Duration from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.exts.moderation.modlog import ModLog from bot.utils import lock, scheduling +from bot.utils.message_cache import MessageCache from bot.utils.messages import format_user, send_attachments @@ -122,6 +124,7 @@ class AntiSpam(Cog): key=itemgetter('interval') ) self.max_interval = max_interval_config['interval'] + self.cache = MessageCache(AntiSpamConfig.cache_size, newest_first=True) self.bot.loop.create_task(self.alert_on_validation_error(), name="AntiSpam.alert_on_validation_error") @@ -162,12 +165,11 @@ class AntiSpam(Cog): ): return + self.cache.append(message) + # Store history messages since `interval` seconds ago in a list to prevent unnecessary API calls. earliest_relevant_at = datetime.utcnow() - timedelta(seconds=self.max_interval) - relevant_messages = [ - msg async for msg in message.channel.history(after=earliest_relevant_at, oldest_first=False) - if not msg.author.bot - ] + relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, self.cache)) for rule_name in AntiSpamConfig.rules: rule_config = AntiSpamConfig.rules[rule_name] @@ -175,9 +177,10 @@ class AntiSpam(Cog): # Create a list of messages that were sent in the interval that the rule cares about. latest_interesting_stamp = datetime.utcnow() - timedelta(seconds=rule_config['interval']) - messages_for_rule = [ - msg for msg in relevant_messages if msg.created_at > latest_interesting_stamp - ] + messages_for_rule = list( + takewhile(lambda msg: msg.created_at > latest_interesting_stamp, relevant_messages) + ) + result = await rule_function(message, messages_for_rule, rule_config) # If the rule returns `None`, that means the message didn't violate it. @@ -212,7 +215,7 @@ class AntiSpam(Cog): name=f"AntiSpam.punish(message={message.id}, member={member.id}, rule={rule_name})" ) - await self.maybe_delete_messages(channel, relevant_messages) + await self.maybe_delete_messages(channel, messages_for_rule) break @lock.lock_arg("antispam.punish", "member", attrgetter("id")) @@ -264,6 +267,11 @@ class AntiSpam(Cog): deletion_context = self.message_deletion_queue.pop(context_id) await deletion_context.upload_messages(self.bot.user.id, self.mod_log) + @Cog.listener() + async def on_message_edit(self, before: Message, after: Message) -> None: + """Updates the message in the cache, if it's cached.""" + self.cache.update(after) + def validate_config(rules_: Mapping = AntiSpamConfig.rules) -> Dict[str, str]: """Validates the antispam configs.""" diff --git a/bot/exts/info/pep.py b/bot/exts/info/pep.py index 8ac96bbdb..b11b34db0 100644 --- a/bot/exts/info/pep.py +++ b/bot/exts/info/pep.py @@ -9,7 +9,7 @@ from discord.ext.commands import Cog, Context, command from bot.bot import Bot from bot.constants import Keys -from bot.utils.cache import AsyncCache +from bot.utils.caching import AsyncCache log = logging.getLogger(__name__) diff --git a/bot/utils/cache.py b/bot/utils/cache.py deleted file mode 100644 index 68ce15607..000000000 --- a/bot/utils/cache.py +++ /dev/null @@ -1,41 +0,0 @@ -import functools -from collections import OrderedDict -from typing import Any, Callable - - -class AsyncCache: - """ - LRU cache implementation for coroutines. - - Once the cache exceeds the maximum size, keys are deleted in FIFO order. - - An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key. - """ - - def __init__(self, max_size: int = 128): - self._cache = OrderedDict() - self._max_size = max_size - - def __call__(self, arg_offset: int = 0) -> Callable: - """Decorator for async cache.""" - - def decorator(function: Callable) -> Callable: - """Define the async cache decorator.""" - - @functools.wraps(function) - async def wrapper(*args) -> Any: - """Decorator wrapper for the caching logic.""" - key = args[arg_offset:] - - if key not in self._cache: - if len(self._cache) > self._max_size: - self._cache.popitem(last=False) - - self._cache[key] = await function(*args) - return self._cache[key] - return wrapper - return decorator - - def clear(self) -> None: - """Clear cache instance.""" - self._cache.clear() diff --git a/bot/utils/caching.py b/bot/utils/caching.py new file mode 100644 index 000000000..68ce15607 --- /dev/null +++ b/bot/utils/caching.py @@ -0,0 +1,41 @@ +import functools +from collections import OrderedDict +from typing import Any, Callable + + +class AsyncCache: + """ + LRU cache implementation for coroutines. + + Once the cache exceeds the maximum size, keys are deleted in FIFO order. + + An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key. + """ + + def __init__(self, max_size: int = 128): + self._cache = OrderedDict() + self._max_size = max_size + + def __call__(self, arg_offset: int = 0) -> Callable: + """Decorator for async cache.""" + + def decorator(function: Callable) -> Callable: + """Define the async cache decorator.""" + + @functools.wraps(function) + async def wrapper(*args) -> Any: + """Decorator wrapper for the caching logic.""" + key = args[arg_offset:] + + if key not in self._cache: + if len(self._cache) > self._max_size: + self._cache.popitem(last=False) + + self._cache[key] = await function(*args) + return self._cache[key] + return wrapper + return decorator + + def clear(self) -> None: + """Clear cache instance.""" + self._cache.clear() diff --git a/bot/utils/message_cache.py b/bot/utils/message_cache.py new file mode 100644 index 000000000..b2f8f66bf --- /dev/null +++ b/bot/utils/message_cache.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import typing as t +from math import ceil + +from discord import Message + + +class MessageCache: + """ + A data structure for caching messages. + + The cache is implemented as a circular buffer to allow constant time append, prepend, push, pop, + and lookup by index. The cache therefore does not support removal at an arbitrary index (although it can be + implemented to work in linear time relative to the maximum size). + + The object additionally holds a mapping from Discord message ID's to the index in which the corresponding message + is stored, to allow for constant time lookup by message ID. + + The cache has a size limit operating the same as with a collections.deque, and most of its method names mirror those + of a deque. + + The implementation is transparent to the user: to the user the first element is always at index 0, and there are + only as many elements as were inserted (meaning, without any pre-allocated placeholder values). + """ + + def __init__(self, maxlen: int, *, newest_first: bool = False): + if maxlen <= 0: + raise ValueError("maxlen must be positive") + self.maxlen = maxlen + self.newest_first = newest_first + + self._start = 0 + self._end = 0 + + self._messages: list[t.Optional[Message]] = [None] * self.maxlen + self._message_id_mapping = {} + + def append(self, message: Message) -> None: + """Add the received message to the cache, depending on the order of messages defined by `newest_first`.""" + if self.newest_first: + self._appendleft(message) + else: + self._appendright(message) + + def _appendright(self, message: Message) -> None: + """Add the received message to the end of the cache.""" + if self._is_full(): + del self._message_id_mapping[self._messages[self._start].id] + self._start = (self._start + 1) % self.maxlen + + self._messages[self._end] = message + self._message_id_mapping[message.id] = self._end + self._end = (self._end + 1) % self.maxlen + + def _appendleft(self, message: Message) -> None: + """Add the received message to the beginning of the cache.""" + if self._is_full(): + self._end = (self._end - 1) % self.maxlen + del self._message_id_mapping[self._messages[self._end].id] + + self._start = (self._start - 1) % self.maxlen + self._messages[self._start] = message + self._message_id_mapping[message.id] = self._start + + def pop(self) -> Message: + """Remove the last message in the cache and return it.""" + if self._is_empty(): + raise IndexError("pop from an empty cache") + + self._end = (self._end - 1) % self.maxlen + message = self._messages[self._end] + del self._message_id_mapping[message.id] + self._messages[self._end] = None + + return message + + def popleft(self) -> Message: + """Return the first message in the cache and return it.""" + if self._is_empty(): + raise IndexError("pop from an empty cache") + + message = self._messages[self._start] + del self._message_id_mapping[message.id] + self._messages[self._start] = None + self._start = (self._start + 1) % self.maxlen + + return message + + def clear(self) -> None: + """Remove all messages from the cache.""" + self._messages: list[t.Optional[Message]] = [None] * self.maxlen + self._message_id_mapping = {} + + self._start = 0 + self._end = 0 + + def get_message(self, message_id: int) -> t.Optional[Message]: + """Return the message that has the given message ID, if it is cached.""" + index = self._message_id_mapping.get(message_id, None) + return self._messages[index] if index is not None else None + + def update(self, message: Message) -> bool: + """ + Update a cached message with new contents. + + Return True if the given message had a matching ID in the cache. + """ + index = self._message_id_mapping.get(message.id, None) + if index is None: + return False + self._messages[index] = message + return True + + def __contains__(self, message_id: int) -> bool: + """Return True if the cache contains a message with the given ID .""" + return message_id in self._message_id_mapping + + def __getitem__(self, item: t.Union[int, slice]) -> t.Union[Message, list[Message]]: + """ + Return the message(s) in the index or slice provided. + + This method makes the circular buffer implementation transparent to the user. + Providing 0 will return the message at the position perceived by the user to be the beginning of the cache, + meaning at `self._start`. + """ + if isinstance(item, int): + if item >= len(self) or item < -len(self): + raise IndexError("cache index out of range") + return self._messages[(item + self._start) % self.maxlen] + + elif isinstance(item, slice): + length = len(self) + start, stop, step = item.indices(length) + + # This needs to be checked explicitly now, because otherwise self._start >= self._end is a valid state. + if (start >= stop and step >= 0) or (start <= stop and step <= 0): + return [] + + start = (start + self._start) % self.maxlen + stop = (stop + self._start) % self.maxlen + + # Having empty cells is an implementation detail. To the user the cache contains as many elements as they + # inserted, therefore any empty cells should be ignored. There can only be Nones at the tail. + if ( + (self._start < self._end and not self._start < stop <= self._end) + or (self._start > self._end and self._end < stop <= self._start) + ): + stop = self._end + + if (start < stop and step > 0) or (start > stop and step < 0): + return self._messages[start:stop:step] + # step != 1 may require a start offset in the second slicing. + if step > 0: + offset = ceil((self.maxlen - start) / step) * step + start - self.maxlen + else: + offset = self.maxlen - ((start + 1) % step) + return self._messages[start::step] + self._messages[offset:stop:step] + + else: + raise TypeError(f"cache indices must be integers or slices, not {type(item)}") + + def __len__(self): + """Get the number of non-empty cells in the cache.""" + if self._is_empty(): + return 0 + if self._end > self._start: + return self._end - self._start + return self.maxlen - self._start + self._end + + def _is_empty(self) -> bool: + """Return True if the cache has no messages.""" + return self._messages[self._start] is None + + def _is_full(self) -> bool: + """Return True if every cell in the cache already contains a message.""" + return self._messages[self._end] is not None diff --git a/config-default.yml b/config-default.yml index 881a7df76..2412a7016 100644 --- a/config-default.yml +++ b/config-default.yml @@ -377,6 +377,8 @@ urls: anti_spam: + cache_size: 100 + # Clean messages that violate a rule. clean_offending: true ping_everyone: true diff --git a/tests/bot/utils/test_message_cache.py b/tests/bot/utils/test_message_cache.py new file mode 100644 index 000000000..ff313c6d6 --- /dev/null +++ b/tests/bot/utils/test_message_cache.py @@ -0,0 +1,208 @@ +import unittest + +from bot.utils.message_cache import MessageCache +from tests.helpers import MockMessage + + +# noinspection SpellCheckingInspection +class TestMessageCache(unittest.TestCase): + """Tests for the MessageCache class in the `bot.utils.caching` module.""" + + def test_first_append_sets_the_first_value(self): + """Test if the first append adds the message to the first cell.""" + cache = MessageCache(maxlen=10) + message = MockMessage() + + cache.append(message) + + self.assertEqual(cache[0], message) + + def test_append_adds_in_the_right_order(self): + """Test if two appends are added in the same order if newest_first is False, or in reverse order otherwise.""" + messages = [MockMessage(), MockMessage()] + + cache = MessageCache(maxlen=10, newest_first=False) + for msg in messages: + cache.append(msg) + self.assertListEqual(messages, list(cache)) + + cache = MessageCache(maxlen=10, newest_first=True) + for msg in messages: + cache.append(msg) + self.assertListEqual(messages[::-1], list(cache)) + + def test_appending_over_maxlen_removes_oldest(self): + """Test if three appends to a 2-cell cache leave the two newest messages.""" + cache = MessageCache(maxlen=2) + messages = [MockMessage() for _ in range(3)] + + for msg in messages: + cache.append(msg) + + self.assertListEqual(messages[1:], list(cache)) + + def test_appending_over_maxlen_with_newest_first_removes_oldest(self): + """Test if three appends to a 2-cell cache leave the two newest messages if newest_first is True.""" + cache = MessageCache(maxlen=2, newest_first=True) + messages = [MockMessage() for _ in range(3)] + + for msg in messages: + cache.append(msg) + + self.assertListEqual(messages[:0:-1], list(cache)) + + def test_pop_removes_from_the_end(self): + """Test if a pop removes the right-most message.""" + cache = MessageCache(maxlen=3) + messages = [MockMessage() for _ in range(3)] + + for msg in messages: + cache.append(msg) + msg = cache.pop() + + self.assertEqual(msg, messages[-1]) + self.assertListEqual(messages[:-1], list(cache)) + + def test_popleft_removes_from_the_beginning(self): + """Test if a popleft removes the left-most message.""" + cache = MessageCache(maxlen=3) + messages = [MockMessage() for _ in range(3)] + + for msg in messages: + cache.append(msg) + msg = cache.popleft() + + self.assertEqual(msg, messages[0]) + self.assertListEqual(messages[1:], list(cache)) + + def test_clear(self): + """Test if a clear makes the cache empty.""" + cache = MessageCache(maxlen=5) + messages = [MockMessage() for _ in range(3)] + + for msg in messages: + cache.append(msg) + cache.clear() + + self.assertListEqual(list(cache), []) + self.assertEqual(len(cache), 0) + + def test_get_message_returns_the_message(self): + """Test if get_message returns the cached message.""" + cache = MessageCache(maxlen=5) + message = MockMessage(id=1234) + + cache.append(message) + + self.assertEqual(cache.get_message(1234), message) + + def test_get_message_returns_none(self): + """Test if get_message returns None for an ID of a non-cached message.""" + cache = MessageCache(maxlen=5) + message = MockMessage(id=1234) + + cache.append(message) + + self.assertIsNone(cache.get_message(4321)) + + def test_update_replaces_old_element(self): + """Test if an update replaced the old message with the same ID.""" + cache = MessageCache(maxlen=5) + message = MockMessage(id=1234) + + cache.append(message) + message = MockMessage(id=1234) + cache.update(message) + + self.assertIs(cache.get_message(1234), message) + self.assertEqual(len(cache), 1) + + def test_contains_returns_true_for_cached_message(self): + """Test if contains returns True for an ID of a cached message.""" + cache = MessageCache(maxlen=5) + message = MockMessage(id=1234) + + cache.append(message) + + self.assertIn(1234, cache) + + def test_contains_returns_false_for_non_cached_message(self): + """Test if contains returns False for an ID of a non-cached message.""" + cache = MessageCache(maxlen=5) + message = MockMessage(id=1234) + + cache.append(message) + + self.assertNotIn(4321, cache) + + def test_indexing(self): + """Test if the cache returns the correct messages by index.""" + cache = MessageCache(maxlen=5) + messages = [MockMessage() for _ in range(5)] + + for msg in messages: + cache.append(msg) + + for current_loop in range(-5, 5): + with self.subTest(current_loop=current_loop): + self.assertEqual(cache[current_loop], messages[current_loop]) + + def test_bad_index_raises_index_error(self): + """Test if the cache raises IndexError for invalid indices.""" + cache = MessageCache(maxlen=5) + messages = [MockMessage() for _ in range(3)] + test_cases = (-10, -4, 3, 4, 5) + + for msg in messages: + cache.append(msg) + + for current_loop in test_cases: + with self.subTest(current_loop=current_loop): + with self.assertRaises(IndexError): + cache[current_loop] + + def test_slicing_with_unfilled_cache(self): + """Test if slicing returns the correct messages if the cache is not yet fully filled.""" + cache = MessageCache(maxlen=5) + messages = [MockMessage() for _ in range(4)] + + for msg in messages: + cache.append(msg) + + test_cases = ( + slice(None), slice(2, None), slice(None, 2), slice(None, None, 2), slice(None, None, 3), slice(-1, 2), + slice(-1, 3000), slice(-3, -1), slice(-10, 3), slice(-10, 4, 2), slice(None, None, -1), slice(None, 3, -2), + slice(None, None, -3) + ) + + for current_loop in test_cases: + with self.subTest(current_loop=current_loop): + self.assertListEqual(cache[current_loop], messages[current_loop]) + + def test_slicing_with_overfilled_cache(self): + """Test if slicing returns the correct messages if the cache was appended with more messages it can contain.""" + cache = MessageCache(maxlen=5) + messages = [MockMessage() for _ in range(8)] + + for msg in messages: + cache.append(msg) + messages = messages[3:] + + test_cases = ( + slice(None), slice(2, None), slice(None, 2), slice(None, None, 2), slice(None, None, 3), slice(-1, 2), + slice(-1, 3000), slice(-3, -1), slice(-10, 3), slice(-10, 4, 2), slice(None, None, -1), slice(None, 3, -2), + slice(None, None, -3) + ) + + for current_loop in test_cases: + with self.subTest(current_loop=current_loop): + self.assertListEqual(cache[current_loop], messages[current_loop]) + + def test_length(self): + """Test if len returns the correct number of items in the cache.""" + cache = MessageCache(maxlen=5) + + for current_loop in range(10): + with self.subTest(current_loop=current_loop): + self.assertEqual(len(cache), min(current_loop, 5)) + cache.append(MockMessage()) -- cgit v1.2.3 From e1e104dd7d4cb63cf64436d73a0ae4c4df0f2c9a Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 21 Aug 2021 04:10:35 +0300 Subject: AntiSpam deletes from all spammed channels The anti-spam cog was amended to handle cross-channel spam. --- bot/exts/filters/antispam.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 58cd8dec4..2df93a867 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -1,5 +1,6 @@ import asyncio import logging +from collections import defaultdict from collections.abc import Mapping from dataclasses import dataclass, field from datetime import datetime, timedelta @@ -46,7 +47,8 @@ RULE_FUNCTION_MAPPING = { class DeletionContext: """Represents a Deletion Context for a single spam event.""" - channel: TextChannel + triggered_in: TextChannel + channels: set[TextChannel] members: Dict[int, Member] = field(default_factory=dict) rules: Set[str] = field(default_factory=set) messages: Dict[int, Message] = field(default_factory=dict) @@ -72,10 +74,13 @@ class DeletionContext: async def upload_messages(self, actor_id: int, modlog: ModLog) -> None: """Method that takes care of uploading the queue and posting modlog alert.""" triggered_by_users = ", ".join(format_user(m) for m in self.members.values()) + triggered_in_channel = f"**Triggered in:** {self.triggered_in.mention}\n" if len(self.channels) > 1 else "" + channels_description = ", ".join(channel.mention for channel in self.channels) mod_alert_message = ( f"**Triggered by:** {triggered_by_users}\n" - f"**Channel:** {self.channel.mention}\n" + f"{triggered_in_channel}" + f"**Channels:** {channels_description}\n" f"**Rules:** {', '.join(rule for rule in self.rules)}\n" ) @@ -167,7 +172,6 @@ class AntiSpam(Cog): self.cache.append(message) - # Store history messages since `interval` seconds ago in a list to prevent unnecessary API calls. earliest_relevant_at = datetime.utcnow() - timedelta(seconds=self.max_interval) relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, self.cache)) @@ -196,7 +200,8 @@ class AntiSpam(Cog): channel = message.channel if channel.id not in self.message_deletion_queue: log.trace(f"Creating queue for channel `{channel.id}`") - self.message_deletion_queue[message.channel.id] = DeletionContext(channel) + affected_channels = set(message.channel for message in messages_for_rule) + self.message_deletion_queue[message.channel.id] = DeletionContext(channel, affected_channels) scheduling.create_task( self._process_deletion_context(message.channel.id), name=f"AntiSpam._process_deletion_context({message.channel.id})" @@ -215,7 +220,7 @@ class AntiSpam(Cog): name=f"AntiSpam.punish(message={message.id}, member={member.id}, rule={rule_name})" ) - await self.maybe_delete_messages(channel, messages_for_rule) + await self.maybe_delete_messages(messages_for_rule) break @lock.lock_arg("antispam.punish", "member", attrgetter("id")) @@ -237,14 +242,18 @@ class AntiSpam(Cog): reason=reason ) - async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]) -> None: + async def maybe_delete_messages(self, messages: List[Message]) -> None: """Cleans the messages if cleaning is configured.""" if AntiSpamConfig.clean_offending: # If we have more than one message, we can use bulk delete. if len(messages) > 1: message_ids = [message.id for message in messages] self.mod_log.ignore(Event.message_delete, *message_ids) - await channel.delete_messages(messages) + channel_messages = defaultdict(list) + for message in messages: + channel_messages[message.channel].append(message) + for channel, messages in channel_messages.items(): + await channel.delete_messages(messages) # Otherwise, the bulk delete endpoint will throw up. # Delete the message directly instead. -- cgit v1.2.3 From ad3d1974b546cd426537b3c1c692af56a3c3dad1 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 21 Aug 2021 12:47:36 +0300 Subject: Identify DeletionContext by members, not channels Since the anti-spam now works cross-channels, it makes no sense to identify it by the channel in which it was invoked. The DeletionContext class was changed to accept a frozenset of members, and the message_deletion_queue dict uses the frozensets as keys. DeletionContext still accepts a channel on creation, because while it might get added more channels, there's only one channel in which the mute message will be sent. Using members as the key can run into the issue of one member becoming irrelevant to the filter while others still are, resulting in another log message being sent, but it's an unlikely edge case since the users should be muted almost immediately, and we're currently not using any multi-member filters in the first place. --- bot/exts/filters/antispam.py | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 2df93a867..987060779 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -47,20 +47,18 @@ RULE_FUNCTION_MAPPING = { class DeletionContext: """Represents a Deletion Context for a single spam event.""" + members: frozenset[Member] triggered_in: TextChannel - channels: set[TextChannel] - members: Dict[int, Member] = field(default_factory=dict) + channels: set[TextChannel] = field(default_factory=set) rules: Set[str] = field(default_factory=set) messages: Dict[int, Message] = field(default_factory=dict) attachments: List[List[str]] = field(default_factory=list) - async def add(self, rule_name: str, members: Iterable[Member], messages: Iterable[Message]) -> None: + async def add(self, rule_name: str, channels: Iterable[TextChannel], messages: Iterable[Message]) -> None: """Adds new rule violation events to the deletion context.""" self.rules.add(rule_name) - for member in members: - if member.id not in self.members: - self.members[member.id] = member + self.channels.update(channels) for message in messages: if message.id not in self.messages: @@ -73,7 +71,7 @@ class DeletionContext: async def upload_messages(self, actor_id: int, modlog: ModLog) -> None: """Method that takes care of uploading the queue and posting modlog alert.""" - triggered_by_users = ", ".join(format_user(m) for m in self.members.values()) + triggered_by_users = ", ".join(format_user(m) for m in self.members) triggered_in_channel = f"**Triggered in:** {self.triggered_in.mention}\n" if len(self.channels) > 1 else "" channels_description = ", ".join(channel.mention for channel in self.channels) @@ -197,20 +195,19 @@ class AntiSpam(Cog): full_reason = f"`{rule_name}` rule: {reason}" # If there's no spam event going on for this channel, start a new Message Deletion Context - channel = message.channel - if channel.id not in self.message_deletion_queue: - log.trace(f"Creating queue for channel `{channel.id}`") - affected_channels = set(message.channel for message in messages_for_rule) - self.message_deletion_queue[message.channel.id] = DeletionContext(channel, affected_channels) + authors_set = frozenset(members) + if authors_set not in self.message_deletion_queue: + log.trace(f"Creating queue for members `{authors_set}`") + self.message_deletion_queue[authors_set] = DeletionContext(authors_set, message.channel) scheduling.create_task( - self._process_deletion_context(message.channel.id), - name=f"AntiSpam._process_deletion_context({message.channel.id})" + self._process_deletion_context(authors_set), + name=f"AntiSpam._process_deletion_context({authors_set})" ) # Add the relevant of this trigger to the Deletion Context - await self.message_deletion_queue[message.channel.id].add( + await self.message_deletion_queue[authors_set].add( rule_name=rule_name, - members=members, + channels=set(message.channel for message in messages_for_rule), messages=relevant_messages ) @@ -264,7 +261,7 @@ class AntiSpam(Cog): except NotFound: log.info(f"Tried to delete message `{messages[0].id}`, but message could not be found.") - async def _process_deletion_context(self, context_id: int) -> None: + async def _process_deletion_context(self, context_id: frozenset) -> None: """Processes the Deletion Context queue.""" log.trace("Sleeping before processing message deletion queue.") await asyncio.sleep(10) -- cgit v1.2.3 From b4ddc0b7fa3601807bb36139efd06693a3bfe9fc Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 21 Aug 2021 15:13:04 +0300 Subject: Fix MessageCache slicing bugs, improve tests --- bot/utils/message_cache.py | 23 ++++++++++++------ tests/bot/utils/test_message_cache.py | 44 ++++++++++++++++++++--------------- 2 files changed, 41 insertions(+), 26 deletions(-) diff --git a/bot/utils/message_cache.py b/bot/utils/message_cache.py index b2f8f66bf..67da8ecf3 100644 --- a/bot/utils/message_cache.py +++ b/bot/utils/message_cache.py @@ -142,20 +142,29 @@ class MessageCache: # Having empty cells is an implementation detail. To the user the cache contains as many elements as they # inserted, therefore any empty cells should be ignored. There can only be Nones at the tail. - if ( - (self._start < self._end and not self._start < stop <= self._end) - or (self._start > self._end and self._end < stop <= self._start) - ): - stop = self._end + if step > 0: + if ( + (self._start < self._end and not self._start < stop <= self._end) + or (self._start > self._end and self._end < stop <= self._start) + ): + stop = self._end + else: + lower_boundary = (self._start - 1) % self.maxlen + if ( + (self._start < self._end and not self._start - 1 <= stop < self._end) + or (self._start > self._end and self._end < stop < lower_boundary) + ): + stop = lower_boundary if (start < stop and step > 0) or (start > stop and step < 0): return self._messages[start:stop:step] # step != 1 may require a start offset in the second slicing. if step > 0: offset = ceil((self.maxlen - start) / step) * step + start - self.maxlen + return self._messages[start::step] + self._messages[offset:stop:step] else: - offset = self.maxlen - ((start + 1) % step) - return self._messages[start::step] + self._messages[offset:stop:step] + offset = ceil((start + 1) / -step) * -step - start - 1 + return self._messages[start::step] + self._messages[self.maxlen - 1 - offset:stop:step] else: raise TypeError(f"cache indices must be integers or slices, not {type(item)}") diff --git a/tests/bot/utils/test_message_cache.py b/tests/bot/utils/test_message_cache.py index ff313c6d6..5e871cd19 100644 --- a/tests/bot/utils/test_message_cache.py +++ b/tests/bot/utils/test_message_cache.py @@ -163,40 +163,46 @@ class TestMessageCache(unittest.TestCase): def test_slicing_with_unfilled_cache(self): """Test if slicing returns the correct messages if the cache is not yet fully filled.""" - cache = MessageCache(maxlen=5) - messages = [MockMessage() for _ in range(4)] - - for msg in messages: - cache.append(msg) + sizes = (5, 10, 55, 101) - test_cases = ( + slices = ( slice(None), slice(2, None), slice(None, 2), slice(None, None, 2), slice(None, None, 3), slice(-1, 2), slice(-1, 3000), slice(-3, -1), slice(-10, 3), slice(-10, 4, 2), slice(None, None, -1), slice(None, 3, -2), slice(None, None, -3) ) - for current_loop in test_cases: - with self.subTest(current_loop=current_loop): - self.assertListEqual(cache[current_loop], messages[current_loop]) + for size in sizes: + cache = MessageCache(maxlen=size) + messages = [MockMessage() for _ in range(size // 3 * 2)] + + for msg in messages: + cache.append(msg) + + for slice_ in slices: + with self.subTest(current_loop=(size, slice_)): + self.assertListEqual(cache[slice_], messages[slice_]) def test_slicing_with_overfilled_cache(self): """Test if slicing returns the correct messages if the cache was appended with more messages it can contain.""" - cache = MessageCache(maxlen=5) - messages = [MockMessage() for _ in range(8)] - - for msg in messages: - cache.append(msg) - messages = messages[3:] + sizes = (5, 10, 55, 101) - test_cases = ( + slices = ( slice(None), slice(2, None), slice(None, 2), slice(None, None, 2), slice(None, None, 3), slice(-1, 2), slice(-1, 3000), slice(-3, -1), slice(-10, 3), slice(-10, 4, 2), slice(None, None, -1), slice(None, 3, -2), slice(None, None, -3) ) - for current_loop in test_cases: - with self.subTest(current_loop=current_loop): - self.assertListEqual(cache[current_loop], messages[current_loop]) + for size in sizes: + cache = MessageCache(maxlen=size) + messages = [MockMessage() for _ in range(size * 3 // 2)] + + for msg in messages: + cache.append(msg) + messages = messages[size // 2:] + + for slice_ in slices: + with self.subTest(current_loop=(size, slice_)): + self.assertListEqual(cache[slice_], messages[slice_]) def test_length(self): """Test if len returns the correct number of items in the cache.""" -- cgit v1.2.3 From aa2a6b436746f7e8b66f960bf3d41748c784469f Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 21 Aug 2021 16:39:27 +0300 Subject: Clean up code Removed unused import, corrected docstring, and removed unnedded type annotation. --- bot/utils/message_cache.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/utils/message_cache.py b/bot/utils/message_cache.py index 67da8ecf3..6d219c313 100644 --- a/bot/utils/message_cache.py +++ b/bot/utils/message_cache.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import typing as t from math import ceil @@ -10,7 +8,7 @@ class MessageCache: """ A data structure for caching messages. - The cache is implemented as a circular buffer to allow constant time append, prepend, push, pop, + The cache is implemented as a circular buffer to allow constant time append, prepend, pop from either side, and lookup by index. The cache therefore does not support removal at an arbitrary index (although it can be implemented to work in linear time relative to the maximum size). @@ -89,7 +87,7 @@ class MessageCache: def clear(self) -> None: """Remove all messages from the cache.""" - self._messages: list[t.Optional[Message]] = [None] * self.maxlen + self._messages = [None] * self.maxlen self._message_id_mapping = {} self._start = 0 -- cgit v1.2.3 From 0531b1ec1fd55018d358d07f3ab52d0ada3cdaae Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 21 Aug 2021 20:52:12 +0300 Subject: Additional comments and tests for slicing --- bot/utils/message_cache.py | 3 +++ tests/bot/utils/test_message_cache.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/utils/message_cache.py b/bot/utils/message_cache.py index 6d219c313..f5656cdeb 100644 --- a/bot/utils/message_cache.py +++ b/bot/utils/message_cache.py @@ -122,6 +122,9 @@ class MessageCache: Providing 0 will return the message at the position perceived by the user to be the beginning of the cache, meaning at `self._start`. """ + # Keep in mind that for the modulo operator used throughout this function, Python modulo behaves similarly when + # the left operand is negative. E.g -1 % 5 == 4, because the closest number from the bottom that wholly divides + # by 5 is -5. if isinstance(item, int): if item >= len(self) or item < -len(self): raise IndexError("cache index out of range") diff --git a/tests/bot/utils/test_message_cache.py b/tests/bot/utils/test_message_cache.py index 5e871cd19..04bfd28d1 100644 --- a/tests/bot/utils/test_message_cache.py +++ b/tests/bot/utils/test_message_cache.py @@ -168,7 +168,7 @@ class TestMessageCache(unittest.TestCase): slices = ( slice(None), slice(2, None), slice(None, 2), slice(None, None, 2), slice(None, None, 3), slice(-1, 2), slice(-1, 3000), slice(-3, -1), slice(-10, 3), slice(-10, 4, 2), slice(None, None, -1), slice(None, 3, -2), - slice(None, None, -3) + slice(None, None, -3), slice(-1, -10, -2), slice(-3, -7, -1) ) for size in sizes: @@ -189,7 +189,7 @@ class TestMessageCache(unittest.TestCase): slices = ( slice(None), slice(2, None), slice(None, 2), slice(None, None, 2), slice(None, None, 3), slice(-1, 2), slice(-1, 3000), slice(-3, -1), slice(-10, 3), slice(-10, 4, 2), slice(None, None, -1), slice(None, 3, -2), - slice(None, None, -3) + slice(None, None, -3), slice(-1, -10, -2), slice(-3, -7, -1) ) for size in sizes: -- cgit v1.2.3 From 7e8d24f88eb199dc6ba790816d4abaa3269144e0 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Sun, 22 Aug 2021 11:06:38 +0200 Subject: Modify reminder response messages to be in the correct format --- bot/exts/utils/reminders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 03c7d0323..f0aa280b6 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -18,7 +18,7 @@ from bot.utils.checks import has_any_role_check, has_no_roles_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 TimestampFormats, discord_timestamp, time_since +from bot.utils.time import TimestampFormats, discord_timestamp log = logging.getLogger(__name__) @@ -177,7 +177,7 @@ class Reminders(Cog): embed.colour = discord.Colour.red() embed.set_author( icon_url=Icons.remind_red, - name=f"Sorry it should have arrived {time_since(expected_time)} !" + name="Sorry, your reminder should have arrived earlier!" ) else: embed.colour = discord.Colour.blurple() -- cgit v1.2.3 From 2a00be51f64a3980be3e174778b82329378abdc5 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Sun, 22 Aug 2021 02:45:56 -0700 Subject: Error to info log level on missing reminder message. --- bot/exts/utils/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index f0aa280b6..cc13f6ebe 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -200,7 +200,7 @@ class Reminders(Cog): try: await partial_message.reply(content=f"{additional_mentions}", embed=embed) except discord.HTTPException as e: - log.error( + log.info( f"There was an error when trying to reply to a reminder invocation message, {e}, " "fall back to using jump_url" ) -- cgit v1.2.3 From 9ad34a8f22fc19dba28498174da7f14df14a78c2 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 22 Aug 2021 11:32:54 +0100 Subject: Move metabase error handling to a cog error handler --- bot/exts/moderation/metabase.py | 59 +++++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py index e9faf7240..80dabbbb0 100644 --- a/bot/exts/moderation/metabase.py +++ b/bot/exts/moderation/metabase.py @@ -42,6 +42,25 @@ class Metabase(Cog): self.init_task = self.bot.loop.create_task(self.init_cog()) + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Handle ClientResponseError errors locally to invalidate token if needed.""" + if not isinstance(error.original, ClientResponseError): + return + + if error.original.status == 403: + # User doesn't have access to the given question + log.warning(f"Failed to auth with Metabase for {error.original.url}.") + await ctx.send(f":x: {ctx.author.mention} Failed to auth with Metabase for that question.") + elif error.original.status == 404: + await ctx.send(f":x: {ctx.author.mention} That question could not be found.") + else: + # User credentials are invalid, or the refresh failed. + # Delete the expiry time, to force a refresh on next startup. + await self.session_info.delete("session_expiry") + log.exception("Session token is invalid or refresh failed.") + await ctx.send(f":x: {ctx.author.mention} Session token is invalid or refresh failed.") + error.handled = True + async def init_cog(self) -> None: """Initialise the metabase session.""" expiry_time = await self.session_info.get("session_expiry") @@ -112,32 +131,20 @@ class Metabase(Cog): await self.init_task url = f"{MetabaseConfig.url}/card/{question_id}/query/{extension}" - try: - async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp: - if extension == "csv": - out = await resp.text(encoding="utf-8") - # Save the output for use with int e - self.exports[question_id] = list(csv.DictReader(StringIO(out))) - - elif extension == "json": - out = await resp.json(encoding="utf-8") - # Save the output for use with int e - self.exports[question_id] = out - - # Format it nicely for human eyes - out = json.dumps(out, indent=4, sort_keys=True) - except ClientResponseError as e: - if e.status == 403: - # User doesn't have access to the given question - log.warning(f"Failed to auth with Metabase for question {question_id}.") - await ctx.send(f":x: {ctx.author.mention} Failed to auth with Metabase for that question.") - else: - # User credentials are invalid, or the refresh failed. - # Delete the expiry time, to force a refresh on next startup. - await self.session_info.delete("session_expiry") - log.exception("Session token is invalid or refresh failed.") - await ctx.send(f":x: {ctx.author.mention} Session token is invalid or refresh failed.") - return + + async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp: + if extension == "csv": + out = await resp.text(encoding="utf-8") + # Save the output for use with int e + self.exports[question_id] = list(csv.DictReader(StringIO(out))) + + elif extension == "json": + out = await resp.json(encoding="utf-8") + # Save the output for use with int e + self.exports[question_id] = out + + # Format it nicely for human eyes + out = json.dumps(out, indent=4, sort_keys=True) paste_link = await send_to_paste_service(out, extension=extension) if paste_link: -- cgit v1.2.3 From 30bcc28b2caf90ac6ed0e2f5df530c617ba03b4a Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 22 Aug 2021 11:38:14 +0100 Subject: Change metabase config to base url, rather than api url --- bot/constants.py | 2 +- bot/exts/moderation/metabase.py | 4 ++-- config-default.yml | 10 ++++------ 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 500803f33..12b5c02e5 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -568,7 +568,7 @@ class Metabase(metaclass=YAMLGetter): username: Optional[str] password: Optional[str] - url: str + base_url: str max_session_age: int diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py index 80dabbbb0..bfa94b7c1 100644 --- a/bot/exts/moderation/metabase.py +++ b/bot/exts/moderation/metabase.py @@ -84,7 +84,7 @@ class Metabase(Cog): "username": MetabaseConfig.username, "password": MetabaseConfig.password } - async with self.bot.http_session.post(f"{MetabaseConfig.url}/session", json=data) as resp: + async with self.bot.http_session.post(f"{MetabaseConfig.base_url}/api/session", json=data) as resp: json_data = await resp.json() self.session_token = json_data.get("id") @@ -130,7 +130,7 @@ class Metabase(Cog): # Make sure we have a session token before running anything await self.init_task - url = f"{MetabaseConfig.url}/card/{question_id}/query/{extension}" + url = f"{MetabaseConfig.base_url}/api/card/{question_id}/query/{extension}" async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp: if extension == "csv": diff --git a/config-default.yml b/config-default.yml index 881a7df76..79828dd77 100644 --- a/config-default.yml +++ b/config-default.yml @@ -432,14 +432,12 @@ anti_spam: max: 3 - metabase: - username: !ENV "METABASE_USERNAME" - password: !ENV "METABASE_PASSWORD" - url: "http://metabase.default.svc.cluster.local/api" + username: !ENV "METABASE_USERNAME" + password: !ENV "METABASE_PASSWORD" + base_url: "http://metabase.default.svc.cluster.local" # 14 days, see https://www.metabase.com/docs/latest/operations-guide/environment-variables.html#max_session_age - max_session_age: 20160 - + max_session_age: 20160 big_brother: -- cgit v1.2.3 From ce80b92199f7b81d49845a212dabf2e02b3d4684 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 22 Aug 2021 11:38:28 +0100 Subject: Add alias for metabase export --- bot/exts/moderation/metabase.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py index bfa94b7c1..fa5f60ca6 100644 --- a/bot/exts/moderation/metabase.py +++ b/bot/exts/moderation/metabase.py @@ -105,7 +105,7 @@ class Metabase(Cog): """A group of commands for interacting with metabase.""" await ctx.send_help(ctx.command) - @metabase_group.command(name="extract") + @metabase_group.command(name="extract", aliases=("export",)) async def metabase_extract( self, ctx: Context, -- cgit v1.2.3 From eb5cedea9a432b6b98d5ef831a016f9e33d72cbb Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 22 Aug 2021 11:42:04 +0100 Subject: Trigger typing, to avoid wrapping whole func in a context manager --- bot/exts/moderation/metabase.py | 60 ++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py index fa5f60ca6..d97b355a5 100644 --- a/bot/exts/moderation/metabase.py +++ b/bot/exts/moderation/metabase.py @@ -125,36 +125,36 @@ class Metabase(Cog): Valid extensions are: csv and json. """ - async with ctx.typing(): - - # Make sure we have a session token before running anything - await self.init_task - - url = f"{MetabaseConfig.base_url}/api/card/{question_id}/query/{extension}" - - async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp: - if extension == "csv": - out = await resp.text(encoding="utf-8") - # Save the output for use with int e - self.exports[question_id] = list(csv.DictReader(StringIO(out))) - - elif extension == "json": - out = await resp.json(encoding="utf-8") - # Save the output for use with int e - self.exports[question_id] = out - - # Format it nicely for human eyes - out = json.dumps(out, indent=4, sort_keys=True) - - paste_link = await send_to_paste_service(out, extension=extension) - if paste_link: - message = f":+1: {ctx.author.mention} Here's your link: {paste_link}" - else: - message = f":x: {ctx.author.mention} Link service is unavailible." - await ctx.send( - f"{message}\nYou can also access this data within internal eval by doing: " - f"`bot.get_cog('Metabase').exports[{question_id}]`" - ) + await ctx.trigger_typing() + + # Make sure we have a session token before running anything + await self.init_task + + url = f"{MetabaseConfig.base_url}/api/card/{question_id}/query/{extension}" + + async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp: + if extension == "csv": + out = await resp.text(encoding="utf-8") + # Save the output for use with int e + self.exports[question_id] = list(csv.DictReader(StringIO(out))) + + elif extension == "json": + out = await resp.json(encoding="utf-8") + # Save the output for use with int e + self.exports[question_id] = out + + # Format it nicely for human eyes + out = json.dumps(out, indent=4, sort_keys=True) + + paste_link = await send_to_paste_service(out, extension=extension) + if paste_link: + message = f":+1: {ctx.author.mention} Here's your link: {paste_link}" + else: + message = f":x: {ctx.author.mention} Link service is unavailible." + await ctx.send( + f"{message}\nYou can also access this data within internal eval by doing: " + f"`bot.get_cog('Metabase').exports[{question_id}]`" + ) # This cannot be static (must have a __func__ attribute). async def cog_check(self, ctx: Context) -> bool: -- cgit v1.2.3 From 4a0d6196a1f56433191a2a71b349209f6291fc22 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 22 Aug 2021 11:38:46 +0100 Subject: Add ability to publish metabase questions --- bot/exts/moderation/metabase.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py index d97b355a5..3b454ab18 100644 --- a/bot/exts/moderation/metabase.py +++ b/bot/exts/moderation/metabase.py @@ -156,6 +156,20 @@ class Metabase(Cog): f"`bot.get_cog('Metabase').exports[{question_id}]`" ) + @metabase_group.command(name="publish", aliases=("share",)) + async def metabase_publish(self, ctx: Context, question_id: int) -> None: + """Publically shares the given question and posts the link.""" + await ctx.trigger_typing() + # Make sure we have a session token before running anything + await self.init_task + + url = f"{MetabaseConfig.base_url}/api/card/{question_id}/public_link" + + async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp: + response_json = await resp.json(encoding="utf-8") + sharing_url = f"{MetabaseConfig.base_url}/public/question/{response_json['uuid']}" + await ctx.send(f":+1: {ctx.author.mention} Here's your sharing link: {sharing_url}") + # This cannot be static (must have a __func__ attribute). async def cog_check(self, ctx: Context) -> bool: """Only allow admins inside moderator channels to invoke the commands in this cog.""" -- cgit v1.2.3 From d0d140438c13110bad99a18a7e42e60b325b1176 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Sun, 22 Aug 2021 15:38:44 +0300 Subject: Improve cache iteration speed getitem based iteration included operations that aren't necessary when iterating over the cache continuously. Adding an iter method to the class seems to have improved iteration speed by several orders of magnitude. --- bot/utils/message_cache.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/utils/message_cache.py b/bot/utils/message_cache.py index f5656cdeb..f68d280c9 100644 --- a/bot/utils/message_cache.py +++ b/bot/utils/message_cache.py @@ -170,6 +170,16 @@ class MessageCache: else: raise TypeError(f"cache indices must be integers or slices, not {type(item)}") + def __iter__(self) -> t.Iterator[Message]: + if self._is_empty(): + return + + if self._start < self._end: + yield from self._messages[self._start:self._end] + else: + yield from self._messages[self._start:] + yield from self._messages[:self._end] + def __len__(self): """Get the number of non-empty cells in the cache.""" if self._is_empty(): -- cgit v1.2.3 From 9c199dbbb8683584ef01955197232314eb0cf372 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Sun, 22 Aug 2021 18:55:45 +0100 Subject: Fix edge-case of `user.joined_at` being `None` in userinfo command. --- bot/exts/info/information.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 83ca59bea..85d3c0e73 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -257,7 +257,11 @@ class Information(Cog): badges.append(emoji) if on_server: - joined = discord_timestamp(user.joined_at, TimestampFormats.RELATIVE) + if user.joined_at: + joined = discord_timestamp(user.joined_at, TimestampFormats.RELATIVE) + else: + joined = "Unable to get join date" + # The 0 is for excluding the default @everyone role, # and the -1 is for reversing the order of the roles to highest to lowest in hierarchy. roles = ", ".join(role.mention for role in user.roles[:0:-1]) -- cgit v1.2.3 From 73bc01123efb8dbc1e3efb64545b028d21c044d2 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 11 Aug 2021 16:41:45 +0100 Subject: Remove converters made redundant by dpy V1.6's UserConverter update --- bot/converters.py | 71 +------------------------------- bot/exts/moderation/infraction/_utils.py | 2 - 2 files changed, 1 insertion(+), 72 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 595809517..3df613379 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -439,29 +439,6 @@ class HushDurationConverter(Converter): return duration -def proxy_user(user_id: str) -> discord.Object: - """ - Create a proxy user object from the given id. - - Used when a Member or User object cannot be resolved. - """ - log.trace(f"Attempting to create a proxy user for the user id {user_id}.") - - try: - user_id = int(user_id) - except ValueError: - log.debug(f"Failed to create proxy user {user_id}: could not convert to int.") - raise BadArgument(f"User ID `{user_id}` is invalid - could not convert to an integer.") - - user = discord.Object(user_id) - user.mention = user.id - user.display_name = f"<@{user.id}>" - user.avatar_url_as = lambda static_format: None - user.bot = False - - return user - - class UserMentionOrID(UserConverter): """ Converts to a `discord.User`, but only if a mention or userID is provided. @@ -480,51 +457,6 @@ class UserMentionOrID(UserConverter): raise BadArgument(f"`{argument}` is not a User mention or a User ID.") -class FetchedUser(UserConverter): - """ - Converts to a `discord.User` or, if it fails, a `discord.Object`. - - Unlike the default `UserConverter`, which only does lookups via the global user cache, this - converter attempts to fetch the user via an API call to Discord when the using the cache is - unsuccessful. - - If the fetch also fails and the error doesn't imply the user doesn't exist, then a - `discord.Object` is returned via the `user_proxy` converter. - - The lookup strategy is as follows (in order): - - 1. Lookup by ID. - 2. Lookup by mention. - 3. Lookup by name#discrim - 4. Lookup by name - 5. Lookup via API - 6. Create a proxy user with discord.Object - """ - - async def convert(self, ctx: Context, arg: str) -> t.Union[discord.User, discord.Object]: - """Convert the `arg` to a `discord.User` or `discord.Object`.""" - try: - return await super().convert(ctx, arg) - except BadArgument: - pass - - try: - user_id = int(arg) - log.trace(f"Fetching user {user_id}...") - return await ctx.bot.fetch_user(user_id) - except ValueError: - log.debug(f"Failed to fetch user {arg}: could not convert to int.") - raise BadArgument(f"The provided argument can't be turned into integer: `{arg}`") - except discord.HTTPException as e: - # If the Discord error isn't `Unknown user`, return a proxy instead - if e.code != 10013: - log.info(f"Failed to fetch user, returning a proxy instead: status {e.status}") - return proxy_user(arg) - - log.debug(f"Failed to fetch user {arg}: user does not exist.") - raise BadArgument(f"User `{arg}` does not exist") - - def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int: """ Extract the snowflake from `arg` using a regex `pattern` and return it as an int. @@ -568,5 +500,4 @@ class Infraction(Converter): Expiry = t.Union[Duration, ISODateTime] -FetchedMember = t.Union[discord.Member, FetchedUser] -UserMention = partial(_snowflake_from_regex, RE_USER_MENTION) +FetchedMember = t.Union[discord.Member, discord.User] diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index a4059a6e9..e3fcda730 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -24,8 +24,6 @@ INFRACTION_ICONS = { RULES_URL = "https://pythondiscord.com/pages/rules" # Type aliases -UserObject = t.Union[discord.Member, discord.User] -UserSnowflake = t.Union[UserObject, discord.Object] Infraction = t.Dict[str, t.Union[str, int, bool]] APPEAL_EMAIL = "appeals@pythondiscord.com" -- cgit v1.2.3 From 17f3750cbe53eeb740795d77c192b5822d086a4b Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 11 Aug 2021 16:51:28 +0100 Subject: Update to new converters --- bot/exts/fun/duck_pond.py | 3 ++- bot/exts/moderation/infraction/_scheduler.py | 6 +++--- bot/exts/moderation/infraction/_utils.py | 18 ++++++++---------- bot/exts/moderation/infraction/infractions.py | 5 ++--- bot/exts/moderation/infraction/management.py | 16 +++++----------- bot/exts/recruitment/talentpool/_cog.py | 2 +- 6 files changed, 21 insertions(+), 29 deletions(-) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index c78b9c141..a8d927353 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -8,6 +8,7 @@ from discord.ext.commands import Cog, Context, command from bot import constants from bot.bot import Bot +from bot.converters import FetchedMember from bot.utils.checks import has_any_role from bot.utils.messages import count_unique_users_reaction, send_attachments from bot.utils.webhooks import send_webhook @@ -36,7 +37,7 @@ class DuckPond(Cog): log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") @staticmethod - def is_staff(member: Union[User, Member]) -> bool: + def is_staff(member: FetchedMember) -> bool: """Check if a specific member or user is staff.""" if hasattr(member, "roles"): for role in member.roles: diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 8286d3635..89869820c 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -13,8 +13,8 @@ from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Colours +from bot.converters import FetchedMember from bot.exts.moderation.infraction import _utils -from bot.exts.moderation.infraction._utils import UserSnowflake from bot.exts.moderation.modlog import ModLog from bot.utils import messages, scheduling, time from bot.utils.channel import is_mod_channel @@ -115,7 +115,7 @@ class InfractionScheduler: self, ctx: Context, infraction: _utils.Infraction, - user: UserSnowflake, + user: FetchedMember, action_coro: t.Optional[t.Awaitable] = None, user_reason: t.Optional[str] = None, additional_info: str = "", @@ -264,7 +264,7 @@ class InfractionScheduler: self, ctx: Context, infr_type: str, - user: UserSnowflake, + user: FetchedMember, send_msg: bool = True ) -> None: """ diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index e3fcda730..9c3f9d804 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -7,6 +7,7 @@ from discord.ext.commands import Context from bot.api import ResponseCodeError from bot.constants import Colours, Icons +from bot.converters import FetchedMember from bot.errors import InvalidInfractedUserError log = logging.getLogger(__name__) @@ -43,7 +44,7 @@ INFRACTION_DESCRIPTION_TEMPLATE = ( ) -async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: +async def post_user(ctx: Context, user: FetchedMember) -> t.Optional[dict]: """ Create a new user in the database. @@ -51,9 +52,6 @@ async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: """ log.trace(f"Attempting to add user {user.id} to the database.") - if not isinstance(user, (discord.Member, discord.User)): - log.debug("The user being added to the DB is not a Member or User object.") - payload = { 'discriminator': int(getattr(user, 'discriminator', 0)), 'id': user.id, @@ -73,7 +71,7 @@ async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: async def post_infraction( ctx: Context, - user: UserSnowflake, + user: FetchedMember, infr_type: str, reason: str, expires_at: datetime = None, @@ -81,7 +79,7 @@ async def post_infraction( active: bool = True ) -> t.Optional[dict]: """Posts an infraction to the API.""" - if isinstance(user, (discord.Member, discord.User)) and user.bot: + if isinstance(user, FetchedMember) and user.bot: log.trace(f"Posting of {infr_type} infraction for {user} to the API aborted. User is a bot.") raise InvalidInfractedUserError(user) @@ -116,7 +114,7 @@ async def post_infraction( async def get_active_infraction( ctx: Context, - user: UserSnowflake, + user: FetchedMember, infr_type: str, send_msg: bool = True ) -> t.Optional[dict]: @@ -151,7 +149,7 @@ async def get_active_infraction( async def notify_infraction( - user: UserObject, + user: FetchedMember, infr_type: str, expires_at: t.Optional[str] = None, reason: t.Optional[str] = None, @@ -187,7 +185,7 @@ async def notify_infraction( async def notify_pardon( - user: UserObject, + user: FetchedMember, title: str, content: str, icon_url: str = Icons.user_verified @@ -205,7 +203,7 @@ async def notify_pardon( return await send_private_embed(user, embed) -async def send_private_embed(user: UserObject, embed: discord.Embed) -> bool: +async def send_private_embed(user: FetchedMember, embed: discord.Embed) -> bool: """ A helper method for sending an embed to a user's DMs. diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index f19323c7c..dfffb2b20 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -14,7 +14,6 @@ from bot.converters import Duration, Expiry, FetchedMember from bot.decorators import respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler -from bot.exts.moderation.infraction._utils import UserSnowflake from bot.utils.messages import format_user log = logging.getLogger(__name__) @@ -320,7 +319,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def apply_ban( self, ctx: Context, - user: UserSnowflake, + user: FetchedMember, reason: t.Optional[str], purge_days: t.Optional[int] = 0, **kwargs @@ -376,7 +375,7 @@ class Infractions(InfractionScheduler, commands.Cog): await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) @respect_role_hierarchy(member_arg=2) - async def apply_voice_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: + async def apply_voice_ban(self, ctx: Context, user: FetchedMember, reason: t.Optional[str], **kwargs) -> None: """Apply a voice ban infraction with kwargs passed to `post_infraction`.""" if await _utils.get_active_infraction(ctx, user, "voice_ban"): return diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 3094159cd..d1437c635 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -12,7 +12,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry, Infraction, Snowflake, UserMention, allowed_strings, proxy_user +from bot.converters import Expiry, FetchedMember, Infraction, allowed_strings from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator @@ -201,27 +201,21 @@ class ModManagement(commands.Cog): # region: Search infractions @infraction_group.group(name="search", aliases=('s',), invoke_without_command=True) - async def infraction_search_group(self, ctx: Context, query: t.Union[UserMention, Snowflake, str]) -> None: + async def infraction_search_group(self, ctx: Context, query: t.Union[FetchedMember, str]) -> None: """Searches for infractions in the database.""" - if isinstance(query, int): - await self.search_user(ctx, discord.Object(query)) + if isinstance(query, FetchedMember): + await self.search_user(ctx, query) else: 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: + async def search_user(self, ctx: Context, user: FetchedMember) -> None: """Search for infractions by member.""" infraction_list = await self.bot.api_client.get( 'bot/infractions/expanded', params={'user__id': str(user.id)} ) - user = self.bot.get_user(user.id) - if not user and infraction_list: - # Use the user data retrieved from the DB for the username. - user = infraction_list[0]["user"] - user = escape_markdown(user["name"]) + f"#{user['discriminator']:04}" - embed = discord.Embed( title=f"Infractions for {user} ({len(infraction_list)} total)", colour=discord.Colour.orange() diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 80bd48534..9b09c4f7b 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -417,7 +417,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await ctx.message.add_reaction(Emojis.check_mark) @Cog.listener() - async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None: + async def on_member_ban(self, guild: Guild, user: Union[FetchedMember]) -> None: """Remove `user` from the talent pool after they are banned.""" await self.unwatch(user.id, "User was banned.") -- cgit v1.2.3 From bb2c20fd5acf998d440c835345132bdaee34fb64 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 11 Aug 2021 17:28:13 +0100 Subject: Remove unused imports --- bot/converters.py | 1 - bot/errors.py | 2 +- bot/exts/fun/duck_pond.py | 2 +- bot/exts/recruitment/talentpool/_cog.py | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 3df613379..1cfbd29b8 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -2,7 +2,6 @@ import logging import re import typing as t from datetime import datetime -from functools import partial from ssl import CertificateError import dateutil.parser diff --git a/bot/errors.py b/bot/errors.py index 5785faa44..d472334d4 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -1,4 +1,4 @@ -from typing import Hashable, Union +from typing import Hashable from discord import Member, User diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index a8d927353..7c09e27f5 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -3,7 +3,7 @@ import logging from typing import Union import discord -from discord import Color, Embed, Member, Message, RawReactionActionEvent, TextChannel, User, errors +from discord import Color, Embed, Message, RawReactionActionEvent, TextChannel, errors from discord.ext.commands import Cog, Context, command from bot import constants diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 9b09c4f7b..304bdafba 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -6,7 +6,7 @@ from typing import Union import discord from async_rediscache import RedisCache -from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent, User +from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent from discord.ext.commands import Cog, Context, group, has_any_role from bot.api import ResponseCodeError -- cgit v1.2.3 From ad873f91aadb7698c3fd73e1e263ad7bc0ca21e9 Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 11 Aug 2021 18:53:05 +0100 Subject: Rename converter from FetchedMember to MemberOrUser --- bot/converters.py | 2 +- bot/errors.py | 5 ++-- bot/exts/fun/duck_pond.py | 4 +-- bot/exts/info/information.py | 14 +++++----- bot/exts/moderation/infraction/_scheduler.py | 6 ++--- bot/exts/moderation/infraction/_utils.py | 16 ++++++------ bot/exts/moderation/infraction/infractions.py | 34 ++++++++++++------------- bot/exts/moderation/infraction/management.py | 8 +++--- bot/exts/moderation/watchchannels/bigbrother.py | 10 ++++---- bot/exts/recruitment/talentpool/_cog.py | 16 ++++++------ 10 files changed, 58 insertions(+), 57 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 1cfbd29b8..ff9f6e5d9 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -499,4 +499,4 @@ class Infraction(Converter): Expiry = t.Union[Duration, ISODateTime] -FetchedMember = t.Union[discord.Member, discord.User] +MemberOrUser = t.Union[discord.Member, discord.User] diff --git a/bot/errors.py b/bot/errors.py index d472334d4..5186aa3bb 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -1,6 +1,6 @@ from typing import Hashable -from discord import Member, User +from converters import MemberOrUser class LockedResourceError(RuntimeError): @@ -30,7 +30,8 @@ class InvalidInfractedUserError(Exception): `user` -- User or Member which is invalid """ - def __init__(self, user: Union[Member, User], reason: str = "User infracted is a bot."): + def __init__(self, user: MemberOrUser, reason: str = "User infracted is a bot."): + self.user = user self.reason = reason diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 7c09e27f5..0ac985139 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -8,7 +8,7 @@ from discord.ext.commands import Cog, Context, command from bot import constants from bot.bot import Bot -from bot.converters import FetchedMember +from bot.converters import MemberOrUser from bot.utils.checks import has_any_role from bot.utils.messages import count_unique_users_reaction, send_attachments from bot.utils.webhooks import send_webhook @@ -37,7 +37,7 @@ class DuckPond(Cog): log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") @staticmethod - def is_staff(member: FetchedMember) -> bool: + def is_staff(member: MemberOrUser) -> bool: """Check if a specific member or user is staff.""" if hasattr(member, "roles"): for role in member.roles: diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 167731e64..ff3fb9408 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -12,7 +12,7 @@ from discord.ext.commands import BucketType, Cog, Context, Paginator, command, g from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot -from bot.converters import FetchedMember +from bot.converters import MemberOrUser from bot.decorators import in_whitelist from bot.errors import NonExistentRoleError from bot.pagination import LinePaginator @@ -220,7 +220,7 @@ class Information(Cog): await ctx.send(embed=embed) @command(name="user", aliases=["user_info", "member", "member_info", "u"]) - async def user_info(self, ctx: Context, user: FetchedMember = None) -> None: + async def user_info(self, ctx: Context, user: MemberOrUser = None) -> None: """Returns info about a user.""" if user is None: user = ctx.author @@ -235,7 +235,7 @@ class Information(Cog): embed = await self.create_user_embed(ctx, user) await ctx.send(embed=embed) - async def create_user_embed(self, ctx: Context, user: FetchedMember) -> Embed: + async def create_user_embed(self, ctx: Context, user: MemberOrUser) -> Embed: """Creates an embed containing information on the `user`.""" on_server = bool(ctx.guild.get_member(user.id)) @@ -307,7 +307,7 @@ class Information(Cog): return embed - async def basic_user_infraction_counts(self, user: FetchedMember) -> Tuple[str, str]: + async def basic_user_infraction_counts(self, user: MemberOrUser) -> Tuple[str, str]: """Gets the total and active infraction counts for the given `member`.""" infractions = await self.bot.api_client.get( 'bot/infractions', @@ -324,7 +324,7 @@ class Information(Cog): return "Infractions", infraction_output - async def expanded_user_infraction_counts(self, user: FetchedMember) -> Tuple[str, str]: + async def expanded_user_infraction_counts(self, user: MemberOrUser) -> Tuple[str, str]: """ Gets expanded infraction counts for the given `member`. @@ -365,7 +365,7 @@ class Information(Cog): return "Infractions", "\n".join(infraction_output) - async def user_nomination_counts(self, user: FetchedMember) -> Tuple[str, str]: + async def user_nomination_counts(self, user: MemberOrUser) -> Tuple[str, str]: """Gets the active and historical nomination counts for the given `member`.""" nominations = await self.bot.api_client.get( 'bot/nominations', @@ -390,7 +390,7 @@ class Information(Cog): return "Nominations", "\n".join(output) - async def user_messages(self, user: FetchedMember) -> Tuple[Union[bool, str], Tuple[str, str]]: + async def user_messages(self, user: MemberOrUser) -> Tuple[Union[bool, str], Tuple[str, str]]: """ Gets the amount of messages for `member`. diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 89869820c..13f59bb76 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -13,7 +13,7 @@ from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Colours -from bot.converters import FetchedMember +from bot.converters import MemberOrUser from bot.exts.moderation.infraction import _utils from bot.exts.moderation.modlog import ModLog from bot.utils import messages, scheduling, time @@ -115,7 +115,7 @@ class InfractionScheduler: self, ctx: Context, infraction: _utils.Infraction, - user: FetchedMember, + user: MemberOrUser, action_coro: t.Optional[t.Awaitable] = None, user_reason: t.Optional[str] = None, additional_info: str = "", @@ -264,7 +264,7 @@ class InfractionScheduler: self, ctx: Context, infr_type: str, - user: FetchedMember, + user: MemberOrUser, send_msg: bool = True ) -> None: """ diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 9c3f9d804..fe9815600 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -7,7 +7,7 @@ from discord.ext.commands import Context from bot.api import ResponseCodeError from bot.constants import Colours, Icons -from bot.converters import FetchedMember +from bot.converters import MemberOrUser from bot.errors import InvalidInfractedUserError log = logging.getLogger(__name__) @@ -44,7 +44,7 @@ INFRACTION_DESCRIPTION_TEMPLATE = ( ) -async def post_user(ctx: Context, user: FetchedMember) -> t.Optional[dict]: +async def post_user(ctx: Context, user: MemberOrUser) -> t.Optional[dict]: """ Create a new user in the database. @@ -71,7 +71,7 @@ async def post_user(ctx: Context, user: FetchedMember) -> t.Optional[dict]: async def post_infraction( ctx: Context, - user: FetchedMember, + user: MemberOrUser, infr_type: str, reason: str, expires_at: datetime = None, @@ -79,7 +79,7 @@ async def post_infraction( active: bool = True ) -> t.Optional[dict]: """Posts an infraction to the API.""" - if isinstance(user, FetchedMember) and user.bot: + if isinstance(user, MemberOrUser) and user.bot: log.trace(f"Posting of {infr_type} infraction for {user} to the API aborted. User is a bot.") raise InvalidInfractedUserError(user) @@ -114,7 +114,7 @@ async def post_infraction( async def get_active_infraction( ctx: Context, - user: FetchedMember, + user: MemberOrUser, infr_type: str, send_msg: bool = True ) -> t.Optional[dict]: @@ -149,7 +149,7 @@ async def get_active_infraction( async def notify_infraction( - user: FetchedMember, + user: MemberOrUser, infr_type: str, expires_at: t.Optional[str] = None, reason: t.Optional[str] = None, @@ -185,7 +185,7 @@ async def notify_infraction( async def notify_pardon( - user: FetchedMember, + user: MemberOrUser, title: str, content: str, icon_url: str = Icons.user_verified @@ -203,7 +203,7 @@ async def notify_pardon( return await send_private_embed(user, embed) -async def send_private_embed(user: FetchedMember, embed: discord.Embed) -> bool: +async def send_private_embed(user: MemberOrUser, embed: discord.Embed) -> bool: """ A helper method for sending an embed to a user's DMs. diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index dfffb2b20..48ffbd773 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -10,7 +10,7 @@ from discord.ext.commands import Context, command from bot import constants from bot.bot import Bot from bot.constants import Event -from bot.converters import Duration, Expiry, FetchedMember +from bot.converters import Duration, Expiry, MemberOrUser from bot.decorators import respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler @@ -53,7 +53,7 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Permanent infractions @command() - async def warn(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + async def warn(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None: """Warn a user for the given reason.""" if not isinstance(user, Member): await ctx.send(":x: The user doesn't appear to be on the server.") @@ -66,7 +66,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user) @command() - async def kick(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + async def kick(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None: """Kick a user for the given reason.""" if not isinstance(user, Member): await ctx.send(":x: The user doesn't appear to be on the server.") @@ -78,7 +78,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def ban( self, ctx: Context, - user: FetchedMember, + user: MemberOrUser, duration: t.Optional[Expiry] = None, *, reason: t.Optional[str] = None @@ -94,7 +94,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def purgeban( self, ctx: Context, - user: FetchedMember, + user: MemberOrUser, duration: t.Optional[Expiry] = None, *, reason: t.Optional[str] = None @@ -110,7 +110,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def voiceban( self, ctx: Context, - user: FetchedMember, + user: MemberOrUser, duration: t.Optional[Expiry] = None, *, reason: t.Optional[str] @@ -128,7 +128,7 @@ class Infractions(InfractionScheduler, commands.Cog): @command(aliases=["mute"]) async def tempmute( self, ctx: Context, - user: FetchedMember, + user: MemberOrUser, duration: t.Optional[Expiry] = None, *, reason: t.Optional[str] = None @@ -162,7 +162,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def tempban( self, ctx: Context, - user: FetchedMember, + user: MemberOrUser, duration: Expiry, *, reason: t.Optional[str] = None @@ -188,7 +188,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def tempvoiceban( self, ctx: Context, - user: FetchedMember, + user: MemberOrUser, duration: Expiry, *, reason: t.Optional[str] @@ -214,7 +214,7 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Permanent shadow infractions @command(hidden=True) - async def note(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + async def note(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None: """Create a private note for a user with the given reason without notifying the user.""" infraction = await _utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False) if infraction is None: @@ -223,7 +223,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user) @command(hidden=True, aliases=['shadowban', 'sban']) - async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + async def shadow_ban(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None: """Permanently ban a user for the given reason without notifying the user.""" await self.apply_ban(ctx, user, reason, hidden=True) @@ -234,7 +234,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def shadow_tempban( self, ctx: Context, - user: FetchedMember, + user: MemberOrUser, duration: Expiry, *, reason: t.Optional[str] = None @@ -260,17 +260,17 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Remove infractions (un- commands) @command() - async def unmute(self, ctx: Context, user: FetchedMember) -> None: + async def unmute(self, ctx: Context, user: MemberOrUser) -> None: """Prematurely end the active mute infraction for the user.""" await self.pardon_infraction(ctx, "mute", user) @command() - async def unban(self, ctx: Context, user: FetchedMember) -> None: + async def unban(self, ctx: Context, user: MemberOrUser) -> None: """Prematurely end the active ban infraction for the user.""" await self.pardon_infraction(ctx, "ban", user) @command(aliases=("uvban",)) - async def unvoiceban(self, ctx: Context, user: FetchedMember) -> None: + async def unvoiceban(self, ctx: Context, user: MemberOrUser) -> None: """Prematurely end the active voice ban infraction for the user.""" await self.pardon_infraction(ctx, "voice_ban", user) @@ -319,7 +319,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def apply_ban( self, ctx: Context, - user: FetchedMember, + user: MemberOrUser, reason: t.Optional[str], purge_days: t.Optional[int] = 0, **kwargs @@ -375,7 +375,7 @@ class Infractions(InfractionScheduler, commands.Cog): await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) @respect_role_hierarchy(member_arg=2) - async def apply_voice_ban(self, ctx: Context, user: FetchedMember, reason: t.Optional[str], **kwargs) -> None: + async def apply_voice_ban(self, ctx: Context, user: MemberOrUser, reason: t.Optional[str], **kwargs) -> None: """Apply a voice ban infraction with kwargs passed to `post_infraction`.""" if await _utils.get_active_infraction(ctx, user, "voice_ban"): return diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index d1437c635..68ebab8ba 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -12,7 +12,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry, FetchedMember, Infraction, allowed_strings +from bot.converters import Expiry, MemberOrUser, Infraction, allowed_strings from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator @@ -201,15 +201,15 @@ class ModManagement(commands.Cog): # region: Search infractions @infraction_group.group(name="search", aliases=('s',), invoke_without_command=True) - async def infraction_search_group(self, ctx: Context, query: t.Union[FetchedMember, str]) -> None: + async def infraction_search_group(self, ctx: Context, query: t.Union[MemberOrUser, str]) -> None: """Searches for infractions in the database.""" - if isinstance(query, FetchedMember): + if isinstance(query, MemberOrUser): await self.search_user(ctx, query) else: await self.search_reason(ctx, query) @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx: Context, user: FetchedMember) -> None: + async def search_user(self, ctx: Context, user: MemberOrUser) -> None: """Search for infractions by member.""" infraction_list = await self.bot.api_client.get( 'bot/infractions/expanded', diff --git a/bot/exts/moderation/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py index c6ee844ef..4ee69ec9b 100644 --- a/bot/exts/moderation/watchchannels/bigbrother.py +++ b/bot/exts/moderation/watchchannels/bigbrother.py @@ -6,7 +6,7 @@ from discord.ext.commands import Cog, Context, group, has_any_role from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES, Webhooks -from bot.converters import FetchedMember +from bot.converters import MemberOrUser from bot.exts.moderation.infraction._utils import post_infraction from bot.exts.moderation.watchchannels._watchchannel import WatchChannel @@ -60,7 +60,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): @bigbrother_group.command(name='watch', aliases=('w',), root_aliases=('watch',)) @has_any_role(*MODERATION_ROLES) - async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + async def watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None: """ Relay messages sent by the given `user` to the `#big-brother` channel. @@ -71,11 +71,11 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): @bigbrother_group.command(name='unwatch', aliases=('uw',), root_aliases=('unwatch',)) @has_any_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + async def unwatch_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None: """Stop relaying messages by the given `user`.""" await self.apply_unwatch(ctx, user, reason) - async def apply_watch(self, ctx: Context, user: FetchedMember, reason: str) -> None: + async def apply_watch(self, ctx: Context, user: MemberOrUser, reason: str) -> None: """ Add `user` to watched users and apply a watch infraction with `reason`. @@ -125,7 +125,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await ctx.send(msg) - async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, send_message: bool = True) -> None: + async def apply_unwatch(self, ctx: Context, user: MemberOrUser, reason: str, send_message: bool = True) -> None: """ Remove `user` from watched users and mark their infraction as inactive with `reason`. diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 304bdafba..5c1a1cd3f 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -12,7 +12,7 @@ from discord.ext.commands import Cog, Context, group, has_any_role from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES, Webhooks -from bot.converters import FetchedMember +from bot.converters import MemberOrUser from bot.exts.moderation.watchchannels._watchchannel import WatchChannel from bot.exts.recruitment.talentpool._review import Reviewer from bot.pagination import LinePaginator @@ -178,7 +178,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='forcewatch', aliases=('fw', 'forceadd', 'fa'), root_aliases=("forcenominate",)) @has_any_role(*MODERATION_ROLES) - async def force_watch_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None: + async def force_watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None: """ Adds the given `user` to the talent pool, from any channel. @@ -188,7 +188,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",)) @has_any_role(*STAFF_ROLES) - async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None: + async def watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None: """ Adds the given `user` to the talent pool. @@ -207,7 +207,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await self._watch_user(ctx, user, reason) - async def _watch_user(self, ctx: Context, user: FetchedMember, reason: str) -> None: + async def _watch_user(self, ctx: Context, user: MemberOrUser, reason: str) -> None: """Adds the given user to the talent pool.""" if user.bot: await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") @@ -271,7 +271,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='history', aliases=('info', 'search')) @has_any_role(*MODERATION_ROLES) - async def history_command(self, ctx: Context, user: FetchedMember) -> None: + async def history_command(self, ctx: Context, user: MemberOrUser) -> None: """Shows the specified user's nomination history.""" result = await self.bot.api_client.get( self.api_endpoint, @@ -300,7 +300,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",)) @has_any_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + async def unwatch_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None: """ Ends the active nomination of the specified user with the given reason. @@ -323,7 +323,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_edit_group.command(name='reason') @has_any_role(*MODERATION_ROLES) - async def edit_reason_command(self, ctx: Context, nomination_id: int, actor: FetchedMember, *, reason: str) -> None: + async def edit_reason_command(self, ctx: Context, nomination_id: int, actor: MemberOrUser, *, reason: str) -> None: """Edits the reason of a specific nominator in a specific active nomination.""" if len(reason) > REASON_MAX_CHARS: await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.") @@ -417,7 +417,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await ctx.message.add_reaction(Emojis.check_mark) @Cog.listener() - async def on_member_ban(self, guild: Guild, user: Union[FetchedMember]) -> None: + async def on_member_ban(self, guild: Guild, user: Union[MemberOrUser]) -> None: """Remove `user` from the talent pool after they are banned.""" await self.unwatch(user.id, "User was banned.") -- cgit v1.2.3 From 4c8d7754ec1879acf43925f3bc2c998d2890385f Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 11 Aug 2021 19:08:20 +0100 Subject: Fix import order --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 68ebab8ba..e2755efc5 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -12,7 +12,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry, MemberOrUser, Infraction, allowed_strings +from bot.converters import Expiry, Infraction, MemberOrUser, allowed_strings from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator -- cgit v1.2.3 From 22268276556d568f6d254fe2715f509c213119e3 Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 11 Aug 2021 19:14:36 +0100 Subject: Fix import --- bot/errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/errors.py b/bot/errors.py index 5186aa3bb..08396ec3e 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -1,6 +1,6 @@ from typing import Hashable -from converters import MemberOrUser +from bot.converters import MemberOrUser class LockedResourceError(RuntimeError): -- cgit v1.2.3 From aa8c455f9ef919cfc21f0a99cae5a878ec57853f Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 11 Aug 2021 19:56:10 +0100 Subject: Fix isinstance check --- bot/exts/moderation/infraction/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index fe9815600..827623936 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -79,7 +79,7 @@ async def post_infraction( active: bool = True ) -> t.Optional[dict]: """Posts an infraction to the API.""" - if isinstance(user, MemberOrUser) and user.bot: + if isinstance(user, (discord.Member, discord.User)) and user.bot: log.trace(f"Posting of {infr_type} infraction for {user} to the API aborted. User is a bot.") raise InvalidInfractedUserError(user) -- cgit v1.2.3 From 4e6af7be36da47cdd083ef27e4757f809b09aafd Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 11 Aug 2021 23:34:19 +0100 Subject: Remove redundant function --- bot/converters.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index ff9f6e5d9..37eb91c7f 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -456,19 +456,6 @@ class UserMentionOrID(UserConverter): raise BadArgument(f"`{argument}` is not a User mention or a User ID.") -def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int: - """ - Extract the snowflake from `arg` using a regex `pattern` and return it as an int. - - The snowflake is expected to be within the first capture group in `pattern`. - """ - match = pattern.match(arg) - if not match: - raise BadArgument(f"Mention {str!r} is invalid.") - - return int(match.group(1)) - - class Infraction(Converter): """ Attempts to convert a given infraction ID into an infraction. -- cgit v1.2.3 From c90908effae17aba4f656516b306f40c8844bd40 Mon Sep 17 00:00:00 2001 From: Izan Date: Thu, 12 Aug 2021 00:07:11 +0100 Subject: Re-add ability to search infractions of deleted account --- bot/exts/moderation/infraction/management.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index e2755efc5..058bc9db0 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -12,7 +12,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry, Infraction, MemberOrUser, allowed_strings +from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, allowed_strings from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator @@ -201,21 +201,24 @@ class ModManagement(commands.Cog): # region: Search infractions @infraction_group.group(name="search", aliases=('s',), invoke_without_command=True) - async def infraction_search_group(self, ctx: Context, query: t.Union[MemberOrUser, str]) -> None: + async def infraction_search_group(self, ctx: Context, query: t.Union[MemberOrUser, Snowflake, str]) -> None: """Searches for infractions in the database.""" - if isinstance(query, MemberOrUser): - await self.search_user(ctx, query) - else: + if isinstance(query, int): + await self.search_user(ctx, discord.Object(query)) + elif isinstance(query, str): await self.search_reason(ctx, query) + else: + await self.search_user(ctx, query) @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx: Context, user: MemberOrUser) -> None: + async def search_user(self, ctx: Context, user: t.Union[MemberOrUser, discord.Object]) -> None: """Search for infractions by member.""" infraction_list = await self.bot.api_client.get( 'bot/infractions/expanded', params={'user__id': str(user.id)} ) + user_string = str(user if isinstance(user, (discord.Member, discord.User)) else user.id) embed = discord.Embed( title=f"Infractions for {user} ({len(infraction_list)} total)", colour=discord.Colour.orange() -- cgit v1.2.3 From 9770d2ed7d9b0ce92e0284f9c96d0944e331509c Mon Sep 17 00:00:00 2001 From: Izan Date: Fri, 13 Aug 2021 10:16:17 +0100 Subject: Add missing call to `escape_markdown` --- bot/exts/moderation/infraction/management.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 058bc9db0..725e8798c 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -218,9 +218,13 @@ class ModManagement(commands.Cog): params={'user__id': str(user.id)} ) - user_string = str(user if isinstance(user, (discord.Member, discord.User)) else user.id) + if isinstance(user, (discord.Member, discord.User)): + user_str = escape_markdown(user.name) + user.discriminator + else: + user_str = str(user.id) + embed = discord.Embed( - title=f"Infractions for {user} ({len(infraction_list)} total)", + title=f"Infractions for {user_str} ({len(infraction_list)} total)", colour=discord.Colour.orange() ) await self.send_infraction_list(ctx, embed, infraction_list) -- cgit v1.2.3 From 39818408bcd9ca2a65d2459a216c81db39c80d18 Mon Sep 17 00:00:00 2001 From: Izan Date: Fri, 13 Aug 2021 10:18:31 +0100 Subject: Remove redundant fetching of user code --- bot/exts/moderation/infraction/_scheduler.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 13f59bb76..3c5e5d3bf 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -165,17 +165,10 @@ class InfractionScheduler: dm_result = f"{constants.Emojis.failmail} " dm_log_text = "\nDM: **Failed**" - # Sometimes user is a discord.Object; make it a proper user. - try: - if not isinstance(user, (discord.Member, discord.User)): - user = await self.bot.fetch_user(user.id) - except discord.HTTPException as e: - log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})") - else: - # Accordingly display whether the user was successfully notified via DM. - if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon): - dm_result = ":incoming_envelope: " - dm_log_text = "\nDM: Sent" + # Accordingly display whether the user was successfully notified via DM. + if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon): + dm_result = ":incoming_envelope: " + dm_log_text = "\nDM: Sent" end_msg = "" if infraction["actor"] == self.bot.user.id: -- cgit v1.2.3 From 59aeaab40655a3bc91264e24802d2bc6e02d3372 Mon Sep 17 00:00:00 2001 From: Izan Date: Fri, 13 Aug 2021 10:22:02 +0100 Subject: Remove redundant getattr protection --- bot/exts/moderation/infraction/_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 827623936..9d94bca2d 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -53,10 +53,10 @@ async def post_user(ctx: Context, user: MemberOrUser) -> t.Optional[dict]: log.trace(f"Attempting to add user {user.id} to the database.") payload = { - 'discriminator': int(getattr(user, 'discriminator', 0)), + 'discriminator': int(user.discriminator), 'id': user.id, 'in_guild': False, - 'name': getattr(user, 'name', 'Name unknown'), + 'name': user.name, 'roles': [] } -- cgit v1.2.3 From 36d0ea88dc4ac071778355de948e7f06420f1c41 Mon Sep 17 00:00:00 2001 From: Izan Date: Sat, 14 Aug 2021 09:53:53 +0100 Subject: Attempt to get user from DB when discord.Object passed to search_user --- bot/exts/moderation/infraction/management.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 725e8798c..b05b051cc 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -219,9 +219,13 @@ class ModManagement(commands.Cog): ) if isinstance(user, (discord.Member, discord.User)): - user_str = escape_markdown(user.name) + user.discriminator + user_str = escape_markdown(str(user)) else: - user_str = str(user.id) + if infraction_list: + user = infraction_list[0]["user"] + user_str = escape_markdown(user["name"]) + f"#{user['discriminator']:04}" + else: + user_str = str(user.id) embed = discord.Embed( title=f"Infractions for {user_str} ({len(infraction_list)} total)", -- cgit v1.2.3 From df23edb837ac1d50821976605208dc51c54f5c6c Mon Sep 17 00:00:00 2001 From: Izan Date: Fri, 20 Aug 2021 09:12:54 +0100 Subject: Update `infraction_search_group` typehint to `UserMentionOrId` --- bot/exts/moderation/infraction/management.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index b05b051cc..9ff30da59 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -12,7 +12,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, allowed_strings +from bot.converters import Expiry, Infraction, Snowflake, UserMentionOrID, allowed_strings from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator @@ -201,7 +201,7 @@ class ModManagement(commands.Cog): # region: Search infractions @infraction_group.group(name="search", aliases=('s',), invoke_without_command=True) - async def infraction_search_group(self, ctx: Context, query: t.Union[MemberOrUser, Snowflake, str]) -> None: + async def infraction_search_group(self, ctx: Context, query: t.Union[UserMentionOrID, Snowflake, str]) -> None: """Searches for infractions in the database.""" if isinstance(query, int): await self.search_user(ctx, discord.Object(query)) @@ -211,7 +211,7 @@ class ModManagement(commands.Cog): await self.search_user(ctx, query) @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx: Context, user: t.Union[MemberOrUser, discord.Object]) -> None: + async def search_user(self, ctx: Context, user: t.Union[UserMentionOrID, discord.Object]) -> None: """Search for infractions by member.""" infraction_list = await self.bot.api_client.get( 'bot/infractions/expanded', -- cgit v1.2.3 From 4e158b2a64e6f640847f0f7d2f229c96cecb2c46 Mon Sep 17 00:00:00 2001 From: Izan Date: Sat, 21 Aug 2021 22:07:29 +0100 Subject: Revert `search_user` typehint back to `MemberOrUser` --- bot/exts/moderation/infraction/management.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 9ff30da59..641ad0410 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -12,7 +12,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry, Infraction, Snowflake, UserMentionOrID, allowed_strings +from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, UserMentionOrID, allowed_strings from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator @@ -211,7 +211,7 @@ class ModManagement(commands.Cog): await self.search_user(ctx, query) @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx: Context, user: t.Union[UserMentionOrID, discord.Object]) -> None: + async def search_user(self, ctx: Context, user: t.Union[MemberOrUser, discord.Object]) -> None: """Search for infractions by member.""" infraction_list = await self.bot.api_client.get( 'bot/infractions/expanded', -- cgit v1.2.3 From 9c41f0bf3c9487a01f7b5aca32c8b288b99ac382 Mon Sep 17 00:00:00 2001 From: Izan Date: Sun, 22 Aug 2021 21:48:34 +0100 Subject: Update outdated comment --- bot/exts/moderation/watchchannels/bigbrother.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py index 4ee69ec9b..3aa253fea 100644 --- a/bot/exts/moderation/watchchannels/bigbrother.py +++ b/bot/exts/moderation/watchchannels/bigbrother.py @@ -94,7 +94,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await ctx.send(f":x: {user} is already being watched.") return - # FetchedUser instances don't have a roles attribute + # discord.User instances don't have a roles attribute if hasattr(user, "roles") and any(role.id in MODERATION_ROLES for role in user.roles): await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I must be kind to my masters.") return -- cgit v1.2.3 From a831aa011821254387bf079795ecd21b3439051a Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Mon, 23 Aug 2021 08:25:18 +0100 Subject: Update time format in reminder message to DAY_TIME from RELATIVE (#1766) * Update time format in reminder message to DAY_TIME from RELATIVE Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/exts/utils/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index cc13f6ebe..2e60f5030 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -270,7 +270,7 @@ class Reminders(Cog): } ) - mention_string = f"Your reminder will arrive {discord_timestamp(expiration, TimestampFormats.RELATIVE)}" + mention_string = f"Your reminder will arrive on {discord_timestamp(expiration, TimestampFormats.DAY_TIME)}" if mentions: mention_string += f" and will mention {len(mentions)} other(s)" -- cgit v1.2.3 From 26073a9a445bb17408884fb862e91ae5e45dd3c7 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Mon, 23 Aug 2021 10:49:16 +0100 Subject: Update reminders command to use `UserMentionOrID` instead of `discord.Member` to fix greediness issues. (#1768) --- 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 2e60f5030..144f7b537 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -12,7 +12,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, Roles, STAFF_ROLES -from bot.converters import Duration +from bot.converters import Duration, UserMentionOrID from bot.pagination import LinePaginator from bot.utils.checks import has_any_role_check, has_no_roles_check from bot.utils.lock import lock_arg @@ -27,6 +27,7 @@ WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 Mentionable = t.Union[discord.Member, discord.Role] +ReminderMention = t.Union[UserMentionOrID, discord.Role] class Reminders(Cog): @@ -211,14 +212,14 @@ class Reminders(Cog): @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) async def remind_group( - self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str + self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: str ) -> None: """Commands for managing your reminders.""" await self.new_reminder(ctx, mentions=mentions, expiration=expiration, content=content) @remind_group.command(name="new", aliases=("add", "create")) async def new_reminder( - self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str + self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: str ) -> None: """ Set yourself a simple reminder. @@ -363,7 +364,7 @@ class Reminders(Cog): await self.edit_reminder(ctx, id_, {"content": content}) @edit_reminder_group.command(name="mentions", aliases=("pings",)) - async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None: + async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[ReminderMention]) -> None: """Edit one of your reminder's mentions.""" # Remove duplicate mentions mentions = set(mentions) -- cgit v1.2.3 From 0199600317ceba0784c3d6c23627f8d8b84649af Mon Sep 17 00:00:00 2001 From: wookie184 Date: Mon, 23 Aug 2021 17:54:57 +0100 Subject: Ignore bot mentions in antispam mentions rule --- bot/rules/mentions.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/rules/mentions.py b/bot/rules/mentions.py index 79725a4b1..6f5addad1 100644 --- a/bot/rules/mentions.py +++ b/bot/rules/mentions.py @@ -13,7 +13,11 @@ async def apply( if msg.author == last_message.author ) - total_recent_mentions = sum(len(msg.mentions) for msg in relevant_messages) + total_recent_mentions = sum( + not user.bot + for msg in relevant_messages + for user in msg.mentions + ) if total_recent_mentions > config['max']: return ( -- cgit v1.2.3 From eb4aab50132b8f26530160b0ced3791f1e42993f Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Mon, 23 Aug 2021 17:59:59 +0100 Subject: Escape markdown in user's name for `!user` command --- bot/exts/info/information.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index a9ea403f7..8bef6a8cd 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -8,6 +8,7 @@ from typing import Any, DefaultDict, Mapping, Optional, Tuple, Union import rapidfuzz from discord import AllowedMentions, Colour, Embed, Guild, Message, Role from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role +from discord.utils import escape_markdown from bot import constants from bot.api import ResponseCodeError @@ -244,6 +245,7 @@ class Information(Cog): name = str(user) if on_server and user.nick: name = f"{user.nick} ({name})" + name = escape_markdown(name) if user.public_flags.verified_bot: name += f" {constants.Emojis.verified_bot}" -- cgit v1.2.3 From 7daab91633b324901d48653b99fa1bfdf6b093ff Mon Sep 17 00:00:00 2001 From: wookie184 Date: Mon, 23 Aug 2021 18:20:37 +0100 Subject: Fix current tests by using MockMember in mention lists --- tests/bot/rules/test_mentions.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index 6444532f2..a5e42d0a9 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -2,12 +2,14 @@ from typing import Iterable from bot.rules import mentions from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage +from tests.helpers import MockMember, MockMessage -def make_msg(author: str, total_mentions: int) -> MockMessage: +def make_msg(author: str, total_user_mentions: int, total_bot_mentions: int = 0) -> MockMessage: """Makes a message with `total_mentions` mentions.""" - return MockMessage(author=author, mentions=list(range(total_mentions))) + user_mentions = [MockMember() for _ in range(total_user_mentions)] + bot_mentions = [MockMember(bot=True) for _ in range(total_bot_mentions)] + return MockMessage(author=author, mentions=user_mentions+bot_mentions) class TestMentions(RuleTest): -- cgit v1.2.3 From 26af16e986eab0bd561faf2cdd0828d77c3586be Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 23 Aug 2021 19:27:02 +0200 Subject: Remove restriction on snowflake command --- bot/exts/utils/utils.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 98e43c32b..28c7ec27b 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -14,7 +14,6 @@ from bot.converters import Snowflake from bot.decorators import in_whitelist from bot.pagination import LinePaginator from bot.utils import messages -from bot.utils.checks import has_no_roles_check from bot.utils.time import time_since log = logging.getLogger(__name__) @@ -160,9 +159,6 @@ class Utils(Cog): @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) async def snowflake(self, ctx: Context, *snowflakes: Snowflake) -> None: """Get Discord snowflake creation time.""" - if len(snowflakes) > 1 and await has_no_roles_check(ctx, *STAFF_ROLES): - raise BadArgument("Cannot process more than one snowflake in one invocation.") - if not snowflakes: raise BadArgument("At least one snowflake must be provided.") -- cgit v1.2.3 From a6121e6aa6e5aeaa9ae95a8973408e947958c6e0 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Mon, 23 Aug 2021 18:33:35 +0100 Subject: Added some more test cases to ensure bot mentions aren't counted --- tests/bot/rules/test_mentions.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index a5e42d0a9..f8805ac48 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -50,11 +50,27 @@ class TestMentions(RuleTest): [make_msg("bob", 2), make_msg("alice", 3), make_msg("bob", 2)], ("bob",), 4, - ) + ), + DisallowedCase( + [make_msg("bob", 3, 1)], + ("bob",), + 3, + ), ) await self.run_disallowed(cases) + async def test_ignore_bot_mentions(self): + """Messages with an allowed amount of mentions, also containing bot mentions.""" + cases = ( + [make_msg("bob", 0, 3)], + [make_msg("bob", 2, 1)], + [make_msg("bob", 1, 2), make_msg("bob", 1, 2)], + [make_msg("bob", 1, 5), make_msg("alice", 2, 5)] + ) + + await self.run_allowed(cases) + def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: last_message = case.recent_messages[0] return tuple( -- cgit v1.2.3 From 9dc4b3e26e1c355c2626a4fca3bc6327c2e9d132 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 23 Aug 2021 21:13:08 +0200 Subject: remove redundant index assignments Co-authored-by: Bluenix --- bot/exts/info/tags.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 8bb682366..520089e19 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -117,12 +117,12 @@ def _fuzzy_search(search: str, target: str) -> float: _search = REGEX_NON_ALPHABET.sub("", search.lower()) _targets = iter(REGEX_NON_ALPHABET.split(target.lower())) - current, index = 0, 0 + current = 0 for _target in _targets: + index = 0 while index < len(_target) and _search[current] == _target[index]: current += 1 index += 1 - index = 0 return current / len(_search) -- cgit v1.2.3 From 0ead9a5e53548107d06ab8c69522359b9558061d Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 23 Aug 2021 21:16:48 +0200 Subject: Fix tag fuzzy matching when searching against a longer target --- bot/exts/info/tags.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 520089e19..884c76ec4 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -120,9 +120,13 @@ def _fuzzy_search(search: str, target: str) -> float: current = 0 for _target in _targets: index = 0 - while index < len(_target) and _search[current] == _target[index]: - current += 1 - index += 1 + try: + while index < len(_target) and _search[current] == _target[index]: + current += 1 + index += 1 + except IndexError: + # Exit when _search runs out + break return current / len(_search) -- cgit v1.2.3 From be9bc9c814c4e43bdb6850e871678e9281958091 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 23 Aug 2021 21:39:32 +0200 Subject: Prevent import cycle when importing EXTENSIONS --- bot/errors.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/errors.py b/bot/errors.py index 08396ec3e..2633390a8 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -1,6 +1,8 @@ -from typing import Hashable +from __future__ import annotations -from bot.converters import MemberOrUser +from typing import Hashable, TYPE_CHECKING +if TYPE_CHECKING: + from bot.converters import MemberOrUser class LockedResourceError(RuntimeError): -- cgit v1.2.3 From 37804ce14c4aeb322ef772b14e7fe55658f80ebf Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 23 Aug 2021 21:48:31 +0200 Subject: Remove TagContentConverter --- bot/converters.py | 20 -------------------- tests/bot/test_converters.py | 38 -------------------------------------- 2 files changed, 58 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 1c0fd673d..0118cc48a 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -314,25 +314,6 @@ class TagNameConverter(Converter): return tag_name -class TagContentConverter(Converter): - """Ensure proposed tag content is not empty and contains at least one non-whitespace character.""" - - @staticmethod - async def convert(ctx: Context, tag_content: str) -> str: - """ - Ensure tag_content is non-empty and contains at least one non-whitespace character. - - If tag_content is valid, return the stripped version. - """ - tag_content = tag_content.strip() - - # The tag contents should not be empty, or filled with whitespace. - if not tag_content: - raise BadArgument("Tag contents should not be empty, or filled with whitespace.") - - return tag_content - - class SourceConverter(Converter): """Convert an argument into a help command, tag, command, or cog.""" @@ -570,7 +551,6 @@ if t.TYPE_CHECKING: Inventory = t.Tuple[str, _inventory_parser.InventoryDict] # noqa: F811 Snowflake = int # noqa: F811 TagNameConverter = str # noqa: F811 - TagContentConverter = str # noqa: F811 SourceConverter = SourceType # noqa: F811 DurationDelta = relativedelta # noqa: F811 Duration = datetime # noqa: F811 diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index 2a1c4e543..6e3a6b898 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -11,7 +11,6 @@ from bot.converters import ( HushDurationConverter, ISODateTime, PackageName, - TagContentConverter, TagNameConverter, ) @@ -26,43 +25,6 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase): cls.fixed_utc_now = datetime.datetime.fromisoformat('2019-01-01T00:00:00') - async def test_tag_content_converter_for_valid(self): - """TagContentConverter should return correct values for valid input.""" - test_values = ( - ('hello', 'hello'), - (' h ello ', 'h ello'), - ) - - for content, expected_conversion in test_values: - with self.subTest(content=content, expected_conversion=expected_conversion): - conversion = await TagContentConverter.convert(self.context, content) - self.assertEqual(conversion, expected_conversion) - - async def test_tag_content_converter_for_invalid(self): - """TagContentConverter should raise the proper exception for invalid input.""" - test_values = ( - ('', "Tag contents should not be empty, or filled with whitespace."), - (' ', "Tag contents should not be empty, or filled with whitespace."), - ) - - for value, exception_message in test_values: - with self.subTest(tag_content=value, exception_message=exception_message): - with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): - await TagContentConverter.convert(self.context, value) - - async def test_tag_name_converter_for_valid(self): - """TagNameConverter should return the correct values for valid tag names.""" - test_values = ( - ('tracebacks', 'tracebacks'), - ('Tracebacks', 'tracebacks'), - (' Tracebacks ', 'tracebacks'), - ) - - for name, expected_conversion in test_values: - with self.subTest(name=name, expected_conversion=expected_conversion): - conversion = await TagNameConverter.convert(self.context, name) - self.assertEqual(conversion, expected_conversion) - async def test_tag_name_converter_for_invalid(self): """TagNameConverter should raise the correct exception for invalid tag names.""" test_values = ( -- cgit v1.2.3 From b6b05251238df2ccff4cab721e5b03e2208aa713 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 23 Aug 2021 13:19:53 -0700 Subject: Enable debug mode by default --- bot/constants.py | 2 +- config-default.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index b7c6ffa70..a6dd3ab65 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -687,7 +687,7 @@ class VideoPermission(metaclass=YAMLGetter): # Debug mode -DEBUG_MODE: bool = _CONFIG_YAML["debug"] +DEBUG_MODE: bool = _CONFIG_YAML["debug"] == "true" # Paths BOT_DIR = os.path.dirname(__file__) diff --git a/config-default.yml b/config-default.yml index 40c6d691e..85532ad28 100644 --- a/config-default.yml +++ b/config-default.yml @@ -1,4 +1,4 @@ -debug: false +debug: !ENV ["BOT_DEBUG", "true"] bot: -- cgit v1.2.3 From 5d448f1b035f44ac551d3ad23ae27bb8897b31de Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Tue, 24 Aug 2021 12:11:51 +0100 Subject: Update nomination messages to display user mention --- bot/exts/recruitment/talentpool/_cog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 5c1a1cd3f..c297f70c2 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -263,7 +263,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): } ) - msg = f"✅ The nomination for {user} has been added to the talent pool" + msg = f"✅ The nomination for {user.mention} has been added to the talent pool" if history: msg += f"\n\n({len(history)} previous nominations in total)" @@ -311,7 +311,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): return if await self.unwatch(user.id, reason): - await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") + await ctx.send(f":white_check_mark: Messages sent by {user.mention} will no longer be relayed") else: await ctx.send(":x: The specified user does not have an active nomination") @@ -344,7 +344,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): return if not any(entry["actor"] == actor.id for entry in nomination["entries"]): - await ctx.send(f":x: {actor} doesn't have an entry in this nomination.") + await ctx.send(f":x: {actor.mention} doesn't have an entry in this nomination.") return self.log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}") -- cgit v1.2.3 From 429ca2a4a359a2e1f580100911fe94352fa8192d Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 24 Aug 2021 15:39:41 +0100 Subject: Add constants.STAFF_PARTNERS_COMMUNITY_ROLES --- bot/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/constants.py b/bot/constants.py index 12b5c02e5..5b629a735 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -696,6 +696,7 @@ PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir)) # Default role combinations MODERATION_ROLES = Guild.moderation_roles STAFF_ROLES = Guild.staff_roles +STAFF_PARTNERS_COMMUNITY_ROLES = STAFF_ROLES + [Roles.partners, Roles.python_community] # Channel combinations MODERATION_CHANNELS = Guild.moderation_channels -- cgit v1.2.3 From 2c93e2deedddb98efa26e9e513b54df1a457adf6 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 24 Aug 2021 15:51:45 +0100 Subject: Allow partners & members of the python community to use commands in any channel Allow partners & members of the python community to use `!remind`, `!help` `!user`, `!snowflake`, `!ping`, `!charinfo` in any channel --- bot/exts/info/help.py | 4 ++-- bot/exts/info/information.py | 2 +- bot/exts/utils/ping.py | 4 ++-- bot/exts/utils/reminders.py | 6 +++--- bot/exts/utils/utils.py | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 0235bbaf3..21a6cf752 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -10,7 +10,7 @@ from rapidfuzz import fuzz, process from rapidfuzz.utils import default_process from bot import constants -from bot.constants import Channels, STAFF_ROLES +from bot.constants import Channels, STAFF_PARTNERS_COMMUNITY_ROLES from bot.decorators import redirect_output from bot.pagination import LinePaginator from bot.utils.messages import wait_for_deletion @@ -54,7 +54,7 @@ class CustomHelpCommand(HelpCommand): def __init__(self): super().__init__(command_attrs={"help": "Shows help for bot commands"}) - @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES) + @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_PARTNERS_COMMUNITY_ROLES) async def command_callback(self, ctx: Context, *, command: str = None) -> None: """Attempts to match the provided query with a valid command or cog.""" # the only reason we need to tamper with this is because d.py does not support "categories", diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index a9ea403f7..67677a8ad 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -231,7 +231,7 @@ class Information(Cog): return # Will redirect to #bot-commands if it fails. - if in_whitelist_check(ctx, roles=constants.STAFF_ROLES): + if in_whitelist_check(ctx, roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES): embed = await self.create_user_embed(ctx, user) await ctx.send(embed=embed) diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index c6d7bd900..cf0e3265e 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -5,7 +5,7 @@ from discord import Embed from discord.ext import commands from bot.bot import Bot -from bot.constants import Channels, STAFF_ROLES, URLs +from bot.constants import Channels, STAFF_PARTNERS_COMMUNITY_ROLES, URLs from bot.decorators import in_whitelist DESCRIPTIONS = ( @@ -23,7 +23,7 @@ class Latency(commands.Cog): self.bot = bot @commands.command() - @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) + @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_PARTNERS_COMMUNITY_ROLES) async def ping(self, ctx: commands.Context) -> None: """ Gets different measures of latency within the bot. diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 2e60f5030..8aa437895 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -11,7 +11,7 @@ from dateutil.parser import isoparse from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot -from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_ROLES +from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES, STAFF_ROLES from bot.converters import Duration from bot.pagination import LinePaginator from bot.utils.checks import has_any_role_check, has_no_roles_check @@ -225,8 +225,8 @@ class Reminders(Cog): Expiration is parsed per: http://strftime.org/ """ - # If the user is not staff, we need to verify whether or not to make a reminder at all. - if await has_no_roles_check(ctx, *STAFF_ROLES): + # If the user is not staff, partner or part of the python community, we need to verify whether or not to make a reminder at all. + if await has_no_roles_check(ctx, *STAFF_PARTNERS_COMMUNITY_ROLES): # If they don't have permission to set a reminder in this channel if ctx.channel.id not in WHITELISTED_CHANNELS: diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 98e43c32b..c131213e7 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -9,7 +9,7 @@ from discord.ext.commands import BadArgument, Cog, Context, clean_content, comma from discord.utils import snowflake_time from bot.bot import Bot -from bot.constants import Channels, MODERATION_ROLES, Roles, STAFF_ROLES +from bot.constants import Channels, MODERATION_ROLES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES, STAFF_ROLES from bot.converters import Snowflake from bot.decorators import in_whitelist from bot.pagination import LinePaginator @@ -50,7 +50,7 @@ class Utils(Cog): self.bot = bot @command() - @in_whitelist(channels=(Channels.bot_commands, Channels.discord_py), roles=STAFF_ROLES) + @in_whitelist(channels=(Channels.bot_commands, Channels.discord_py), roles=STAFF_PARTNERS_COMMUNITY_ROLES) async def charinfo(self, ctx: Context, *, characters: str) -> None: """Shows you information on up to 50 unicode characters.""" match = re.match(r"<(a?):(\w+):(\d+)>", characters) @@ -157,7 +157,7 @@ class Utils(Cog): await ctx.send(embed=embed) @command(aliases=("snf", "snfl", "sf")) - @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) + @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_PARTNERS_COMMUNITY_ROLES) async def snowflake(self, ctx: Context, *snowflakes: Snowflake) -> None: """Get Discord snowflake creation time.""" if len(snowflakes) > 1 and await has_no_roles_check(ctx, *STAFF_ROLES): -- cgit v1.2.3 From 675b96153bec329c9c18744536e1c0f06210497a Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 24 Aug 2021 16:04:24 +0100 Subject: Allow partners & members of the python community to use `!role` and `!roles` --- 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 67677a8ad..f13567d02 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -94,7 +94,7 @@ class Information(Cog): {python_general.mention} cooldown: {python_general.slowmode_delay}s """) - @has_any_role(*constants.STAFF_ROLES) + @has_any_role(*constants.STAFF_PARTNERS_COMMUNITY_ROLES) @command(name="roles") async def roles_info(self, ctx: Context) -> None: """Returns a list of all roles and their corresponding IDs.""" @@ -114,7 +114,7 @@ class Information(Cog): await LinePaginator.paginate(role_list, ctx, embed, empty=False) - @has_any_role(*constants.STAFF_ROLES) + @has_any_role(*constants.STAFF_PARTNERS_COMMUNITY_ROLES) @command(name="role") async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None: """ -- cgit v1.2.3 From 5be8cd1ad40fcaaa8e7bdbfc7a0619aceca2660e Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 24 Aug 2021 16:07:00 +0100 Subject: Allow partners & members of the python community to bypass cooldown and use `!raw` in any channel --- 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 f13567d02..664b6cb13 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -453,9 +453,9 @@ class Information(Cog): # remove trailing whitespace return out.rstrip() - @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) + @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES) @group(invoke_without_command=True) - @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES) + @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES) async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: """Shows information about the raw API response.""" if ctx.author not in message.channel.members: -- cgit v1.2.3 From 5998a8e0df0b133bc8bc0ac1eaf25d0b4681ccb0 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 24 Aug 2021 16:09:39 +0100 Subject: Minor linting fixes --- bot/exts/utils/reminders.py | 7 +++++-- bot/exts/utils/utils.py | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 8aa437895..a97080a4c 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -11,7 +11,10 @@ from dateutil.parser import isoparse from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot -from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES, STAFF_ROLES +from bot.constants import ( + Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, + Roles, STAFF_PARTNERS_COMMUNITY_ROLES, STAFF_ROLES +) from bot.converters import Duration from bot.pagination import LinePaginator from bot.utils.checks import has_any_role_check, has_no_roles_check @@ -136,7 +139,7 @@ class Reminders(Cog): """Converts Role and Member ids to their corresponding objects if possible.""" guild = self.bot.get_guild(Guild.id) for mention_id in mention_ids: - if (mentionable := (guild.get_member(mention_id) or guild.get_role(mention_id))): + if mentionable := (guild.get_member(mention_id) or guild.get_role(mention_id)): yield mentionable def schedule_reminder(self, reminder: dict) -> None: diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index c131213e7..c4a466943 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -55,15 +55,17 @@ class Utils(Cog): """Shows you information on up to 50 unicode characters.""" match = re.match(r"<(a?):(\w+):(\d+)>", characters) if match: - return await messages.send_denial( + await messages.send_denial( ctx, "**Non-Character Detected**\n" "Only unicode characters can be processed, but a custom Discord emoji " "was found. Please remove it and try again." ) + return if len(characters) > 50: - return await messages.send_denial(ctx, f"Too many characters ({len(characters)}/50)") + await messages.send_denial(ctx, f"Too many characters ({len(characters)}/50)") + return def get_info(char: str) -> Tuple[str, str]: digit = f"{ord(char):x}" -- cgit v1.2.3 From dc92d6e767931e1a6be01ff35b5bb60d603274d2 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 24 Aug 2021 16:19:49 +0100 Subject: Ignore partners & members of the python community as well as staff from `!liststream` --- bot/exts/moderation/stream.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index 07ee4099e..e667006ff 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -9,7 +9,7 @@ from async_rediscache import RedisCache from discord.ext import commands from bot.bot import Bot -from bot.constants import Colours, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES, VideoPermission +from bot.constants import Colours, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES, VideoPermission from bot.converters import Expiry from bot.pagination import LinePaginator from bot.utils.scheduling import Scheduler @@ -193,17 +193,19 @@ class Stream(commands.Cog): @commands.command(aliases=('lstream',)) @commands.has_any_role(*MODERATION_ROLES) async def liststream(self, ctx: commands.Context) -> None: - """Lists all non-staff users who have permission to stream.""" - non_staff_members_with_stream = [ + """ + Lists all users who aren't staff, partners or members of the python community and have permission to stream. + """ + non_staff_partners_community_members_with_stream = [ member for member in ctx.guild.get_role(Roles.video).members - if not any(role.id in STAFF_ROLES for role in member.roles) + if not any(role.id in STAFF_PARTNERS_COMMUNITY_ROLES for role in member.roles) ] # List of tuples (UtcPosixTimestamp, str) # So that the list can be sorted on the UtcPosixTimestamp before the message is passed to the paginator. streamer_info = [] - for member in non_staff_members_with_stream: + for member in non_staff_partners_community_members_with_stream: if revoke_time := await self.task_cache.get(member.id): # Member only has temporary streaming perms revoke_delta = Arrow.utcfromtimestamp(revoke_time).humanize() -- cgit v1.2.3 From b1aafff149eb2fd2262f4b06a950ebad460f7281 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 24 Aug 2021 16:30:16 +0100 Subject: Allow partners & members of the python community to have mentions in `!remind` --- bot/exts/utils/reminders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index a97080a4c..847883fc7 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -13,7 +13,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, - Roles, STAFF_PARTNERS_COMMUNITY_ROLES, STAFF_ROLES + Roles, STAFF_PARTNERS_COMMUNITY_ROLES ) from bot.converters import Duration from bot.pagination import LinePaginator @@ -113,7 +113,7 @@ class Reminders(Cog): If mentions aren't allowed, also return the type of mention(s) disallowed. """ - if await has_no_roles_check(ctx, *STAFF_ROLES): + if await has_no_roles_check(ctx, *STAFF_PARTNERS_COMMUNITY_ROLES): return False, "members/roles" elif await has_no_roles_check(ctx, *MODERATION_ROLES): return all(isinstance(mention, discord.Member) for mention in mentions), "roles" -- cgit v1.2.3 From f5246e1e8bf0c88320dba0480560bf4b2d2981d2 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 24 Aug 2021 17:07:49 +0100 Subject: Fix linting --- bot/exts/moderation/stream.py | 9 +++++---- bot/exts/utils/reminders.py | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index e667006ff..01d2614b0 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -9,7 +9,10 @@ from async_rediscache import RedisCache from discord.ext import commands from bot.bot import Bot -from bot.constants import Colours, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES, VideoPermission +from bot.constants import ( + Colours, Emojis, Guild, MODERATION_ROLES, Roles, + STAFF_PARTNERS_COMMUNITY_ROLES, VideoPermission +) from bot.converters import Expiry from bot.pagination import LinePaginator from bot.utils.scheduling import Scheduler @@ -193,9 +196,7 @@ class Stream(commands.Cog): @commands.command(aliases=('lstream',)) @commands.has_any_role(*MODERATION_ROLES) async def liststream(self, ctx: commands.Context) -> None: - """ - Lists all users who aren't staff, partners or members of the python community and have permission to stream. - """ + """Lists all users who aren't staff, partners or members of the python community and have stream permissions.""" non_staff_partners_community_members_with_stream = [ member for member in ctx.guild.get_role(Roles.video).members diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 847883fc7..2496ce5cc 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -228,7 +228,8 @@ class Reminders(Cog): Expiration is parsed per: http://strftime.org/ """ - # If the user is not staff, partner or part of the python community, we need to verify whether or not to make a reminder at all. + # If the user is not staff, partner or part of the python community, + # we need to verify whether or not to make a reminder at all. if await has_no_roles_check(ctx, *STAFF_PARTNERS_COMMUNITY_ROLES): # If they don't have permission to set a reminder in this channel -- cgit v1.2.3 From 8e2f515c277484145319d0642f588af9287b6af8 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 24 Aug 2021 17:21:11 +0100 Subject: Fix linting --- 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 f91a9fee6..0139a6ad3 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -9,7 +9,7 @@ from discord.ext.commands import BadArgument, Cog, Context, clean_content, comma from discord.utils import snowflake_time from bot.bot import Bot -from bot.constants import Channels, MODERATION_ROLES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES, STAFF_ROLES +from bot.constants import Channels, MODERATION_ROLES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES from bot.converters import Snowflake from bot.decorators import in_whitelist from bot.pagination import LinePaginator -- cgit v1.2.3 From 93386c48ca775ff7897090b3af98303d66d1f116 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 24 Aug 2021 17:47:38 +0100 Subject: Fix tests --- tests/bot/exts/info/test_information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 0aa41d889..d8250befb 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -507,7 +507,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") 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.STAFF_PARTNERS_COMMUNITY_ROLES = [self.moderator_role.id] ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200)) await self.cog.user_info(self.cog, ctx) @@ -519,7 +519,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): 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] + constants.STAFF_PARTNERS_COMMUNITY_ROLES = [self.moderator_role.id] ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=50)) await self.cog.user_info(self.cog, ctx, self.target) -- cgit v1.2.3 From ed966d4ace253de5945b88766441551e3a19a21d Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Tue, 24 Aug 2021 23:47:27 +0300 Subject: Fix antispam looking at wrong messages --- bot/exts/filters/antispam.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 987060779..8c075fa95 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -207,7 +207,7 @@ class AntiSpam(Cog): # Add the relevant of this trigger to the Deletion Context await self.message_deletion_queue[authors_set].add( rule_name=rule_name, - channels=set(message.channel for message in messages_for_rule), + channels=set(message.channel for message in relevant_messages), messages=relevant_messages ) @@ -217,7 +217,7 @@ class AntiSpam(Cog): name=f"AntiSpam.punish(message={message.id}, member={member.id}, rule={rule_name})" ) - await self.maybe_delete_messages(messages_for_rule) + await self.maybe_delete_messages(relevant_messages) break @lock.lock_arg("antispam.punish", "member", attrgetter("id")) -- cgit v1.2.3 From 506f1d2ff8cb8927175a45317bc47f1f3f449192 Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 25 Aug 2021 10:57:16 +0100 Subject: Add time units and example to docstring of `!remind` and `!remind new` Supported time units and an example invocation are now displayed in the help message for `!remind` and `!remind new` --- bot/exts/utils/reminders.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 90ad7ef2e..9d567cdbc 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -217,7 +217,20 @@ class Reminders(Cog): async def remind_group( self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: str ) -> None: - """Commands for managing your reminders.""" + """ + Commands for managing your reminders. + + The `expiration` duration of `!remind new` supports the following symbols for each unit of time: + - years: `Y`, `y`, `year`, `years` + - months: `m`, `month`, `months` + - weeks: `w`, `W`, `week`, `weeks` + - days: `d`, `D`, `day`, `days` + - hours: `H`, `h`, `hour`, `hours` + - minutes: `M`, `minute`, `minutes` + - seconds: `S`, `s`, `second`, `seconds` + + For example, to set a reminder that expires in 3 days and 1 minute, you can do `!remind new 3d1M Do something`. + """ await self.new_reminder(ctx, mentions=mentions, expiration=expiration, content=content) @remind_group.command(name="new", aliases=("add", "create")) @@ -227,7 +240,16 @@ class Reminders(Cog): """ Set yourself a simple reminder. - Expiration is parsed per: http://strftime.org/ + The `expiration` duration supports the following symbols for each unit of time: + - years: `Y`, `y`, `year`, `years` + - months: `m`, `month`, `months` + - weeks: `w`, `W`, `week`, `weeks` + - days: `d`, `D`, `day`, `days` + - hours: `H`, `h`, `hour`, `hours` + - minutes: `M`, `minute`, `minutes` + - seconds: `S`, `s`, `second`, `seconds` + + For example, to set a reminder that expires in 3 days and 1 minute, you can do `!remind new 3d1M Do something`. """ # If the user is not staff, partner or part of the python community, # we need to verify whether or not to make a reminder at all. -- cgit v1.2.3 From b3b9d3fb15f8909067be4bd8aedf1d17e51f7968 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 25 Aug 2021 16:35:45 +0200 Subject: CI: check for dependency licenses Since our project is licensed under the MIT License, we can't be using any dependencies in our project. This commit adds a step to the CI lint phase that will use pip-licenses to verify that all the installed packages are part of an ALLOWED_LICENSE variable. This variable is currently set to be every license we currently use. We opted to use an allowlist instead of a denylist to make sure that new licenses are reviewed by hand and added to that variable. --- .github/workflows/lint-test.yml | 17 ++++ poetry.lock | 201 +++++++++++++++++++++++----------------- pyproject.toml | 1 + 3 files changed, 133 insertions(+), 86 deletions(-) diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index e99e6d181..53d9baa59 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -11,6 +11,16 @@ jobs: lint-test: runs-on: ubuntu-latest env: + # List of licenses that are compatible with the MIT License and + # can be used in our project + ALLOWED_LICENSE: Apache Software License; + BSD License; + GNU Library or Lesser General Public License (LGPL); + MIT License; + Mozilla Public License 2.0 (MPL 2.0); + Public Domain; + Python Software Foundation License + # Dummy values for required bot environment variables BOT_API_KEY: foo BOT_SENTRY_DSN: blah @@ -67,6 +77,13 @@ jobs: pip install poetry poetry install + # Check all the dependencies are compatible with the MIT license. + # If you added a new dependencies that is being rejected, + # please make sure it is compatible with the license for this project, + # and add it to the ALLOWED_LICENSE variable + - name: Check Dependencies License + run: pip-licenses --allow-only="$ALLOWED_LICENSE" + # This step caches our pre-commit environment. To make sure we # do create a new environment when our pre-commit setup changes, # we create a cache key based on relevant factors. diff --git a/poetry.lock b/poetry.lock index a4ce5d1a9..ae8b299f4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -174,7 +174,7 @@ pycparser = "*" [[package]] name = "cfgv" -version = "3.3.0" +version = "3.3.1" description = "Validate configuration and produce human readable error messages." category = "dev" optional = false @@ -317,7 +317,7 @@ testing = ["pre-commit"] [[package]] name = "fakeredis" -version = "1.5.2" +version = "1.6.0" description = "Fake implementation of redis API for testing purposes." category = "main" optional = false @@ -329,7 +329,7 @@ six = ">=1.12" sortedcontainers = "*" [package.extras] -aioredis = ["aioredis (<2)"] +aioredis = ["aioredis"] lua = ["lupa"] [[package]] @@ -437,14 +437,14 @@ flake8 = "*" [[package]] name = "flake8-tidy-imports" -version = "4.3.0" +version = "4.4.1" description = "A flake8 plugin that helps you write tidier imports." category = "dev" optional = false python-versions = ">=3.6" [package.dependencies] -flake8 = ">=3.0,<3.2.0 || >3.2.0,<4" +flake8 = ">=3.8.0,<4" [[package]] name = "flake8-todo" @@ -478,7 +478,7 @@ pyreadline = {version = "*", markers = "sys_platform == \"win32\""} [[package]] name = "identify" -version = "2.2.11" +version = "2.2.13" description = "File identification library for Python" category = "dev" optional = false @@ -601,7 +601,7 @@ codegen = ["lxml"] [[package]] name = "pep8-naming" -version = "0.12.0" +version = "0.12.1" description = "Check PEP-8 naming conventions, plugin for flake8" category = "dev" optional = false @@ -611,6 +611,20 @@ python-versions = "*" flake8 = ">=3.9.1" flake8-polyfill = ">=1.0.2,<2" +[[package]] +name = "pip-licenses" +version = "3.5.1" +description = "Dump the software license list of Python packages installed with pip." +category = "dev" +optional = false +python-versions = "~=3.6" + +[package.dependencies] +PTable = "*" + +[package.extras] +test = ["docutils", "pytest-cov", "pytest-pycodestyle", "pytest-runner"] + [[package]] name = "platformdirs" version = "2.2.0" @@ -636,7 +650,7 @@ dev = ["pre-commit", "tox"] [[package]] name = "pre-commit" -version = "2.13.0" +version = "2.14.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -661,6 +675,14 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.extras] test = ["ipaddress", "mock", "unittest2", "enum34", "pywin32", "wmi"] +[[package]] +name = "ptable" +version = "0.9.2" +description = "A simple Python library for easily displaying tabular data in a visually appealing ASCII table format" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "py" version = "1.10.0" @@ -851,7 +873,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [[package]] name = "rapidfuzz" -version = "1.4.1" +version = "1.5.0" description = "rapid fuzzy string matching" category = "main" optional = false @@ -1015,7 +1037,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.7.0" +version = "20.7.2" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -1047,7 +1069,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "f46fe1d2d9e0621e4e06d4c2ba5f6190ec4574ac6ca809abe8bf542a3b55204e" +content-hash = "dfa0b6e9cc8373cfa5c3bcf35c352b79a08ecca8fe23f21737d8beb6a8c89fa4" [metadata.files] aio-pika = [ @@ -1186,8 +1208,8 @@ cffi = [ {file = "cffi-1.14.6.tar.gz", hash = "sha256:c9a875ce9d7fe32887784274dd533c57909b7b1dcadcc128a2ac21331a9765dd"}, ] cfgv = [ - {file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"}, - {file = "cfgv-3.3.0.tar.gz", hash = "sha256:9e600479b3b99e8af981ecdfc80a0296104ee610cab48a5ae4ffd0b668650eb1"}, + {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, + {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] chardet = [ {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, @@ -1286,8 +1308,8 @@ execnet = [ {file = "execnet-1.9.0.tar.gz", hash = "sha256:8f694f3ba9cc92cab508b152dcfe322153975c29bda272e2fd7f3f00f36e47c5"}, ] fakeredis = [ - {file = "fakeredis-1.5.2-py3-none-any.whl", hash = "sha256:f1ffdb134538e6d7c909ddfb4fc5edeb4a73d0ea07245bc69b8135fbc4144b04"}, - {file = "fakeredis-1.5.2.tar.gz", hash = "sha256:18fc1808d2ce72169d3f11acdb524a00ef96bd29970c6d34cfeb2edb3fc0c020"}, + {file = "fakeredis-1.6.0-py3-none-any.whl", hash = "sha256:3449b306f3a85102b28f8180c24722ef966fcb1e3c744758b6f635ec80321a5c"}, + {file = "fakeredis-1.6.0.tar.gz", hash = "sha256:11ccfc9769d718d37e45b382e64a6ba02586b622afa0371a6bd85766d72255f3"}, ] feedparser = [ {file = "feedparser-6.0.8-py3-none-any.whl", hash = "sha256:1b7f57841d9cf85074deb316ed2c795091a238adb79846bc46dccdaf80f9c59a"}, @@ -1326,8 +1348,8 @@ flake8-string-format = [ {file = "flake8_string_format-0.3.0-py2.py3-none-any.whl", hash = "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af"}, ] flake8-tidy-imports = [ - {file = "flake8-tidy-imports-4.3.0.tar.gz", hash = "sha256:e66d46f58ed108f36da920e7781a728dc2d8e4f9269e7e764274105700c0a90c"}, - {file = "flake8_tidy_imports-4.3.0-py3-none-any.whl", hash = "sha256:d6e64cb565ca9474d13d5cb3f838b8deafb5fed15906998d4a674daf55bd6d89"}, + {file = "flake8-tidy-imports-4.4.1.tar.gz", hash = "sha256:c18b3351b998787db071e766e318da1f0bd9d5cecc69c4022a69e7aa2efb2c51"}, + {file = "flake8_tidy_imports-4.4.1-py3-none-any.whl", hash = "sha256:631a1ba9daaedbe8bb53f6086c5a92b390e98371205259e0e311a378df8c3dc8"}, ] flake8-todo = [ {file = "flake8-todo-0.7.tar.gz", hash = "sha256:6e4c5491ff838c06fe5a771b0e95ee15fc005ca57196011011280fc834a85915"}, @@ -1380,8 +1402,8 @@ humanfriendly = [ {file = "humanfriendly-9.2.tar.gz", hash = "sha256:f7dba53ac7935fd0b4a2fc9a29e316ddd9ea135fb3052d3d0279d10c18ff9c48"}, ] identify = [ - {file = "identify-2.2.11-py2.py3-none-any.whl", hash = "sha256:7abaecbb414e385752e8ce02d8c494f4fbc780c975074b46172598a28f1ab839"}, - {file = "identify-2.2.11.tar.gz", hash = "sha256:a0e700637abcbd1caae58e0463861250095dfe330a8371733a471af706a4a29a"}, + {file = "identify-2.2.13-py2.py3-none-any.whl", hash = "sha256:7199679b5be13a6b40e6e19ea473e789b11b4e3b60986499b1f589ffb03c217c"}, + {file = "identify-2.2.13.tar.gz", hash = "sha256:7bc6e829392bd017236531963d2d937d66fc27cadc643ac0aba2ce9f26157c79"}, ] idna = [ {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, @@ -1510,8 +1532,12 @@ pamqp = [ {file = "pamqp-2.3.0.tar.gz", hash = "sha256:5cd0f5a85e89f20d5f8e19285a1507788031cfca4a9ea6f067e3cf18f5e294e8"}, ] pep8-naming = [ - {file = "pep8-naming-0.12.0.tar.gz", hash = "sha256:1f9a3ecb2f3fd83240fd40afdd70acc89695c49c333413e49788f93b61827e12"}, - {file = "pep8_naming-0.12.0-py2.py3-none-any.whl", hash = "sha256:2321ac2b7bf55383dd19a6a9c8ae2ebf05679699927a3af33e60dd7d337099d3"}, + {file = "pep8-naming-0.12.1.tar.gz", hash = "sha256:bb2455947757d162aa4cad55dba4ce029005cd1692f2899a21d51d8630ca7841"}, + {file = "pep8_naming-0.12.1-py2.py3-none-any.whl", hash = "sha256:4a8daeaeb33cfcde779309fc0c9c0a68a3bbe2ad8a8308b763c5068f86eb9f37"}, +] +pip-licenses = [ + {file = "pip-licenses-3.5.1.tar.gz", hash = "sha256:6c60096cfa1ee04d7db8d374d4326939369d5871368421e7a7d5da026519bc24"}, + {file = "pip_licenses-3.5.1-py3-none-any.whl", hash = "sha256:e44e4e92e6af2b9d11dfafbf22a3052bf9a05fdaa5654befdba9db735832ac7f"}, ] platformdirs = [ {file = "platformdirs-2.2.0-py3-none-any.whl", hash = "sha256:4666d822218db6a262bdfdc9c39d21f23b4cfdb08af331a81e92751daf6c866c"}, @@ -1522,8 +1548,8 @@ pluggy = [ {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] pre-commit = [ - {file = "pre_commit-2.13.0-py2.py3-none-any.whl", hash = "sha256:b679d0fddd5b9d6d98783ae5f10fd0c4c59954f375b70a58cbe1ce9bcf9809a4"}, - {file = "pre_commit-2.13.0.tar.gz", hash = "sha256:764972c60693dc668ba8e86eb29654ec3144501310f7198742a767bec385a378"}, + {file = "pre_commit-2.14.0-py2.py3-none-any.whl", hash = "sha256:ec3045ae62e1aa2eecfb8e86fa3025c2e3698f77394ef8d2011ce0aedd85b2d4"}, + {file = "pre_commit-2.14.0.tar.gz", hash = "sha256:2386eeb4cf6633712c7cc9ede83684d53c8cafca6b59f79c738098b51c6d206c"}, ] psutil = [ {file = "psutil-5.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0066a82f7b1b37d334e68697faba68e5ad5e858279fd6351c8ca6024e8d6ba64"}, @@ -1555,6 +1581,9 @@ psutil = [ {file = "psutil-5.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:f4634b033faf0d968bb9220dd1c793b897ab7f1189956e1aa9eae752527127d3"}, {file = "psutil-5.8.0.tar.gz", hash = "sha256:0c9ccb99ab76025f2f0bbecf341d4656e9c1351db8cc8a03ccd62e318ab4b5c6"}, ] +ptable = [ + {file = "PTable-0.9.2.tar.gz", hash = "sha256:aa7fc151cb40f2dabcd2275ba6f7fd0ff8577a86be3365cd3fb297cbe09cc292"}, +] py = [ {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, @@ -1679,67 +1708,67 @@ pyyaml = [ {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] rapidfuzz = [ - {file = "rapidfuzz-1.4.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:72878878d6744883605b5453c382361716887e9e552f677922f76d93d622d8cb"}, - {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:56a67a5b3f783e9af73940f6945366408b3a2060fc6ab18466e5a2894fd85617"}, - {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f5d396b64f8ae3a793633911a1fb5d634ac25bf8f13d440139fa729131be42d8"}, - {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:4990698233e7eda7face7c09f5874a09760c7524686045cbb10317e3a7f3225f"}, - {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:a87e212855b18a951e79ec71d71dbd856d98cd2019d0c2bd46ec30688a8aa68a"}, - {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1897d2ef03f5b51bc19bdb2d0398ae968766750fa319843733f0a8f12ddde986"}, - {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2014_ppc64le.whl", hash = "sha256:e1fc4fd219057f5f1fa40bb9bc5e880f8ef45bf19350d4f5f15ca2ce7f61c99b"}, - {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2014_s390x.whl", hash = "sha256:21300c4d048798985c271a8bf1ed1611902ebd4479fcacda1a3eaaebbad2f744"}, - {file = "rapidfuzz-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:d2659967c6ac74211a87a1109e79253e4bc179641057c64800ef4e2dc0534fdb"}, - {file = "rapidfuzz-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:26ac4bfe564c516e053fc055f1543d2b2433338806738c7582e1f75ed0485f7e"}, - {file = "rapidfuzz-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3b485c98ad1ce3c04556f65aaab5d6d6d72121cde656d43505169c71ae956476"}, - {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:59db06356eaf22c83f44b0dded964736cbb137291cdf2cf7b4974c0983b94932"}, - {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fef95249af9a535854b617a68788c38cd96308d97ee14d44bc598cc73e986167"}, - {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:7d8c186e8270e103d339b26ef498581cf3178470ccf238dfd5fd0e47d80e4c7d"}, - {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:9246b9c5c8992a83a08ac7813c8bbff2e674ad0b681f9b3fb1ec7641eff6c21f"}, - {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:f58c17f7a82b1bcc2ce304942cae14287223e6b6eead7071241273da7d9b9770"}, - {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:ed708620b23a09ac52eaaec0761943c1bbc9a62d19ecd2feb4da8c3f79ef9d37"}, - {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:bdec9ae5fd8a8d4d8813b4aac3505c027b922b4033a32a7aab66a9b2f03a7b47"}, - {file = "rapidfuzz-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:fc668fd706ad1162ce14f26ca2957b4690d47770d23609756536c918a855ced0"}, - {file = "rapidfuzz-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f9f35df5dd9b02669ff6b1d4a386607ff56982c86a7e57d95eb08c6afbab4ddd"}, - {file = "rapidfuzz-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8427310ea29ce2968e1c6f6779ae5a458b3a4984f9150fc4d16f92b96456f848"}, - {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1430dc745476e3798742ad835f61f6e6bf5d3e9a22cf9cd0288b28b7440a9872"}, - {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1d20311da611c8f4638a09e2bc5e04b327bae010cb265ef9628d9c13c6d5da7b"}, - {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7881965e428cf6fe248d6e702e6d5857da02278ab9b21313bee717c080e443e"}, - {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:f76c965f15861ec4d39e904bd65b84a39121334439ac17bfb8b900d1e6779a93"}, - {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:61167f989415e701ac379de247e6b0a21ea62afc86c54d8a79f485b4f0173c02"}, - {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:645cfb9456229f0bd5752b3eda69f221d825fbb8cbb8855433516bc185111506"}, - {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:c28be57c9bc47b3d7f484340fab1bec8ed4393dee1090892c2774a4584435eb8"}, - {file = "rapidfuzz-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:3c94b6d3513c693f253ff762112cc4580d3bd377e4abacb96af31a3d606fbe14"}, - {file = "rapidfuzz-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:506d50a066451502ee2f8bf016bc3ba3e3b04eede7a4059d7956248e2dd96179"}, - {file = "rapidfuzz-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:80b375098658bb3db14215a975d354f6573d3943ac2ae0c4627c7760d57ce075"}, - {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ba8f7cbd8fdbd3ae115f4484888f3cb94bc2ac7cbd4eb1ca95a3d4f874261ff8"}, - {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:5fa8570720b0fdfc52f24f5663d66c52ea88ba19cb8b1ff6a39a8bc0b925b33b"}, - {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:f35c8a4c690447fd335bfd77df4da42dfea37cfa06a8ecbf22543d86dc720e12"}, - {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:27f9eef48e212d73e78f0f5ceedc62180b68f6a25fa0752d2ccfaedc3a840bec"}, - {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:31e99216e2a04aec4f281d472b28a683921f1f669a429cf605d11526623eaeed"}, - {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:f22bf7ba6eddd59764457f74c637ab5c3ed976c5fcfaf827e1d320cc0478e12b"}, - {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:c43ddb354abd00e56f024ce80affb3023fa23206239bb81916d5877cba7f2d1e"}, - {file = "rapidfuzz-1.4.1-cp38-cp38-win32.whl", hash = "sha256:62c1f4ac20c8019ce8d481fb27235306ef3912a8d0b9a60b17905699f43ff072"}, - {file = "rapidfuzz-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:2963f356c70b710dc6337b012ec976ce2fc2b81c2a9918a686838fead6eb4e1d"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c07f301fd549b266410654850c6918318d7dcde8201350e9ac0819f0542cf147"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa4c8b6fc7e93e3a3fb9be9566f1fe7ef920735eadcee248a0d70f3ca8941341"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c200bd813bbd3b146ba0fd284a9ad314bbad9d95ed542813273bdb9d0ee4e796"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2cccc84e1f0c6217747c09cafe93164e57d3644e18a334845a2dfbdd2073cd2c"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f2033e3d61d1e498f618123b54dc7436d50510b0d18fd678d867720e8d7b2f23"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:26b7f48b3ddd9d97cf8482a88f0f6cba47ac13ff16e63386ea7ce06178174770"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:bf18614f87fe3bfff783f0a3d0fad0eb59c92391e52555976e55570a651d2330"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:8cb5c2502ff06028a1468bdf61323b53cc3a37f54b5d62d62c5371795b81086a"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:f37f80c1541d6e0a30547261900086b8c0bac519ebc12c9cd6b61a9a43a7e195"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:c13cd1e840aa93639ac1d131fbfa740a609fd20dfc2a462d5cd7bce747a2398d"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-win32.whl", hash = "sha256:0ec346f271e96c485716c091c8b0b78ba52da33f7c6ebb52a349d64094566c2d"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:5208ce1b1989a10e6fc5b5ef5d0bb7d1ffe5408838f3106abde241aff4dab08c"}, - {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4fa195ea9ca35bacfa2a4319c6d4ab03aa6a283ad2089b70d2dfa0f6a7d9c1bc"}, - {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:6e336cfd8103b0b38e107e01502e9d6bf7c7f04e49b970fb11a4bf6c7a932b94"}, - {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:c798c5b87efe8a7e63f408e07ff3bc03ba8b94f4498a89b48eaab3a9f439d52c"}, - {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-win32.whl", hash = "sha256:bb16a10b40f5bd3c645f7748fbd36f49699a03f550c010a2c665905cc8937de8"}, - {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2278001924031d9d75f821bff2c5fef565c8376f252562e04d8eec8857475c36"}, - {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-manylinux1_x86_64.whl", hash = "sha256:a89d11f3b5da35fdf3e839186203b9367d56e2be792e8dccb098f47634ec6eb9"}, - {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:f8c79cd11b4778d387366a59aa747f5268433f9d68be37b00d16f4fb08fdf850"}, - {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-win32.whl", hash = "sha256:4364db793ed4b439f9dd28a335bee14e2a828283d3b93c2d2686cc645eeafdd5"}, - {file = "rapidfuzz-1.4.1.tar.gz", hash = "sha256:de20550178376d21bfe1b34a7dc42ab107bb282ef82069cf6dfe2805a0029e26"}, + {file = "rapidfuzz-1.5.0-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:670a330e90e962de5823e01e8ae1b8903af788325fbce1ef3fd5ece4d22e0ba4"}, + {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:079afafa6e6b00ee799e16d9fc6c6522132cbd7742a7a9e78bd301321e1b5ad6"}, + {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:26cb066e79c9867d313450514bb70124d392ac457640c4ec090d29eb68b75541"}, + {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:542fbe8fb4403af36bfffd53e42cb1ff3f8d969a046208373d004804072b744c"}, + {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:407a5c4d2af813e803b828b004f8686300baf298e9bf90b3388a568b1637a8dc"}, + {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:662b4021951ac9edb9a0d026820529e891cea69c11f280188c5b80fefe6ee257"}, + {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2014_ppc64le.whl", hash = "sha256:03c97beb1c7ce5cb1d12bbb8eb87777e9a5fad23216dab78d6850cafdd3ecaf1"}, + {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2014_s390x.whl", hash = "sha256:eaafa0349d47850ed2c3ae121b62e078a63daf1d533b1cd43fca0c675a85a025"}, + {file = "rapidfuzz-1.5.0-cp35-cp35m-win32.whl", hash = "sha256:f0b7e15209208ee74bc264b97e111a3c73e19336eda7255c406e56cc6fbbd384"}, + {file = "rapidfuzz-1.5.0-cp35-cp35m-win_amd64.whl", hash = "sha256:0679af3d85082dcb27e75ea30c5047dbcc99340f38490c7d4769ae16909c246a"}, + {file = "rapidfuzz-1.5.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3a3ef319fd1162e7e38bf11259d86fc6ea3885d2abae6359e5b4dafad62592db"}, + {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:60ea1cee33a5a847aeac91a35865c6f7f35a87613df282bda2e7f984e91526f5"}, + {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2ba6ffe8ac66dbeae91a0b2cb50f4836ec16920f58746eaf46ff3e9c4f9c0ad8"}, + {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:7c101bafb27436affcaa14c631e2bf99d6a7a7860a201ce17ee98447c9c0e7f4"}, + {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:a8f3f374b4e8e80516b955a1da6364c526d480311a5c6be48264cf7dc06d2fba"}, + {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:f2fe161526cce52eae224c2af9ae1b9c475ae3e1001fe76024603b290bc8f719"}, + {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:8b086b2f70571c9bf16ead5f65976414f8e75a1c680220a839b8ddf005743060"}, + {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:814cd474c31db0383c69eed5b457571f63521f38829955c842b141b4835f067f"}, + {file = "rapidfuzz-1.5.0-cp36-cp36m-win32.whl", hash = "sha256:0a901aa223a4b051846cb828c33967a6f9c66b8fe0ba7e2a4dc70f6612006988"}, + {file = "rapidfuzz-1.5.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f03a5fa9fe38d7f8d566bff0b66600f488d56700469bf1e5e36078f4b58290b6"}, + {file = "rapidfuzz-1.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:122b7c25792eb27ca59ab23623a922a7290d881d296556d0c23da63ed1691cd5"}, + {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:73509dbfcf556233d62683aed0e5f23282ec7138eeedc3ecda2938ad8e8c969d"}, + {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6e8c4fd87361699e0cf5cf7ff075e4cd70a2698e9f914368f0c3e198c77c755c"}, + {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d627ec73d324d804af4c95909e2fa30b0e59f7efaf69264e553a0e498034404b"}, + {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:c57f3b74942ae0d0869336e613cbd0760de61a462ff441095eb5fca6575cf964"}, + {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:075b8bf76dd4bbc9ccb5177806c9867424d365898415433bf88e7b8e88dc4dfe"}, + {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:8049a500b431724d283ddf97d67fe48aa67b4523d617a203c22fd9da3a496223"}, + {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:a2d84fde07c32514758d283dd1227453db3ed5372a3e9eae85d0c29b2953f252"}, + {file = "rapidfuzz-1.5.0-cp37-cp37m-win32.whl", hash = "sha256:0e35b9b92a955018ebd09d4d9d70f8e81a0106fe1ed04bc82e3a05166cd04ea5"}, + {file = "rapidfuzz-1.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:8ae7bf62f0382d13e9b36babc897742bac5e7ee04b4e5e94cd67085bfccfd2fd"}, + {file = "rapidfuzz-1.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:466d9c644fa235278ef376eefb1fc4382107b07764fbc3c7280533ad9ce49bb4"}, + {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:d04a8465738363d0b9ee39abb3b289e1198d1f3cbc98bc43b8e21ec8e0b21774"}, + {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2c1ce8e8419ac8462289a6e021b8802701ea0f111ebde7607ba3c9588c3d6f30"}, + {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:f44564a29e96af0925e68733859d8247a692968034e1b37407d9cfa746d3a853"}, + {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d2d1bea50f54387bc1e82b93f6e3a433084e0fa538a7ada8e4d4d7200bae4b83"}, + {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:b409f0f86a316b6132253258185c7b011e779ed2170d1ad83c79515fea7d78c8"}, + {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:bf5a6f4f2eb44f32271e9c2d1e46b657764dbd1b933dd84d7c0433eab48741f8"}, + {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bbdee2e3c2cee9c59e1d1a3f351760a1b510e96379d14ba2fa2484a79f56d0ea"}, + {file = "rapidfuzz-1.5.0-cp38-cp38-win32.whl", hash = "sha256:575a0eceaf84632f2014fd55a42a0621e448115adf6fcbc2b0e5c7ae1c18b501"}, + {file = "rapidfuzz-1.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:cd6603b94e2a3d56d143a5100f8f3c1d29ad8f5416bdc2a25b079f96eee3c306"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3fa261479e3828eff1f3d0265def8d0d893f2e2f90692d5dae96b3f4ae44d69e"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7a386fe0aad7e89b5017768492ea085d241c32f6dc5a6774b0a309d28f61e720"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68156a67d541bb4584cb31e366fb7de9326f5b77ed07f9882e9b9aaa40b2e5b8"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:b62b2a2d2532d357d1b970107a90e85305bdd8e302995dd251f67a19495033f5"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:190b48ba8e3fbcb1cfc522300dbd6a007f50c13cd71002c95bd3946a63b749f6"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:51f9ac3316e713b4a10554a4d6b75fe6f802dd9b4073082cc98968ace6377cac"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:e00198aa7ca8408616d9821501ff90157c429c952d55a2a53987a9b064f73d49"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:5784c24e2de539064d8d5ce3f68756630b54fc33af31e054373a65bbed68823a"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:712a4d510c466d6ca75138dad53a1cbd8db0da4bbfa5fc431fcebb0a426e5323"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:2647e00e2211ed741aecb4e676461b7202ce46d536c3439ede911b088432b7a4"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-win32.whl", hash = "sha256:0b77ca0dacb129e878c2583295b76e12da890bd091115417d23b4049b02c2566"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:dec0d429d117ffd7df1661e5f6ca56bfb6806e117be0b75b5d414df43aa4b6d5"}, + {file = "rapidfuzz-1.5.0-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a533d17d177d11b7c177c849adb728035621462f6ce2baaeb9cf1f42ba3e326c"}, + {file = "rapidfuzz-1.5.0-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:ac9a2d5a47a4a4eab060882a162d3626889abdec69f899a59fe7b9e01ce122c9"}, + {file = "rapidfuzz-1.5.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:0e6e2f02bb67a35d75a5613509bb49f0050c0ec4471a9af14da3ad5488d6d5ff"}, + {file = "rapidfuzz-1.5.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:8c61ced6729146e695ecad403165bf3a07e60b8e8a18df91962b3abf72aae6d5"}, + {file = "rapidfuzz-1.5.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:360415125e967d8682291f00bcea311c738101e0aee4cb90e5572d7e54483f0d"}, + {file = "rapidfuzz-1.5.0-pp37-pypy37_pp73-manylinux1_x86_64.whl", hash = "sha256:2fb9d47fc16a2e8f5e900c8334d823a7307148ea764321f861b876f85a880d57"}, + {file = "rapidfuzz-1.5.0-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:2134ac91e8951d42c9a7de131d767580b8ac50820475221024e5bd63577a376f"}, + {file = "rapidfuzz-1.5.0-pp37-pypy37_pp73-win32.whl", hash = "sha256:04c4fd372e858f25e0898ba27b5bb7ed8dc528b0915b7aa02d20237e9cdd4feb"}, + {file = "rapidfuzz-1.5.0.tar.gz", hash = "sha256:141ee381c16f7e58640ef1f1dbf76beb953d248297a7165f7ba25d81ac1161c7"}, ] redis = [ {file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"}, @@ -1837,8 +1866,8 @@ urllib3 = [ {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"}, ] virtualenv = [ - {file = "virtualenv-20.7.0-py2.py3-none-any.whl", hash = "sha256:fdfdaaf0979ac03ae7f76d5224a05b58165f3c804f8aa633f3dd6f22fbd435d5"}, - {file = "virtualenv-20.7.0.tar.gz", hash = "sha256:97066a978431ec096d163e72771df5357c5c898ffdd587048f45e0aecc228094"}, + {file = "virtualenv-20.7.2-py2.py3-none-any.whl", hash = "sha256:e4670891b3a03eb071748c569a87cceaefbf643c5bac46d996c5a45c34aa0f06"}, + {file = "virtualenv-20.7.2.tar.gz", hash = "sha256:9ef4e8ee4710826e98ff3075c9a4739e2cb1040de6a2a8d35db0055840dc96a0"}, ] yarl = [ {file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"}, diff --git a/pyproject.toml b/pyproject.toml index 2ae79f9e4..865fa1d3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ flake8-todo = "~=0.7" pep8-naming = "~=0.9" pre-commit = "~=2.1" taskipy = "~=1.7.0" +pip-licenses = "~=3.5.1" python-dotenv = "~=0.17.1" pytest = "~=6.2.4" pytest-cov = "~=2.12.1" -- cgit v1.2.3 From fe840ca63647d6d6e92760cb4f0f49ac67926954 Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 25 Aug 2021 17:32:06 +0100 Subject: Add time units to the `!remind edit` and `!remind edit duration` help messages --- bot/exts/utils/reminders.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 9d567cdbc..2bed5157f 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -372,7 +372,20 @@ class Reminders(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.""" + """ + Commands for modifying your current reminders. + + The `expiration` duration supports the following symbols for each unit of time: + - years: `Y`, `y`, `year`, `years` + - months: `m`, `month`, `months` + - weeks: `w`, `W`, `week`, `weeks` + - days: `d`, `D`, `day`, `days` + - hours: `H`, `h`, `hour`, `hours` + - minutes: `M`, `minute`, `minutes` + - seconds: `S`, `s`, `second`, `seconds` + + For example, to edit a reminder to expire in 3 days and 1 minute, you can do `!remind edit duration 1234 3d1M`. + """ await ctx.send_help(ctx.command) @edit_reminder_group.command(name="duration", aliases=("time",)) @@ -380,7 +393,16 @@ class Reminders(Cog): """ Edit one of your reminder's expiration. - Expiration is parsed per: http://strftime.org/ + The `expiration` duration supports the following symbols for each unit of time: + - years: `Y`, `y`, `year`, `years` + - months: `m`, `month`, `months` + - weeks: `w`, `W`, `week`, `weeks` + - days: `d`, `D`, `day`, `days` + - hours: `H`, `h`, `hour`, `hours` + - minutes: `M`, `minute`, `minutes` + - seconds: `S`, `s`, `second`, `seconds` + + For example, to edit a reminder to expire in 3 days and 1 minute, you can do `!remind edit duration 1234 3d1M`. """ await self.edit_reminder(ctx, id_, {'expiration': expiration.isoformat()}) -- cgit v1.2.3 From 52bed3d8fab847d7eb9df003b6be7758f6cdbd1d Mon Sep 17 00:00:00 2001 From: Izan Date: Thu, 26 Aug 2021 08:15:32 +0100 Subject: Add message link support to `!user` command --- bot/exts/info/information.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 38b436b7d..ae547b1b8 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -221,8 +221,13 @@ class Information(Cog): await ctx.send(embed=embed) @command(name="user", aliases=["user_info", "member", "member_info", "u"]) - async def user_info(self, ctx: Context, user: MemberOrUser = None) -> None: + async def user_info(self, ctx: Context, user_or_message: Union[MemberOrUser, Message] = None) -> None: """Returns info about a user.""" + if isinstance(user_or_message, Message): + user = user_or_message.author + else: + user = user_or_message + if user is None: user = ctx.author -- cgit v1.2.3 From 78d8bde653187cbf23a2f0ef8a6952058ad95bf4 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 26 Aug 2021 16:39:04 +0100 Subject: Use publically available URL when outputting metabase share URL In production we use the internal URL to call the metabase API, to avoid egress but we still want to output the public url when giving the sharing link. Making it a constant like this makes it easier to change/overwrite in future if needed. --- bot/constants.py | 1 + bot/exts/moderation/metabase.py | 2 +- config-default.yml | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index 80e01b174..4e99df7f3 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -569,6 +569,7 @@ class Metabase(metaclass=YAMLGetter): username: Optional[str] password: Optional[str] base_url: str + public_url: str max_session_age: int diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py index 3b454ab18..9eeeec074 100644 --- a/bot/exts/moderation/metabase.py +++ b/bot/exts/moderation/metabase.py @@ -167,7 +167,7 @@ class Metabase(Cog): async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp: response_json = await resp.json(encoding="utf-8") - sharing_url = f"{MetabaseConfig.base_url}/public/question/{response_json['uuid']}" + sharing_url = f"{MetabaseConfig.public_url}/public/question/{response_json['uuid']}" await ctx.send(f":+1: {ctx.author.mention} Here's your sharing link: {sharing_url}") # This cannot be static (must have a __func__ attribute). diff --git a/config-default.yml b/config-default.yml index 8e0b97a51..baece5c51 100644 --- a/config-default.yml +++ b/config-default.yml @@ -441,6 +441,7 @@ metabase: username: !ENV "METABASE_USERNAME" password: !ENV "METABASE_PASSWORD" base_url: "http://metabase.default.svc.cluster.local" + public_url: "https://metabase.pythondiscord.com" # 14 days, see https://www.metabase.com/docs/latest/operations-guide/environment-variables.html#max_session_age max_session_age: 20160 -- cgit v1.2.3 From b0ea616add8f6122ba5b98b348a4fa5d41774d21 Mon Sep 17 00:00:00 2001 From: Hunter2807 <46440327+Hunter2807@users.noreply.github.com> Date: Fri, 27 Aug 2021 07:39:26 +0530 Subject: Added bot variables tag (#1784) * Added a new tag with the name bot_var Co-authored-by: Bluenix --- bot/resources/tags/bot_var.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 bot/resources/tags/bot_var.md diff --git a/bot/resources/tags/bot_var.md b/bot/resources/tags/bot_var.md new file mode 100644 index 000000000..6833b3cd8 --- /dev/null +++ b/bot/resources/tags/bot_var.md @@ -0,0 +1,23 @@ +Python allows you to set custom attributes to class instances, like your bot! By adding variables as attributes to your bot you can access them anywhere you access your bot. In the discord.py library, these custom attributes are commonly known as "bot variables" and can be a lifesaver if your bot is divided into many different files. An example on how to use custom attributes on your bot is shown below: + +```py +bot = commands.Bot(command_prefix="!") +# Set an attribute on our bot +bot.test = "I am accessible everywhere!" + +@bot.command() +async def get(ctx: commands.Context): + """A command to get the current value of `test`.""" + # Send what the test attribute is currently set to + await ctx.send(ctx.bot.test) + +@bot.command() +async def setval(ctx: commands.Context, *, new_text: str): + """A command to set a new value of `test`.""" + # Here we change the attribute to what was specified in new_text + bot.test = new_text +``` + +This all applies to cogs as well! You can set attributes to `self` as you wish. + +*Be sure **not** to overwrite attributes discord.py uses, like `cogs` or `users`. Name your attributes carefully!* -- cgit v1.2.3 From 97aa87a2d3d262cfa06e922fa93889cc26b7c2cb Mon Sep 17 00:00:00 2001 From: mbaruh Date: Fri, 27 Aug 2021 14:49:29 +0300 Subject: Moved clean cog to moderation ext The cog is moderation related and all commands are exclusive to moderators. --- bot/exts/moderation/clean.py | 388 +++++++++++++++++++++++++++++++++++++++++++ bot/exts/utils/clean.py | 388 ------------------------------------------- 2 files changed, 388 insertions(+), 388 deletions(-) create mode 100644 bot/exts/moderation/clean.py delete mode 100644 bot/exts/utils/clean.py diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py new file mode 100644 index 000000000..25582165a --- /dev/null +++ b/bot/exts/moderation/clean.py @@ -0,0 +1,388 @@ +import logging +import re +import time +from collections import defaultdict +from typing import Any, Callable, DefaultDict, Iterable, List, Optional, Tuple + +from discord import Colour, Embed, Message, NotFound, TextChannel, User, errors +from discord.ext import commands +from discord.ext.commands import Cog, Context, group, has_any_role +from discord.ext.commands.errors import BadArgument, MaxConcurrencyReached, MissingRequiredArgument + +from bot.bot import Bot +from bot.constants import ( + Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES +) +from bot.exts.moderation.modlog import ModLog +from bot.utils.channel import is_mod_channel + +log = logging.getLogger(__name__) + +# Type alias for checks +Predicate = Callable[[Message], bool] + + +class Clean(Cog): + """ + A cog that allows messages to be deleted in bulk, while applying various filters. + + You can delete messages sent by a specific user, messages sent by bots, all messages, or messages that match a + specific regular expression. + + The deleted messages are saved and uploaded to the database via an API endpoint, and a URL is returned which can be + used to view the messages in the Discord dark theme style. + """ + + def __init__(self, bot: Bot): + self.bot = bot + self.cleaning = False + + @property + def mod_log(self) -> ModLog: + """Get currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @staticmethod + def is_older_than_14d(message: Message) -> bool: + """ + Precisely checks if message is older than 14 days, bulk deletion limit. + + Inspired by how purge works internally. + Comparison on message age could possibly be less accurate which in turn would resort in problems + with message deletion if said messages are very close to the 14d mark. + """ + two_weeks_old_snowflake = int((time.time() - 14 * 24 * 60 * 60) * 1000.0 - 1420070400000) << 22 + return message.id < two_weeks_old_snowflake + + async def _delete_messages_individually(self, messages: List[Message]) -> None: + for message in messages: + # Ensure that deletion was not canceled + if not self.cleaning: + return + try: + await message.delete() + except NotFound: + # Message doesn't exist or was already deleted + continue + + def _get_messages_from_cache(self, amount: int, to_delete: Predicate) -> Tuple[DefaultDict, List[int]]: + """Helper function for getting messages from the cache.""" + message_mappings = defaultdict(list) + message_ids = [] + for message in self.bot.cached_messages: + if not self.cleaning: + # Cleaning was canceled + return (message_mappings, message_ids) + + if to_delete(message): + message_mappings[message.channel].append(message) + message_ids.append(message.id) + + if len(message_ids) == amount: + # We've got the requested amount of messages + return message_mappings, message_ids + + # Amount exceeds amount of messages matching the check + return message_mappings, message_ids + + async def _get_messages_from_channels( + self, + amount: int, + channels: Iterable[TextChannel], + to_delete: Predicate, + until_message: Optional[Message] = None + ) -> tuple[defaultdict[Any, list], list]: + message_mappings = defaultdict(list) + message_ids = [] + + for channel in channels: + + async for message in channel.history(limit=amount): + + if not self.cleaning: + # Cleaning was canceled, return empty containers + return defaultdict(list), [] + + if until_message: + + # We could use ID's here however in case if the message we are looking for gets deleted, + # We won't have a way to figure that out thus checking for datetime should be more reliable + if message.created_at < until_message.created_at: + # Means we have found the message until which we were supposed to be deleting. + break + + if to_delete(message): + message_mappings[message.channel].append(message) + message_ids.append(message.id) + + return message_mappings, message_ids + + async def _clean_messages( + self, + amount: int, + ctx: Context, + channels: Iterable[TextChannel], + bots_only: bool = False, + use_cache: bool = False, + user: User = None, + regex: Optional[str] = None, + until_message: Optional[Message] = None, + after_message: Optional[Message] = None, + ) -> None: + """A helper function that does the actual message cleaning.""" + def predicate_bots_only(message: Message) -> bool: + """Return True if the message was sent by a bot.""" + return message.author.bot + + def predicate_specific_user(message: Message) -> bool: + """Return True if the message was sent by the user provided in the _clean_messages call.""" + return message.author == user + + def predicate_regex(message: Message) -> bool: + """Check if the regex provided in _clean_messages matches the message content or any embed attributes.""" + content = [message.content] + + # Add the content for all embed attributes + for embed in message.embeds: + content.append(embed.title) + content.append(embed.description) + content.append(embed.footer.text) + content.append(embed.author.name) + for field in embed.fields: + content.append(field.name) + content.append(field.value) + + # Get rid of empty attributes and turn it into a string + content = [attr for attr in content if attr] + content = "\n".join(content) + + # Now let's see if there's a regex match + if not content: + return False + else: + return bool(re.search(regex.lower(), content.lower())) + + def predicate_range(message: Message) -> bool: + """Check if message is older than message provided in after_message but younger than until_message.""" + return after_message.created_at <= message.created_at <= until_message.created_at + + # Is this an acceptable amount of messages to clean? + if amount > CleanMessages.message_limit: + raise BadArgument(f"You cannot clean more than {CleanMessages.message_limit} messages.") + + if after_message: + # Ensure that until_message is specified. + if not until_message: + raise MissingRequiredArgument("`until_message` must be specified if `after_message` is specified.") + + # Messages are not in same channel + if after_message.channel != until_message.channel: + raise BadArgument("You cannot do range clean across several channel.") + + # Ensure that after_message is younger than until_message + if after_message.created_at >= until_message.created_at: + raise BadArgument("`after` message must be younger than `until` message") + + # Are we already performing a clean? + if self.cleaning: + raise MaxConcurrencyReached("Please wait for the currently ongoing clean operation to complete.") + + # Set up the correct predicate + if bots_only: + predicate = predicate_bots_only # Delete messages from bots + elif user: + predicate = predicate_specific_user # Delete messages from specific user + elif regex: + predicate = predicate_regex # Delete messages that match regex + elif after_message: + predicate = predicate_range # Delete messages older than specific message + else: + predicate = lambda m: True # Delete all messages # noqa: E731 + + # Default to using the invoking context's channel + if not channels: + channels = [ctx.channel] + + if not is_mod_channel(ctx.channel): + + # Delete the invocation first + self.mod_log.ignore(Event.message_delete, ctx.message.id) + try: + await ctx.message.delete() + except errors.NotFound: + # Invocation message has already been deleted + log.info("Tried to delete invocation message, but it was already deleted.") + + self.cleaning = True + + if use_cache: + message_mappings, message_ids = self._get_messages_from_cache(amount=amount, to_delete=predicate) + else: + message_mappings, message_ids = await self._get_messages_from_channels( + amount=amount, + channels=channels, + to_delete=predicate, + until_message=until_message + ) + + if not self.cleaning: + # Means that the cleaning was canceled + return + + # Now let's delete the actual messages with purge. + self.mod_log.ignore(Event.message_delete, *message_ids) + + for channel, messages in message_mappings.items(): + + to_delete = [] + + for current_index, message in enumerate(messages): + + if not self.cleaning: + # Means that the cleaning was canceled + return + + if self.is_older_than_14d(message): + # further messages are too old to be deleted in bulk + await self._delete_messages_individually(messages[current_index:]) + if not self.cleaning: + # Means that deletion was canceled while deleting the individual messages + return + break + + to_delete.append(message) + + if len(to_delete) == 100: + # we can only delete up to 100 messages in a bulk + await channel.delete_messages(to_delete) + to_delete.clear() + + if len(to_delete) > 0: + # deleting any leftover messages if there are any + await channel.delete_messages(to_delete) + + self.cleaning = False + + log_messages = [] + + for messages in message_mappings.values(): + log_messages.extend(messages) + + if log_messages: + # Reverse the list to restore chronological order + log_messages = reversed(log_messages) + log_url = await self.mod_log.upload_log(log_messages, ctx.author.id) + else: + # Can't build an embed, nothing to clean! + raise BadArgument("No matching messages could be found.") + + # Build the embed and send it + target_channels = ", ".join(channel.mention for channel in channels) + + message = ( + f"**{len(message_ids)}** messages deleted in {target_channels} by " + f"{ctx.author.mention}\n\n" + f"A log of the deleted messages can be found [here]({log_url})." + ) + + await self.mod_log.send_log_message( + icon_url=Icons.message_bulk_delete, + colour=Colour(Colours.soft_red), + title="Bulk message delete", + text=message, + channel_id=Channels.mod_log, + ) + + @group(invoke_without_command=True, name="clean", aliases=["clear", "purge"]) + @has_any_role(*MODERATION_ROLES) + async def clean_group(self, ctx: Context) -> None: + """Commands for cleaning messages in channels.""" + await ctx.send_help(ctx.command) + + @clean_group.command(name="user", aliases=["users"]) + @has_any_role(*MODERATION_ROLES) + async def clean_user( + self, + ctx: Context, + user: User, + amount: Optional[int] = 10, + channels: commands.Greedy[TextChannel] = None + ) -> None: + """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages.""" + use_cache = not channels + await self._clean_messages(amount, ctx, user=user, channels=channels, use_cache=use_cache) + + @clean_group.command(name="all", aliases=["everything"]) + @has_any_role(*MODERATION_ROLES) + async def clean_all( + self, + ctx: Context, + amount: Optional[int] = 10, + channels: commands.Greedy[TextChannel] = None + ) -> None: + """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages.""" + await self._clean_messages(amount, ctx, channels=channels) + + @clean_group.command(name="bots", aliases=["bot"]) + @has_any_role(*MODERATION_ROLES) + async def clean_bots( + self, + ctx: Context, + amount: Optional[int] = 10, + channels: commands.Greedy[TextChannel] = None + ) -> None: + """Delete all messages posted by a bot, stop cleaning after traversing `amount` messages.""" + await self._clean_messages(amount, ctx, bots_only=True, channels=channels) + + @clean_group.command(name="regex", aliases=["word", "expression"]) + @has_any_role(*MODERATION_ROLES) + async def clean_regex( + self, + ctx: Context, + regex: str, + amount: Optional[int] = 10, + channels: commands.Greedy[TextChannel] = None + ) -> None: + """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" + use_cache = not channels + await self._clean_messages(amount, ctx, regex=regex, channels=channels, use_cache=use_cache) + + @clean_group.command(name="until") + @has_any_role(*MODERATION_ROLES) + async def clean_until(self, ctx: Context, message: Message) -> None: + """Delete all messages until certain message, stop cleaning after hitting the `message`.""" + await self._clean_messages( + CleanMessages.message_limit, + ctx, + channels=[message.channel], + until_message=message + ) + + @clean_group.command(name="from-to", aliases=["after-until", "between"]) + @has_any_role(*MODERATION_ROLES) + async def clean_from_to(self, ctx: Context, after_message: Message, until_message: Message) -> None: + """Delete all messages within range of messages.""" + await self._clean_messages( + CleanMessages.message_limit, + ctx, + channels=[until_message.channel], + until_message=until_message, + after_message=after_message, + ) + + @clean_group.command(name="stop", aliases=["cancel", "abort"]) + @has_any_role(*MODERATION_ROLES) + async def clean_cancel(self, ctx: Context) -> None: + """If there is an ongoing cleaning process, attempt to immediately cancel it.""" + self.cleaning = False + + embed = Embed( + color=Colour.blurple(), + description="Clean interrupted." + ) + await ctx.send(embed=embed, delete_after=10) + + +def setup(bot: Bot) -> None: + """Load the Clean cog.""" + bot.add_cog(Clean(bot)) diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py deleted file mode 100644 index 25582165a..000000000 --- a/bot/exts/utils/clean.py +++ /dev/null @@ -1,388 +0,0 @@ -import logging -import re -import time -from collections import defaultdict -from typing import Any, Callable, DefaultDict, Iterable, List, Optional, Tuple - -from discord import Colour, Embed, Message, NotFound, TextChannel, User, errors -from discord.ext import commands -from discord.ext.commands import Cog, Context, group, has_any_role -from discord.ext.commands.errors import BadArgument, MaxConcurrencyReached, MissingRequiredArgument - -from bot.bot import Bot -from bot.constants import ( - Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES -) -from bot.exts.moderation.modlog import ModLog -from bot.utils.channel import is_mod_channel - -log = logging.getLogger(__name__) - -# Type alias for checks -Predicate = Callable[[Message], bool] - - -class Clean(Cog): - """ - A cog that allows messages to be deleted in bulk, while applying various filters. - - You can delete messages sent by a specific user, messages sent by bots, all messages, or messages that match a - specific regular expression. - - The deleted messages are saved and uploaded to the database via an API endpoint, and a URL is returned which can be - used to view the messages in the Discord dark theme style. - """ - - def __init__(self, bot: Bot): - self.bot = bot - self.cleaning = False - - @property - def mod_log(self) -> ModLog: - """Get currently loaded ModLog cog instance.""" - return self.bot.get_cog("ModLog") - - @staticmethod - def is_older_than_14d(message: Message) -> bool: - """ - Precisely checks if message is older than 14 days, bulk deletion limit. - - Inspired by how purge works internally. - Comparison on message age could possibly be less accurate which in turn would resort in problems - with message deletion if said messages are very close to the 14d mark. - """ - two_weeks_old_snowflake = int((time.time() - 14 * 24 * 60 * 60) * 1000.0 - 1420070400000) << 22 - return message.id < two_weeks_old_snowflake - - async def _delete_messages_individually(self, messages: List[Message]) -> None: - for message in messages: - # Ensure that deletion was not canceled - if not self.cleaning: - return - try: - await message.delete() - except NotFound: - # Message doesn't exist or was already deleted - continue - - def _get_messages_from_cache(self, amount: int, to_delete: Predicate) -> Tuple[DefaultDict, List[int]]: - """Helper function for getting messages from the cache.""" - message_mappings = defaultdict(list) - message_ids = [] - for message in self.bot.cached_messages: - if not self.cleaning: - # Cleaning was canceled - return (message_mappings, message_ids) - - if to_delete(message): - message_mappings[message.channel].append(message) - message_ids.append(message.id) - - if len(message_ids) == amount: - # We've got the requested amount of messages - return message_mappings, message_ids - - # Amount exceeds amount of messages matching the check - return message_mappings, message_ids - - async def _get_messages_from_channels( - self, - amount: int, - channels: Iterable[TextChannel], - to_delete: Predicate, - until_message: Optional[Message] = None - ) -> tuple[defaultdict[Any, list], list]: - message_mappings = defaultdict(list) - message_ids = [] - - for channel in channels: - - async for message in channel.history(limit=amount): - - if not self.cleaning: - # Cleaning was canceled, return empty containers - return defaultdict(list), [] - - if until_message: - - # We could use ID's here however in case if the message we are looking for gets deleted, - # We won't have a way to figure that out thus checking for datetime should be more reliable - if message.created_at < until_message.created_at: - # Means we have found the message until which we were supposed to be deleting. - break - - if to_delete(message): - message_mappings[message.channel].append(message) - message_ids.append(message.id) - - return message_mappings, message_ids - - async def _clean_messages( - self, - amount: int, - ctx: Context, - channels: Iterable[TextChannel], - bots_only: bool = False, - use_cache: bool = False, - user: User = None, - regex: Optional[str] = None, - until_message: Optional[Message] = None, - after_message: Optional[Message] = None, - ) -> None: - """A helper function that does the actual message cleaning.""" - def predicate_bots_only(message: Message) -> bool: - """Return True if the message was sent by a bot.""" - return message.author.bot - - def predicate_specific_user(message: Message) -> bool: - """Return True if the message was sent by the user provided in the _clean_messages call.""" - return message.author == user - - def predicate_regex(message: Message) -> bool: - """Check if the regex provided in _clean_messages matches the message content or any embed attributes.""" - content = [message.content] - - # Add the content for all embed attributes - for embed in message.embeds: - content.append(embed.title) - content.append(embed.description) - content.append(embed.footer.text) - content.append(embed.author.name) - for field in embed.fields: - content.append(field.name) - content.append(field.value) - - # Get rid of empty attributes and turn it into a string - content = [attr for attr in content if attr] - content = "\n".join(content) - - # Now let's see if there's a regex match - if not content: - return False - else: - return bool(re.search(regex.lower(), content.lower())) - - def predicate_range(message: Message) -> bool: - """Check if message is older than message provided in after_message but younger than until_message.""" - return after_message.created_at <= message.created_at <= until_message.created_at - - # Is this an acceptable amount of messages to clean? - if amount > CleanMessages.message_limit: - raise BadArgument(f"You cannot clean more than {CleanMessages.message_limit} messages.") - - if after_message: - # Ensure that until_message is specified. - if not until_message: - raise MissingRequiredArgument("`until_message` must be specified if `after_message` is specified.") - - # Messages are not in same channel - if after_message.channel != until_message.channel: - raise BadArgument("You cannot do range clean across several channel.") - - # Ensure that after_message is younger than until_message - if after_message.created_at >= until_message.created_at: - raise BadArgument("`after` message must be younger than `until` message") - - # Are we already performing a clean? - if self.cleaning: - raise MaxConcurrencyReached("Please wait for the currently ongoing clean operation to complete.") - - # Set up the correct predicate - if bots_only: - predicate = predicate_bots_only # Delete messages from bots - elif user: - predicate = predicate_specific_user # Delete messages from specific user - elif regex: - predicate = predicate_regex # Delete messages that match regex - elif after_message: - predicate = predicate_range # Delete messages older than specific message - else: - predicate = lambda m: True # Delete all messages # noqa: E731 - - # Default to using the invoking context's channel - if not channels: - channels = [ctx.channel] - - if not is_mod_channel(ctx.channel): - - # Delete the invocation first - self.mod_log.ignore(Event.message_delete, ctx.message.id) - try: - await ctx.message.delete() - except errors.NotFound: - # Invocation message has already been deleted - log.info("Tried to delete invocation message, but it was already deleted.") - - self.cleaning = True - - if use_cache: - message_mappings, message_ids = self._get_messages_from_cache(amount=amount, to_delete=predicate) - else: - message_mappings, message_ids = await self._get_messages_from_channels( - amount=amount, - channels=channels, - to_delete=predicate, - until_message=until_message - ) - - if not self.cleaning: - # Means that the cleaning was canceled - return - - # Now let's delete the actual messages with purge. - self.mod_log.ignore(Event.message_delete, *message_ids) - - for channel, messages in message_mappings.items(): - - to_delete = [] - - for current_index, message in enumerate(messages): - - if not self.cleaning: - # Means that the cleaning was canceled - return - - if self.is_older_than_14d(message): - # further messages are too old to be deleted in bulk - await self._delete_messages_individually(messages[current_index:]) - if not self.cleaning: - # Means that deletion was canceled while deleting the individual messages - return - break - - to_delete.append(message) - - if len(to_delete) == 100: - # we can only delete up to 100 messages in a bulk - await channel.delete_messages(to_delete) - to_delete.clear() - - if len(to_delete) > 0: - # deleting any leftover messages if there are any - await channel.delete_messages(to_delete) - - self.cleaning = False - - log_messages = [] - - for messages in message_mappings.values(): - log_messages.extend(messages) - - if log_messages: - # Reverse the list to restore chronological order - log_messages = reversed(log_messages) - log_url = await self.mod_log.upload_log(log_messages, ctx.author.id) - else: - # Can't build an embed, nothing to clean! - raise BadArgument("No matching messages could be found.") - - # Build the embed and send it - target_channels = ", ".join(channel.mention for channel in channels) - - message = ( - f"**{len(message_ids)}** messages deleted in {target_channels} by " - f"{ctx.author.mention}\n\n" - f"A log of the deleted messages can be found [here]({log_url})." - ) - - await self.mod_log.send_log_message( - icon_url=Icons.message_bulk_delete, - colour=Colour(Colours.soft_red), - title="Bulk message delete", - text=message, - channel_id=Channels.mod_log, - ) - - @group(invoke_without_command=True, name="clean", aliases=["clear", "purge"]) - @has_any_role(*MODERATION_ROLES) - async def clean_group(self, ctx: Context) -> None: - """Commands for cleaning messages in channels.""" - await ctx.send_help(ctx.command) - - @clean_group.command(name="user", aliases=["users"]) - @has_any_role(*MODERATION_ROLES) - async def clean_user( - self, - ctx: Context, - user: User, - amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None - ) -> None: - """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages.""" - use_cache = not channels - await self._clean_messages(amount, ctx, user=user, channels=channels, use_cache=use_cache) - - @clean_group.command(name="all", aliases=["everything"]) - @has_any_role(*MODERATION_ROLES) - async def clean_all( - self, - ctx: Context, - amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None - ) -> None: - """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, channels=channels) - - @clean_group.command(name="bots", aliases=["bot"]) - @has_any_role(*MODERATION_ROLES) - async def clean_bots( - self, - ctx: Context, - amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None - ) -> None: - """Delete all messages posted by a bot, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, bots_only=True, channels=channels) - - @clean_group.command(name="regex", aliases=["word", "expression"]) - @has_any_role(*MODERATION_ROLES) - async def clean_regex( - self, - ctx: Context, - regex: str, - amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None - ) -> None: - """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" - use_cache = not channels - await self._clean_messages(amount, ctx, regex=regex, channels=channels, use_cache=use_cache) - - @clean_group.command(name="until") - @has_any_role(*MODERATION_ROLES) - async def clean_until(self, ctx: Context, message: Message) -> None: - """Delete all messages until certain message, stop cleaning after hitting the `message`.""" - await self._clean_messages( - CleanMessages.message_limit, - ctx, - channels=[message.channel], - until_message=message - ) - - @clean_group.command(name="from-to", aliases=["after-until", "between"]) - @has_any_role(*MODERATION_ROLES) - async def clean_from_to(self, ctx: Context, after_message: Message, until_message: Message) -> None: - """Delete all messages within range of messages.""" - await self._clean_messages( - CleanMessages.message_limit, - ctx, - channels=[until_message.channel], - until_message=until_message, - after_message=after_message, - ) - - @clean_group.command(name="stop", aliases=["cancel", "abort"]) - @has_any_role(*MODERATION_ROLES) - async def clean_cancel(self, ctx: Context) -> None: - """If there is an ongoing cleaning process, attempt to immediately cancel it.""" - self.cleaning = False - - embed = Embed( - color=Colour.blurple(), - description="Clean interrupted." - ) - await ctx.send(embed=embed, delete_after=10) - - -def setup(bot: Bot) -> None: - """Load the Clean cog.""" - bot.add_cog(Clean(bot)) -- cgit v1.2.3 From 2b5a5311110f52328651e5a19a186f4e3552ee84 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Fri, 27 Aug 2021 17:21:18 +0300 Subject: Move clean logging to a helper function --- bot/exts/moderation/clean.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 25582165a..e198dde9c 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -2,6 +2,7 @@ import logging import re import time from collections import defaultdict +from itertools import chain from typing import Any, Callable, DefaultDict, Iterable, List, Optional, Tuple from discord import Colour, Embed, Message, NotFound, TextChannel, User, errors @@ -263,25 +264,24 @@ class Clean(Cog): self.cleaning = False - log_messages = [] + await self._log_clean(list(chain.from_iterable(message_mappings.values())), channels, ctx.author) - for messages in message_mappings.values(): - log_messages.extend(messages) - - if log_messages: - # Reverse the list to restore chronological order - log_messages = reversed(log_messages) - log_url = await self.mod_log.upload_log(log_messages, ctx.author.id) - else: + async def _log_clean(self, messages: list[Message], channels: Iterable[TextChannel], invoker: User) -> None: + """Log the deleted messages to the modlog.""" + if not messages: # Can't build an embed, nothing to clean! raise BadArgument("No matching messages could be found.") + # Reverse the list to restore chronological order + log_messages = reversed(messages) + log_url = await self.mod_log.upload_log(log_messages, invoker.id) + # Build the embed and send it target_channels = ", ".join(channel.mention for channel in channels) message = ( - f"**{len(message_ids)}** messages deleted in {target_channels} by " - f"{ctx.author.mention}\n\n" + f"**{len(messages)}** messages deleted in {target_channels} by " + f"{invoker.mention}\n\n" f"A log of the deleted messages can be found [here]({log_url})." ) -- cgit v1.2.3 From 841a148f45bfe265815eac1aa9f22d84e332f548 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Fri, 27 Aug 2021 17:31:54 +0300 Subject: Move setting cleaning flag to correct line Between the concurrency check and setting the cleaning flag to True there was an await statement, which could potentially cause race conditions.The setting of the flag was moved to right below the concurrency check. --- bot/exts/moderation/clean.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index e198dde9c..2e3f9ac77 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -187,6 +187,7 @@ class Clean(Cog): # Are we already performing a clean? if self.cleaning: raise MaxConcurrencyReached("Please wait for the currently ongoing clean operation to complete.") + self.cleaning = True # Set up the correct predicate if bots_only: @@ -214,8 +215,6 @@ class Clean(Cog): # Invocation message has already been deleted log.info("Tried to delete invocation message, but it was already deleted.") - self.cleaning = True - if use_cache: message_mappings, message_ids = self._get_messages_from_cache(amount=amount, to_delete=predicate) else: -- cgit v1.2.3 From 8aeec5fc96bc5dcb8db1dbdbad870ade55ef5540 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Fri, 27 Aug 2021 17:33:07 +0300 Subject: Correct logging comment --- bot/exts/moderation/clean.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 2e3f9ac77..19f64e0e7 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -271,7 +271,7 @@ class Clean(Cog): # Can't build an embed, nothing to clean! raise BadArgument("No matching messages could be found.") - # Reverse the list to restore chronological order + # Reverse the list to have reverse chronological order log_messages = reversed(messages) log_url = await self.mod_log.upload_log(log_messages, invoker.id) -- cgit v1.2.3 From 33e05017bfb0530a736fe1473f5e2b3c275f18f0 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Fri, 27 Aug 2021 17:37:09 +0300 Subject: Change `from-to` primary name to `between` --- bot/exts/moderation/clean.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 19f64e0e7..007aba317 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -357,9 +357,9 @@ class Clean(Cog): until_message=message ) - @clean_group.command(name="from-to", aliases=["after-until", "between"]) + @clean_group.command(name="between", aliases=["after-until", "from-to"]) @has_any_role(*MODERATION_ROLES) - async def clean_from_to(self, ctx: Context, after_message: Message, until_message: Message) -> None: + async def clean_between(self, ctx: Context, after_message: Message, until_message: Message) -> None: """Delete all messages within range of messages.""" await self._clean_messages( CleanMessages.message_limit, -- cgit v1.2.3 From f9d2e6919ab746b046c510daab2133e4b53bda6d Mon Sep 17 00:00:00 2001 From: mbaruh Date: Fri, 27 Aug 2021 17:52:13 +0300 Subject: Don't delete clean cancel embed in mod channel --- bot/exts/moderation/clean.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 007aba317..504ecccd1 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -379,7 +379,10 @@ class Clean(Cog): color=Colour.blurple(), description="Clean interrupted." ) - await ctx.send(embed=embed, delete_after=10) + delete_after = 10 + if is_mod_channel(ctx.channel): + delete_after = None + await ctx.send(embed=embed, delete_after=delete_after) def setup(bot: Bot) -> None: -- cgit v1.2.3 From 384d5b4b6b945e1aac3e1dbf82c1faa81cebbcf4 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 28 Aug 2021 00:09:28 +0200 Subject: Upgrade pip-licenses to 5.3.2 Thanks to https://github.com/raimon49/pip-licenses/pull/109, we are now able to ignore spaces around the allow-only parameter. Rejoice! --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index ae8b299f4..81b51b8da 100644 --- a/poetry.lock +++ b/poetry.lock @@ -613,7 +613,7 @@ flake8-polyfill = ">=1.0.2,<2" [[package]] name = "pip-licenses" -version = "3.5.1" +version = "3.5.2" description = "Dump the software license list of Python packages installed with pip." category = "dev" optional = false @@ -1069,7 +1069,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "dfa0b6e9cc8373cfa5c3bcf35c352b79a08ecca8fe23f21737d8beb6a8c89fa4" +content-hash = "ceddbb2621849f480f736985d71f37cebefd08a9b38bc3943a6f72706258b6ee" [metadata.files] aio-pika = [ @@ -1536,8 +1536,8 @@ pep8-naming = [ {file = "pep8_naming-0.12.1-py2.py3-none-any.whl", hash = "sha256:4a8daeaeb33cfcde779309fc0c9c0a68a3bbe2ad8a8308b763c5068f86eb9f37"}, ] pip-licenses = [ - {file = "pip-licenses-3.5.1.tar.gz", hash = "sha256:6c60096cfa1ee04d7db8d374d4326939369d5871368421e7a7d5da026519bc24"}, - {file = "pip_licenses-3.5.1-py3-none-any.whl", hash = "sha256:e44e4e92e6af2b9d11dfafbf22a3052bf9a05fdaa5654befdba9db735832ac7f"}, + {file = "pip-licenses-3.5.2.tar.gz", hash = "sha256:c5e984f461b34ad04dafa151d0048eb9d049e3d6439966c6440bb6b53ad077b6"}, + {file = "pip_licenses-3.5.2-py3-none-any.whl", hash = "sha256:62deafc82d5dccea1a4cab55172706e02f228abcd67f4d53e382fcb1497e9b62"}, ] platformdirs = [ {file = "platformdirs-2.2.0-py3-none-any.whl", hash = "sha256:4666d822218db6a262bdfdc9c39d21f23b4cfdb08af331a81e92751daf6c866c"}, diff --git a/pyproject.toml b/pyproject.toml index 865fa1d3f..23cbba19b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ flake8-todo = "~=0.7" pep8-naming = "~=0.9" pre-commit = "~=2.1" taskipy = "~=1.7.0" -pip-licenses = "~=3.5.1" +pip-licenses = "~=3.5.2" python-dotenv = "~=0.17.1" pytest = "~=6.2.4" pytest-cov = "~=2.12.1" -- cgit v1.2.3 From 3f2b233f1743da694dea2ae452af0e30bdffff17 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 28 Aug 2021 00:13:12 +0200 Subject: CI: add ISC License (ISCL) to allowed licenses --- .github/workflows/lint-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index 53d9baa59..ac7e47f0e 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -16,6 +16,7 @@ jobs: ALLOWED_LICENSE: Apache Software License; BSD License; GNU Library or Lesser General Public License (LGPL); + ISC License (ISCL); MIT License; Mozilla Public License 2.0 (MPL 2.0); Public Domain; -- cgit v1.2.3 From 27e2666e7c8828f49c432ef29e0098c37bd5fc3a Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sat, 28 Aug 2021 00:47:12 +0200 Subject: Remove unnecessary line in help Co-authored-by: Bluenix --- bot/exts/info/tags.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 884c76ec4..91046c654 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -370,7 +370,6 @@ class Tags(Cog): tag_name: TagNameConverter = None, ) -> bool: """ - When arguments are passed in: If a single argument matching a group name is given, list all accessible tags from that group Otherwise display the tag if one was found for the given arguments, or try to display suggestions for that name -- cgit v1.2.3 From 15205e11fa132e076f992bfdbad2dd95894d2216 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sat, 28 Aug 2021 00:53:59 +0200 Subject: simplify assignment and add comment explaining its purpose --- bot/exts/info/tags.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 91046c654..931c01e3a 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -146,9 +146,9 @@ class Tags(Cog): for file in base_path.glob("**/*"): if file.is_file(): parent_dir = file.relative_to(base_path).parent - tag_name = file.stem - tag_group = parent_dir.name if parent_dir.name else None + # Files directly under `base_path` have an empty string as the parent directory name + tag_group = parent_dir.name or None self.tags[TagIdentifier(tag_group, tag_name)] = Tag(file) -- cgit v1.2.3 From 2a83c5f8f7b9bfd60a50e67f20c04f31651bbcaa Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sat, 28 Aug 2021 11:38:38 +0100 Subject: Only check URL-like objects against domain filters Previously a message such as 'https://google.com hello! flask.request.method' would be filtered due to us filtering the url shortener t.me. This commit changes to logic so that we only check parts of the messages that matched the URL regex against our blacklist, to avoid these false-positives. --- bot/exts/filters/filtering.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 10cc7885d..b7e91395e 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -478,15 +478,16 @@ class Filtering(Cog): Second return value is a reason of URL blacklisting (can be None). """ text = self.clean_input(text) - if not URL_RE.search(text): + matches = URL_RE.findall(text) + if not matches: return False, None - text = text.lower() domain_blacklist = self._get_filterlist_items("domain_name", allowed=False) for url in domain_blacklist: - if url.lower() in text: - return True, self._get_filterlist_value("domain_name", url, allowed=False)["comment"] + for match in matches: + if url.lower() in match.lower(): + return True, self._get_filterlist_value("domain_name", url, allowed=False)["comment"] return False, None -- cgit v1.2.3 From d81b550594d02f25fcc310eb22cda5bd930fd197 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 28 Aug 2021 17:12:34 +0300 Subject: Change cache usage The cache is used only when all channels are used, as before. Unlike before, using all channels requires using "*" in the channels argument. Before all channels would be used if use_cache was set to True. Using all channels uses the cache by default. To traverse every single text channel in the server, setting use_cache to False is required in the command. --- bot/exts/moderation/clean.py | 64 +++++++++++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 504ecccd1..15a48ea75 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -3,11 +3,11 @@ import re import time from collections import defaultdict from itertools import chain -from typing import Any, Callable, DefaultDict, Iterable, List, Optional, Tuple +from typing import Any, Callable, DefaultDict, Iterable, List, Literal, Optional, TYPE_CHECKING, Tuple, Union from discord import Colour, Embed, Message, NotFound, TextChannel, User, errors -from discord.ext import commands -from discord.ext.commands import Cog, Context, group, has_any_role +from discord.ext.commands import Cog, Context, Converter, group, has_any_role +from discord.ext.commands.converter import TextChannelConverter from discord.ext.commands.errors import BadArgument, MaxConcurrencyReached, MissingRequiredArgument from bot.bot import Bot @@ -23,6 +23,22 @@ log = logging.getLogger(__name__) Predicate = Callable[[Message], bool] +class CleanChannels(Converter): + """A converter that turns the given string to a list of channels to clean, or the literal `*` for all channels.""" + + _channel_converter = TextChannelConverter() + + async def convert(self, ctx: Context, argument: str) -> Union[Literal["*"], list[TextChannel]]: + """Converts a string to a list of channels to clean, or the literal `*` for all channels.""" + if argument == "*": + return "*" + return [await self._channel_converter.convert(ctx, channel) for channel in argument.split()] + + +if TYPE_CHECKING: + CleanChannels = Union[Literal["*"], list[TextChannel]] # noqa: F811 + + class Clean(Cog): """ A cog that allows messages to be deleted in bulk, while applying various filters. @@ -122,13 +138,13 @@ class Clean(Cog): self, amount: int, ctx: Context, - channels: Iterable[TextChannel], + channels: CleanChannels, bots_only: bool = False, - use_cache: bool = False, user: User = None, regex: Optional[str] = None, until_message: Optional[Message] = None, after_message: Optional[Message] = None, + use_cache: Optional[bool] = True ) -> None: """A helper function that does the actual message cleaning.""" def predicate_bots_only(message: Message) -> bool: @@ -215,12 +231,15 @@ class Clean(Cog): # Invocation message has already been deleted log.info("Tried to delete invocation message, but it was already deleted.") - if use_cache: + if channels == "*" and use_cache: message_mappings, message_ids = self._get_messages_from_cache(amount=amount, to_delete=predicate) else: + deletion_channels = channels + if channels == "*": + deletion_channels = [channel for channel in ctx.guild.channels if isinstance(channel, TextChannel)] message_mappings, message_ids = await self._get_messages_from_channels( amount=amount, - channels=channels, + channels=deletion_channels, to_delete=predicate, until_message=until_message ) @@ -265,7 +284,7 @@ class Clean(Cog): await self._log_clean(list(chain.from_iterable(message_mappings.values())), channels, ctx.author) - async def _log_clean(self, messages: list[Message], channels: Iterable[TextChannel], invoker: User) -> None: + async def _log_clean(self, messages: list[Message], channels: CleanChannels, invoker: User) -> None: """Log the deleted messages to the modlog.""" if not messages: # Can't build an embed, nothing to clean! @@ -276,7 +295,10 @@ class Clean(Cog): log_url = await self.mod_log.upload_log(log_messages, invoker.id) # Build the embed and send it - target_channels = ", ".join(channel.mention for channel in channels) + if channels == "*": + target_channels = "all channels" + else: + target_channels = ", ".join(channel.mention for channel in channels) message = ( f"**{len(messages)}** messages deleted in {target_channels} by " @@ -305,10 +327,11 @@ class Clean(Cog): ctx: Context, user: User, amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None + use_cache: Optional[bool] = True, + *, + channels: Optional[CleanChannels] = None ) -> None: """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages.""" - use_cache = not channels await self._clean_messages(amount, ctx, user=user, channels=channels, use_cache=use_cache) @clean_group.command(name="all", aliases=["everything"]) @@ -317,10 +340,12 @@ class Clean(Cog): self, ctx: Context, amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None + use_cache: Optional[bool] = True, + *, + channels: Optional[CleanChannels] = None ) -> None: """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, channels=channels) + await self._clean_messages(amount, ctx, channels=channels, use_cache=use_cache) @clean_group.command(name="bots", aliases=["bot"]) @has_any_role(*MODERATION_ROLES) @@ -328,22 +353,25 @@ class Clean(Cog): self, ctx: Context, amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None + use_cache: Optional[bool] = True, + *, + channels: Optional[CleanChannels] = None ) -> None: """Delete all messages posted by a bot, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, bots_only=True, channels=channels) + await self._clean_messages(amount, ctx, bots_only=True, channels=channels, use_cache=use_cache) - @clean_group.command(name="regex", aliases=["word", "expression"]) + @clean_group.command(name="regex", aliases=["word", "expression", "pattern"]) @has_any_role(*MODERATION_ROLES) async def clean_regex( self, ctx: Context, regex: str, amount: Optional[int] = 10, - channels: commands.Greedy[TextChannel] = None + use_cache: Optional[bool] = True, + *, + channels: Optional[CleanChannels] = None ) -> None: """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" - use_cache = not channels await self._clean_messages(amount, ctx, regex=regex, channels=channels, use_cache=use_cache) @clean_group.command(name="until") -- cgit v1.2.3 From 4334988a664bbb516760a6046a2e8106e9777eab Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 28 Aug 2021 17:30:14 +0300 Subject: Rename "amount" argument to "traverse" This name as been confusing moderators for a while now. "amount" sounds like this is the amount of messages that the bot will try to delete, and keep going until it reaches that number. In reality it's the amount of latest messages per channel the bot will traverse. Hopefully the new name conveys that better. --- bot/exts/moderation/clean.py | 48 ++++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 15a48ea75..5954672fe 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -82,7 +82,7 @@ class Clean(Cog): # Message doesn't exist or was already deleted continue - def _get_messages_from_cache(self, amount: int, to_delete: Predicate) -> Tuple[DefaultDict, List[int]]: + def _get_messages_from_cache(self, traverse: int, to_delete: Predicate) -> Tuple[DefaultDict, List[int]]: """Helper function for getting messages from the cache.""" message_mappings = defaultdict(list) message_ids = [] @@ -95,16 +95,16 @@ class Clean(Cog): message_mappings[message.channel].append(message) message_ids.append(message.id) - if len(message_ids) == amount: - # We've got the requested amount of messages + if len(message_ids) == traverse: + # We traversed the requested amount of messages. return message_mappings, message_ids - # Amount exceeds amount of messages matching the check + # There are fewer messages in the cache than the number requested to traverse. return message_mappings, message_ids async def _get_messages_from_channels( self, - amount: int, + traverse: int, channels: Iterable[TextChannel], to_delete: Predicate, until_message: Optional[Message] = None @@ -114,7 +114,7 @@ class Clean(Cog): for channel in channels: - async for message in channel.history(limit=amount): + async for message in channel.history(limit=traverse): if not self.cleaning: # Cleaning was canceled, return empty containers @@ -136,7 +136,7 @@ class Clean(Cog): async def _clean_messages( self, - amount: int, + traverse: int, ctx: Context, channels: CleanChannels, bots_only: bool = False, @@ -183,9 +183,9 @@ class Clean(Cog): """Check if message is older than message provided in after_message but younger than until_message.""" return after_message.created_at <= message.created_at <= until_message.created_at - # Is this an acceptable amount of messages to clean? - if amount > CleanMessages.message_limit: - raise BadArgument(f"You cannot clean more than {CleanMessages.message_limit} messages.") + # Is this an acceptable amount of messages to traverse? + if traverse > CleanMessages.message_limit: + raise BadArgument(f"You cannot traverse more than {CleanMessages.message_limit} messages.") if after_message: # Ensure that until_message is specified. @@ -232,13 +232,13 @@ class Clean(Cog): log.info("Tried to delete invocation message, but it was already deleted.") if channels == "*" and use_cache: - message_mappings, message_ids = self._get_messages_from_cache(amount=amount, to_delete=predicate) + message_mappings, message_ids = self._get_messages_from_cache(traverse=traverse, to_delete=predicate) else: deletion_channels = channels if channels == "*": deletion_channels = [channel for channel in ctx.guild.channels if isinstance(channel, TextChannel)] message_mappings, message_ids = await self._get_messages_from_channels( - amount=amount, + traverse=traverse, channels=deletion_channels, to_delete=predicate, until_message=until_message @@ -326,39 +326,39 @@ class Clean(Cog): self, ctx: Context, user: User, - amount: Optional[int] = 10, + traverse: Optional[int] = 10, use_cache: Optional[bool] = True, *, channels: Optional[CleanChannels] = None ) -> None: - """Delete messages posted by the provided user, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, user=user, channels=channels, use_cache=use_cache) + """Delete messages posted by the provided user, stop cleaning after traversing `traverse` messages.""" + await self._clean_messages(traverse, ctx, user=user, channels=channels, use_cache=use_cache) @clean_group.command(name="all", aliases=["everything"]) @has_any_role(*MODERATION_ROLES) async def clean_all( self, ctx: Context, - amount: Optional[int] = 10, + traverse: Optional[int] = 10, use_cache: Optional[bool] = True, *, channels: Optional[CleanChannels] = None ) -> None: - """Delete all messages, regardless of poster, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, channels=channels, use_cache=use_cache) + """Delete all messages, regardless of poster, stop cleaning after traversing `traverse` messages.""" + await self._clean_messages(traverse, ctx, channels=channels, use_cache=use_cache) @clean_group.command(name="bots", aliases=["bot"]) @has_any_role(*MODERATION_ROLES) async def clean_bots( self, ctx: Context, - amount: Optional[int] = 10, + traverse: Optional[int] = 10, use_cache: Optional[bool] = True, *, channels: Optional[CleanChannels] = None ) -> None: - """Delete all messages posted by a bot, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, bots_only=True, channels=channels, use_cache=use_cache) + """Delete all messages posted by a bot, stop cleaning after traversing `traverse` messages.""" + await self._clean_messages(traverse, ctx, bots_only=True, channels=channels, use_cache=use_cache) @clean_group.command(name="regex", aliases=["word", "expression", "pattern"]) @has_any_role(*MODERATION_ROLES) @@ -366,13 +366,13 @@ class Clean(Cog): self, ctx: Context, regex: str, - amount: Optional[int] = 10, + traverse: Optional[int] = 10, use_cache: Optional[bool] = True, *, channels: Optional[CleanChannels] = None ) -> None: - """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" - await self._clean_messages(amount, ctx, regex=regex, channels=channels, use_cache=use_cache) + """Delete all messages that match a certain regex, stop cleaning after traversing `traverse` messages.""" + await self._clean_messages(traverse, ctx, regex=regex, channels=channels, use_cache=use_cache) @clean_group.command(name="until") @has_any_role(*MODERATION_ROLES) -- cgit v1.2.3 From 570490f58cbbb166a9a1e4b0276cd6f2552a24f1 Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Sun, 29 Aug 2021 01:13:35 +0530 Subject: Replace UserMentionOrID with UnambiguousUser --- bot/converters.py | 30 ++++++++++++++++++++-------- bot/exts/moderation/infraction/management.py | 4 ++-- bot/exts/utils/reminders.py | 4 ++-- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 0118cc48a..c4979972b 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -495,22 +495,36 @@ class HushDurationConverter(Converter): return duration -class UserMentionOrID(UserConverter): +def _is_an_unambiguous_user_argument(argument: str) -> bool: + """Check if the provided argument is a user mention, user id, or username.""" + has_id_or_mention = bool(IDConverter()._get_id_match(argument) or RE_USER_MENTION.match(argument)) + + if not has_id_or_mention: + if argument[0] == '@': + argument = argument[1:] + + # Check to see if the author passed a username (a discriminator exists) + if len(argument) > 5 and argument[-5] == '#': + return True + + return has_id_or_mention + + +class UnambiguousUser(UserConverter): """ - Converts to a `discord.User`, but only if a mention or userID is provided. + Converts to a `discord.User`, but only if a mention, userID or a username is provided. - Unlike the default `UserConverter`, it doesn't allow conversion from a name or name#descrim. + Unlike the default `UserConverter`, it doesn't allow conversion from a name. This is useful in cases where that lookup strategy would lead to ambiguity. """ async def convert(self, ctx: Context, argument: str) -> discord.User: """Convert the `arg` to a `discord.User`.""" - match = self._get_id_match(argument) or RE_USER_MENTION.match(argument) - - if match is not None: + if _is_an_unambiguous_user_argument(argument): return await super().convert(ctx, argument) else: - raise BadArgument(f"`{argument}` is not a User mention or a User ID.") + raise BadArgument(f"`{argument}` is not a User mention, a User ID or a Username in the format" + " `name#discriminator`.") class Infraction(Converter): @@ -557,7 +571,7 @@ if t.TYPE_CHECKING: OffTopicName = str # noqa: F811 ISODateTime = datetime # noqa: F811 HushDurationConverter = int # noqa: F811 - UserMentionOrID = discord.User # noqa: F811 + UnambiguousUser = discord.User # noqa: F811 Infraction = t.Optional[dict] # noqa: F811 Expiry = t.Union[Duration, ISODateTime] diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 641ad0410..7f27896d7 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -12,7 +12,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, UserMentionOrID, allowed_strings +from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, UnambiguousUser, allowed_strings from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator @@ -201,7 +201,7 @@ class ModManagement(commands.Cog): # region: Search infractions @infraction_group.group(name="search", aliases=('s',), invoke_without_command=True) - async def infraction_search_group(self, ctx: Context, query: t.Union[UserMentionOrID, Snowflake, str]) -> None: + async def infraction_search_group(self, ctx: Context, query: t.Union[UnambiguousUser, Snowflake, str]) -> None: """Searches for infractions in the database.""" if isinstance(query, int): await self.search_user(ctx, discord.Object(query)) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 2bed5157f..41b6cac5c 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -15,7 +15,7 @@ from bot.constants import ( Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES ) -from bot.converters import Duration, UserMentionOrID +from bot.converters import Duration, UnambiguousUser from bot.pagination import LinePaginator from bot.utils.checks import has_any_role_check, has_no_roles_check from bot.utils.lock import lock_arg @@ -30,7 +30,7 @@ WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 Mentionable = t.Union[discord.Member, discord.Role] -ReminderMention = t.Union[UserMentionOrID, discord.Role] +ReminderMention = t.Union[UnambiguousUser, discord.Role] class Reminders(Cog): -- cgit v1.2.3 From be9bce46ab5479d3fdbb8e7baa26f1ad947685f6 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 28 Aug 2021 23:09:20 +0300 Subject: Refactor code, correct logging This commit further splits the bulky _clean_messages function, and all its helper functions are grouped together in the same region. Additionally, this commit fixes logging by logging only the messages that have been successfully deleted, before being possibly interrupted by the cancel command. --- bot/exts/moderation/clean.py | 229 +++++++++++++++++++++++++------------------ 1 file changed, 133 insertions(+), 96 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 5954672fe..455d28faa 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -2,7 +2,6 @@ import logging import re import time from collections import defaultdict -from itertools import chain from typing import Any, Callable, DefaultDict, Iterable, List, Literal, Optional, TYPE_CHECKING, Tuple, Union from discord import Colour, Embed, Message, NotFound, TextChannel, User, errors @@ -59,28 +58,35 @@ class Clean(Cog): """Get currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") + # region: Helper functions + @staticmethod - def is_older_than_14d(message: Message) -> bool: - """ - Precisely checks if message is older than 14 days, bulk deletion limit. + def _validate_input( + traverse: int, + channels: CleanChannels, + bots_only: bool, + user: User, + until_message: Message, + after_message: Message, + use_cache: bool + ) -> None: + """Raise errors if an argument value or a combination of values is invalid.""" + # Is this an acceptable amount of messages to traverse? + if traverse > CleanMessages.message_limit: + raise BadArgument(f"You cannot traverse more than {CleanMessages.message_limit} messages.") - Inspired by how purge works internally. - Comparison on message age could possibly be less accurate which in turn would resort in problems - with message deletion if said messages are very close to the 14d mark. - """ - two_weeks_old_snowflake = int((time.time() - 14 * 24 * 60 * 60) * 1000.0 - 1420070400000) << 22 - return message.id < two_weeks_old_snowflake + if after_message: + # Ensure that until_message is specified. + if not until_message: + raise MissingRequiredArgument("`until_message` must be specified if `after_message` is specified.") - async def _delete_messages_individually(self, messages: List[Message]) -> None: - for message in messages: - # Ensure that deletion was not canceled - if not self.cleaning: - return - try: - await message.delete() - except NotFound: - # Message doesn't exist or was already deleted - continue + # Messages are not in same channel + if after_message.channel != until_message.channel: + raise BadArgument("You cannot do range clean across several channel.") + + # Ensure that after_message is younger than until_message + if after_message.created_at >= until_message.created_at: + raise BadArgument("`after` message must be younger than `until` message") def _get_messages_from_cache(self, traverse: int, to_delete: Predicate) -> Tuple[DefaultDict, List[int]]: """Helper function for getting messages from the cache.""" @@ -134,6 +140,107 @@ class Clean(Cog): return message_mappings, message_ids + @staticmethod + def is_older_than_14d(message: Message) -> bool: + """ + Precisely checks if message is older than 14 days, bulk deletion limit. + + Inspired by how purge works internally. + Comparison on message age could possibly be less accurate which in turn would resort in problems + with message deletion if said messages are very close to the 14d mark. + """ + two_weeks_old_snowflake = int((time.time() - 14 * 24 * 60 * 60) * 1000.0 - 1420070400000) << 22 + return message.id < two_weeks_old_snowflake + + async def _delete_messages_individually(self, messages: List[Message]) -> list[Message]: + """Delete each message in the list unless cleaning is cancelled. Return the deleted messages.""" + deleted = [] + for message in messages: + # Ensure that deletion was not canceled + if not self.cleaning: + return deleted + try: + await message.delete() + except NotFound: + # Message doesn't exist or was already deleted + continue + else: + deleted.append(message) + return deleted + + async def _delete_found(self, message_mappings: dict[TextChannel, list[Message]]) -> list[Message]: + """ + Delete the detected messages. + + Deletion is made in bulk per channel for messages less than 14d old. + The function returns the deleted messages. + If cleaning was cancelled in the middle, return messages already deleted. + """ + deleted = [] + for channel, messages in message_mappings.items(): + to_delete = [] + + for current_index, message in enumerate(messages): + if not self.cleaning: + # Means that the cleaning was canceled + return deleted + + if self.is_older_than_14d(message): + # further messages are too old to be deleted in bulk + deleted_remaining = await self._delete_messages_individually(messages[current_index:]) + deleted.extend(deleted_remaining) + if not self.cleaning: + # Means that deletion was canceled while deleting the individual messages + return deleted + break + + to_delete.append(message) + + if len(to_delete) == 100: + # we can only delete up to 100 messages in a bulk + await channel.delete_messages(to_delete) + deleted.extend(to_delete) + to_delete.clear() + + if len(to_delete) > 0: + # deleting any leftover messages if there are any + await channel.delete_messages(to_delete) + deleted.extend(to_delete) + + return deleted + + async def _log_clean(self, messages: list[Message], channels: CleanChannels, invoker: User) -> None: + """Log the deleted messages to the modlog.""" + if not messages: + # Can't build an embed, nothing to clean! + raise BadArgument("No matching messages could be found.") + + # Reverse the list to have reverse chronological order + log_messages = reversed(messages) + log_url = await self.mod_log.upload_log(log_messages, invoker.id) + + # Build the embed and send it + if channels == "*": + target_channels = "all channels" + else: + target_channels = ", ".join(channel.mention for channel in channels) + + message = ( + f"**{len(messages)}** messages deleted in {target_channels} by " + f"{invoker.mention}\n\n" + f"A log of the deleted messages can be found [here]({log_url})." + ) + + await self.mod_log.send_log_message( + icon_url=Icons.message_bulk_delete, + colour=Colour(Colours.soft_red), + title="Bulk message delete", + text=message, + channel_id=Channels.mod_log, + ) + + # endregion + async def _clean_messages( self, traverse: int, @@ -183,22 +290,7 @@ class Clean(Cog): """Check if message is older than message provided in after_message but younger than until_message.""" return after_message.created_at <= message.created_at <= until_message.created_at - # Is this an acceptable amount of messages to traverse? - if traverse > CleanMessages.message_limit: - raise BadArgument(f"You cannot traverse more than {CleanMessages.message_limit} messages.") - - if after_message: - # Ensure that until_message is specified. - if not until_message: - raise MissingRequiredArgument("`until_message` must be specified if `after_message` is specified.") - - # Messages are not in same channel - if after_message.channel != until_message.channel: - raise BadArgument("You cannot do range clean across several channel.") - - # Ensure that after_message is younger than until_message - if after_message.created_at >= until_message.created_at: - raise BadArgument("`after` message must be younger than `until` message") + self._validate_input(traverse, channels, bots_only, user, until_message, after_message, use_cache) # Are we already performing a clean? if self.cleaning: @@ -250,69 +342,12 @@ class Clean(Cog): # Now let's delete the actual messages with purge. self.mod_log.ignore(Event.message_delete, *message_ids) - - for channel, messages in message_mappings.items(): - - to_delete = [] - - for current_index, message in enumerate(messages): - - if not self.cleaning: - # Means that the cleaning was canceled - return - - if self.is_older_than_14d(message): - # further messages are too old to be deleted in bulk - await self._delete_messages_individually(messages[current_index:]) - if not self.cleaning: - # Means that deletion was canceled while deleting the individual messages - return - break - - to_delete.append(message) - - if len(to_delete) == 100: - # we can only delete up to 100 messages in a bulk - await channel.delete_messages(to_delete) - to_delete.clear() - - if len(to_delete) > 0: - # deleting any leftover messages if there are any - await channel.delete_messages(to_delete) - + deleted_messages = await self._delete_found(message_mappings) self.cleaning = False - await self._log_clean(list(chain.from_iterable(message_mappings.values())), channels, ctx.author) - - async def _log_clean(self, messages: list[Message], channels: CleanChannels, invoker: User) -> None: - """Log the deleted messages to the modlog.""" - if not messages: - # Can't build an embed, nothing to clean! - raise BadArgument("No matching messages could be found.") - - # Reverse the list to have reverse chronological order - log_messages = reversed(messages) - log_url = await self.mod_log.upload_log(log_messages, invoker.id) + await self._log_clean(deleted_messages, channels, ctx.author) - # Build the embed and send it - if channels == "*": - target_channels = "all channels" - else: - target_channels = ", ".join(channel.mention for channel in channels) - - message = ( - f"**{len(messages)}** messages deleted in {target_channels} by " - f"{invoker.mention}\n\n" - f"A log of the deleted messages can be found [here]({log_url})." - ) - - await self.mod_log.send_log_message( - icon_url=Icons.message_bulk_delete, - colour=Colour(Colours.soft_red), - title="Bulk message delete", - text=message, - channel_id=Channels.mod_log, - ) + # region: Commands @group(invoke_without_command=True, name="clean", aliases=["clear", "purge"]) @has_any_role(*MODERATION_ROLES) @@ -412,6 +447,8 @@ class Clean(Cog): delete_after = None await ctx.send(embed=embed, delete_after=delete_after) + # endregion + def setup(bot: Bot) -> None: """Load the Clean cog.""" -- cgit v1.2.3 From 70ac9595e5bfc7ceaf2ac9f2be2e8f5f4bdb34d1 Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Sun, 29 Aug 2021 01:43:05 +0530 Subject: Add the UnambiguousMember converter --- bot/converters.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index c4979972b..597f841c4 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -11,7 +11,7 @@ import dateutil.tz import discord from aiohttp import ClientConnectorError from dateutil.relativedelta import relativedelta -from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, UserConverter +from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, MemberConverter, UserConverter from discord.utils import DISCORD_EPOCH, escape_markdown, snowflake_time from bot import exts @@ -510,6 +510,10 @@ def _is_an_unambiguous_user_argument(argument: str) -> bool: return has_id_or_mention +AMBIGUOUS_ARGUMENT_MSG = ("`{argument}` is not a User mention, a User ID or a Username in the format" + " `name#discriminator`.") + + class UnambiguousUser(UserConverter): """ Converts to a `discord.User`, but only if a mention, userID or a username is provided. @@ -523,8 +527,23 @@ class UnambiguousUser(UserConverter): if _is_an_unambiguous_user_argument(argument): return await super().convert(ctx, argument) else: - raise BadArgument(f"`{argument}` is not a User mention, a User ID or a Username in the format" - " `name#discriminator`.") + raise BadArgument(AMBIGUOUS_ARGUMENT_MSG.format(argument=argument)) + + +class UnambiguousMember(MemberConverter): + """ + Converts to a `discord.Member`, but only if a mention, userID or a username is provided. + + Unlike the default `MemberConverter`, it doesn't allow conversion from a name or nickname. + This is useful in cases where that lookup strategy would lead to ambiguity. + """ + + async def convert(self, ctx: Context, argument: str) -> discord.Member: + """Convert the `arg` to a `discord.Member`.""" + if _is_an_unambiguous_user_argument(argument): + return await super().convert(ctx, argument) + else: + raise BadArgument(AMBIGUOUS_ARGUMENT_MSG.format(argument=argument)) class Infraction(Converter): @@ -572,6 +591,7 @@ if t.TYPE_CHECKING: ISODateTime = datetime # noqa: F811 HushDurationConverter = int # noqa: F811 UnambiguousUser = discord.User # noqa: F811 + UnambiguousMember = discord.Member # noqa: F811 Infraction = t.Optional[dict] # noqa: F811 Expiry = t.Union[Duration, ISODateTime] -- cgit v1.2.3 From 1ffe27a82855ea3d49794aa6bf2618c05d673ae6 Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Sun, 29 Aug 2021 01:43:45 +0530 Subject: Fix mismatches in parameter names and docstrings --- bot/converters.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 597f841c4..97919cfb7 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -523,7 +523,7 @@ class UnambiguousUser(UserConverter): """ async def convert(self, ctx: Context, argument: str) -> discord.User: - """Convert the `arg` to a `discord.User`.""" + """Convert the `argument` to a `discord.User`.""" if _is_an_unambiguous_user_argument(argument): return await super().convert(ctx, argument) else: @@ -539,7 +539,7 @@ class UnambiguousMember(MemberConverter): """ async def convert(self, ctx: Context, argument: str) -> discord.Member: - """Convert the `arg` to a `discord.Member`.""" + """Convert the `argument` to a `discord.Member`.""" if _is_an_unambiguous_user_argument(argument): return await super().convert(ctx, argument) else: -- cgit v1.2.3 From 675630a620afe9dee4772bc659a16289be9665d7 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 28 Aug 2021 23:22:28 +0300 Subject: Add checkmark after command completes in mod channels --- bot/exts/moderation/clean.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 455d28faa..f8526b1b9 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -11,7 +11,7 @@ from discord.ext.commands.errors import BadArgument, MaxConcurrencyReached, Miss from bot.bot import Bot from bot.constants import ( - Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES + Channels, CleanMessages, Colours, Emojis, Event, Icons, MODERATION_ROLES ) from bot.exts.moderation.modlog import ModLog from bot.utils.channel import is_mod_channel @@ -347,6 +347,9 @@ class Clean(Cog): await self._log_clean(deleted_messages, channels, ctx.author) + if is_mod_channel(ctx.channel): + await ctx.message.add_reaction(Emojis.check_mark) + # region: Commands @group(invoke_without_command=True, name="clean", aliases=["clear", "purge"]) -- cgit v1.2.3 From 7b0cb52bc05261200a03428a51a48813eb3ccf0b Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 28 Aug 2021 23:33:32 +0300 Subject: Send message when no messages found This commit changes the clean command to send a message instead of raising BadArgument when no messages are found. Not finding messages is not an error, and doesn't necessarily require the help embed to spring up, just that the parameters might need tweaking. --- bot/exts/moderation/clean.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index f8526b1b9..1d323fa0b 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -209,15 +209,17 @@ class Clean(Cog): return deleted - async def _log_clean(self, messages: list[Message], channels: CleanChannels, invoker: User) -> None: - """Log the deleted messages to the modlog.""" + async def _log_clean(self, messages: list[Message], channels: CleanChannels, ctx: Context) -> bool: + """Log the deleted messages to the modlog. Return True if logging was successful.""" if not messages: # Can't build an embed, nothing to clean! - raise BadArgument("No matching messages could be found.") + delete_after = None if is_mod_channel(ctx.channel) else 5 + await ctx.send(":x: No matching messages could be found.", delete_after=delete_after) + return False # Reverse the list to have reverse chronological order log_messages = reversed(messages) - log_url = await self.mod_log.upload_log(log_messages, invoker.id) + log_url = await self.mod_log.upload_log(log_messages, ctx.author.id) # Build the embed and send it if channels == "*": @@ -227,7 +229,7 @@ class Clean(Cog): message = ( f"**{len(messages)}** messages deleted in {target_channels} by " - f"{invoker.mention}\n\n" + f"{ctx.author.mention}\n\n" f"A log of the deleted messages can be found [here]({log_url})." ) @@ -239,6 +241,8 @@ class Clean(Cog): channel_id=Channels.mod_log, ) + return True + # endregion async def _clean_messages( @@ -345,9 +349,9 @@ class Clean(Cog): deleted_messages = await self._delete_found(message_mappings) self.cleaning = False - await self._log_clean(deleted_messages, channels, ctx.author) + logged = await self._log_clean(deleted_messages, channels, ctx) - if is_mod_channel(ctx.channel): + if logged and is_mod_channel(ctx.channel): await ctx.message.add_reaction(Emojis.check_mark) # region: Commands -- cgit v1.2.3 From 8b1108df2798607ba9c2a8807b022166a482e09c Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Sun, 29 Aug 2021 02:05:55 +0530 Subject: Use unambiguous converters for infraction commands --- bot/converters.py | 1 + bot/exts/moderation/infraction/infractions.py | 30 +++++++++++++-------------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 97919cfb7..57c513246 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -596,3 +596,4 @@ if t.TYPE_CHECKING: Expiry = t.Union[Duration, ISODateTime] MemberOrUser = t.Union[discord.Member, discord.User] +UnambiguousMemberOrUser = t.Union[UnambiguousMember, UnambiguousUser] diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 2f9083c29..eaba97703 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -10,7 +10,7 @@ from discord.ext.commands import Context, command from bot import constants from bot.bot import Bot from bot.constants import Event -from bot.converters import Duration, Expiry, MemberOrUser +from bot.converters import Duration, Expiry, MemberOrUser, UnambiguousMemberOrUser from bot.decorators import respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler @@ -53,7 +53,7 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Permanent infractions @command() - async def warn(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None: + async def warn(self, ctx: Context, user: UnambiguousMemberOrUser, *, reason: t.Optional[str] = None) -> None: """Warn a user for the given reason.""" if not isinstance(user, Member): await ctx.send(":x: The user doesn't appear to be on the server.") @@ -66,7 +66,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user) @command() - async def kick(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None: + async def kick(self, ctx: Context, user: UnambiguousMemberOrUser, *, reason: t.Optional[str] = None) -> None: """Kick a user for the given reason.""" if not isinstance(user, Member): await ctx.send(":x: The user doesn't appear to be on the server.") @@ -78,7 +78,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def ban( self, ctx: Context, - user: MemberOrUser, + user: UnambiguousMemberOrUser, duration: t.Optional[Expiry] = None, *, reason: t.Optional[str] = None @@ -94,7 +94,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def purgeban( self, ctx: Context, - user: MemberOrUser, + user: UnambiguousMemberOrUser, duration: t.Optional[Expiry] = None, *, reason: t.Optional[str] = None @@ -110,7 +110,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def voiceban( self, ctx: Context, - user: MemberOrUser, + user: UnambiguousMemberOrUser, duration: t.Optional[Expiry] = None, *, reason: t.Optional[str] @@ -128,7 +128,7 @@ class Infractions(InfractionScheduler, commands.Cog): @command(aliases=["mute"]) async def tempmute( self, ctx: Context, - user: MemberOrUser, + user: UnambiguousMemberOrUser, duration: t.Optional[Expiry] = None, *, reason: t.Optional[str] = None @@ -162,7 +162,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def tempban( self, ctx: Context, - user: MemberOrUser, + user: UnambiguousMemberOrUser, duration: Expiry, *, reason: t.Optional[str] = None @@ -188,7 +188,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def tempvoiceban( self, ctx: Context, - user: MemberOrUser, + user: UnambiguousMemberOrUser, duration: Expiry, *, reason: t.Optional[str] @@ -214,7 +214,7 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Permanent shadow infractions @command(hidden=True) - async def note(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None: + async def note(self, ctx: Context, user: UnambiguousMemberOrUser, *, reason: t.Optional[str] = None) -> None: """Create a private note for a user with the given reason without notifying the user.""" infraction = await _utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False) if infraction is None: @@ -223,7 +223,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user) @command(hidden=True, aliases=['shadowban', 'sban']) - async def shadow_ban(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None: + async def shadow_ban(self, ctx: Context, user: UnambiguousMemberOrUser, *, reason: t.Optional[str] = None) -> None: """Permanently ban a user for the given reason without notifying the user.""" await self.apply_ban(ctx, user, reason, hidden=True) @@ -234,7 +234,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def shadow_tempban( self, ctx: Context, - user: MemberOrUser, + user: UnambiguousMemberOrUser, duration: Expiry, *, reason: t.Optional[str] = None @@ -260,17 +260,17 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Remove infractions (un- commands) @command() - async def unmute(self, ctx: Context, user: MemberOrUser) -> None: + async def unmute(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: """Prematurely end the active mute infraction for the user.""" await self.pardon_infraction(ctx, "mute", user) @command() - async def unban(self, ctx: Context, user: MemberOrUser) -> None: + async def unban(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: """Prematurely end the active ban infraction for the user.""" await self.pardon_infraction(ctx, "ban", user) @command(aliases=("uvban",)) - async def unvoiceban(self, ctx: Context, user: MemberOrUser) -> None: + async def unvoiceban(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: """Prematurely end the active voice ban infraction for the user.""" await self.pardon_infraction(ctx, "voice_ban", user) -- cgit v1.2.3 From 13308200ff62784832ba9f9084b69cd3a214b966 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 29 Aug 2021 02:10:43 +0300 Subject: `until` and `between` overhaul - The two subcommands can now accept a time delta and an ISO date time in addition to messages. - The two limits are now exclusive. Meaning cleaning until a message will not delete that message. - Added a separate predicate for the `until` case, as the combination of that command and cache usage would result in incorrect behavior. Additionally, deleting from cache now correctly traverses only `traverse` messages, rather than trying to delete `traverse` messages. --- bot/converters.py | 19 ++++++ bot/exts/moderation/clean.py | 145 +++++++++++++++++++++++++++---------------- 2 files changed, 111 insertions(+), 53 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 0118cc48a..546f6e8f4 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -388,6 +388,24 @@ class Duration(DurationDelta): raise BadArgument(f"`{duration}` results in a datetime outside the supported range.") +class Age(DurationDelta): + """Convert duration strings into UTC datetime.datetime objects.""" + + async def convert(self, ctx: Context, duration: str) -> datetime: + """ + Converts a `duration` string to a datetime object that's `duration` in the past. + + The converter supports the same symbols for each unit of time as its parent class. + """ + delta = await super().convert(ctx, duration) + now = datetime.utcnow() + + try: + return now - delta + except (ValueError, OverflowError): + raise BadArgument(f"`{duration}` results in a datetime outside the supported range.") + + class OffTopicName(Converter): """A converter that ensures an added off-topic name is valid.""" @@ -554,6 +572,7 @@ if t.TYPE_CHECKING: SourceConverter = SourceType # noqa: F811 DurationDelta = relativedelta # noqa: F811 Duration = datetime # noqa: F811 + Age = datetime # noqa: F811 OffTopicName = str # noqa: F811 ISODateTime = datetime # noqa: F811 HushDurationConverter = int # noqa: F811 diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 1d323fa0b..90f7f3e03 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -2,17 +2,20 @@ import logging import re import time from collections import defaultdict +from datetime import datetime +from itertools import islice from typing import Any, Callable, DefaultDict, Iterable, List, Literal, Optional, TYPE_CHECKING, Tuple, Union from discord import Colour, Embed, Message, NotFound, TextChannel, User, errors from discord.ext.commands import Cog, Context, Converter, group, has_any_role from discord.ext.commands.converter import TextChannelConverter -from discord.ext.commands.errors import BadArgument, MaxConcurrencyReached, MissingRequiredArgument +from discord.ext.commands.errors import BadArgument, MaxConcurrencyReached from bot.bot import Bot from bot.constants import ( Channels, CleanMessages, Colours, Emojis, Event, Icons, MODERATION_ROLES ) +from bot.converters import Age, ISODateTime from bot.exts.moderation.modlog import ModLog from bot.utils.channel import is_mod_channel @@ -21,6 +24,8 @@ log = logging.getLogger(__name__) # Type alias for checks Predicate = Callable[[Message], bool] +CleanLimit = Union[Message, Age, ISODateTime] + class CleanChannels(Converter): """A converter that turns the given string to a list of channels to clean, or the literal `*` for all channels.""" @@ -66,46 +71,40 @@ class Clean(Cog): channels: CleanChannels, bots_only: bool, user: User, - until_message: Message, - after_message: Message, + first_limit: CleanLimit, + second_limit: CleanLimit, use_cache: bool ) -> None: """Raise errors if an argument value or a combination of values is invalid.""" # Is this an acceptable amount of messages to traverse? if traverse > CleanMessages.message_limit: - raise BadArgument(f"You cannot traverse more than {CleanMessages.message_limit} messages.") + raise BadArgument(f"Cannot traverse more than {CleanMessages.message_limit} messages.") - if after_message: - # Ensure that until_message is specified. - if not until_message: - raise MissingRequiredArgument("`until_message` must be specified if `after_message` is specified.") + if (isinstance(first_limit, Message) or isinstance(first_limit, Message)) and channels: + raise BadArgument("Both a message limit and channels specified.") - # Messages are not in same channel - if after_message.channel != until_message.channel: - raise BadArgument("You cannot do range clean across several channel.") + if isinstance(first_limit, Message) and isinstance(second_limit, Message): + # Messages are not in same channel. + if first_limit.channel != second_limit.channel: + raise BadArgument("Message limits are in different channels.") - # Ensure that after_message is younger than until_message - if after_message.created_at >= until_message.created_at: - raise BadArgument("`after` message must be younger than `until` message") + # This is an implementation error rather than user error. + if second_limit and not first_limit: + raise ValueError("Second limit specified without the first.") def _get_messages_from_cache(self, traverse: int, to_delete: Predicate) -> Tuple[DefaultDict, List[int]]: """Helper function for getting messages from the cache.""" message_mappings = defaultdict(list) message_ids = [] - for message in self.bot.cached_messages: + for message in islice(self.bot.cached_messages, traverse): if not self.cleaning: # Cleaning was canceled - return (message_mappings, message_ids) + return message_mappings, message_ids if to_delete(message): message_mappings[message.channel].append(message) message_ids.append(message.id) - if len(message_ids) == traverse: - # We traversed the requested amount of messages. - return message_mappings, message_ids - - # There are fewer messages in the cache than the number requested to traverse. return message_mappings, message_ids async def _get_messages_from_channels( @@ -113,27 +112,19 @@ class Clean(Cog): traverse: int, channels: Iterable[TextChannel], to_delete: Predicate, - until_message: Optional[Message] = None + before: Optional[datetime] = None, + after: Optional[datetime] = None ) -> tuple[defaultdict[Any, list], list]: message_mappings = defaultdict(list) message_ids = [] for channel in channels: - - async for message in channel.history(limit=traverse): + async for message in channel.history(limit=traverse, before=before, after=after): if not self.cleaning: - # Cleaning was canceled, return empty containers + # Cleaning was canceled, return empty containers. return defaultdict(list), [] - if until_message: - - # We could use ID's here however in case if the message we are looking for gets deleted, - # We won't have a way to figure that out thus checking for datetime should be more reliable - if message.created_at < until_message.created_at: - # Means we have found the message until which we were supposed to be deleting. - break - if to_delete(message): message_mappings[message.channel].append(message) message_ids.append(message.id) @@ -253,8 +244,8 @@ class Clean(Cog): bots_only: bool = False, user: User = None, regex: Optional[str] = None, - until_message: Optional[Message] = None, - after_message: Optional[Message] = None, + first_limit: Optional[CleanLimit] = None, + second_limit: Optional[CleanLimit] = None, use_cache: Optional[bool] = True ) -> None: """A helper function that does the actual message cleaning.""" @@ -291,10 +282,14 @@ class Clean(Cog): return bool(re.search(regex.lower(), content.lower())) def predicate_range(message: Message) -> bool: - """Check if message is older than message provided in after_message but younger than until_message.""" - return after_message.created_at <= message.created_at <= until_message.created_at + """Check if the message age is between the two limits.""" + return first_limit <= message.created_at <= second_limit - self._validate_input(traverse, channels, bots_only, user, until_message, after_message, use_cache) + def predicate_after(message: Message) -> bool: + """Check if the message is older than the first limit.""" + return message.created_at >= first_limit + + self._validate_input(traverse, channels, bots_only, user, first_limit, second_limit, use_cache) # Are we already performing a clean? if self.cleaning: @@ -308,17 +303,31 @@ class Clean(Cog): predicate = predicate_specific_user # Delete messages from specific user elif regex: predicate = predicate_regex # Delete messages that match regex - elif after_message: - predicate = predicate_range # Delete messages older than specific message + elif second_limit: + predicate = predicate_range # Delete messages in the specified age range + elif first_limit: + predicate = predicate_after # Delete messages older than specific message else: predicate = lambda m: True # Delete all messages # noqa: E731 - # Default to using the invoking context's channel + # Default to using the invoking context's channel or the channel of the message limit(s). if not channels: - channels = [ctx.channel] + # At this point second_limit is guaranteed to not exist, be a datetime, or a message in the same channel. + if isinstance(first_limit, Message): + channels = [first_limit.channel] + elif isinstance(second_limit, Message): + channels = [second_limit.channel] + else: + channels = [ctx.channel] - if not is_mod_channel(ctx.channel): + if isinstance(first_limit, Message): + first_limit = first_limit.created_at + if isinstance(second_limit, Message): + second_limit = second_limit.created_at + if first_limit and second_limit: + first_limit, second_limit = sorted([first_limit, second_limit]) + if not is_mod_channel(ctx.channel): # Delete the invocation first self.mod_log.ignore(Event.message_delete, ctx.message.id) try: @@ -337,7 +346,8 @@ class Clean(Cog): traverse=traverse, channels=deletion_channels, to_delete=predicate, - until_message=until_message + before=second_limit, + after=first_limit # Remember first is the earlier datetime. ) if not self.cleaning: @@ -418,25 +428,54 @@ class Clean(Cog): @clean_group.command(name="until") @has_any_role(*MODERATION_ROLES) - async def clean_until(self, ctx: Context, message: Message) -> None: - """Delete all messages until certain message, stop cleaning after hitting the `message`.""" + async def clean_until( + self, + ctx: Context, + until: CleanLimit, + use_cache: Optional[bool] = True, + *, + channels: Optional[CleanChannels] = None) -> None: + """ + Delete all messages until a certain limit. + + A limit can be either a message, and ISO date-time string, or a time delta. + If a message is specified, `channels` cannot be specified. + """ await self._clean_messages( CleanMessages.message_limit, ctx, - channels=[message.channel], - until_message=message + channels=channels, + first_limit=until, + use_cache=use_cache ) @clean_group.command(name="between", aliases=["after-until", "from-to"]) @has_any_role(*MODERATION_ROLES) - async def clean_between(self, ctx: Context, after_message: Message, until_message: Message) -> None: - """Delete all messages within range of messages.""" + async def clean_between( + self, + ctx: Context, + first_limit: CleanLimit, + second_limit: CleanLimit, + use_cache: Optional[bool] = True, + *, + channels: Optional[CleanChannels] = None + ) -> None: + """ + Delete all messages within range. + + The range is specified through two limits. + A limit can be either a message, and ISO date-time string, or a time delta. + + If two messages are specified, they both must be in the same channel. + If a message is specified, `channels` cannot be specified. + """ await self._clean_messages( CleanMessages.message_limit, ctx, - channels=[until_message.channel], - until_message=until_message, - after_message=after_message, + channels=channels, + first_limit=first_limit, + second_limit=second_limit, + use_cache=use_cache ) @clean_group.command(name="stop", aliases=["cancel", "abort"]) -- cgit v1.2.3 From 1dea7637b4c8f7ee63224a992bcb60cfb502caea Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Sun, 29 Aug 2021 14:56:10 +0530 Subject: Make the helper function more readable --- bot/converters.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 57c513246..54ee2a90a 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -499,15 +499,11 @@ def _is_an_unambiguous_user_argument(argument: str) -> bool: """Check if the provided argument is a user mention, user id, or username.""" has_id_or_mention = bool(IDConverter()._get_id_match(argument) or RE_USER_MENTION.match(argument)) - if not has_id_or_mention: - if argument[0] == '@': - argument = argument[1:] + # Check to see if the author passed a username (a discriminator exists) + argument = argument.removeprefix('@') + has_username = len(argument) > 5 and argument[-5] == '#' - # Check to see if the author passed a username (a discriminator exists) - if len(argument) > 5 and argument[-5] == '#': - return True - - return has_id_or_mention + return has_id_or_mention or has_username AMBIGUOUS_ARGUMENT_MSG = ("`{argument}` is not a User mention, a User ID or a Username in the format" -- cgit v1.2.3 From 9f124b9eefd24bd1e3bc7210361fe927e8f9eeba Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 29 Aug 2021 13:47:37 +0300 Subject: Restrict until and between to a single channel The subcommands should stay simple and answer the most common use cases. Deleting all messages within a time range across many channels seems esoteric and gives just more room for mistakes. --- bot/exts/moderation/clean.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 90f7f3e03..6c7f3c22d 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -432,21 +432,19 @@ class Clean(Cog): self, ctx: Context, until: CleanLimit, - use_cache: Optional[bool] = True, - *, - channels: Optional[CleanChannels] = None) -> None: + channel: Optional[TextChannel] = None + ) -> None: """ Delete all messages until a certain limit. A limit can be either a message, and ISO date-time string, or a time delta. - If a message is specified, `channels` cannot be specified. + If a message is specified, `channel` cannot be specified. """ await self._clean_messages( CleanMessages.message_limit, ctx, - channels=channels, + channels=[channel] if channel else None, first_limit=until, - use_cache=use_cache ) @clean_group.command(name="between", aliases=["after-until", "from-to"]) @@ -456,9 +454,7 @@ class Clean(Cog): ctx: Context, first_limit: CleanLimit, second_limit: CleanLimit, - use_cache: Optional[bool] = True, - *, - channels: Optional[CleanChannels] = None + channel: Optional[TextChannel] = None ) -> None: """ Delete all messages within range. @@ -467,15 +463,14 @@ class Clean(Cog): A limit can be either a message, and ISO date-time string, or a time delta. If two messages are specified, they both must be in the same channel. - If a message is specified, `channels` cannot be specified. + If a message is specified, `channel` cannot be specified. """ await self._clean_messages( CleanMessages.message_limit, ctx, - channels=channels, + channels=[channel] if channel else None, first_limit=first_limit, second_limit=second_limit, - use_cache=use_cache ) @clean_group.command(name="stop", aliases=["cancel", "abort"]) -- cgit v1.2.3 From ec8f06312d756325fff31d7735ea56465440ed57 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 29 Aug 2021 13:53:06 +0300 Subject: Use a cog-wide role check --- bot/exts/moderation/clean.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 6c7f3c22d..950c0c82e 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -367,13 +367,11 @@ class Clean(Cog): # region: Commands @group(invoke_without_command=True, name="clean", aliases=["clear", "purge"]) - @has_any_role(*MODERATION_ROLES) async def clean_group(self, ctx: Context) -> None: """Commands for cleaning messages in channels.""" await ctx.send_help(ctx.command) @clean_group.command(name="user", aliases=["users"]) - @has_any_role(*MODERATION_ROLES) async def clean_user( self, ctx: Context, @@ -387,7 +385,6 @@ class Clean(Cog): await self._clean_messages(traverse, ctx, user=user, channels=channels, use_cache=use_cache) @clean_group.command(name="all", aliases=["everything"]) - @has_any_role(*MODERATION_ROLES) async def clean_all( self, ctx: Context, @@ -400,7 +397,6 @@ class Clean(Cog): await self._clean_messages(traverse, ctx, channels=channels, use_cache=use_cache) @clean_group.command(name="bots", aliases=["bot"]) - @has_any_role(*MODERATION_ROLES) async def clean_bots( self, ctx: Context, @@ -413,7 +409,6 @@ class Clean(Cog): await self._clean_messages(traverse, ctx, bots_only=True, channels=channels, use_cache=use_cache) @clean_group.command(name="regex", aliases=["word", "expression", "pattern"]) - @has_any_role(*MODERATION_ROLES) async def clean_regex( self, ctx: Context, @@ -427,7 +422,6 @@ class Clean(Cog): await self._clean_messages(traverse, ctx, regex=regex, channels=channels, use_cache=use_cache) @clean_group.command(name="until") - @has_any_role(*MODERATION_ROLES) async def clean_until( self, ctx: Context, @@ -448,7 +442,6 @@ class Clean(Cog): ) @clean_group.command(name="between", aliases=["after-until", "from-to"]) - @has_any_role(*MODERATION_ROLES) async def clean_between( self, ctx: Context, @@ -474,7 +467,6 @@ class Clean(Cog): ) @clean_group.command(name="stop", aliases=["cancel", "abort"]) - @has_any_role(*MODERATION_ROLES) async def clean_cancel(self, ctx: Context) -> None: """If there is an ongoing cleaning process, attempt to immediately cancel it.""" self.cleaning = False @@ -490,6 +482,10 @@ class Clean(Cog): # endregion + async def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return await has_any_role(*MODERATION_ROLES).predicate(ctx) + def setup(bot: Bot) -> None: """Load the Clean cog.""" -- cgit v1.2.3 From 13af64d4f275b82380b885dbd31fe49a747b69f3 Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Sun, 29 Aug 2021 16:06:21 +0530 Subject: Add more clarity to docstrings Clarify what Discord usernames are and the usage of the word "ambiguity". --- bot/converters.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 54ee2a90a..bd4044c7e 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -496,7 +496,7 @@ class HushDurationConverter(Converter): def _is_an_unambiguous_user_argument(argument: str) -> bool: - """Check if the provided argument is a user mention, user id, or username.""" + """Check if the provided argument is a user mention, user id, or username (name#discrim).""" has_id_or_mention = bool(IDConverter()._get_id_match(argument) or RE_USER_MENTION.match(argument)) # Check to see if the author passed a username (a discriminator exists) @@ -512,10 +512,10 @@ AMBIGUOUS_ARGUMENT_MSG = ("`{argument}` is not a User mention, a User ID or a Us class UnambiguousUser(UserConverter): """ - Converts to a `discord.User`, but only if a mention, userID or a username is provided. + Converts to a `discord.User`, but only if a mention, userID or a username (name#discrim) is provided. Unlike the default `UserConverter`, it doesn't allow conversion from a name. - This is useful in cases where that lookup strategy would lead to ambiguity. + This is useful in cases where that lookup strategy would lead to too much ambiguity. """ async def convert(self, ctx: Context, argument: str) -> discord.User: @@ -528,10 +528,10 @@ class UnambiguousUser(UserConverter): class UnambiguousMember(MemberConverter): """ - Converts to a `discord.Member`, but only if a mention, userID or a username is provided. + Converts to a `discord.Member`, but only if a mention, userID or a username (name#discrim) is provided. Unlike the default `MemberConverter`, it doesn't allow conversion from a name or nickname. - This is useful in cases where that lookup strategy would lead to ambiguity. + This is useful in cases where that lookup strategy would lead to too much ambiguity. """ async def convert(self, ctx: Context, argument: str) -> discord.Member: -- cgit v1.2.3 From fee4c0c8be7d0e7d0bcb8358bb11255feb3f66b8 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 29 Aug 2021 15:06:50 +0300 Subject: Handle reacted message being deleted --- bot/exts/moderation/clean.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 950c0c82e..6fb33c692 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -2,6 +2,7 @@ import logging import re import time from collections import defaultdict +from contextlib import suppress from datetime import datetime from itertools import islice from typing import Any, Callable, DefaultDict, Iterable, List, Literal, Optional, TYPE_CHECKING, Tuple, Union @@ -362,7 +363,8 @@ class Clean(Cog): logged = await self._log_clean(deleted_messages, channels, ctx) if logged and is_mod_channel(ctx.channel): - await ctx.message.add_reaction(Emojis.check_mark) + with suppress(NotFound): # Can happen if the invoker deleted their own messages. + await ctx.message.add_reaction(Emojis.check_mark) # region: Commands -- cgit v1.2.3 From b9b19ba92cea6baa52c6db6f5eefd79cffbb7bf9 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 29 Aug 2021 17:43:57 +0200 Subject: Fix punctuation Co-authored-by: Bluenix --- bot/exts/info/tags.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 931c01e3a..cd02f1768 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -371,9 +371,9 @@ class Tags(Cog): ) -> bool: """ If a single argument matching a group name is given, list all accessible tags from that group - Otherwise display the tag if one was found for the given arguments, or try to display suggestions for that name + Otherwise display the tag if one was found for the given arguments, or try to display suggestions for that name. - With no arguments, list all accessible tags + With no arguments, list all accessible tags. Returns True if a message was sent, or if the tag is on cooldown. Returns False if no message was sent. -- cgit v1.2.3 From 9c740dca0f7685a0e0e02b7a1de5557f900b84fc Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sun, 29 Aug 2021 19:04:25 +0200 Subject: Simplify group_score definition Co-authored-by: Bluenix --- bot/exts/info/tags.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index cd02f1768..b7c361c78 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -43,19 +43,14 @@ class TagIdentifier(NamedTuple): def get_fuzzy_score(self, fuzz_tag_identifier: TagIdentifier) -> float: """Get fuzzy score, using `fuzz_tag_identifier` as the identifier to fuzzy match with.""" - if self.group is None: - if fuzz_tag_identifier.group is None: - # We're only fuzzy matching the name - group_score = 1 - else: - # Ignore tags without groups if the identifier contains a group - return .0 + if (self.group is None) != (fuzz_tag_identifier.group is None): + # Ignore tags without groups if the identifier has a group and vice versa + return .0 + if self.group == fuzz_tag_identifier.group: + # Completely identical, or both None + group_score = 1 else: - if fuzz_tag_identifier.group is None: - # Ignore tags with groups if the identifier does not have a group - return .0 - else: - group_score = _fuzzy_search(fuzz_tag_identifier.group, self.group) + group_score = _fuzzy_search(fuzz_tag_identifier.group, self.group) fuzzy_score = group_score * _fuzzy_search(fuzz_tag_identifier.name, self.name) * 100 if fuzzy_score: -- cgit v1.2.3 From ab155fb20ea77c4c7ab60e6368b76733662b93d7 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 29 Aug 2021 17:47:27 +0300 Subject: Added master command The subcommands are kept simple and with few arguments, as they deal with most cases and their usage shouldn't be cumbersome. However we might to clean by criteria of several functionalities offered by the subcommands, for example delete a specific user's messages but only those that contain a certain pattern. For this reason the top-level command can now accept all arguments available in any of the subcommands, and will combine the criteria. Because the channels list has to be the last argument in order to accept either a list of channel or "*", I had to force a specific pattern in the regex argument for it to not consume the first channel specified. The regex argument must now have an "r" prefix and be enclosed in single quotes. For example: r'\d+'. For patterns with spaces the whole thing still needs to be enclosed in double quotes. For consistency the `clean regex` subcommand was changed to require the same. --- bot/exts/moderation/clean.py | 230 +++++++++++++++++++++++++++++-------------- 1 file changed, 156 insertions(+), 74 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 6fb33c692..bf018e8aa 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -8,7 +8,7 @@ from itertools import islice from typing import Any, Callable, DefaultDict, Iterable, List, Literal, Optional, TYPE_CHECKING, Tuple, Union from discord import Colour, Embed, Message, NotFound, TextChannel, User, errors -from discord.ext.commands import Cog, Context, Converter, group, has_any_role +from discord.ext.commands import Cog, Context, Converter, Greedy, group, has_any_role from discord.ext.commands.converter import TextChannelConverter from discord.ext.commands.errors import BadArgument, MaxConcurrencyReached @@ -22,6 +22,8 @@ from bot.utils.channel import is_mod_channel log = logging.getLogger(__name__) +DEFAULT_TRAVERSE = 10 + # Type alias for checks Predicate = Callable[[Message], bool] @@ -40,8 +42,17 @@ class CleanChannels(Converter): return [await self._channel_converter.convert(ctx, channel) for channel in argument.split()] +class Regex(Converter): + """A converter that takes a string in the form r'.+' and strips the 'r' prefix and the single quotes.""" + + async def convert(self, ctx: Context, argument: str) -> str: + """Strips the 'r' prefix and the enclosing single quotes from the string.""" + return re.match(r"r'(.+?)'", argument).group(1) + + if TYPE_CHECKING: CleanChannels = Union[Literal["*"], list[TextChannel]] # noqa: F811 + Regex = str # noqa: F811 class Clean(Cog): @@ -71,10 +82,9 @@ class Clean(Cog): traverse: int, channels: CleanChannels, bots_only: bool, - user: User, + users: list[User], first_limit: CleanLimit, second_limit: CleanLimit, - use_cache: bool ) -> None: """Raise errors if an argument value or a combination of values is invalid.""" # Is this an acceptable amount of messages to traverse? @@ -89,10 +99,85 @@ class Clean(Cog): if first_limit.channel != second_limit.channel: raise BadArgument("Message limits are in different channels.") + if users and bots_only: + raise BadArgument("Marked as bots only, but users were specified.") + # This is an implementation error rather than user error. if second_limit and not first_limit: raise ValueError("Second limit specified without the first.") + @staticmethod + def _build_predicate( + bots_only: bool = False, + users: list[User] = None, + regex: Optional[str] = None, + first_limit: Optional[datetime] = None, + second_limit: Optional[datetime] = None, + ) -> Predicate: + """Return the predicate that decides whether to delete a given message.""" + def predicate_bots_only(message: Message) -> bool: + """Return True if the message was sent by a bot.""" + return message.author.bot + + def predicate_specific_users(message: Message) -> bool: + """Return True if the message was sent by the user provided in the _clean_messages call.""" + return message.author in users + + def predicate_regex(message: Message) -> bool: + """Check if the regex provided in _clean_messages matches the message content or any embed attributes.""" + content = [message.content] + + # Add the content for all embed attributes + for embed in message.embeds: + content.append(embed.title) + content.append(embed.description) + content.append(embed.footer.text) + content.append(embed.author.name) + for field in embed.fields: + content.append(field.name) + content.append(field.value) + + # Get rid of empty attributes and turn it into a string + content = [attr for attr in content if attr] + content = "\n".join(content) + + # Now let's see if there's a regex match + if not content: + return False + else: + return bool(re.search(regex.lower(), content.lower())) + + def predicate_range(message: Message) -> bool: + """Check if the message age is between the two limits.""" + return first_limit <= message.created_at <= second_limit + + def predicate_after(message: Message) -> bool: + """Check if the message is older than the first limit.""" + return message.created_at >= first_limit + + predicates = [] + # Set up the correct predicate + if bots_only: + predicates.append(predicate_bots_only) # Delete messages from bots + if users: + predicates.append(predicate_specific_users) # Delete messages from specific user + if regex: + predicates.append(predicate_regex) # Delete messages that match regex + # Add up to one of the following: + if second_limit: + predicates.append(predicate_range) # Delete messages in the specified age range + elif first_limit: + predicates.append(predicate_after) # Delete messages older than specific message + + if not predicates: + predicate = lambda m: True # Delete all messages # noqa: E731 + elif len(predicates) == 1: + predicate = predicates[0] + else: + predicate = lambda m: all(pred(m) for pred in predicates) # noqa: E731 + + return predicate + def _get_messages_from_cache(self, traverse: int, to_delete: Predicate) -> Tuple[DefaultDict, List[int]]: """Helper function for getting messages from the cache.""" message_mappings = defaultdict(list) @@ -239,78 +324,24 @@ class Clean(Cog): async def _clean_messages( self, - traverse: int, ctx: Context, + traverse: int, channels: CleanChannels, bots_only: bool = False, - user: User = None, + users: list[User] = None, regex: Optional[str] = None, first_limit: Optional[CleanLimit] = None, second_limit: Optional[CleanLimit] = None, use_cache: Optional[bool] = True ) -> None: """A helper function that does the actual message cleaning.""" - def predicate_bots_only(message: Message) -> bool: - """Return True if the message was sent by a bot.""" - return message.author.bot - - def predicate_specific_user(message: Message) -> bool: - """Return True if the message was sent by the user provided in the _clean_messages call.""" - return message.author == user - - def predicate_regex(message: Message) -> bool: - """Check if the regex provided in _clean_messages matches the message content or any embed attributes.""" - content = [message.content] - - # Add the content for all embed attributes - for embed in message.embeds: - content.append(embed.title) - content.append(embed.description) - content.append(embed.footer.text) - content.append(embed.author.name) - for field in embed.fields: - content.append(field.name) - content.append(field.value) - - # Get rid of empty attributes and turn it into a string - content = [attr for attr in content if attr] - content = "\n".join(content) - - # Now let's see if there's a regex match - if not content: - return False - else: - return bool(re.search(regex.lower(), content.lower())) - - def predicate_range(message: Message) -> bool: - """Check if the message age is between the two limits.""" - return first_limit <= message.created_at <= second_limit - - def predicate_after(message: Message) -> bool: - """Check if the message is older than the first limit.""" - return message.created_at >= first_limit - - self._validate_input(traverse, channels, bots_only, user, first_limit, second_limit, use_cache) + self._validate_input(traverse, channels, bots_only, users, first_limit, second_limit) # Are we already performing a clean? if self.cleaning: raise MaxConcurrencyReached("Please wait for the currently ongoing clean operation to complete.") self.cleaning = True - # Set up the correct predicate - if bots_only: - predicate = predicate_bots_only # Delete messages from bots - elif user: - predicate = predicate_specific_user # Delete messages from specific user - elif regex: - predicate = predicate_regex # Delete messages that match regex - elif second_limit: - predicate = predicate_range # Delete messages in the specified age range - elif first_limit: - predicate = predicate_after # Delete messages older than specific message - else: - predicate = lambda m: True # Delete all messages # noqa: E731 - # Default to using the invoking context's channel or the channel of the message limit(s). if not channels: # At this point second_limit is guaranteed to not exist, be a datetime, or a message in the same channel. @@ -328,6 +359,9 @@ class Clean(Cog): if first_limit and second_limit: first_limit, second_limit = sorted([first_limit, second_limit]) + # Needs to be called after standardizing the input. + predicate = self._build_predicate(bots_only, users, regex, first_limit, second_limit) + if not is_mod_channel(ctx.channel): # Delete the invocation first self.mod_log.ignore(Event.message_delete, ctx.message.id) @@ -369,9 +403,51 @@ class Clean(Cog): # region: Commands @group(invoke_without_command=True, name="clean", aliases=["clear", "purge"]) - async def clean_group(self, ctx: Context) -> None: - """Commands for cleaning messages in channels.""" - await ctx.send_help(ctx.command) + async def clean_group( + self, + ctx: Context, + traverse: Optional[int] = None, + users: Greedy[User] = None, + first_limit: Optional[CleanLimit] = None, + second_limit: Optional[CleanLimit] = None, + use_cache: Optional[bool] = None, + bots_only: Optional[bool] = False, + regex: Optional[Regex] = None, + *, + channels: Optional[CleanChannels] = None + ) -> None: + """ + Commands for cleaning messages in channels. + + If arguments are provided, will act as a master command from which all subcommands can be derived. + `traverse`: The number of messages to look at in each channel. + `users`: A series of user mentions, ID's, or names. + `first_limit` and `second_limit`: A message, a duration delta, or an ISO datetime. + If a message is provided, cleaning will happen in that channel, and channels cannot be provided. + If only one of them is provided, acts as `clean until`. If both are provided, acts as `clean between`. + `use_cache`: Whether to use the message cache. + If not provided, will default to False unless an asterisk is used for the channels. + `bots_only`: Whether to delete only bots. If specified, users cannot be specified. + `regex`: A regex pattern the message must contain to be deleted. + The pattern must be provided with an "r" prefix and enclosed in single quotes. + If the pattern contains spaces, it still needs to be enclosed in double quotes on top of that. + `channels`: A series of channels to delete in, or an asterisk to delete from all channels. + """ + if not any([traverse, users, first_limit, second_limit, regex]): + await ctx.send_help(ctx.command) + return + + if not traverse: + if first_limit: + traverse = CleanMessages.message_limit + else: + traverse = DEFAULT_TRAVERSE + if not use_cache: + use_cache = channels == "*" + + await self._clean_messages( + ctx, traverse, channels, bots_only, users, regex, first_limit, second_limit, use_cache + ) @clean_group.command(name="user", aliases=["users"]) async def clean_user( @@ -384,44 +460,50 @@ class Clean(Cog): channels: Optional[CleanChannels] = None ) -> None: """Delete messages posted by the provided user, stop cleaning after traversing `traverse` messages.""" - await self._clean_messages(traverse, ctx, user=user, channels=channels, use_cache=use_cache) + await self._clean_messages(ctx, traverse, users=[user], channels=channels, use_cache=use_cache) @clean_group.command(name="all", aliases=["everything"]) async def clean_all( self, ctx: Context, - traverse: Optional[int] = 10, + traverse: Optional[int] = DEFAULT_TRAVERSE, use_cache: Optional[bool] = True, *, channels: Optional[CleanChannels] = None ) -> None: """Delete all messages, regardless of poster, stop cleaning after traversing `traverse` messages.""" - await self._clean_messages(traverse, ctx, channels=channels, use_cache=use_cache) + await self._clean_messages(ctx, traverse, channels=channels, use_cache=use_cache) @clean_group.command(name="bots", aliases=["bot"]) async def clean_bots( self, ctx: Context, - traverse: Optional[int] = 10, + traverse: Optional[int] = DEFAULT_TRAVERSE, use_cache: Optional[bool] = True, *, channels: Optional[CleanChannels] = None ) -> None: """Delete all messages posted by a bot, stop cleaning after traversing `traverse` messages.""" - await self._clean_messages(traverse, ctx, bots_only=True, channels=channels, use_cache=use_cache) + await self._clean_messages(ctx, traverse, bots_only=True, channels=channels, use_cache=use_cache) @clean_group.command(name="regex", aliases=["word", "expression", "pattern"]) async def clean_regex( self, ctx: Context, - regex: str, - traverse: Optional[int] = 10, + regex: Regex, + traverse: Optional[int] = DEFAULT_TRAVERSE, use_cache: Optional[bool] = True, *, channels: Optional[CleanChannels] = None ) -> None: - """Delete all messages that match a certain regex, stop cleaning after traversing `traverse` messages.""" - await self._clean_messages(traverse, ctx, regex=regex, channels=channels, use_cache=use_cache) + """ + Delete all messages that match a certain regex, stop cleaning after traversing `traverse` messages. + + The pattern must be provided with an "r" prefix and enclosed in single quotes. + If the pattern contains spaces, and still needs to be enclosed in double quotes on top of that. + For example: r'[0-9]+' + """ + await self._clean_messages(ctx, traverse, regex=regex, channels=channels, use_cache=use_cache) @clean_group.command(name="until") async def clean_until( @@ -437,8 +519,8 @@ class Clean(Cog): If a message is specified, `channel` cannot be specified. """ await self._clean_messages( - CleanMessages.message_limit, ctx, + CleanMessages.message_limit, channels=[channel] if channel else None, first_limit=until, ) @@ -461,8 +543,8 @@ class Clean(Cog): If a message is specified, `channel` cannot be specified. """ await self._clean_messages( - CleanMessages.message_limit, ctx, + CleanMessages.message_limit, channels=[channel] if channel else None, first_limit=first_limit, second_limit=second_limit, -- cgit v1.2.3 From d560cdfcaac6b4694ee4b810d9c8df85ec1fae6c Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Mon, 30 Aug 2021 14:14:02 +0530 Subject: Remove the json argument from the raw command. (#1792) --- bot/exts/info/information.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index ae547b1b8..bcf8c10d2 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -460,11 +460,12 @@ class Information(Cog): # remove trailing whitespace return out.rstrip() - @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES) - @group(invoke_without_command=True) - @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES) - async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: - """Shows information about the raw API response.""" + async def send_raw_content(self, ctx: Context, message: Message, json: bool = False) -> None: + """ + Send information about the raw API response for a `discord.Message`. + + If `json` is True, send the information in a copy-pasteable Python format. + """ if ctx.author not in message.channel.members: await ctx.send(":x: You do not have permissions to see the channel this message is in.") return @@ -500,10 +501,17 @@ class Information(Cog): for page in paginator.pages: await ctx.send(page, allowed_mentions=AllowedMentions.none()) + @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES) + @group(invoke_without_command=True) + @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES) + async def raw(self, ctx: Context, message: Message) -> None: + """Shows information about the raw API response.""" + await self.send_raw_content(ctx, message) + @raw.command() async def json(self, ctx: Context, message: Message) -> None: """Shows information about the raw API response in a copy-pasteable Python format.""" - await ctx.invoke(self.raw, message=message, json=True) + await self.send_raw_content(ctx, message, json=True) def setup(bot: Bot) -> None: -- cgit v1.2.3 From ef9a78d84d2c7759d45f3b1a0e06cf89eb3fd576 Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Mon, 30 Aug 2021 14:53:29 +0530 Subject: Allow dmrelay to only be used in mod channels. --- bot/exts/moderation/dm_relay.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/dm_relay.py b/bot/exts/moderation/dm_relay.py index 1d2206e27..0051db82f 100644 --- a/bot/exts/moderation/dm_relay.py +++ b/bot/exts/moderation/dm_relay.py @@ -5,6 +5,7 @@ from discord.ext.commands import Cog, Context, command, has_any_role from bot.bot import Bot from bot.constants import Emojis, MODERATION_ROLES +from bot.utils.channel import is_mod_channel from bot.utils.services import send_to_paste_service log = logging.getLogger(__name__) @@ -63,8 +64,9 @@ class DMRelay(Cog): await ctx.send(paste_link) async def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return await has_any_role(*MODERATION_ROLES).predicate(ctx) + """Only allow moderators to invoke the commands in this cog in mod channels.""" + return (await has_any_role(*MODERATION_ROLES).predicate(ctx) + and is_mod_channel(ctx.channel)) def setup(bot: Bot) -> None: -- cgit v1.2.3 From 857468ce4efbe26220d4c36a8840a13f89b30c44 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 30 Aug 2021 19:11:11 +0200 Subject: create a helper function to get the redis key of a doc item --- bot/exts/info/doc/_redis_cache.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/bot/exts/info/doc/_redis_cache.py b/bot/exts/info/doc/_redis_cache.py index ad764816f..0c635bf6e 100644 --- a/bot/exts/info/doc/_redis_cache.py +++ b/bot/exts/info/doc/_redis_cache.py @@ -24,8 +24,7 @@ class DocRedisCache(RedisObject): All keys from a single page are stored together, expiring a week after the first set. """ - url_key = remove_suffix(item.relative_url_path, ".html") - redis_key = f"{self.namespace}:{item.package}:{url_key}" + redis_key = f"{self.namespace}:{item_key(item)}" needs_expire = False with await self._get_pool_connection() as connection: @@ -43,10 +42,8 @@ class DocRedisCache(RedisObject): @namespace_lock async def get(self, item: DocItem) -> Optional[str]: """Return the Markdown content of the symbol `item` if it exists.""" - url_key = remove_suffix(item.relative_url_path, ".html") - with await self._get_pool_connection() as connection: - return await connection.hget(f"{self.namespace}:{item.package}:{url_key}", item.symbol_id, encoding="utf8") + return await connection.hget(f"{self.namespace}:{item_key(item)}", item.symbol_id, encoding="utf8") @namespace_lock async def delete(self, package: str) -> bool: @@ -61,10 +58,6 @@ class DocRedisCache(RedisObject): return False -def remove_suffix(string: str, suffix: str) -> str: - """Remove `suffix` from end of `string`.""" - # TODO replace usages with str.removesuffix on 3.9 - if string.endswith(suffix): - return string[:-len(suffix)] - else: - return string +def item_key(item: DocItem) -> str: + """Get the redis redis key string from `item`.""" + return f"{item.package}:{item.relative_url_path.removesuffix('.html')}" -- cgit v1.2.3 From 48b1a7b042ec23488243ae471842bdfcce8ee9a4 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 30 Aug 2021 20:23:23 +0200 Subject: Prevent erroneous symbols from always raising stale warnings Some doc symbols are improperly generated and never exist on the doc page the inventory file defines them in, causing the stale warning to get raised every time the page is parsed (at a maximum every week because of the redis expire). This can be prevented by keeping a counter in redis for the items which were stale, every time the item is warned for the counter is incremented and set to expire in 3 weeks. Then a warning is only raised when the counter is below 3, resulting in the unpreventable warning only being raised twice until it is fixed by the maintainers after it expires in 3 weeks after the last increment. --- bot/exts/info/doc/_batch_parser.py | 20 +++++++++++++------- bot/exts/info/doc/_redis_cache.py | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/bot/exts/info/doc/_batch_parser.py b/bot/exts/info/doc/_batch_parser.py index 369bb462c..cadf1e121 100644 --- a/bot/exts/info/doc/_batch_parser.py +++ b/bot/exts/info/doc/_batch_parser.py @@ -16,6 +16,7 @@ from bot.constants import Channels from bot.utils import scheduling from . import _cog, doc_cache from ._parsing import get_symbol_markdown +from ._redis_cache import StaleItemCounter log = logging.getLogger(__name__) @@ -23,6 +24,8 @@ log = logging.getLogger(__name__) class StaleInventoryNotifier: """Handle sending notifications about stale inventories through `DocItem`s to dev log.""" + symbol_counter = StaleItemCounter() + def __init__(self): self._init_task = bot.instance.loop.create_task( self._init_channel(), @@ -38,13 +41,16 @@ class StaleInventoryNotifier: async def send_warning(self, doc_item: _cog.DocItem) -> None: """Send a warning to dev log if one wasn't already sent for `item`'s url.""" if doc_item.url not in self._warned_urls: - self._warned_urls.add(doc_item.url) - await self._init_task - embed = discord.Embed( - description=f"Doc item `{doc_item.symbol_id=}` present in loaded documentation inventories " - f"not found on [site]({doc_item.url}), inventories may need to be refreshed." - ) - await self._dev_log.send(embed=embed) + # Only warn if the item got less than 3 warnings + # or if it has been more than 3 weeks since the last warning + if await self.symbol_counter.increment_for(doc_item) < 3: + self._warned_urls.add(doc_item.url) + await self._init_task + embed = discord.Embed( + description=f"Doc item `{doc_item.symbol_id=}` present in loaded documentation inventories " + f"not found on [site]({doc_item.url}), inventories may need to be refreshed." + ) + await self._dev_log.send(embed=embed) class QueueItem(NamedTuple): diff --git a/bot/exts/info/doc/_redis_cache.py b/bot/exts/info/doc/_redis_cache.py index 0c635bf6e..3fa3460ca 100644 --- a/bot/exts/info/doc/_redis_cache.py +++ b/bot/exts/info/doc/_redis_cache.py @@ -58,6 +58,22 @@ class DocRedisCache(RedisObject): return False +class StaleItemCounter(RedisObject): + """Manage increment counters for stale `DocItem`s.""" + + @namespace_lock + async def increment_for(self, item: DocItem) -> int: + """ + Increment the counter for `item` by 1, set it to expire in 3 weeks and return the new value. + + If the counter didn't exist, initialize it with 1. + """ + key = f"{self.namespace}:{item_key(item)}:{item.symbol_id}" + with await self._get_pool_connection() as connection: + await connection.expire(key, WEEK_SECONDS * 3) + return int(await connection.incr(key)) + + def item_key(item: DocItem) -> str: """Get the redis redis key string from `item`.""" return f"{item.package}:{item.relative_url_path.removesuffix('.html')}" -- cgit v1.2.3 From 727ef751ec2bb308d4a2d8bb0e348e438620494c Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 30 Aug 2021 20:44:49 +0200 Subject: Delete stale item counters when clearing doc cache --- bot/exts/info/doc/_cog.py | 1 + bot/exts/info/doc/_redis_cache.py | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index fb9b2584a..6c3110306 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -439,6 +439,7 @@ class DocCog(commands.Cog): ) -> None: """Clear the persistent redis cache for `package`.""" if await doc_cache.delete(package_name): + await self.item_fetcher.stale_inventory_notifier.symbol_counter.delete() await ctx.send(f"Successfully cleared the cache for `{package_name}`.") else: await ctx.send("No keys matching the package found.") diff --git a/bot/exts/info/doc/_redis_cache.py b/bot/exts/info/doc/_redis_cache.py index 3fa3460ca..05871eef7 100644 --- a/bot/exts/info/doc/_redis_cache.py +++ b/bot/exts/info/doc/_redis_cache.py @@ -73,6 +73,18 @@ class StaleItemCounter(RedisObject): await connection.expire(key, WEEK_SECONDS * 3) return int(await connection.incr(key)) + @namespace_lock + async def delete(self, package: str) -> bool: + """Remove all values for `package`; return True if at least one key was deleted, False otherwise.""" + with await self._get_pool_connection() as connection: + package_keys = [ + package_key async for package_key in connection.iscan(match=f"{self.namespace}:{package}:*") + ] + if package_keys: + await connection.delete(*package_keys) + return True + return False + def item_key(item: DocItem) -> str: """Get the redis redis key string from `item`.""" -- cgit v1.2.3 From dc01b874db2f2909dd90f62847d995e7c94a17eb Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 30 Aug 2021 19:47:36 +0100 Subject: Add concurrency rules to all GitHub workflows This concurrency rule means that workflow actions will be cancelled if a new set of actions are triggered on the same ref. A ref is either a PR or a branch itself. Doing this means that if someone pushes to a PR multiple times in quick succession, workflows won't get queued up. Instead, only the workflows from the most recent changes will run, as this is all we really care about anyway. I see the benefits of this as twofold. 1. The author gets faster feedback about the most recent change pushed, rather than have to wait for all previous runs to completed 2. Other contributors don't need to wait for a queue of redundant workflows to finish. --- .github/workflows/build.yml | 4 ++++ .github/workflows/deploy.yml | 4 ++++ .github/workflows/lint-test.yml | 3 +++ .github/workflows/sentry_release.yml | 4 ++++ .github/workflows/status_embed.yaml | 4 ++++ 5 files changed, 19 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 84a671917..f8f2c8888 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,6 +8,10 @@ on: types: - completed +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8b809b777..0f030ebd7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,6 +8,10 @@ on: types: - completed +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: environment: production diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index e99e6d181..c4f87d522 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -6,6 +6,9 @@ on: - main pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: lint-test: diff --git a/.github/workflows/sentry_release.yml b/.github/workflows/sentry_release.yml index f6a1e1f0e..48f5e50f4 100644 --- a/.github/workflows/sentry_release.yml +++ b/.github/workflows/sentry_release.yml @@ -5,6 +5,10 @@ on: branches: - main +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: create_sentry_release: runs-on: ubuntu-latest diff --git a/.github/workflows/status_embed.yaml b/.github/workflows/status_embed.yaml index b6a71b887..4178c366d 100644 --- a/.github/workflows/status_embed.yaml +++ b/.github/workflows/status_embed.yaml @@ -9,6 +9,10 @@ on: types: - completed +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: status_embed: # We need to send a status embed whenever the workflow -- cgit v1.2.3 From c2d1c974bb95d75b275260e869ec67d3e92d1ae1 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 30 Aug 2021 21:23:54 +0100 Subject: Change all references to watch to nominate instead --- bot/exts/recruitment/talentpool/_cog.py | 42 ++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 8db2d7eac..99567d55e 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -25,7 +25,7 @@ log = logging.getLogger(__name__) class TalentPool(Cog, name="Talentpool"): - """Relays messages of helper candidates to a watch channel to observe them.""" + """Used to nominate potential helper candidates.""" # RedisCache[str, bool] # Can contain a single key, "autoreview_enabled", with the value a bool indicating if autoreview is enabled. @@ -59,7 +59,7 @@ class TalentPool(Cog, name="Talentpool"): params=self.api_default_params ) except ResponseCodeError as err: - log.exception("Failed to fetch the watched users from the API", exc_info=err) + log.exception("Failed to fetch the currently nominated users from the API", exc_info=err) return False self.cache = defaultdict(dict) @@ -120,7 +120,11 @@ class TalentPool(Cog, name="Talentpool"): else: await ctx.send("Autoreview is currently disabled") - @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",)) + @nomination_group.command( + name="nominess", + aliases=("nominated", "all", "list", "watched"), + root_aliases=("nominees",) + ) @has_any_role(*MODERATION_ROLES) async def list_command( self, @@ -201,19 +205,23 @@ class TalentPool(Cog, name="Talentpool"): """ await ctx.invoke(self.list_command, oldest_first=True, update_cache=update_cache) - @nomination_group.command(name='forcewatch', aliases=('fw', 'forceadd', 'fa'), root_aliases=("forcenominate",)) + @nomination_group.command( + name="forcenominate", + aliases=("fw", "forceadd", "fa", "fn", "forcewatch"), + root_aliases=("forcenominate",) + ) @has_any_role(*MODERATION_ROLES) - async def force_watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None: + async def force_nominate_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None: """ Adds the given `user` to the talent pool, from any channel. A `reason` for adding the user to the talent pool is optional. """ - await self._watch_user(ctx, user, reason) + await self._nominate_user(ctx, user, reason) - @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",)) + @nomination_group.command(name='nominate', aliases=("w", "add", "a", "watch"), root_aliases=("nominate",)) @has_any_role(*STAFF_ROLES) - async def watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None: + async def nominate_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None: """ Adds the given `user` to the talent pool. @@ -224,18 +232,18 @@ class TalentPool(Cog, name="Talentpool"): if any(role.id in MODERATION_ROLES for role in ctx.author.roles): await ctx.send( f":x: Nominations should be run in the <#{Channels.nominations}> channel. " - "Use `!tp forcewatch` to override this check." + "Use `!tp forcenominate` to override this check." ) else: await ctx.send(f":x: Nominations must be run in the <#{Channels.nominations}> channel") return - await self._watch_user(ctx, user, reason) + await self._nominate_user(ctx, user, reason) - async def _watch_user(self, ctx: Context, user: MemberOrUser, reason: str) -> None: + async def _nominate_user(self, ctx: Context, user: MemberOrUser, reason: str) -> None: """Adds the given user to the talent pool.""" if user.bot: - await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") + await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. Only humans can be nominated.") return if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles): @@ -325,7 +333,7 @@ class TalentPool(Cog, name="Talentpool"): @nomination_group.command(name='end', aliases=('unwatch',), root_aliases=("unnominate",)) @has_any_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None: + async def end_nomination_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None: """ Ends the active nomination of the specified user with the given reason. @@ -335,7 +343,7 @@ class TalentPool(Cog, name="Talentpool"): await ctx.send(f":x: Maximum allowed characters for the end reason is {REASON_MAX_CHARS}.") return - if await self.unwatch(user.id, reason): + if await self.end_nomination(user.id, reason): await ctx.send(f":white_check_mark: Messages sent by {user.mention} will no longer be relayed") else: await ctx.send(":x: The specified user does not have an active nomination") @@ -444,7 +452,7 @@ class TalentPool(Cog, name="Talentpool"): @Cog.listener() async def on_member_ban(self, guild: Guild, user: Union[MemberOrUser]) -> None: """Remove `user` from the talent pool after they are banned.""" - await self.unwatch(user.id, "User was banned.") + await self.end_nomination(user.id, "User was banned.") @Cog.listener() async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None: @@ -466,7 +474,7 @@ class TalentPool(Cog, name="Talentpool"): log.info(f"Archiving nomination {message.id}") await self.reviewer.archive_vote(message, emoji == Emojis.incident_actioned) - async def unwatch(self, user_id: int, reason: str) -> bool: + async def end_nomination(self, user_id: int, reason: str) -> bool: """End the active nomination of a user with the given reason and return True on success.""" active_nomination = await self.bot.api_client.get( 'bot/nominations', @@ -536,7 +544,7 @@ class TalentPool(Cog, name="Talentpool"): {entries_string} End date: {end_date} - Unwatch reason: {nomination_object["end_reason"]} + Unnominate reason: {nomination_object["end_reason"]} =============== """ ) -- cgit v1.2.3 From 20aa8b23a386ad2d570a1d771825331b07074bb1 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 30 Aug 2021 21:27:54 +0100 Subject: Remove code that is no longer hit --- bot/exts/recruitment/talentpool/_cog.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 99567d55e..78f9b189b 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -47,10 +47,6 @@ class TalentPool(Cog, name="Talentpool"): """Return whether automatic posting of nomination reviews is enabled.""" return await self.talentpool_settings.get(AUTOREVIEW_ENABLED_KEY, True) - # Stores talentpool users in cache - self.cache = defaultdict(dict) - self.api_default_params = {'active': 'true', 'ordering': '-inserted_at'} - async def refresh_cache(self) -> bool: """Updates TalentPool users cache.""" try: -- cgit v1.2.3 From ffed769fbb1f450ac966564cc0ba75c2406e18ae Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 30 Aug 2021 21:39:20 +0100 Subject: Update comment to reference the new TalentPool cache name --- bot/exts/recruitment/talentpool/_review.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index dfdcdcffe..1cc9c900c 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -103,10 +103,10 @@ class Reviewer: """Format a generic review of a user and return it with the reviewed emoji.""" log.trace(f"Formatting the review of {user_id}") - # Since `watched_users` is a defaultdict, we should take care + # Since `cache` is a defaultdict, we should take care # not to accidentally insert the IDs of users that have no - # active nominated by using the `watched_users.get(user_id)` - # instead of `watched_users[user_id]`. + # active nominated by using the `cache.get(user_id)` + # instead of `cache[user_id]`. nomination = self._pool.cache.get(user_id) if not nomination: log.trace(f"There doesn't appear to be an active nomination for {user_id}") -- cgit v1.2.3 From 6a35307ada47a1c3091b23fb3a33dea78fe63c15 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 30 Aug 2021 21:44:48 +0100 Subject: Update api endpoint name in talent pool review --- bot/exts/recruitment/talentpool/_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 1cc9c900c..3ffbf93f3 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -97,7 +97,7 @@ class Reviewer: if update_database: nomination = self._pool.cache.get(user_id) - await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) + await self.bot.api_client.patch(f"bot/nominations/{nomination['id']}", json={"reviewed": True}) async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji]]: """Format a generic review of a user and return it with the reviewed emoji.""" -- cgit v1.2.3 From 892fafcd653b35c53ba48d7af42decd50b7fdda9 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 30 Aug 2021 21:58:59 +0100 Subject: Update unnominate message to remove reference to relaying messages. --- bot/exts/recruitment/talentpool/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 78f9b189b..4a77de24c 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -340,7 +340,7 @@ class TalentPool(Cog, name="Talentpool"): return if await self.end_nomination(user.id, reason): - await ctx.send(f":white_check_mark: Messages sent by {user.mention} will no longer be relayed") + await ctx.send(f":white_check_mark: Successfully un-nominated {user}") else: await ctx.send(":x: The specified user does not have an active nomination") -- cgit v1.2.3 From bcb664e19c3e417c17f00562180393f601c43a5a Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 30 Aug 2021 22:04:09 +0100 Subject: Add back missing default params for TalentPool cog --- bot/exts/recruitment/talentpool/_cog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 4a77de24c..38fefddd4 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -34,6 +34,7 @@ class TalentPool(Cog, name="Talentpool"): def __init__(self, bot: Bot) -> None: self.bot = bot self.reviewer = Reviewer(self.__class__.__name__, bot, self) + self.api_default_params = {'active': 'true', 'ordering': '-inserted_at'} self.bot.loop.create_task(self.schedule_autoreviews()) async def schedule_autoreviews(self) -> None: -- cgit v1.2.3 From ea6bd12f09c7978db24184cf09b91e1d643cffba Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 30 Aug 2021 22:04:46 +0100 Subject: use global logger in talent pool, over a self var that has been deleted --- bot/exts/recruitment/talentpool/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 38fefddd4..f932fc003 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -42,7 +42,7 @@ class TalentPool(Cog, name="Talentpool"): if await self.autoreview_enabled(): await self.reviewer.reschedule_reviews() else: - self.log.trace("Not scheduling reviews as autoreview is disabled.") + log.trace("Not scheduling reviews as autoreview is disabled.") async def autoreview_enabled(self) -> bool: """Return whether automatic posting of nomination reviews is enabled.""" -- cgit v1.2.3 From 5ca1fec1e9a2bb767863701bee1ffcad729331ec Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 30 Aug 2021 22:46:26 +0100 Subject: Rename list nominated user command for clarity --- bot/exts/recruitment/talentpool/_cog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index f932fc003..6dc36058b 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -137,9 +137,9 @@ class TalentPool(Cog, name="Talentpool"): The optional kwarg `update_cache` can be used to update the user cache using the API before listing the users. """ - await self.list_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + await self.list_nominated_users(ctx, oldest_first=oldest_first, update_cache=update_cache) - async def list_users( + async def list_nominated_users( self, ctx: Context, oldest_first: bool = False, -- cgit v1.2.3 From 1b04bdfec2ebf1399c93bf43b9bce735b47a8279 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 30 Aug 2021 23:47:52 +0200 Subject: Make the base_url argument in doc set optional --- bot/exts/info/doc/_cog.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index fb9b2584a..a2119a53d 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -150,6 +150,8 @@ class DocCog(commands.Cog): self.update_or_reschedule_inventory(api_package_name, base_url, inventory_url), ) else: + if not base_url: + base_url = self.base_url_from_inventory_url(inventory_url) self.update_single(api_package_name, base_url, package) def ensure_unique_symbol_name(self, package_name: str, group_name: str, symbol_name: str) -> str: @@ -352,6 +354,11 @@ class DocCog(commands.Cog): msg = await ctx.send(embed=doc_embed) await wait_for_deletion(msg, (ctx.author.id,)) + @staticmethod + def base_url_from_inventory_url(inventory_url: str) -> str: + """Get a base url from the url to an objects inventory by removing the last path segment.""" + return inventory_url.removesuffix("/").rsplit("/", maxsplit=1)[0] + "/" + @docs_group.command(name="setdoc", aliases=("s",)) @commands.has_any_role(*MODERATION_ROLES) @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) @@ -359,21 +366,21 @@ class DocCog(commands.Cog): self, ctx: commands.Context, package_name: PackageName, - base_url: ValidURL, inventory: Inventory, + base_url: ValidURL = "", ) -> None: """ Adds a new documentation metadata object to the site's database. The database will update the object, should an existing item with the specified `package_name` already exist. + If the base url is not specified, a default created by removing the last segment of the inventory url is used. Example: !docs setdoc \ python \ - https://docs.python.org/3/ \ https://docs.python.org/3/objects.inv """ - if not base_url.endswith("/"): + if base_url and not base_url.endswith("/"): raise commands.BadArgument("The base url must end with a slash.") inventory_url, inventory_dict = inventory body = { @@ -388,6 +395,8 @@ class DocCog(commands.Cog): + "\n".join(f"{key}: {value}" for key, value in body.items()) ) + if not base_url: + base_url = self.base_url_from_inventory_url(inventory_url) self.update_single(package_name, base_url, inventory_dict) await ctx.send(f"Added the package `{package_name}` to the database and updated the inventories.") -- cgit v1.2.3 From 9ef8306c2bc8ab0285da62ba2c7c42e003f015e2 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 30 Aug 2021 22:51:47 +0100 Subject: Fix spelling of a TalentPool command name --- bot/exts/recruitment/talentpool/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 6dc36058b..c0b3f8348 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -118,7 +118,7 @@ class TalentPool(Cog, name="Talentpool"): await ctx.send("Autoreview is currently disabled") @nomination_group.command( - name="nominess", + name="nominees", aliases=("nominated", "all", "list", "watched"), root_aliases=("nominees",) ) -- cgit v1.2.3 From 23a3e5e53e1c9229433439de90e423499a9742b7 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Tue, 31 Aug 2021 03:59:19 +0200 Subject: Raise for status to prevent parsing of invalid pages --- bot/exts/info/doc/_batch_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/doc/_batch_parser.py b/bot/exts/info/doc/_batch_parser.py index cadf1e121..62b04b649 100644 --- a/bot/exts/info/doc/_batch_parser.py +++ b/bot/exts/info/doc/_batch_parser.py @@ -107,7 +107,7 @@ class BatchParser: if doc_item not in self._item_futures and doc_item not in self._queue: self._item_futures[doc_item].user_requested = True - async with bot.instance.http_session.get(doc_item.url) as response: + async with bot.instance.http_session.get(doc_item.url, raise_for_status=True) as response: soup = await bot.instance.loop.run_in_executor( None, BeautifulSoup, -- cgit v1.2.3 From f2fb9f3dd449d58162471525ecaccc6db7d721f0 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Tue, 31 Aug 2021 20:49:15 +0300 Subject: Disallow time range cleaning in multiple channels Cleaning in the same time range across several channels seems like an arbitrary decision. --- bot/exts/moderation/clean.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index bf018e8aa..1148b3eb5 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -91,6 +91,9 @@ class Clean(Cog): if traverse > CleanMessages.message_limit: raise BadArgument(f"Cannot traverse more than {CleanMessages.message_limit} messages.") + if first_limit and channels and (channels == "*" or len(channels) > 1): + raise BadArgument("Message or time range specified across multiple channels.") + if (isinstance(first_limit, Message) or isinstance(first_limit, Message)) and channels: raise BadArgument("Both a message limit and channels specified.") @@ -424,6 +427,7 @@ class Clean(Cog): `users`: A series of user mentions, ID's, or names. `first_limit` and `second_limit`: A message, a duration delta, or an ISO datetime. If a message is provided, cleaning will happen in that channel, and channels cannot be provided. + If a limit is provided, multiple channels cannot be provided. If only one of them is provided, acts as `clean until`. If both are provided, acts as `clean between`. `use_cache`: Whether to use the message cache. If not provided, will default to False unless an asterisk is used for the channels. -- cgit v1.2.3 From 3ccb533686d464e11bf330ac19900a9f6cfc4366 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Tue, 31 Aug 2021 22:06:53 +0300 Subject: Changed regex formatting to wrapped in backticks After discussion, backticks seems like the preferrable formatting as it also cancels Discord's formatting. Additionally removed the Optionals from the last args in the commands, to not silently ignore incorrect input. --- bot/exts/moderation/clean.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 1148b3eb5..5b64693cc 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -43,11 +43,11 @@ class CleanChannels(Converter): class Regex(Converter): - """A converter that takes a string in the form r'.+' and strips the 'r' prefix and the single quotes.""" + """A converter that takes a string in the form `.+` and returns the contents of the inline code.""" async def convert(self, ctx: Context, argument: str) -> str: - """Strips the 'r' prefix and the enclosing single quotes from the string.""" - return re.match(r"r'(.+?)'", argument).group(1) + """Strips the backticks from the string.""" + return re.fullmatch(r"`(.+?)`", argument).group(1) if TYPE_CHECKING: @@ -417,7 +417,7 @@ class Clean(Cog): bots_only: Optional[bool] = False, regex: Optional[Regex] = None, *, - channels: Optional[CleanChannels] = None + channels: CleanChannels = None ) -> None: """ Commands for cleaning messages in channels. @@ -433,11 +433,11 @@ class Clean(Cog): If not provided, will default to False unless an asterisk is used for the channels. `bots_only`: Whether to delete only bots. If specified, users cannot be specified. `regex`: A regex pattern the message must contain to be deleted. - The pattern must be provided with an "r" prefix and enclosed in single quotes. + The pattern must be provided enclosed in backticks. If the pattern contains spaces, it still needs to be enclosed in double quotes on top of that. `channels`: A series of channels to delete in, or an asterisk to delete from all channels. """ - if not any([traverse, users, first_limit, second_limit, regex]): + if not any([traverse, users, first_limit, second_limit, regex, channels]): await ctx.send_help(ctx.command) return @@ -461,7 +461,7 @@ class Clean(Cog): traverse: Optional[int] = 10, use_cache: Optional[bool] = True, *, - channels: Optional[CleanChannels] = None + channels: CleanChannels = None ) -> None: """Delete messages posted by the provided user, stop cleaning after traversing `traverse` messages.""" await self._clean_messages(ctx, traverse, users=[user], channels=channels, use_cache=use_cache) @@ -473,7 +473,7 @@ class Clean(Cog): traverse: Optional[int] = DEFAULT_TRAVERSE, use_cache: Optional[bool] = True, *, - channels: Optional[CleanChannels] = None + channels: CleanChannels = None ) -> None: """Delete all messages, regardless of poster, stop cleaning after traversing `traverse` messages.""" await self._clean_messages(ctx, traverse, channels=channels, use_cache=use_cache) @@ -485,7 +485,7 @@ class Clean(Cog): traverse: Optional[int] = DEFAULT_TRAVERSE, use_cache: Optional[bool] = True, *, - channels: Optional[CleanChannels] = None + channels: CleanChannels = None ) -> None: """Delete all messages posted by a bot, stop cleaning after traversing `traverse` messages.""" await self._clean_messages(ctx, traverse, bots_only=True, channels=channels, use_cache=use_cache) @@ -498,14 +498,14 @@ class Clean(Cog): traverse: Optional[int] = DEFAULT_TRAVERSE, use_cache: Optional[bool] = True, *, - channels: Optional[CleanChannels] = None + channels: CleanChannels = None ) -> None: """ Delete all messages that match a certain regex, stop cleaning after traversing `traverse` messages. - The pattern must be provided with an "r" prefix and enclosed in single quotes. + The pattern must be provided enclosed in backticks. If the pattern contains spaces, and still needs to be enclosed in double quotes on top of that. - For example: r'[0-9]+' + For example: `[0-9]` """ await self._clean_messages(ctx, traverse, regex=regex, channels=channels, use_cache=use_cache) @@ -514,7 +514,7 @@ class Clean(Cog): self, ctx: Context, until: CleanLimit, - channel: Optional[TextChannel] = None + channel: TextChannel = None ) -> None: """ Delete all messages until a certain limit. @@ -535,7 +535,7 @@ class Clean(Cog): ctx: Context, first_limit: CleanLimit, second_limit: CleanLimit, - channel: Optional[TextChannel] = None + channel: TextChannel = None ) -> None: """ Delete all messages within range. -- cgit v1.2.3 From fce68e91a0dc8a58ffead61d933d2ded7e32e0f6 Mon Sep 17 00:00:00 2001 From: Hunter2807 <46440327+Hunter2807@users.noreply.github.com> Date: Wed, 1 Sep 2021 00:46:57 +0530 Subject: Rewording botvars.md tag (#1786) * Reword botvar.md Co-authored-by: Steele Farnsworth <32915757+swfarnsworth@users.noreply.github.com> --- bot/resources/tags/bot_var.md | 23 ----------------------- bot/resources/tags/botvar.md | 23 +++++++++++++++++++++++ 2 files changed, 23 insertions(+), 23 deletions(-) delete mode 100644 bot/resources/tags/bot_var.md create mode 100644 bot/resources/tags/botvar.md diff --git a/bot/resources/tags/bot_var.md b/bot/resources/tags/bot_var.md deleted file mode 100644 index 6833b3cd8..000000000 --- a/bot/resources/tags/bot_var.md +++ /dev/null @@ -1,23 +0,0 @@ -Python allows you to set custom attributes to class instances, like your bot! By adding variables as attributes to your bot you can access them anywhere you access your bot. In the discord.py library, these custom attributes are commonly known as "bot variables" and can be a lifesaver if your bot is divided into many different files. An example on how to use custom attributes on your bot is shown below: - -```py -bot = commands.Bot(command_prefix="!") -# Set an attribute on our bot -bot.test = "I am accessible everywhere!" - -@bot.command() -async def get(ctx: commands.Context): - """A command to get the current value of `test`.""" - # Send what the test attribute is currently set to - await ctx.send(ctx.bot.test) - -@bot.command() -async def setval(ctx: commands.Context, *, new_text: str): - """A command to set a new value of `test`.""" - # Here we change the attribute to what was specified in new_text - bot.test = new_text -``` - -This all applies to cogs as well! You can set attributes to `self` as you wish. - -*Be sure **not** to overwrite attributes discord.py uses, like `cogs` or `users`. Name your attributes carefully!* diff --git a/bot/resources/tags/botvar.md b/bot/resources/tags/botvar.md new file mode 100644 index 000000000..3db6ae7ac --- /dev/null +++ b/bot/resources/tags/botvar.md @@ -0,0 +1,23 @@ +Python allows you to set custom attributes to most objects, like your bot! By storing things as attributes of the bot object, you can access them anywhere you access your bot. In the discord.py library, these custom attributes are commonly known as "bot variables" and can be a lifesaver if your bot is divided into many different files. An example on how to use custom attributes on your bot is shown below: + +```py +bot = commands.Bot(command_prefix="!") +# Set an attribute on our bot +bot.test = "I am accessible everywhere!" + +@bot.command() +async def get(ctx: commands.Context): + """A command to get the current value of `test`.""" + # Send what the test attribute is currently set to + await ctx.send(ctx.bot.test) + +@bot.command() +async def setval(ctx: commands.Context, *, new_text: str): + """A command to set a new value of `test`.""" + # Here we change the attribute to what was specified in new_text + bot.test = new_text +``` + +This all applies to cogs as well! You can set attributes to `self` as you wish. + +*Be sure **not** to overwrite attributes discord.py uses, like `cogs` or `users`. Name your attributes carefully!* -- cgit v1.2.3 From 75fa14d75e357d91a36666f60ac75c2f6f7e1f96 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Tue, 31 Aug 2021 22:54:22 +0100 Subject: Add support for searching infractions by infraction id (#1787) * Add support for searching infractions by infraction id Can now search by infraction id via `!infraction {id}`. --- bot/exts/moderation/infraction/management.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 7f27896d7..223a124d8 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -11,6 +11,7 @@ from discord.ext.commands import Context from discord.utils import escape_markdown from bot import constants +from bot.api import ResponseCodeError from bot.bot import Bot from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, UnambiguousUser, allowed_strings from bot.exts.moderation.infraction.infractions import Infractions @@ -44,9 +45,25 @@ class ModManagement(commands.Cog): # region: Edit infraction commands @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf', 'i'), invoke_without_command=True) - async def infraction_group(self, ctx: Context) -> None: - """Infraction manipulation commands.""" - await ctx.send_help(ctx.command) + async def infraction_group(self, ctx: Context, infr_id: int = None) -> None: + """Infraction manipulation commands. If `infr_id` is passed then this command fetches that infraction.""" + if infr_id is None: + await ctx.send_help(ctx.command) + return + + try: + infraction_list = [await self.bot.api_client.get(f"bot/infractions/{infr_id}/expanded")] + except ResponseCodeError as e: + if e.status == 404: + await ctx.send(f":x: No infraction with ID `{infr_id}` could be found.") + return + raise e + + embed = discord.Embed( + title=f"Infraction #{infr_id}", + colour=discord.Colour.orange() + ) + await self.send_infraction_list(ctx, embed, infraction_list) @infraction_group.command(name="append", aliases=("amend", "add", "a")) async def infraction_append( @@ -210,7 +227,7 @@ class ModManagement(commands.Cog): else: await self.search_user(ctx, query) - @infraction_search_group.command(name="user", aliases=("member", "id")) + @infraction_search_group.command(name="user", aliases=("member", "userid")) async def search_user(self, ctx: Context, user: t.Union[MemberOrUser, discord.Object]) -> None: """Search for infractions by member.""" infraction_list = await self.bot.api_client.get( -- cgit v1.2.3 From 990bc67c7d02027d1eb3fc30654585bf77bc0d17 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 1 Sep 2021 10:23:05 +0100 Subject: Add an extra alias to unnominate --- bot/exts/recruitment/talentpool/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index c0b3f8348..477fff2fd 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -328,7 +328,7 @@ class TalentPool(Cog, name="Talentpool"): max_size=1000 ) - @nomination_group.command(name='end', aliases=('unwatch',), root_aliases=("unnominate",)) + @nomination_group.command(name="end", aliases=("unwatch", "unnominate"), root_aliases=("unnominate",)) @has_any_role(*MODERATION_ROLES) async def end_nomination_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None: """ -- cgit v1.2.3 From 8e25ed16caf0a510b386d371800b6f93e8855684 Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 1 Sep 2021 11:50:36 +0100 Subject: Prevent infractions raising error due to role hierarchy Now explicitly states that the bot is unable to starify/kick/ban someone who's higher in the role hierarchy --- bot/exts/moderation/infraction/infractions.py | 8 ++++++++ bot/exts/moderation/infraction/superstarify.py | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index eaba97703..4afa66460 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -314,6 +314,10 @@ class Infractions(InfractionScheduler, commands.Cog): @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`.""" + if user.top_role > ctx.me.top_role: + await ctx.send(":x: I can't kick users above me in the role hierarchy.") + return + infraction = await _utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) if infraction is None: return @@ -340,6 +344,10 @@ class Infractions(InfractionScheduler, commands.Cog): Will also remove the banned user from the Big Brother watch list if applicable. """ + if hasattr(user, 'top_role') and user.top_role > ctx.me.top_role: + await ctx.send(":x: I can't ban users above me in the role hierarchy.") + return + # In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active is_temporary = kwargs.get("expires_at") is not None active_infraction = await _utils.get_active_infraction(ctx, user, "ban", is_temporary) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 05a2bbe10..2b111fed7 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -132,6 +132,10 @@ class Superstarify(InfractionScheduler, Cog): An optional reason can be provided, which would be added to a message stating their old nickname and linking to the nickname policy. """ + if member.top_role > ctx.me.top_role: + await ctx.send(":x: I can't starify users above me in the role hierarchy.") + return + if await _utils.get_active_infraction(ctx, member, "superstar"): return -- cgit v1.2.3 From 8fa3fa1d80933a539cef52c1949dd14d0d96dfd1 Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 1 Sep 2021 16:33:10 +0100 Subject: Fix role hierarchy check Now uses `>=` instead of `>`, as is meant to happen. --- bot/exts/moderation/infraction/infractions.py | 8 ++++---- bot/exts/moderation/infraction/superstarify.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 4afa66460..93a6b6c8b 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -314,8 +314,8 @@ class Infractions(InfractionScheduler, commands.Cog): @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`.""" - if user.top_role > ctx.me.top_role: - await ctx.send(":x: I can't kick users above me in the role hierarchy.") + if user.top_role >= ctx.me.top_role: + await ctx.send(":x: I can't kick users above or equal to me in the role hierarchy.") return infraction = await _utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) @@ -344,8 +344,8 @@ class Infractions(InfractionScheduler, commands.Cog): Will also remove the banned user from the Big Brother watch list if applicable. """ - if hasattr(user, 'top_role') and user.top_role > ctx.me.top_role: - await ctx.send(":x: I can't ban users above me in the role hierarchy.") + if hasattr(user, 'top_role') and user.top_role >= ctx.me.top_role: + await ctx.send(":x: I can't ban users above or equal to me in the role hierarchy.") return # In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 2b111fed7..986decdd6 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -132,8 +132,8 @@ class Superstarify(InfractionScheduler, Cog): An optional reason can be provided, which would be added to a message stating their old nickname and linking to the nickname policy. """ - if member.top_role > ctx.me.top_role: - await ctx.send(":x: I can't starify users above me in the role hierarchy.") + if member.top_role >= ctx.me.top_role: + await ctx.send(":x: I can't starify users above or equal to me in the role hierarchy.") return if await _utils.get_active_infraction(ctx, member, "superstar"): -- cgit v1.2.3 From 38dc1c69095c35c72a543ed7b08c8dbdad8931c2 Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 1 Sep 2021 23:11:57 +0100 Subject: Fix tests --- tests/bot/exts/backend/sync/test_users.py | 2 +- tests/bot/exts/moderation/infraction/test_infractions.py | 11 ++++++----- tests/helpers.py | 2 ++ 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py index 27932be95..5469f6b67 100644 --- a/tests/bot/exts/backend/sync/test_users.py +++ b/tests/bot/exts/backend/sync/test_users.py @@ -10,7 +10,7 @@ def fake_user(**kwargs): kwargs.setdefault("id", 43) kwargs.setdefault("name", "bob the test man") kwargs.setdefault("discriminator", 1337) - kwargs.setdefault("roles", [666]) + kwargs.setdefault("roles", [helpers.MockRole(id=666)]) kwargs.setdefault("in_guild", True) return kwargs diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index f844a9181..56528b290 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -13,12 +13,13 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase): """Tests for ban and kick command reason truncation.""" def setUp(self): + self.me = MockMember(id=7890, roles=[MockRole(id=7890, position=5)]) self.bot = MockBot() self.cog = Infractions(self.bot) - self.user = MockMember(id=1234, top_role=MockRole(id=3577, position=10)) - self.target = MockMember(id=1265, top_role=MockRole(id=9876, position=0)) + self.user = MockMember(id=1234, roles=[MockRole(id=3577, position=10)]) + self.target = MockMember(id=1265, roles=[MockRole(id=9876, position=1)]) self.guild = MockGuild(id=4567) - self.ctx = MockContext(bot=self.bot, author=self.user, guild=self.guild) + self.ctx = MockContext(me=self.me, bot=self.bot, author=self.user, guild=self.guild) @patch("bot.exts.moderation.infraction._utils.get_active_infraction") @patch("bot.exts.moderation.infraction._utils.post_infraction") @@ -64,8 +65,8 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.bot = MockBot() - self.mod = MockMember(top_role=10) - self.user = MockMember(top_role=1, roles=[MockRole(id=123456)]) + self.mod = MockMember(roles=[MockRole(id=7890123, position=10)]) + self.user = MockMember(roles=[MockRole(id=123456, position=1)]) self.guild = MockGuild() self.ctx = MockContext(bot=self.bot, author=self.mod) self.cog = Infractions(self.bot) diff --git a/tests/helpers.py b/tests/helpers.py index 3978076ed..8443150eb 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -235,6 +235,7 @@ class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin self.roles = [MockRole(name="@everyone", position=1, id=0)] if roles: self.roles.extend(roles) + self.top_role = max(self.roles) if 'mention' not in kwargs: self.mention = f"@{self.name}" @@ -439,6 +440,7 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock): def __init__(self, **kwargs) -> None: super().__init__(**kwargs) + self.me = kwargs.get('me', MockMember()) self.bot = kwargs.get('bot', MockBot()) self.guild = kwargs.get('guild', MockGuild()) self.author = kwargs.get('author', MockMember()) -- cgit v1.2.3 From 97017f00ecdb70359ff8cf6f4a8fe970c6aa7ad5 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Fri, 3 Sep 2021 00:50:50 -0700 Subject: `Unnominate reason` to `Unnomination reason` --- bot/exts/recruitment/talentpool/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 477fff2fd..c2257c84b 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -541,7 +541,7 @@ class TalentPool(Cog, name="Talentpool"): {entries_string} End date: {end_date} - Unnominate reason: {nomination_object["end_reason"]} + Unnomination reason: {nomination_object["end_reason"]} =============== """ ) -- cgit v1.2.3 From a06367e1691d8ef39e2cf8f8e0d15e5922d29d01 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 3 Sep 2021 09:42:44 +0100 Subject: Pop user from talent pool cache when unnominated --- bot/exts/recruitment/talentpool/_cog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index c2257c84b..a317c6645 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -493,6 +493,7 @@ class TalentPool(Cog, name="Talentpool"): json={'end_reason': reason, 'active': False} ) + self.cache.pop(user_id) if await self.autoreview_enabled(): self.reviewer.cancel(user_id) -- cgit v1.2.3 From da697eef39bf6a786d8293312d41b9bd634c542d Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 4 Sep 2021 09:41:47 +0100 Subject: Update in accordance with python-discord/kubernetes#95 --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8b809b777..f8a8292a4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -38,6 +38,6 @@ jobs: uses: Azure/k8s-deploy@v1 with: manifests: | - bot/deployment.yaml + namespaces/default/bot/deployment.yaml images: 'ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }}' kubectl-version: 'latest' -- cgit v1.2.3 From 66f1ca3ca60b4db46b58dcc8d30a4f45e1f8d122 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sat, 4 Sep 2021 14:49:30 +0100 Subject: Pin platform in Dockerfile Some of our deps don't have wheels for atm processors. With Mac's M1 chips becomming more common, we should make it easier for those users to build our environments. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 4d8592590..30bf8a361 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9-slim +FROM --platform=linux/amd64 python:3.9-slim # Set pip to have no saved cache ENV PIP_NO_CACHE_DIR=false \ -- cgit v1.2.3 From b4c7d61361f7ba567e6638d3721f7b3bbcf334d9 Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Sat, 4 Sep 2021 23:14:15 +0100 Subject: Server command now uses correct TalentPool attr (#1810) This was attr changed when we removed the concept of a user being 'watched' while removing the talentpool. --- bot/exts/info/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index bcf8c10d2..51d47b75c 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -72,7 +72,7 @@ class Information(Cog): """Return additional server info only visible in moderation channels.""" talentpool_info = "" if cog := self.bot.get_cog("Talentpool"): - talentpool_info = f"Nominated: {len(cog.watched_users)}\n" + talentpool_info = f"Nominated: {len(cog.cache)}\n" bb_info = "" if cog := self.bot.get_cog("Big Brother"): -- cgit v1.2.3 From ba2a74ac18f9e40fad917d51ffa2238a340416c0 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 6 Sep 2021 01:02:54 +0200 Subject: simplify fuzzy suggestion func Co-authored-by: Bluenix --- bot/exts/info/tags.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index b7c361c78..909831bc7 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -162,21 +162,16 @@ class Tags(Cog): def get_fuzzy_matches(self, tag_identifier: TagIdentifier) -> list[tuple[TagIdentifier, Tag]]: """Get tags with identifiers similar to `tag_identifier`.""" - if tag_identifier.group is None: - if len(tag_identifier.name) < 3: - return [] - else: - return self._get_suggestions(tag_identifier) - else: - if len(tag_identifier.group) < 3: - suggestions = [] - else: - # Try fuzzy matching with only a name first - suggestions = self._get_suggestions(TagIdentifier(None, tag_identifier.group)) + suggestions = [] + + if tag_identifier.group is not None and len(tag_identifier.group) >= 3: + # Try fuzzy matching with only a name first + suggestions += self._get_suggestions(TagIdentifier(None, tag_identifier.group)) + + if len(tag_identifier.name) >= 3: + suggestions += self._get_suggestions(tag_identifier) - if len(tag_identifier.name) >= 3: - suggestions += self._get_suggestions(tag_identifier) - return suggestions + return suggestions def _get_tags_via_content( self, -- cgit v1.2.3 From f5db0b8e3d4e954a5e25737c27f4454c9b46a1c3 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 6 Sep 2021 01:32:17 +0200 Subject: Remove TagNameConverter The converter was now only used to restrict requested names which can be handled by not matching a tag in the cog and not displaying output, this allows us to be a bit more generous with tag names during the command fallback when a name with invalid symbols is parsed after a group --- bot/converters.py | 35 ---------------------------- bot/exts/backend/error_handler.py | 26 +++++++-------------- bot/exts/info/tags.py | 9 ++++--- tests/bot/exts/backend/test_error_handler.py | 24 ------------------- tests/bot/test_converters.py | 29 ----------------------- 5 files changed, 13 insertions(+), 110 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 2a3943831..038d2a287 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -236,41 +236,6 @@ class Snowflake(IDConverter): return snowflake -class TagNameConverter(Converter): - """ - Ensure that a proposed tag name is valid. - - Valid tag names meet the following conditions: - * All ASCII characters - * Has at least one non-whitespace character - * Not solely numeric - * Shorter than 127 characters - """ - - @staticmethod - async def convert(ctx: Context, tag_name: str) -> str: - """Lowercase & strip whitespace from proposed tag_name & ensure it's valid.""" - tag_name = tag_name.lower().strip() - - # The tag name has at least one invalid character. - if ascii(tag_name)[1:-1] != tag_name: - raise BadArgument("Don't be ridiculous, you can't use that character!") - - # The tag name is either empty, or consists of nothing but whitespace. - elif not tag_name: - raise BadArgument("Tag names should not be empty, or filled with whitespace.") - - # The tag name is longer than 127 characters. - elif len(tag_name) > 127: - raise BadArgument("Are you insane? That's way too long!") - - # The tag name is ascii but does not contain any letters. - elif not any(character.isalpha() for character in tag_name): - raise BadArgument("Tag names must contain at least one letter.") - - return tag_name - - class TagContentConverter(Converter): """Ensure proposed tag content is not empty and contains at least one non-whitespace character.""" diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 51b6bc660..f2e2a964c 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -9,7 +9,6 @@ from sentry_sdk import push_scope from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Colours, Icons, MODERATION_ROLES -from bot.converters import TagNameConverter from bot.errors import InvalidInfractedUser, LockedResourceError from bot.exts.info import tags from bot.utils.checks import ContextCheckFailure @@ -154,23 +153,16 @@ class ErrorHandler(Cog): await self.on_command_error(ctx, tag_error) return - try: - tag_identifier = tags.TagIdentifier.from_string(ctx.message.content) - if tag_identifier.group is not None: - tag_name = await TagNameConverter.convert(ctx, tag_identifier.name) - tag_name_or_group = await TagNameConverter.convert(ctx, tag_identifier.group) - else: - tag_name = None - tag_name_or_group = await TagNameConverter.convert(ctx, tag_identifier.name) - - except errors.BadArgument: - log.debug( - f"{ctx.author} tried to use an invalid command " - f"and the fallback tag failed validation in TagNameConverter." - ) + tag_identifier = tags.TagIdentifier.from_string(ctx.message.content) + if tag_identifier.group is not None: + tag_name = tag_identifier.name + tag_name_or_group = tag_identifier.group else: - if await ctx.invoke(tags_get_command, tag_name_or_group, tag_name): - return + tag_name = None + tag_name_or_group = tag_identifier.name + + if await ctx.invoke(tags_get_command, tag_name_or_group, tag_name): + return if not any(role.id in MODERATION_ROLES for role in ctx.author.roles): await self.send_command_suggestion(ctx, ctx.invoked_with) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 909831bc7..0fc6e99d0 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -14,7 +14,6 @@ from discord.ext.commands import Cog, Context, group from bot import constants from bot.bot import Bot -from bot.converters import TagNameConverter from bot.pagination import LinePaginator from bot.utils.messages import wait_for_deletion @@ -235,8 +234,8 @@ class Tags(Cog): async def tags_group( self, ctx: Context, - tag_name_or_group: TagNameConverter = None, - tag_name: TagNameConverter = None, + tag_name_or_group: str = None, + tag_name: str = None, ) -> None: """Show all known tags, a single tag, or run a subcommand.""" await self.get_command(ctx, tag_name_or_group=tag_name_or_group, tag_name=tag_name) @@ -356,8 +355,8 @@ class Tags(Cog): @tags_group.command(name="get", aliases=("show", "g")) async def get_command( self, ctx: Context, - tag_name_or_group: TagNameConverter = None, - tag_name: TagNameConverter = None, + tag_name_or_group: str = None, + tag_name: str = None, ) -> bool: """ If a single argument matching a group name is given, list all accessible tags from that group diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index 4a466c22e..eafcbae6c 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -296,30 +296,6 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.try_get_tag(self.ctx)) self.cog.on_command_error.assert_awaited_once_with(self.ctx, err) - @patch("bot.exts.backend.error_handler.TagNameConverter") - async def test_try_get_tag_convert_success(self, tag_converter): - """Converting tag should successful.""" - self.ctx.message = MagicMock(content="foo") - tag_converter.convert = AsyncMock(return_value="foo") - self.assertIsNone(await self.cog.try_get_tag(self.ctx)) - tag_converter.convert.assert_awaited_once_with(self.ctx, "foo") - self.ctx.invoke.assert_awaited_once() - - self.ctx.reset_mock() - self.ctx.message = MagicMock(content="foo bar") - tag_converter.convert = AsyncMock(return_value="foo bar") - self.assertIsNone(await self.cog.try_get_tag(self.ctx)) - self.assertEqual(tag_converter.convert.call_count, 2) - self.ctx.invoke.assert_awaited_once() - - @patch("bot.exts.backend.error_handler.TagNameConverter") - async def test_try_get_tag_convert_fail(self, tag_converter): - """Converting tag should raise `BadArgument`.""" - self.ctx.reset_mock() - tag_converter.convert = AsyncMock(side_effect=errors.BadArgument()) - self.assertIsNone(await self.cog.try_get_tag(self.ctx)) - self.ctx.invoke.assert_not_awaited() - async def test_try_get_tag_ctx_invoke(self): """Should call `ctx.invoke` with proper args/kwargs.""" test_cases = ( diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index 4af84dde5..d0d7af1ba 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -12,7 +12,6 @@ from bot.converters import ( ISODateTime, PackageName, TagContentConverter, - TagNameConverter, ) @@ -50,34 +49,6 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase): with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): await TagContentConverter.convert(self.context, value) - async def test_tag_name_converter_for_valid(self): - """TagNameConverter should return the correct values for valid tag names.""" - test_values = ( - ('tracebacks', 'tracebacks'), - ('Tracebacks', 'tracebacks'), - (' Tracebacks ', 'tracebacks'), - ) - - for name, expected_conversion in test_values: - with self.subTest(name=name, expected_conversion=expected_conversion): - conversion = await TagNameConverter.convert(self.context, name) - self.assertEqual(conversion, expected_conversion) - - async def test_tag_name_converter_for_invalid(self): - """TagNameConverter should raise the correct exception for invalid tag names.""" - test_values = ( - ('👋', "Don't be ridiculous, you can't use that character!"), - ('', "Tag names should not be empty, or filled with whitespace."), - (' ', "Tag names should not be empty, or filled with whitespace."), - ('42', "Tag names must contain at least one letter."), - ('x' * 128, "Are you insane? That's way too long!"), - ) - - for invalid_name, exception_message in test_values: - with self.subTest(invalid_name=invalid_name, exception_message=exception_message): - with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): - await TagNameConverter.convert(self.context, invalid_name) - async def test_package_name_for_valid(self): """PackageName returns valid package names unchanged.""" test_values = ('foo', 'le_mon', 'num83r') -- cgit v1.2.3 From f3634f9dbcb6c7cc6952f7d9e40879518c4e6eb1 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 6 Sep 2021 01:40:49 +0200 Subject: Return 0 if search string has no a-z characters --- bot/exts/info/tags.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 0fc6e99d0..3d222933a 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -109,6 +109,9 @@ class Tag: def _fuzzy_search(search: str, target: str) -> float: """A simple scoring algorithm based on how many letters are found / total, with order in mind.""" _search = REGEX_NON_ALPHABET.sub("", search.lower()) + if not _search: + return 0 + _targets = iter(REGEX_NON_ALPHABET.split(target.lower())) current = 0 -- cgit v1.2.3 From e07febdbde4815466b161b535f4a5eaf3593f755 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 6 Sep 2021 03:15:01 +0200 Subject: Use a -inf default for comparison to skip containment check --- bot/exts/info/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 3d222933a..bcffb3b80 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -99,7 +99,7 @@ class Tag: def on_cooldown_in(self, channel: discord.TextChannel) -> bool: """Check whether the tag is on cooldown in `channel`.""" - return channel in self._cooldowns and self._cooldowns[channel] > time.time() + return self._cooldowns.get(channel, float("-inf")) > time.time() def set_cooldown_for(self, channel: discord.TextChannel) -> None: """Set the tag to be on cooldown in `channel` for `constants.Cooldowns.tags` seconds.""" -- cgit v1.2.3 From 6bb0dba2230e9b3fcab2caaf559b749543c26abb Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 5 Sep 2021 09:34:54 +0100 Subject: Declare and refresh TalentPool.cache on init of cog This avoids issues in the server cog trying to access it before it's assigned and refreshed. I also migrated to the tasks to `scheduling.create_task()` as the created tasks currently don't have any error handling they can hide errors in development until the task object is destroyed (if that occurs at all) which logs the exception. The scheduling alternative attaches a callback which logs exceptions to prevent this. --- bot/exts/info/information.py | 3 ++- bot/exts/recruitment/talentpool/_cog.py | 9 ++++++--- bot/exts/recruitment/talentpool/_review.py | 2 -- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 51d47b75c..d44886969 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -72,7 +72,8 @@ class Information(Cog): """Return additional server info only visible in moderation channels.""" talentpool_info = "" if cog := self.bot.get_cog("Talentpool"): - talentpool_info = f"Nominated: {len(cog.cache)}\n" + num_nominated = len(cog.cache) if cog.cache else 0 + talentpool_info = f"Nominated: {num_nominated}\n" bb_info = "" if cog := self.bot.get_cog("Big Brother"): diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index a317c6645..bea5ff72c 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -2,7 +2,7 @@ import logging import textwrap from collections import ChainMap, defaultdict from io import StringIO -from typing import Union +from typing import Optional, Union import discord from async_rediscache import RedisCache @@ -15,7 +15,7 @@ from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAF from bot.converters import MemberOrUser from bot.exts.recruitment.talentpool._review import Reviewer from bot.pagination import LinePaginator -from bot.utils import time +from bot.utils import scheduling, time from bot.utils.time import get_time_delta AUTOREVIEW_ENABLED_KEY = "autoreview_enabled" @@ -34,8 +34,11 @@ class TalentPool(Cog, name="Talentpool"): def __init__(self, bot: Bot) -> None: self.bot = bot self.reviewer = Reviewer(self.__class__.__name__, bot, self) + self.cache: Optional[defaultdict[dict]] = None self.api_default_params = {'active': 'true', 'ordering': '-inserted_at'} - self.bot.loop.create_task(self.schedule_autoreviews()) + + scheduling.create_task(self.refresh_cache(), event_loop=self.bot.loop) + scheduling.create_task(self.schedule_autoreviews(), event_loop=self.bot.loop) async def schedule_autoreviews(self) -> None: """Reschedule reviews for active nominations if autoreview is enabled.""" diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 3ffbf93f3..f4aa73e75 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -57,8 +57,6 @@ class Reviewer: """Reschedule all active nominations to be reviewed at the appropriate time.""" log.trace("Rescheduling reviews") await self.bot.wait_until_guild_available() - # TODO Once the watch channel is removed, this can be done in a smarter way, e.g create a sync function. - await self._pool.refresh_cache() for user_id, user_data in self._pool.cache.items(): if not user_data["reviewed"]: -- cgit v1.2.3 From fddd34158b3fda284bd39970297c00e7d54d122a Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 6 Sep 2021 13:36:42 +0100 Subject: Refactor & simplifiy domain filter check --- bot/exts/filters/filtering.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index b7e91395e..7e698880f 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -478,17 +478,12 @@ class Filtering(Cog): Second return value is a reason of URL blacklisting (can be None). """ text = self.clean_input(text) - matches = URL_RE.findall(text) - if not matches: - return False, None domain_blacklist = self._get_filterlist_items("domain_name", allowed=False) - - for url in domain_blacklist: - for match in matches: - if url.lower() in match.lower(): + for match in URL_RE.finditer(text): + for url in domain_blacklist: + if url.lower() in match.group(1).lower(): return True, self._get_filterlist_value("domain_name", url, allowed=False)["comment"] - return False, None @staticmethod -- cgit v1.2.3 From cace28f844fe6ff022dfff7e2409d34fbc3d8bf4 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 6 Sep 2021 14:18:17 +0100 Subject: Add .gg/ to invite filter regex --- bot/utils/regex.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/utils/regex.py b/bot/utils/regex.py index a8efe1446..7bad1e627 100644 --- a/bot/utils/regex.py +++ b/bot/utils/regex.py @@ -6,7 +6,8 @@ INVITE_RE = re.compile( r"discordapp(?:[\.,]|dot)com(?:\/|slash)invite|" # or discordapp.com/invite/ r"discord(?:[\.,]|dot)me|" # or discord.me r"discord(?:[\.,]|dot)li|" # or discord.li - r"discord(?:[\.,]|dot)io" # or discord.io. + r"discord(?:[\.,]|dot)io|" # or discord.io. + r"(?:[\.,]|dot)gg" # or .gg/ r")(?:[\/]|slash)" # / or 'slash' r"([a-zA-Z0-9\-]+)", # the invite code itself flags=re.IGNORECASE -- cgit v1.2.3 From 2fbe252d24406942ee2ea114b1fffa42aa7070de Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 6 Sep 2021 15:35:34 +0200 Subject: Migrate to Discord.py 2.0a0 Since the Discord.py repository has been archived, we can switch to the latest commit of 2.0a0, knowing no breaking change will occur (still pinned to the commit just in case). This commits fixes any problem related to the migration: - New avatar interface - TZ aware datetimes - Various inernal API changes --- bot/converters.py | 2 +- bot/exts/filters/antispam.py | 10 +- bot/exts/filters/filtering.py | 4 +- bot/exts/filters/token_remover.py | 2 +- bot/exts/filters/webhook_remover.py | 2 +- bot/exts/fun/duck_pond.py | 4 +- bot/exts/info/information.py | 2 +- bot/exts/moderation/defcon.py | 2 +- bot/exts/moderation/incidents.py | 4 +- bot/exts/moderation/infraction/_scheduler.py | 6 +- bot/exts/moderation/infraction/management.py | 2 +- bot/exts/moderation/modlog.py | 12 +- bot/exts/moderation/silence.py | 12 +- bot/exts/moderation/watchchannels/_watchchannel.py | 6 +- bot/exts/utils/clean.py | 2 +- bot/exts/utils/ping.py | 2 +- bot/utils/checks.py | 4 +- bot/utils/messages.py | 2 +- poetry.lock | 184 +++++++++++---------- pyproject.toml | 2 +- tests/base.py | 2 +- tests/bot/exts/backend/test_error_handler.py | 2 +- tests/bot/exts/filters/test_token_remover.py | 4 +- tests/bot/exts/info/test_information.py | 5 +- tests/bot/exts/moderation/test_incidents.py | 4 +- tests/bot/utils/test_checks.py | 1 + tests/helpers.py | 27 ++- tox.ini | 2 +- 28 files changed, 174 insertions(+), 139 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index bd4044c7e..2e2d3d4c9 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -266,7 +266,7 @@ class Snowflake(IDConverter): snowflake = int(arg) try: - time = snowflake_time(snowflake) + time = snowflake_time(snowflake).replace(tzinfo=None) except (OverflowError, OSError) as e: # Not sure if this can ever even happen, but let's be safe. raise BadArgument(f"{error}: {e}") diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 8c075fa95..7c4e3e0f5 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -103,7 +103,7 @@ class DeletionContext: colour=Colour(Colours.soft_red), title="Spam detected!", text=mod_alert_message, - thumbnail=last_message.author.avatar_url_as(static_format="png"), + thumbnail=last_message.author.avatar.url, channel_id=Channels.mod_alerts, ping_everyone=AntiSpamConfig.ping_everyone ) @@ -171,7 +171,9 @@ class AntiSpam(Cog): self.cache.append(message) earliest_relevant_at = datetime.utcnow() - timedelta(seconds=self.max_interval) - relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, self.cache)) + relevant_messages = list( + takewhile(lambda msg: msg.created_at.replace(tzinfo=None) > earliest_relevant_at, self.cache) + ) for rule_name in AntiSpamConfig.rules: rule_config = AntiSpamConfig.rules[rule_name] @@ -180,7 +182,9 @@ class AntiSpam(Cog): # Create a list of messages that were sent in the interval that the rule cares about. latest_interesting_stamp = datetime.utcnow() - timedelta(seconds=rule_config['interval']) messages_for_rule = list( - takewhile(lambda msg: msg.created_at > latest_interesting_stamp, relevant_messages) + takewhile( + lambda msg: msg.created_at.replace(tzinfo=None) > latest_interesting_stamp, relevant_messages + ) ) result = await rule_function(message, messages_for_rule, rule_config) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 10cc7885d..f29ba4694 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -226,7 +226,7 @@ class Filtering(Cog): title="Username filtering alert", text=log_string, channel_id=Channels.mod_alerts, - thumbnail=member.avatar_url + thumbnail=member.avatar.url ) # Update time when alert sent @@ -386,7 +386,7 @@ class Filtering(Cog): colour=Colour(Colours.soft_red), title=f"{_filter['type'].title()} triggered!", text=message, - thumbnail=msg.author.avatar_url_as(static_format="png"), + thumbnail=msg.author.avatar.url, channel_id=Channels.mod_alerts, ping_everyone=ping_everyone, additional_embeds=stats.additional_embeds, diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py index 93f1f3c33..3c66a70f4 100644 --- a/bot/exts/filters/token_remover.py +++ b/bot/exts/filters/token_remover.py @@ -108,7 +108,7 @@ class TokenRemover(Cog): colour=Colour(Colours.soft_red), title="Token removed!", text=log_message + "\n" + userid_message, - thumbnail=msg.author.avatar_url_as(static_format="png"), + thumbnail=msg.author.avatar.url, channel_id=Channels.mod_alerts, ping_everyone=mention_everyone, ) diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index 25e267426..cc639b5fb 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -63,7 +63,7 @@ class WebhookRemover(Cog): colour=Colour(Colours.soft_red), title="Discord webhook URL removed!", text=message, - thumbnail=msg.author.avatar_url_as(static_format="png"), + thumbnail=msg.author.avatar.url, channel_id=Channels.mod_alerts ) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 7f7e4585c..2b32f7acc 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -93,7 +93,7 @@ class DuckPond(Cog): webhook=self.webhook, content=message.clean_content, username=message.author.display_name, - avatar_url=message.author.avatar_url + avatar_url=message.author.avatar.url ) if message.attachments: @@ -108,7 +108,7 @@ class DuckPond(Cog): webhook=self.webhook, embed=e, username=message.author.display_name, - avatar_url=message.author.avatar_url + avatar_url=message.author.avatar.url ) except discord.HTTPException: log.exception("Failed to send an attachment to the webhook") diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 51d47b75c..aa7c72872 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -313,7 +313,7 @@ class Information(Cog): for field_name, field_content in fields: embed.add_field(name=field_name, value=field_content, inline=False) - embed.set_thumbnail(url=user.avatar_url_as(static_format="png")) + embed.set_thumbnail(url=user.avatar.url) embed.colour = user.colour if user.colour != Colour.default() else Colour.blurple() return embed diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 6ac077b93..08032c543 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -137,7 +137,7 @@ class Defcon(Cog): await self.mod_log.send_log_message( Icons.defcon_denied, Colours.soft_red, "Entry denied", - message, member.avatar_url_as(static_format="png") + message, member.avatar.url ) @group(name='defcon', aliases=('dc',), invoke_without_command=True) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 561e0251e..fe7f234be 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -93,7 +93,7 @@ async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: di timestamp=datetime.utcnow(), colour=colour, ) - embed.set_footer(text=footer, icon_url=actioned_by.avatar_url) + embed.set_footer(text=footer, icon_url=actioned_by.avatar.url) if incident.attachments: attachment = incident.attachments[0] # User-sent messages can only contain one attachment @@ -252,7 +252,7 @@ class Incidents(Cog): await webhook.send( embed=embed, username=sub_clyde(incident.author.name), - avatar_url=incident.author.avatar_url, + avatar_url=incident.author.avatar.url, file=attachment_file, ) except Exception: diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 6ba4e74e9..9f130f943 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -239,7 +239,7 @@ class InfractionScheduler: icon_url=icon, colour=Colours.soft_red, title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}", - thumbnail=user.avatar_url_as(static_format="png"), + thumbnail=user.avatar.url, text=textwrap.dedent(f""" Member: {messages.format_user(user)} Actor: {ctx.author.mention}{dm_log_text}{expiry_log_text} @@ -333,7 +333,7 @@ class InfractionScheduler: icon_url=_utils.INFRACTION_ICONS[infr_type][1], colour=Colours.soft_green, title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}", - thumbnail=user.avatar_url_as(static_format="png"), + thumbnail=user.avatar.url, text="\n".join(f"{k}: {v}" for k, v in log_text.items()), footer=footer, content=log_content, @@ -450,7 +450,7 @@ class InfractionScheduler: log_title = "expiration failed" if "Failure" in log_text else "expired" user = self.bot.get_user(user_id) - avatar = user.avatar_url_as(static_format="png") if user else None + avatar = user.avatar.url if user else None # Move reason to end so when reason is too long, this is not gonna cut out required items. log_text["Reason"] = log_text.pop("Reason") diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 223a124d8..2f6aafa71 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -197,7 +197,7 @@ class ModManagement(commands.Cog): if user: user_text = messages.format_user(user) - thumbnail = user.avatar_url_as(static_format="png") + thumbnail = user.avatar.url else: user_text = f"<@{user_id}>" thumbnail = None diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index be2245650..18a0cb463 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -394,7 +394,7 @@ class ModLog(Cog, name="ModLog"): await self.send_log_message( Icons.user_ban, Colours.soft_red, "User banned", format_user(member), - thumbnail=member.avatar_url_as(static_format="png"), + thumbnail=member.avatar.url, channel_id=Channels.user_log ) @@ -415,7 +415,7 @@ class ModLog(Cog, name="ModLog"): await self.send_log_message( Icons.sign_in, Colours.soft_green, "User joined", message, - thumbnail=member.avatar_url_as(static_format="png"), + thumbnail=member.avatar.url, channel_id=Channels.user_log ) @@ -432,7 +432,7 @@ class ModLog(Cog, name="ModLog"): await self.send_log_message( Icons.sign_out, Colours.soft_red, "User left", format_user(member), - thumbnail=member.avatar_url_as(static_format="png"), + thumbnail=member.avatar.url, channel_id=Channels.user_log ) @@ -449,7 +449,7 @@ class ModLog(Cog, name="ModLog"): await self.send_log_message( Icons.user_unban, Colour.blurple(), "User unbanned", format_user(member), - thumbnail=member.avatar_url_as(static_format="png"), + thumbnail=member.avatar.url, channel_id=Channels.mod_log ) @@ -515,7 +515,7 @@ class ModLog(Cog, name="ModLog"): colour=Colour.blurple(), title="Member updated", text=message, - thumbnail=after.avatar_url_as(static_format="png"), + thumbnail=after.avatar.url, channel_id=Channels.user_log ) @@ -820,7 +820,7 @@ class ModLog(Cog, name="ModLog"): colour=colour, title="Voice state updated", text=message, - thumbnail=member.avatar_url_as(static_format="png"), + thumbnail=member.avatar.url, channel_id=Channels.voice_log ) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 95e2792c3..366d4e1f5 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -9,6 +9,7 @@ from async_rediscache import RedisCache from discord import Guild, PermissionOverwrite, TextChannel, VoiceChannel from discord.ext import commands, tasks from discord.ext.commands import Context +from discord.utils import MISSING from bot import constants from bot.bot import Bot @@ -47,7 +48,16 @@ class SilenceNotifier(tasks.Loop): """Loop notifier for posting notices to `alert_channel` containing added channels.""" def __init__(self, alert_channel: TextChannel): - super().__init__(self._notifier, seconds=1, minutes=0, hours=0, count=None, reconnect=True, loop=None) + super().__init__( + self._notifier, + seconds=1, + minutes=0, + hours=0, + count=None, + reconnect=True, + loop=None, + time=MISSING + ) self._silenced_channels = {} self._alert_channel = alert_channel diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index 146426569..38a82e8a2 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -246,7 +246,7 @@ class WatchChannel(metaclass=CogABCMeta): await self.webhook_send( cleaned_content, username=msg.author.display_name, - avatar_url=msg.author.avatar_url + avatar_url=msg.author.avatar.url ) if msg.attachments: @@ -260,7 +260,7 @@ class WatchChannel(metaclass=CogABCMeta): await self.webhook_send( embed=e, username=msg.author.display_name, - avatar_url=msg.author.avatar_url + avatar_url=msg.author.avatar.url ) except discord.HTTPException as exc: self.log.exception( @@ -297,7 +297,7 @@ class WatchChannel(metaclass=CogABCMeta): embed = Embed(description=f"{msg.author.mention} {message_jump}") embed.set_footer(text=textwrap.shorten(footer, width=256, placeholder="...")) - await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) + await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar.url) async def list_watched_users( self, ctx: Context, oldest_first: bool = False, update_cache: bool = True diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index cb662e852..7163e4f59 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -107,7 +107,7 @@ class Clean(Cog): elif regex: predicate = predicate_regex # Delete messages that match regex else: - predicate = None # Delete all messages + predicate = lambda *_: True # Delete all messages # Default to using the invoking context's channel if not channels: diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index cf0e3265e..43d371d87 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -32,7 +32,7 @@ class Latency(commands.Cog): """ # datetime.datetime objects do not have the "milliseconds" attribute. # It must be converted to seconds before converting to milliseconds. - bot_ping = (datetime.utcnow() - ctx.message.created_at).total_seconds() * 1000 + bot_ping = (datetime.utcnow() - ctx.message.created_at.replace(tzinfo=None)).total_seconds() * 1000 if bot_ping <= 0: bot_ping = "Your clock is out of sync, could not calculate ping." else: diff --git a/bot/utils/checks.py b/bot/utils/checks.py index 3d0c8a50c..fc0397fd0 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -134,7 +134,7 @@ def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketTy bypass = set(bypass_roles) # this handles the actual cooldown logic - buckets = CooldownMapping(Cooldown(rate, per, type)) + buckets = CooldownMapping(Cooldown(rate, per), type) # will be called after the command has been parse but before it has been invoked, ensures that # the cooldown won't be updated if the user screws up their input to the command @@ -149,7 +149,7 @@ def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketTy bucket = buckets.get_bucket(ctx.message) retry_after = bucket.update_rate_limit(current) if retry_after: - raise CommandOnCooldown(bucket, retry_after) + raise CommandOnCooldown(bucket, retry_after, type) def wrapper(command: Command) -> Command: # NOTE: this could be changed if a subclass of Command were to be used. I didn't see the need for it diff --git a/bot/utils/messages.py b/bot/utils/messages.py index abeb04021..913dd72d4 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -121,7 +121,7 @@ async def send_attachments( """ webhook_send_kwargs = { 'username': message.author.display_name, - 'avatar_url': message.author.avatar_url, + 'avatar_url': message.author.avatar.url, } webhook_send_kwargs.update(kwargs) webhook_send_kwargs['username'] = sub_clyde(webhook_send_kwargs['username']) diff --git a/poetry.lock b/poetry.lock index 81b51b8da..4068bec12 100644 --- a/poetry.lock +++ b/poetry.lock @@ -264,19 +264,23 @@ murmur = ["mmh3"] [[package]] name = "discord.py" -version = "1.7.3" +version = "2.0.0a0" description = "A Python wrapper for the Discord API" category = "main" optional = false -python-versions = ">=3.5.3" +python-versions = ">=3.8.0" [package.dependencies] aiohttp = ">=3.6.0,<3.8.0" [package.extras] -docs = ["sphinx (==3.0.3)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport"] +docs = ["sphinx (==4.0.2)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport"] +speed = ["orjson (>=3.5.4)"] voice = ["PyNaCl (>=1.3.0,<1.5)"] +[package.source] +type = "url" +url = "https://github.com/Rapptz/discord.py/archive/45d498c1b76deaf3b394d17ccf56112fa691d160.zip" [[package]] name = "distlib" version = "0.3.2" @@ -539,7 +543,7 @@ python-versions = "*" [[package]] name = "more-itertools" -version = "8.8.0" +version = "8.9.0" description = "More routines for operating on iterables, beyond itertools" category = "main" optional = false @@ -627,7 +631,7 @@ test = ["docutils", "pytest-cov", "pytest-pycodestyle", "pytest-runner"] [[package]] name = "platformdirs" -version = "2.2.0" +version = "2.3.0" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false @@ -639,18 +643,19 @@ test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock [[package]] name = "pluggy" -version = "0.13.1" +version = "1.0.0" description = "plugin and hook calling mechanisms for python" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" [package.extras] dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "2.14.0" +version = "2.15.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -761,7 +766,7 @@ python-versions = "*" [[package]] name = "pytest" -version = "6.2.4" +version = "6.2.5" description = "pytest: simple powerful testing with Python" category = "dev" optional = false @@ -773,7 +778,7 @@ attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} iniconfig = "*" packaging = "*" -pluggy = ">=0.12,<1.0.0a1" +pluggy = ">=0.12,<2.0" py = ">=1.8.2" toml = "*" @@ -873,7 +878,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [[package]] name = "rapidfuzz" -version = "1.5.0" +version = "1.5.1" description = "rapid fuzzy string matching" category = "main" optional = false @@ -1016,7 +1021,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "typing-extensions" -version = "3.10.0.0" +version = "3.10.0.2" description = "Backported and Experimental Type Hints for Python 3.5+" category = "main" optional = false @@ -1069,7 +1074,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "ceddbb2621849f480f736985d71f37cebefd08a9b38bc3943a6f72706258b6ee" +content-hash = "f57b80eeba96037ea6ff08d033a7cea7a0b2e47711b64cb0a67fb73d0a03be64" [metadata.files] aio-pika = [ @@ -1289,10 +1294,7 @@ deepdiff = [ {file = "deepdiff-4.3.2-py3-none-any.whl", hash = "sha256:59fc1e3e7a28dd0147b0f2b00e3e27181f0f0ef4286b251d5f214a5bcd9a9bc4"}, {file = "deepdiff-4.3.2.tar.gz", hash = "sha256:91360be1d9d93b1d9c13ae9c5048fa83d9cff17a88eb30afaa0d7ff2d0fee17d"}, ] -"discord.py" = [ - {file = "discord.py-1.7.3-py3-none-any.whl", hash = "sha256:c6f64db136de0e18e090f6752ea68bdd4ab0a61b82dfe7acecefa22d6477bb0c"}, - {file = "discord.py-1.7.3.tar.gz", hash = "sha256:462cd0fe307aef8b29cbfa8dd613e548ae4b2cb581d46da9ac0d46fb6ea19408"}, -] +"discord.py" = [] distlib = [ {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"}, @@ -1470,8 +1472,8 @@ mccabe = [ {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] more-itertools = [ - {file = "more-itertools-8.8.0.tar.gz", hash = "sha256:83f0308e05477c68f56ea3a888172c78ed5d5b3c282addb67508e7ba6c8f813a"}, - {file = "more_itertools-8.8.0-py3-none-any.whl", hash = "sha256:2cf89ec599962f2ddc4d568a05defc40e0a587fbc10d5989713638864c36be4d"}, + {file = "more-itertools-8.9.0.tar.gz", hash = "sha256:8c746e0d09871661520da4f1241ba6b908dc903839733c8203b552cffaf173bd"}, + {file = "more_itertools-8.9.0-py3-none-any.whl", hash = "sha256:70401259e46e216056367a0a6034ee3d3f95e0bf59d3aa6a4eb77837171ed996"}, ] mslex = [ {file = "mslex-0.3.0-py2.py3-none-any.whl", hash = "sha256:380cb14abf8fabf40e56df5c8b21a6d533dc5cbdcfe42406bbf08dda8f42e42a"}, @@ -1540,16 +1542,16 @@ pip-licenses = [ {file = "pip_licenses-3.5.2-py3-none-any.whl", hash = "sha256:62deafc82d5dccea1a4cab55172706e02f228abcd67f4d53e382fcb1497e9b62"}, ] platformdirs = [ - {file = "platformdirs-2.2.0-py3-none-any.whl", hash = "sha256:4666d822218db6a262bdfdc9c39d21f23b4cfdb08af331a81e92751daf6c866c"}, - {file = "platformdirs-2.2.0.tar.gz", hash = "sha256:632daad3ab546bd8e6af0537d09805cec458dce201bccfe23012df73332e181e"}, + {file = "platformdirs-2.3.0-py3-none-any.whl", hash = "sha256:8003ac87717ae2c7ee1ea5a84a1a61e87f3fbd16eb5aadba194ea30a9019f648"}, + {file = "platformdirs-2.3.0.tar.gz", hash = "sha256:15b056538719b1c94bdaccb29e5f81879c7f7f0f4a153f46086d155dffcd4f0f"}, ] pluggy = [ - {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, - {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] pre-commit = [ - {file = "pre_commit-2.14.0-py2.py3-none-any.whl", hash = "sha256:ec3045ae62e1aa2eecfb8e86fa3025c2e3698f77394ef8d2011ce0aedd85b2d4"}, - {file = "pre_commit-2.14.0.tar.gz", hash = "sha256:2386eeb4cf6633712c7cc9ede83684d53c8cafca6b59f79c738098b51c6d206c"}, + {file = "pre_commit-2.15.0-py2.py3-none-any.whl", hash = "sha256:a4ed01000afcb484d9eb8d504272e642c4c4099bbad3a6b27e519bd6a3e928a6"}, + {file = "pre_commit-2.15.0.tar.gz", hash = "sha256:3c25add78dbdfb6a28a651780d5c311ac40dd17f160eb3954a0c59da40a505a7"}, ] psutil = [ {file = "psutil-5.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0066a82f7b1b37d334e68697faba68e5ad5e858279fd6351c8ca6024e8d6ba64"}, @@ -1649,8 +1651,8 @@ pyreadline = [ {file = "pyreadline-2.1.zip", hash = "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1"}, ] pytest = [ - {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"}, - {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"}, + {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, + {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, ] pytest-cov = [ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, @@ -1708,67 +1710,67 @@ pyyaml = [ {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] rapidfuzz = [ - {file = "rapidfuzz-1.5.0-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:670a330e90e962de5823e01e8ae1b8903af788325fbce1ef3fd5ece4d22e0ba4"}, - {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:079afafa6e6b00ee799e16d9fc6c6522132cbd7742a7a9e78bd301321e1b5ad6"}, - {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:26cb066e79c9867d313450514bb70124d392ac457640c4ec090d29eb68b75541"}, - {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:542fbe8fb4403af36bfffd53e42cb1ff3f8d969a046208373d004804072b744c"}, - {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:407a5c4d2af813e803b828b004f8686300baf298e9bf90b3388a568b1637a8dc"}, - {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:662b4021951ac9edb9a0d026820529e891cea69c11f280188c5b80fefe6ee257"}, - {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2014_ppc64le.whl", hash = "sha256:03c97beb1c7ce5cb1d12bbb8eb87777e9a5fad23216dab78d6850cafdd3ecaf1"}, - {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2014_s390x.whl", hash = "sha256:eaafa0349d47850ed2c3ae121b62e078a63daf1d533b1cd43fca0c675a85a025"}, - {file = "rapidfuzz-1.5.0-cp35-cp35m-win32.whl", hash = "sha256:f0b7e15209208ee74bc264b97e111a3c73e19336eda7255c406e56cc6fbbd384"}, - {file = "rapidfuzz-1.5.0-cp35-cp35m-win_amd64.whl", hash = "sha256:0679af3d85082dcb27e75ea30c5047dbcc99340f38490c7d4769ae16909c246a"}, - {file = "rapidfuzz-1.5.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3a3ef319fd1162e7e38bf11259d86fc6ea3885d2abae6359e5b4dafad62592db"}, - {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:60ea1cee33a5a847aeac91a35865c6f7f35a87613df282bda2e7f984e91526f5"}, - {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2ba6ffe8ac66dbeae91a0b2cb50f4836ec16920f58746eaf46ff3e9c4f9c0ad8"}, - {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:7c101bafb27436affcaa14c631e2bf99d6a7a7860a201ce17ee98447c9c0e7f4"}, - {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:a8f3f374b4e8e80516b955a1da6364c526d480311a5c6be48264cf7dc06d2fba"}, - {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:f2fe161526cce52eae224c2af9ae1b9c475ae3e1001fe76024603b290bc8f719"}, - {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:8b086b2f70571c9bf16ead5f65976414f8e75a1c680220a839b8ddf005743060"}, - {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:814cd474c31db0383c69eed5b457571f63521f38829955c842b141b4835f067f"}, - {file = "rapidfuzz-1.5.0-cp36-cp36m-win32.whl", hash = "sha256:0a901aa223a4b051846cb828c33967a6f9c66b8fe0ba7e2a4dc70f6612006988"}, - {file = "rapidfuzz-1.5.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f03a5fa9fe38d7f8d566bff0b66600f488d56700469bf1e5e36078f4b58290b6"}, - {file = "rapidfuzz-1.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:122b7c25792eb27ca59ab23623a922a7290d881d296556d0c23da63ed1691cd5"}, - {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:73509dbfcf556233d62683aed0e5f23282ec7138eeedc3ecda2938ad8e8c969d"}, - {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6e8c4fd87361699e0cf5cf7ff075e4cd70a2698e9f914368f0c3e198c77c755c"}, - {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d627ec73d324d804af4c95909e2fa30b0e59f7efaf69264e553a0e498034404b"}, - {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:c57f3b74942ae0d0869336e613cbd0760de61a462ff441095eb5fca6575cf964"}, - {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:075b8bf76dd4bbc9ccb5177806c9867424d365898415433bf88e7b8e88dc4dfe"}, - {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:8049a500b431724d283ddf97d67fe48aa67b4523d617a203c22fd9da3a496223"}, - {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:a2d84fde07c32514758d283dd1227453db3ed5372a3e9eae85d0c29b2953f252"}, - {file = "rapidfuzz-1.5.0-cp37-cp37m-win32.whl", hash = "sha256:0e35b9b92a955018ebd09d4d9d70f8e81a0106fe1ed04bc82e3a05166cd04ea5"}, - {file = "rapidfuzz-1.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:8ae7bf62f0382d13e9b36babc897742bac5e7ee04b4e5e94cd67085bfccfd2fd"}, - {file = "rapidfuzz-1.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:466d9c644fa235278ef376eefb1fc4382107b07764fbc3c7280533ad9ce49bb4"}, - {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:d04a8465738363d0b9ee39abb3b289e1198d1f3cbc98bc43b8e21ec8e0b21774"}, - {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2c1ce8e8419ac8462289a6e021b8802701ea0f111ebde7607ba3c9588c3d6f30"}, - {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:f44564a29e96af0925e68733859d8247a692968034e1b37407d9cfa746d3a853"}, - {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d2d1bea50f54387bc1e82b93f6e3a433084e0fa538a7ada8e4d4d7200bae4b83"}, - {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:b409f0f86a316b6132253258185c7b011e779ed2170d1ad83c79515fea7d78c8"}, - {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:bf5a6f4f2eb44f32271e9c2d1e46b657764dbd1b933dd84d7c0433eab48741f8"}, - {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bbdee2e3c2cee9c59e1d1a3f351760a1b510e96379d14ba2fa2484a79f56d0ea"}, - {file = "rapidfuzz-1.5.0-cp38-cp38-win32.whl", hash = "sha256:575a0eceaf84632f2014fd55a42a0621e448115adf6fcbc2b0e5c7ae1c18b501"}, - {file = "rapidfuzz-1.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:cd6603b94e2a3d56d143a5100f8f3c1d29ad8f5416bdc2a25b079f96eee3c306"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3fa261479e3828eff1f3d0265def8d0d893f2e2f90692d5dae96b3f4ae44d69e"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7a386fe0aad7e89b5017768492ea085d241c32f6dc5a6774b0a309d28f61e720"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68156a67d541bb4584cb31e366fb7de9326f5b77ed07f9882e9b9aaa40b2e5b8"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:b62b2a2d2532d357d1b970107a90e85305bdd8e302995dd251f67a19495033f5"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:190b48ba8e3fbcb1cfc522300dbd6a007f50c13cd71002c95bd3946a63b749f6"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:51f9ac3316e713b4a10554a4d6b75fe6f802dd9b4073082cc98968ace6377cac"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:e00198aa7ca8408616d9821501ff90157c429c952d55a2a53987a9b064f73d49"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:5784c24e2de539064d8d5ce3f68756630b54fc33af31e054373a65bbed68823a"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:712a4d510c466d6ca75138dad53a1cbd8db0da4bbfa5fc431fcebb0a426e5323"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:2647e00e2211ed741aecb4e676461b7202ce46d536c3439ede911b088432b7a4"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-win32.whl", hash = "sha256:0b77ca0dacb129e878c2583295b76e12da890bd091115417d23b4049b02c2566"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:dec0d429d117ffd7df1661e5f6ca56bfb6806e117be0b75b5d414df43aa4b6d5"}, - {file = "rapidfuzz-1.5.0-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a533d17d177d11b7c177c849adb728035621462f6ce2baaeb9cf1f42ba3e326c"}, - {file = "rapidfuzz-1.5.0-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:ac9a2d5a47a4a4eab060882a162d3626889abdec69f899a59fe7b9e01ce122c9"}, - {file = "rapidfuzz-1.5.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:0e6e2f02bb67a35d75a5613509bb49f0050c0ec4471a9af14da3ad5488d6d5ff"}, - {file = "rapidfuzz-1.5.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:8c61ced6729146e695ecad403165bf3a07e60b8e8a18df91962b3abf72aae6d5"}, - {file = "rapidfuzz-1.5.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:360415125e967d8682291f00bcea311c738101e0aee4cb90e5572d7e54483f0d"}, - {file = "rapidfuzz-1.5.0-pp37-pypy37_pp73-manylinux1_x86_64.whl", hash = "sha256:2fb9d47fc16a2e8f5e900c8334d823a7307148ea764321f861b876f85a880d57"}, - {file = "rapidfuzz-1.5.0-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:2134ac91e8951d42c9a7de131d767580b8ac50820475221024e5bd63577a376f"}, - {file = "rapidfuzz-1.5.0-pp37-pypy37_pp73-win32.whl", hash = "sha256:04c4fd372e858f25e0898ba27b5bb7ed8dc528b0915b7aa02d20237e9cdd4feb"}, - {file = "rapidfuzz-1.5.0.tar.gz", hash = "sha256:141ee381c16f7e58640ef1f1dbf76beb953d248297a7165f7ba25d81ac1161c7"}, + {file = "rapidfuzz-1.5.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:6a951ad31ef121bacf40bbe6fbd387740d5038400ec2bfb41d037e9fd2754ef5"}, + {file = "rapidfuzz-1.5.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:b64acce4f6e745417218fc0bb6ff31b26fac0d723506b82ee4b9cad448b85ebb"}, + {file = "rapidfuzz-1.5.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:a834061e6d4dfb9672e89e28583486f60821796cf0d7cc559643a0d597ce33a9"}, + {file = "rapidfuzz-1.5.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:50548b919bc7608f7b9b4780415ddad135cfc3a54135bdb4bd0bb7ff2cdf9fdf"}, + {file = "rapidfuzz-1.5.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:c39c7f200eef49f4f9d6b808950709334e6f1c22262d570f1f77d6d3d373ad81"}, + {file = "rapidfuzz-1.5.1-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:347973ddf12d66d4d06daf1aca3a096a1bffe12306bcf13b832bdfc8db6d9f4a"}, + {file = "rapidfuzz-1.5.1-cp35-cp35m-manylinux2014_ppc64le.whl", hash = "sha256:e6cd6717d87d02dde2088c080b0851bdba970b77085b68e213a7b786dee4be88"}, + {file = "rapidfuzz-1.5.1-cp35-cp35m-manylinux2014_s390x.whl", hash = "sha256:ba956add4c34da019fb5e8f5e1768604b05569dd68055382795ad9062b9ca55e"}, + {file = "rapidfuzz-1.5.1-cp35-cp35m-win32.whl", hash = "sha256:95164076d8e0433f9f93e218270f19e3020a3a9b8db28a3d74143810d4243600"}, + {file = "rapidfuzz-1.5.1-cp35-cp35m-win_amd64.whl", hash = "sha256:274878c59440d6ad3efca833da61594836306af7dcdd914cc1b6ceb2d4cea23b"}, + {file = "rapidfuzz-1.5.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3b3df0c1a307a64273f6fd64c0f28218e002768eda1d94b9fffdab9371e38a6a"}, + {file = "rapidfuzz-1.5.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:55472932a8bcf008855b2cc8e5bc47d60066b504ef02dbf8d8fd43ddd8f20a6e"}, + {file = "rapidfuzz-1.5.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:170e71f2ec36a086ce5d2667331721cc9b779370d0ef7248ef6979819cd8fb09"}, + {file = "rapidfuzz-1.5.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:00734857b412afc35b29f0ea2f1d9ee26ff93d4cd3fa5f47bb90f6aef385f2a1"}, + {file = "rapidfuzz-1.5.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:ef0cf038c910a3ed626a3224effde8eb49dd7dcda87af59fcd37bc63b78a9bd1"}, + {file = "rapidfuzz-1.5.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:9f454f79bc463e3de08c5d5c0f438fce1b1736cd4da1a1f47f72dc37da156552"}, + {file = "rapidfuzz-1.5.1-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:05e8dede642af1b38ebcf8fb5e9bbfdcdf8debba660ae1aafb5c1b0e6ca3e4de"}, + {file = "rapidfuzz-1.5.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:f08773adb7f21e1f530bad2c6ababaf472f80283650bc265a7e8f614480cd49c"}, + {file = "rapidfuzz-1.5.1-cp36-cp36m-win32.whl", hash = "sha256:4e1a7fb18d4a6c3d471a3ad8f820f179216de61bef74663342340cf9c685a31e"}, + {file = "rapidfuzz-1.5.1-cp36-cp36m-win_amd64.whl", hash = "sha256:09e992579c4aae59310e44db99ed848a8437ed1e8810a513d3bbab7ac7a8f215"}, + {file = "rapidfuzz-1.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:38e2cd05869bd50f25b6d384e0cc73f8cfd6ebb8f1e7bdf1315384e21611f091"}, + {file = "rapidfuzz-1.5.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:b173f8d4c9360b8b32b5ab7a669623f239cb85013e1749bdca03e1b3c297faa7"}, + {file = "rapidfuzz-1.5.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6ab75111e2216a48c7e01d47d8903fc2d0c1df398e7262a6df544d86812e40c7"}, + {file = "rapidfuzz-1.5.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:eb560622d9970eb0c615d5dff26af8a8647ba541a89a927fca1eb0862898f997"}, + {file = "rapidfuzz-1.5.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:59246615b819e4aff685aa57359f5bbaf02441cccc83e8899608037542a3fe36"}, + {file = "rapidfuzz-1.5.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:7a960dfedcf1acdb8435b5b00aebfc2ee8fd53b7b4f7acf613915b4c24fc0ef7"}, + {file = "rapidfuzz-1.5.1-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:3d727a7e09f1a01b61452c86d687d0564bad923d5d209224549ae790408d6449"}, + {file = "rapidfuzz-1.5.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:eb5e43dbef900367b91fb73a4c447efde034656b25b397844c8cf622dae84ac3"}, + {file = "rapidfuzz-1.5.1-cp37-cp37m-win32.whl", hash = "sha256:03ea226734cca3f86bc402fc04b8a38b795795e99dbf02dd834845b80bcf7588"}, + {file = "rapidfuzz-1.5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:5e9beeb6643d663c410ad8ccf88eafbe59ba7aa9b34eea5b51c6312976789803"}, + {file = "rapidfuzz-1.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0d3cdb6ced024ed1567ba0be4b0909b17f691bd6e9e9f29626e4953ecf7cba9e"}, + {file = "rapidfuzz-1.5.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:c06f98bb94fbad9b773c38a3e2cf28a315466b41f862917ba4d228052bcc0966"}, + {file = "rapidfuzz-1.5.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7bf51eb2ff342c4a0d77ab22b3d7de461ef9d2c480fd863c57fb139e7578fa7b"}, + {file = "rapidfuzz-1.5.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:6b0c74f60c03eed4b5d19f866df79c1d1bffc4c61f9bf31b114402c47680997f"}, + {file = "rapidfuzz-1.5.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:bbba42e244a0ebc1639c62ab44e4a172767d3721d2af48f2764ca00de7721479"}, + {file = "rapidfuzz-1.5.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:0b9f76f47b6df8c6aaa02a27fdff52e6aaf64d39296683ed06d0ec9acf2515d2"}, + {file = "rapidfuzz-1.5.1-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:c52ce4b4bfe8e0c2cf102f7b71cca00fc3228113e71712597940c9c340ae31b1"}, + {file = "rapidfuzz-1.5.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:0d3ae040c91f500814df6557276462c0c869b16168ef51d01c8a7da150f54513"}, + {file = "rapidfuzz-1.5.1-cp38-cp38-win32.whl", hash = "sha256:112ecc4825c5d362298d1e3c512d3f942c1a74f26ca69dc4b19a4f2cd95cb764"}, + {file = "rapidfuzz-1.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:b1feec7407df54045bc9d4dce3431ce20855c1ff4dd170480fbace62164f8f9c"}, + {file = "rapidfuzz-1.5.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8b352fe56b92bd2aa4ceae550543a923996c16efecf8f981c955dd5f522d2002"}, + {file = "rapidfuzz-1.5.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9454f46bc4007be9148f18143bb1b615a740a99737a38cf7b9baf3c495d5d17c"}, + {file = "rapidfuzz-1.5.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4c1a75c87a2f4c9709c6e3ecdbb2317f0964ac96f845f6a331d8a437a2944d24"}, + {file = "rapidfuzz-1.5.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:02f8b282a940cb749b1c51196baab7abb5590fcc8c065ce540c5d8414366036d"}, + {file = "rapidfuzz-1.5.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:2def32a4228a717c5e6a699f0742546aee4091eb1e59e79781ceacabfc54452c"}, + {file = "rapidfuzz-1.5.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2e9d494ff51b942ed1504f84c13476319c89fc9bcc6379cc0816b776a7994199"}, + {file = "rapidfuzz-1.5.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:e94a6af7f0cc8ff49ab22842af255d8d927ca3b168b1a7e8e0784f1a2f49bc38"}, + {file = "rapidfuzz-1.5.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:c4ee8b9baaf46447dcaed925ad1d3606d3a375dfc5c67d1f3e33c46a3008cb5a"}, + {file = "rapidfuzz-1.5.1-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:10e236b3ce5851f584bbf178e7fb04ae5d0fbb008f3bc580ef6185bbbb346cd1"}, + {file = "rapidfuzz-1.5.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:068404913619182739fa3fde3079c17e3402744a1117df7f60055db331095a01"}, + {file = "rapidfuzz-1.5.1-cp39-cp39-win32.whl", hash = "sha256:e933e3ce2d88b7584248493abcba2cd27240f42bf73ca040babfd1ce8036750e"}, + {file = "rapidfuzz-1.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:46f5931f7441e13574d0fe33e897212d00ff63f69c0db1d449afbc5e87bafd7f"}, + {file = "rapidfuzz-1.5.1-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7fb25ee0340cc26dad0bb4a97019bf61b4cefaec67a1be64ac9dac2f98c697cd"}, + {file = "rapidfuzz-1.5.1-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:e738ec4e680bebe4442befda5cdd18020c3721d4cd75f9bfe2fb94e78ef55618"}, + {file = "rapidfuzz-1.5.1-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:cb083609923bc4ac602e6f1d61be61a25b35cccfb5ee208d2aa89eb0be357c69"}, + {file = "rapidfuzz-1.5.1-pp36-pypy36_pp73-win32.whl", hash = "sha256:08ecef2995b6ed1187b375d8f28ba4557522f098a1515b6afb0e3b452997a3a4"}, + {file = "rapidfuzz-1.5.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b6ff10d856fce55e2b1c681e4e7cd7da9b9eb6854571df60d6ed8904c777e64b"}, + {file = "rapidfuzz-1.5.1-pp37-pypy37_pp73-manylinux1_x86_64.whl", hash = "sha256:b41c346f16cd1ee71b259106d3cfad3347bd8fff4ff20f334a12738df6736c01"}, + {file = "rapidfuzz-1.5.1-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:f7148a53a0fd3466b82b81d94ad91aee7ce7947f37f16f9fb54319ea7df7f4af"}, + {file = "rapidfuzz-1.5.1-pp37-pypy37_pp73-win32.whl", hash = "sha256:31d83af9ac39f47f47ce4830ee118e6fa53964cccd8161e9a478a326f2a994cf"}, + {file = "rapidfuzz-1.5.1.tar.gz", hash = "sha256:4ebbd071425ee812548c301c60661a4f8faa5e5bcc97a6f0bef5b562585a8025"}, ] redis = [ {file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"}, @@ -1857,9 +1859,9 @@ toml = [ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] typing-extensions = [ - {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, - {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, - {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, + {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, + {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, + {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, ] urllib3 = [ {file = "urllib3-1.26.6-py2.py3-none-any.whl", hash = "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4"}, diff --git a/pyproject.toml b/pyproject.toml index 23cbba19b..6a4cc5bed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ license = "MIT" [tool.poetry.dependencies] python = "3.9.*" +"discord.py" = {url = "https://github.com/Rapptz/discord.py/archive/45d498c1b76deaf3b394d17ccf56112fa691d160.zip"} aio-pika = "~=6.1" aiodns = "~=2.0" aiohttp = "~=3.7" @@ -17,7 +18,6 @@ beautifulsoup4 = "~=4.9" colorama = { version = "~=0.4.3", markers = "sys_platform == 'win32'" } coloredlogs = "~=14.0" deepdiff = "~=4.0" -"discord.py" = "~=1.7.3" emoji = "~=0.6" feedparser = "~=6.0.2" rapidfuzz = "~=1.4" diff --git a/tests/base.py b/tests/base.py index d99b9ac31..79ff5bc7e 100644 --- a/tests/base.py +++ b/tests/base.py @@ -102,4 +102,4 @@ class CommandTestCase(unittest.IsolatedAsyncioTestCase): with self.assertRaises(commands.MissingPermissions) as cm: await cmd.can_run(ctx) - self.assertCountEqual(permissions.keys(), cm.exception.missing_perms) + self.assertCountEqual(permissions.keys(), cm.exception.missing_permissions) diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index 2b0549b98..462f718e6 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -107,7 +107,7 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): """Should send error with `ctx.send` when error is `CommandOnCooldown`.""" self.ctx.reset_mock() cog = ErrorHandler(self.bot) - error = errors.CommandOnCooldown(10, 9) + error = errors.CommandOnCooldown(10, 9, type=None) self.assertIsNone(await cog.on_command_error(self.ctx, error)) self.ctx.send.assert_awaited_once_with(error) diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py index 51feae9cb..47aae97c4 100644 --- a/tests/bot/exts/filters/test_token_remover.py +++ b/tests/bot/exts/filters/test_token_remover.py @@ -26,7 +26,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.msg.guild.get_member.return_value.bot = False self.msg.guild.get_member.return_value.__str__.return_value = "Woody" self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) - self.msg.author.avatar_url_as.return_value = "picture-lemon.png" + self.msg.author.avatar.url = "picture-lemon.png" def test_extract_user_id_valid(self): """Should consider user IDs valid if they decode into an integer ID.""" @@ -375,7 +375,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): colour=Colour(constants.Colours.soft_red), title="Token removed!", text=log_msg + "\n" + userid_log_message, - thumbnail=self.msg.author.avatar_url_as.return_value, + thumbnail=self.msg.author.avatar.url, channel_id=constants.Channels.mod_alerts, ping_everyone=True, ) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index d8250befb..2e992c13b 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -84,7 +84,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(dummy_embed.fields[0].value, str(dummy_role.id)) self.assertEqual(dummy_embed.fields[1].value, f"#{dummy_role.colour.value:0>6x}") - self.assertEqual(dummy_embed.fields[2].value, "0.63 0.48 218") + self.assertEqual(dummy_embed.fields[2].value, "0.65 0.64 242") self.assertEqual(dummy_embed.fields[3].value, "1") self.assertEqual(dummy_embed.fields[4].value, "10") self.assertEqual(dummy_embed.fields[5].value, "0") @@ -435,10 +435,9 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): ctx = helpers.MockContext() user = helpers.MockMember(id=217, colour=0) - user.avatar_url_as.return_value = "avatar url" + user.avatar.url = "avatar url" embed = await self.cog.create_user_embed(ctx, user) - user.avatar_url_as.assert_called_once_with(static_format="png") self.assertEqual(embed.thumbnail.url, "avatar url") diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index cbf7f7bcf..a356e245f 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -3,7 +3,7 @@ import enum import logging import typing as t import unittest -from unittest.mock import AsyncMock, MagicMock, call, patch +from unittest.mock import AsyncMock, MagicMock, Mock, call, patch import aiohttp import discord @@ -379,7 +379,7 @@ class TestArchive(TestIncidents): # Define our own `incident` to be archived incident = MockMessage( content="this is an incident", - author=MockUser(name="author_name", avatar_url="author_avatar"), + author=MockUser(name="author_name", avatar=Mock(url="author_avatar")), id=123, ) built_embed = MagicMock(discord.Embed, id=123) # We patch `make_embed` to return this diff --git a/tests/bot/utils/test_checks.py b/tests/bot/utils/test_checks.py index 883465e0b..4ae11d5d3 100644 --- a/tests/bot/utils/test_checks.py +++ b/tests/bot/utils/test_checks.py @@ -32,6 +32,7 @@ class ChecksTests(unittest.IsolatedAsyncioTestCase): async def test_has_no_roles_check_without_guild(self): """`has_no_roles_check` should return `False` when `Context.guild` is None.""" self.ctx.channel = MagicMock(DMChannel) + self.ctx.guild = None self.assertFalse(await checks.has_no_roles_check(self.ctx)) async def test_has_no_roles_check_returns_false_with_unwanted_role(self): diff --git a/tests/helpers.py b/tests/helpers.py index 3978076ed..d3967af59 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -39,7 +39,7 @@ class HashableMixin(discord.mixins.EqualityComparable): class ColourMixin: - """A mixin for Mocks that provides the aliasing of color->colour like discord.py does.""" + """A mixin for Mocks that provides the aliasing of (accent_)color->(accent_)colour like discord.py does.""" @property def color(self) -> discord.Colour: @@ -49,6 +49,14 @@ class ColourMixin: def color(self, color: discord.Colour) -> None: self.colour = color + @property + def accent_color(self) -> discord.Colour: + return self.accent_colour + + @accent_color.setter + def accent_color(self, color: discord.Colour) -> None: + self.accent_colour = color + class CustomMockMixin: """ @@ -241,7 +249,13 @@ class MockMember(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin # Create a User instance to get a realistic Mock of `discord.User` -user_instance = discord.User(data=unittest.mock.MagicMock(), state=unittest.mock.MagicMock()) +_user_data_mock = collections.defaultdict(unittest.mock.MagicMock, { + "accent_color": 0 +}) +user_instance = discord.User( + data=unittest.mock.MagicMock(get=unittest.mock.Mock(side_effect=_user_data_mock.get)), + state=unittest.mock.MagicMock() +) class MockUser(CustomMockMixin, unittest.mock.Mock, ColourMixin, HashableMixin): @@ -424,7 +438,12 @@ message_instance = discord.Message(state=state, channel=channel, data=message_da # Create a Context instance to get a realistic MagicMock of `discord.ext.commands.Context` -context_instance = Context(message=unittest.mock.MagicMock(), prefix=unittest.mock.MagicMock()) +context_instance = Context( + message=unittest.mock.MagicMock(), + prefix="$", + bot=MockBot(), + view=None +) context_instance.invoked_from_error_handler = None @@ -532,7 +551,7 @@ class MockReaction(CustomMockMixin, unittest.mock.MagicMock): self.__str__.return_value = str(self.emoji) -webhook_instance = discord.Webhook(data=unittest.mock.MagicMock(), adapter=unittest.mock.MagicMock()) +webhook_instance = discord.Webhook(data=unittest.mock.MagicMock(), session=unittest.mock.MagicMock()) class MockAsyncWebhook(CustomMockMixin, unittest.mock.MagicMock): diff --git a/tox.ini b/tox.ini index b8293a3b6..9472c32f9 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,7 @@ import-order-style=pycharm application_import_names=bot,tests exclude=.cache,.venv,.git,constants.py ignore= - B311,W503,E226,S311,T000 + B311,W503,E226,S311,T000,E731 # Missing Docstrings D100,D104,D105,D107, # Docstring Whitespace -- cgit v1.2.3 From ab9132a555b761226c948ff816d2710ce40147e9 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 6 Sep 2021 15:51:35 +0200 Subject: Make the bot auto-join threads --- bot/exts/utils/bot.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py index d84709616..f3a7206fc 100644 --- a/bot/exts/utils/bot.py +++ b/bot/exts/utils/bot.py @@ -1,7 +1,8 @@ import logging +from contextlib import suppress from typing import Optional -from discord import Embed, TextChannel +from discord import Embed, Forbidden, TextChannel, Thread from discord.ext.commands import Cog, Context, command, group, has_any_role from bot.bot import Bot @@ -16,6 +17,21 @@ class BotCog(Cog, name="Bot"): def __init__(self, bot: Bot): self.bot = bot + @Cog.listener() + async def on_thread_join(self, thread: Thread) -> None: + """ + Try to join newly created threads. + + Despite the event name being misleading, this is dispatched when new threads are created. + We want our bots to automatically join threads in order to answer commands using their prefixes. + """ + if thread.me: + # Already in this thread, return early + return + + with suppress(Forbidden): + await thread.join() + @group(invoke_without_command=True, name="bot", hidden=True) async def botinfo_group(self, ctx: Context) -> None: """Bot informational commands.""" -- cgit v1.2.3 From 6b20bfba76e9f9b2c990929edfd5eeffb4555bd5 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 6 Sep 2021 15:54:33 +0200 Subject: Silence: forbid threads from being silenced --- bot/exts/moderation/silence.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 366d4e1f5..bf553f847 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta, timezone from typing import Optional, OrderedDict, Union from async_rediscache import RedisCache -from discord import Guild, PermissionOverwrite, TextChannel, VoiceChannel +from discord import Guild, PermissionOverwrite, TextChannel, Thread, VoiceChannel from discord.ext import commands, tasks from discord.ext.commands import Context from discord.utils import MISSING @@ -182,6 +182,12 @@ class Silence(commands.Cog): channel_info = f"#{channel} ({channel.id})" log.debug(f"{ctx.author} is silencing channel {channel_info}.") + # Since threads don't have specific overrides, we cannot silence them individually. + # The parent channel has to be muted or the thread should be archived. + if isinstance(channel, Thread): + await ctx.send(":x: Threads cannot be silenced.") + return + if not await self._set_silence_overwrites(channel, kick=kick): log.info(f"Tried to silence channel {channel_info} but the channel was already silenced.") await self.send_message(MSG_SILENCE_FAIL, ctx.channel, channel, alert_target=False) -- cgit v1.2.3 From f5d7a006e4a0ebc1be0fd79be76eaf3501c6521a Mon Sep 17 00:00:00 2001 From: mbaruh Date: Tue, 7 Sep 2021 02:13:57 +0300 Subject: Code and comments polish Co-authored-by: Shivansh-007 --- bot/exts/moderation/clean.py | 46 ++++++++++++++++++++------------------------ 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 5b64693cc..a9f936d88 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -5,7 +5,7 @@ from collections import defaultdict from contextlib import suppress from datetime import datetime from itertools import islice -from typing import Any, Callable, DefaultDict, Iterable, List, Literal, Optional, TYPE_CHECKING, Tuple, Union +from typing import Any, Callable, DefaultDict, Iterable, Literal, Optional, TYPE_CHECKING, Union from discord import Colour, Embed, Message, NotFound, TextChannel, User, errors from discord.ext.commands import Cog, Context, Converter, Greedy, group, has_any_role @@ -57,7 +57,7 @@ if TYPE_CHECKING: class Clean(Cog): """ - A cog that allows messages to be deleted in bulk, while applying various filters. + A cog that allows messages to be deleted in bulk while applying various filters. You can delete messages sent by a specific user, messages sent by bots, all messages, or messages that match a specific regular expression. @@ -94,7 +94,7 @@ class Clean(Cog): if first_limit and channels and (channels == "*" or len(channels) > 1): raise BadArgument("Message or time range specified across multiple channels.") - if (isinstance(first_limit, Message) or isinstance(first_limit, Message)) and channels: + if (isinstance(first_limit, Message) or isinstance(second_limit, Message)) and channels: raise BadArgument("Both a message limit and channels specified.") if isinstance(first_limit, Message) and isinstance(second_limit, Message): @@ -141,8 +141,7 @@ class Clean(Cog): content.append(field.value) # Get rid of empty attributes and turn it into a string - content = [attr for attr in content if attr] - content = "\n".join(content) + content = "\n".join(attr for attr in content if attr) # Now let's see if there's a regex match if not content: @@ -173,15 +172,12 @@ class Clean(Cog): predicates.append(predicate_after) # Delete messages older than specific message if not predicates: - predicate = lambda m: True # Delete all messages # noqa: E731 - elif len(predicates) == 1: - predicate = predicates[0] - else: - predicate = lambda m: all(pred(m) for pred in predicates) # noqa: E731 - - return predicate + return lambda m: True + if len(predicates) == 1: + return predicates[0] + return lambda m: all(pred(m) for pred in predicates) - def _get_messages_from_cache(self, traverse: int, to_delete: Predicate) -> Tuple[DefaultDict, List[int]]: + def _get_messages_from_cache(self, traverse: int, to_delete: Predicate) -> tuple[DefaultDict, list[int]]: """Helper function for getting messages from the cache.""" message_mappings = defaultdict(list) message_ids = [] @@ -232,7 +228,7 @@ class Clean(Cog): two_weeks_old_snowflake = int((time.time() - 14 * 24 * 60 * 60) * 1000.0 - 1420070400000) << 22 return message.id < two_weeks_old_snowflake - async def _delete_messages_individually(self, messages: List[Message]) -> list[Message]: + async def _delete_messages_individually(self, messages: list[Message]) -> list[Message]: """Delete each message in the list unless cleaning is cancelled. Return the deleted messages.""" deleted = [] for message in messages: @@ -289,7 +285,7 @@ class Clean(Cog): return deleted - async def _log_clean(self, messages: list[Message], channels: CleanChannels, ctx: Context) -> bool: + async def _modlog_cleaned_messages(self, messages: list[Message], channels: CleanChannels, ctx: Context) -> bool: """Log the deleted messages to the modlog. Return True if logging was successful.""" if not messages: # Can't build an embed, nothing to clean! @@ -347,7 +343,7 @@ class Clean(Cog): # Default to using the invoking context's channel or the channel of the message limit(s). if not channels: - # At this point second_limit is guaranteed to not exist, be a datetime, or a message in the same channel. + # Input was validated - if first_limit is a message, second_limit won't point at a different channel. if isinstance(first_limit, Message): channels = [first_limit.channel] elif isinstance(second_limit, Message): @@ -397,7 +393,7 @@ class Clean(Cog): deleted_messages = await self._delete_found(message_mappings) self.cleaning = False - logged = await self._log_clean(deleted_messages, channels, ctx) + logged = await self._modlog_cleaned_messages(deleted_messages, channels, ctx) if logged and is_mod_channel(ctx.channel): with suppress(NotFound): # Can happen if the invoker deleted their own messages. @@ -417,25 +413,25 @@ class Clean(Cog): bots_only: Optional[bool] = False, regex: Optional[Regex] = None, *, - channels: CleanChannels = None + channels: CleanChannels = None # "Optional" with discord.py silently ignores incorrect input. ) -> None: """ Commands for cleaning messages in channels. If arguments are provided, will act as a master command from which all subcommands can be derived. - `traverse`: The number of messages to look at in each channel. - `users`: A series of user mentions, ID's, or names. - `first_limit` and `second_limit`: A message, a duration delta, or an ISO datetime. + • `traverse`: The number of messages to look at in each channel. + • `users`: A series of user mentions, ID's, or names. + • `first_limit` and `second_limit`: A message, a duration delta, or an ISO datetime. If a message is provided, cleaning will happen in that channel, and channels cannot be provided. If a limit is provided, multiple channels cannot be provided. If only one of them is provided, acts as `clean until`. If both are provided, acts as `clean between`. - `use_cache`: Whether to use the message cache. + • `use_cache`: Whether to use the message cache. If not provided, will default to False unless an asterisk is used for the channels. - `bots_only`: Whether to delete only bots. If specified, users cannot be specified. - `regex`: A regex pattern the message must contain to be deleted. + • `bots_only`: Whether to delete only bots. If specified, users cannot be specified. + • `regex`: A regex pattern the message must contain to be deleted. The pattern must be provided enclosed in backticks. If the pattern contains spaces, it still needs to be enclosed in double quotes on top of that. - `channels`: A series of channels to delete in, or an asterisk to delete from all channels. + • `channels`: A series of channels to delete in, or an asterisk to delete from all channels. """ if not any([traverse, users, first_limit, second_limit, regex, channels]): await ctx.send_help(ctx.command) -- cgit v1.2.3 From ed30eae8b29ad9863a297db541bb1b9fdaf9ab1e Mon Sep 17 00:00:00 2001 From: mbaruh Date: Tue, 7 Sep 2021 20:07:02 +0300 Subject: Fix regex search The regex was lowercased, even though regex patterns are case sensitive. Also adds the DOTALL flag. --- bot/exts/moderation/clean.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index a9f936d88..ca458f066 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -144,10 +144,7 @@ class Clean(Cog): content = "\n".join(attr for attr in content if attr) # Now let's see if there's a regex match - if not content: - return False - else: - return bool(re.search(regex.lower(), content.lower())) + return bool(re.search(regex, content, re.IGNORECASE + re.DOTALL)) def predicate_range(message: Message) -> bool: """Check if the message age is between the two limits.""" -- cgit v1.2.3 From c992b6eacd47b67ba731c229ac0e6ab8df63d25f Mon Sep 17 00:00:00 2001 From: mbaruh Date: Tue, 7 Sep 2021 21:43:10 +0300 Subject: Improve responses - Tells the user if clean cancel was attempted with no ongoing clean. - Fixes MaxConcurrencyReached call bug. There was a missing argument, and it shouldn't invoke the help embed anyway, so it's now a message. - Some code refactoring. --- bot/exts/moderation/clean.py | 55 ++++++++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index ca458f066..7a24833fe 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -7,10 +7,10 @@ from datetime import datetime from itertools import islice from typing import Any, Callable, DefaultDict, Iterable, Literal, Optional, TYPE_CHECKING, Union -from discord import Colour, Embed, Message, NotFound, TextChannel, User, errors +from discord import Colour, Message, NotFound, TextChannel, User, errors from discord.ext.commands import Cog, Context, Converter, Greedy, group, has_any_role from discord.ext.commands.converter import TextChannelConverter -from discord.ext.commands.errors import BadArgument, MaxConcurrencyReached +from discord.ext.commands.errors import BadArgument from bot.bot import Bot from bot.constants import ( @@ -23,6 +23,7 @@ from bot.utils.channel import is_mod_channel log = logging.getLogger(__name__) DEFAULT_TRAVERSE = 10 +MESSAGE_DELETE_DELAY = 5 # Type alias for checks Predicate = Callable[[Message], bool] @@ -109,6 +110,12 @@ class Clean(Cog): if second_limit and not first_limit: raise ValueError("Second limit specified without the first.") + @staticmethod + async def _send_expiring_message(ctx: Context, content: str) -> None: + """Send `content` to the context channel. Automatically delete if it's not a mod channel.""" + delete_after = None if is_mod_channel(ctx.channel) else MESSAGE_DELETE_DELAY + await ctx.send(content, delete_after=delete_after) + @staticmethod def _build_predicate( bots_only: bool = False, @@ -174,6 +181,16 @@ class Clean(Cog): return predicates[0] return lambda m: all(pred(m) for pred in predicates) + async def _delete_invocation(self, ctx: Context) -> None: + """Delete the command invocation if it's not in a mod channel.""" + if not is_mod_channel(ctx.channel): + self.mod_log.ignore(Event.message_delete, ctx.message.id) + try: + await ctx.message.delete() + except errors.NotFound: + # Invocation message has already been deleted + log.info("Tried to delete invocation message, but it was already deleted.") + def _get_messages_from_cache(self, traverse: int, to_delete: Predicate) -> tuple[DefaultDict, list[int]]: """Helper function for getting messages from the cache.""" message_mappings = defaultdict(list) @@ -286,8 +303,7 @@ class Clean(Cog): """Log the deleted messages to the modlog. Return True if logging was successful.""" if not messages: # Can't build an embed, nothing to clean! - delete_after = None if is_mod_channel(ctx.channel) else 5 - await ctx.send(":x: No matching messages could be found.", delete_after=delete_after) + await self._send_expiring_message(ctx, ":x: No matching messages could be found.") return False # Reverse the list to have reverse chronological order @@ -335,7 +351,10 @@ class Clean(Cog): # Are we already performing a clean? if self.cleaning: - raise MaxConcurrencyReached("Please wait for the currently ongoing clean operation to complete.") + await self._send_expiring_message( + ctx, ":x: Please wait for the currently ongoing clean operation to complete." + ) + return self.cleaning = True # Default to using the invoking context's channel or the channel of the message limit(s). @@ -358,14 +377,8 @@ class Clean(Cog): # Needs to be called after standardizing the input. predicate = self._build_predicate(bots_only, users, regex, first_limit, second_limit) - if not is_mod_channel(ctx.channel): - # Delete the invocation first - self.mod_log.ignore(Event.message_delete, ctx.message.id) - try: - await ctx.message.delete() - except errors.NotFound: - # Invocation message has already been deleted - log.info("Tried to delete invocation message, but it was already deleted.") + # Delete the invocation first + await self._delete_invocation(ctx) if channels == "*" and use_cache: message_mappings, message_ids = self._get_messages_from_cache(traverse=traverse, to_delete=predicate) @@ -550,16 +563,14 @@ class Clean(Cog): @clean_group.command(name="stop", aliases=["cancel", "abort"]) async def clean_cancel(self, ctx: Context) -> None: """If there is an ongoing cleaning process, attempt to immediately cancel it.""" - self.cleaning = False + if not self.cleaning: + message = ":question: There's no cleaning going on." + else: + self.cleaning = False + message = f"{Emojis.check_mark} Clean interrupted." - embed = Embed( - color=Colour.blurple(), - description="Clean interrupted." - ) - delete_after = 10 - if is_mod_channel(ctx.channel): - delete_after = None - await ctx.send(embed=embed, delete_after=delete_after) + await self._send_expiring_message(ctx, message) + await self._delete_invocation(ctx) # endregion -- cgit v1.2.3 From 5b8e16bb9e0226e40173a84da4e103d9960b8839 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Tue, 7 Sep 2021 22:54:43 +0300 Subject: Fix delete order In case of old messages, it would delete the old messages first, and only then bulk delete the remainder, which affected logging. This commit corrects the deletion order. --- bot/exts/moderation/clean.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 7a24833fe..c90aff256 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -270,33 +270,38 @@ class Clean(Cog): for channel, messages in message_mappings.items(): to_delete = [] - for current_index, message in enumerate(messages): + delete_old = False + for current_index, message in enumerate(messages): # noqa: B007 if not self.cleaning: # Means that the cleaning was canceled return deleted if self.is_older_than_14d(message): - # further messages are too old to be deleted in bulk - deleted_remaining = await self._delete_messages_individually(messages[current_index:]) - deleted.extend(deleted_remaining) - if not self.cleaning: - # Means that deletion was canceled while deleting the individual messages - return deleted + # Further messages are too old to be deleted in bulk + delete_old = True break to_delete.append(message) if len(to_delete) == 100: - # we can only delete up to 100 messages in a bulk + # Only up to 100 messages can be deleted in a bulk await channel.delete_messages(to_delete) deleted.extend(to_delete) to_delete.clear() + if not self.cleaning: + return deleted if len(to_delete) > 0: - # deleting any leftover messages if there are any + # Deleting any leftover messages if there are any await channel.delete_messages(to_delete) deleted.extend(to_delete) + if not self.cleaning: + return deleted + if delete_old: + old_deleted = await self._delete_messages_individually(messages[current_index:]) + deleted.extend(old_deleted) + return deleted async def _modlog_cleaned_messages(self, messages: list[Message], channels: CleanChannels, ctx: Context) -> bool: -- cgit v1.2.3 From e33b4aad936ac052695a45f62bd986d13f2163b0 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Wed, 8 Sep 2021 00:18:23 +0300 Subject: Switch `users` and `traverse` in main command When providing a user ID it would clash with `traverse` which came first. --- bot/exts/moderation/clean.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index c90aff256..5f97aae22 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -420,8 +420,8 @@ class Clean(Cog): async def clean_group( self, ctx: Context, - traverse: Optional[int] = None, users: Greedy[User] = None, + traverse: Optional[int] = None, first_limit: Optional[CleanLimit] = None, second_limit: Optional[CleanLimit] = None, use_cache: Optional[bool] = None, @@ -434,8 +434,8 @@ class Clean(Cog): Commands for cleaning messages in channels. If arguments are provided, will act as a master command from which all subcommands can be derived. - • `traverse`: The number of messages to look at in each channel. • `users`: A series of user mentions, ID's, or names. + • `traverse`: The number of messages to look at in each channel. • `first_limit` and `second_limit`: A message, a duration delta, or an ISO datetime. If a message is provided, cleaning will happen in that channel, and channels cannot be provided. If a limit is provided, multiple channels cannot be provided. -- cgit v1.2.3 From d9efe01198ae6645d146be2b42c025a47d21bbf4 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Wed, 8 Sep 2021 02:05:22 +0300 Subject: Fix incorrect cache usage --- bot/exts/moderation/clean.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 5f97aae22..f12550ab6 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -457,7 +457,7 @@ class Clean(Cog): traverse = CleanMessages.message_limit else: traverse = DEFAULT_TRAVERSE - if not use_cache: + if use_cache is None: use_cache = channels == "*" await self._clean_messages( -- cgit v1.2.3 From 160f0ecb125fe317abc644100e58ab9557ea7faa Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 8 Sep 2021 09:48:22 +0100 Subject: Add support for `!infraction last` (#1804) * Add support for `!infraction last` `!infraction last` will view details of the last infraction the user made. Also changes the `Infraction` converter to use the `expanded` endpoint 1) Added `InvalidInfraction` to `bot.errors` - called when value passed to `Infraction` converter is invalid 2) Improved error messages for when an invalid infraction is passed to `!infraction {infr_id}` 3) Improved some other messages/docstrings etc. Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/converters.py | 14 +++++++++-- bot/errors.py | 19 ++++++++++++++- bot/exts/moderation/infraction/management.py | 36 +++++++++++++++------------- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index bd4044c7e..18bb6e4e5 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -17,6 +17,7 @@ from discord.utils import DISCORD_EPOCH, escape_markdown, snowflake_time from bot import exts from bot.api import ResponseCodeError from bot.constants import URLs +from bot.errors import InvalidInfraction from bot.exts.info.doc import _inventory_parser from bot.utils.extensions import EXTENSIONS, unqualify from bot.utils.regex import INVITE_RE @@ -558,7 +559,7 @@ class Infraction(Converter): "ordering": "-inserted_at" } - infractions = await ctx.bot.api_client.get("bot/infractions", params=params) + infractions = await ctx.bot.api_client.get("bot/infractions/expanded", params=params) if not infractions: raise BadArgument( @@ -568,7 +569,16 @@ class Infraction(Converter): return infractions[0] else: - return await ctx.bot.api_client.get(f"bot/infractions/{arg}") + try: + return await ctx.bot.api_client.get(f"bot/infractions/{arg}/expanded") + except ResponseCodeError as e: + if e.status == 404: + raise InvalidInfraction( + converter=Infraction, + original=e, + infraction_arg=arg + ) + raise e if t.TYPE_CHECKING: diff --git a/bot/errors.py b/bot/errors.py index 2633390a8..078b645f1 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -1,6 +1,9 @@ from __future__ import annotations -from typing import Hashable, TYPE_CHECKING +from typing import Hashable, TYPE_CHECKING, Union + +from discord.ext.commands import ConversionError, Converter + if TYPE_CHECKING: from bot.converters import MemberOrUser @@ -40,6 +43,20 @@ class InvalidInfractedUserError(Exception): super().__init__(reason) +class InvalidInfraction(ConversionError): + """ + Raised by the Infraction converter when trying to fetch an invalid infraction id. + + Attributes: + `infraction_arg` -- the value that we attempted to convert into an Infraction + """ + + def __init__(self, converter: Converter, original: Exception, infraction_arg: Union[int, str]): + + self.infraction_arg = infraction_arg + super().__init__(converter, original) + + class BrandingMisconfiguration(RuntimeError): """Raised by the Branding cog when a misconfigured event is encountered.""" diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 223a124d8..d72cf8f89 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -11,9 +11,9 @@ from discord.ext.commands import Context from discord.utils import escape_markdown from bot import constants -from bot.api import ResponseCodeError from bot.bot import Bot from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, UnambiguousUser, allowed_strings +from bot.errors import InvalidInfraction from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator @@ -45,25 +45,22 @@ class ModManagement(commands.Cog): # region: Edit infraction commands @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf', 'i'), invoke_without_command=True) - async def infraction_group(self, ctx: Context, infr_id: int = None) -> None: - """Infraction manipulation commands. If `infr_id` is passed then this command fetches that infraction.""" - if infr_id is None: + async def infraction_group(self, ctx: Context, infraction: Infraction = None) -> None: + """ + Infraction manipulation commands. + + If `infraction` is passed then this command fetches that infraction. The `Infraction` converter + supports 'l', 'last' and 'recent' to get the most recent infraction made by `ctx.author`. + """ + if infraction is None: await ctx.send_help(ctx.command) return - try: - infraction_list = [await self.bot.api_client.get(f"bot/infractions/{infr_id}/expanded")] - except ResponseCodeError as e: - if e.status == 404: - await ctx.send(f":x: No infraction with ID `{infr_id}` could be found.") - return - raise e - embed = discord.Embed( - title=f"Infraction #{infr_id}", + title=f"Infraction #{infraction['id']}", colour=discord.Colour.orange() ) - await self.send_infraction_list(ctx, embed, infraction_list) + await self.send_infraction_list(ctx, embed, [infraction]) @infraction_group.command(name="append", aliases=("amend", "add", "a")) async def infraction_append( @@ -348,13 +345,20 @@ class ModManagement(commands.Cog): return all(checks) # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Send a notification to the invoking context on a Union failure.""" + async def cog_command_error(self, ctx: Context, error: commands.CommandError) -> None: + """Handles errors for commands within this cog.""" if isinstance(error, commands.BadUnionArgument): if discord.User in error.converters: await ctx.send(str(error.errors[0])) error.handled = True + elif isinstance(error, InvalidInfraction): + if error.infraction_arg.isdigit(): + await ctx.send(f":x: Could not find an infraction with id `{error.infraction_arg}`.") + else: + await ctx.send(f":x: `{error.infraction_arg}` is not a valid integer infraction id.") + error.handled = True + def setup(bot: Bot) -> None: """Load the ModManagement cog.""" -- cgit v1.2.3 From b412451d38a419c2b66815729cbc724e6fea0ab6 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 6 Sep 2021 13:08:04 +0100 Subject: Wait until login before trying to use the bot api client --- bot/exts/recruitment/talentpool/_cog.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index bea5ff72c..01a2f20e2 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -37,12 +37,14 @@ class TalentPool(Cog, name="Talentpool"): self.cache: Optional[defaultdict[dict]] = None self.api_default_params = {'active': 'true', 'ordering': '-inserted_at'} - scheduling.create_task(self.refresh_cache(), event_loop=self.bot.loop) + self.initial_refresh_task = scheduling.create_task(self.refresh_cache(), event_loop=self.bot.loop) scheduling.create_task(self.schedule_autoreviews(), event_loop=self.bot.loop) async def schedule_autoreviews(self) -> None: """Reschedule reviews for active nominations if autoreview is enabled.""" if await self.autoreview_enabled(): + # Wait for a populated cache first + await self.initial_refresh_task await self.reviewer.reschedule_reviews() else: log.trace("Not scheduling reviews as autoreview is disabled.") @@ -53,6 +55,8 @@ class TalentPool(Cog, name="Talentpool"): async def refresh_cache(self) -> bool: """Updates TalentPool users cache.""" + # Wait until logged in to ensure bot api client exists + await self.bot.wait_until_guild_available() try: data = await self.bot.api_client.get( 'bot/nominations', -- cgit v1.2.3 From 2a2b0cd34807b9a0d5292129c81d08b64ce476be Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 7 Sep 2021 12:30:46 +0100 Subject: Remove previous nominations output This raised questions from helpers when they saw that someone had many previous nominations. There is no reason why a helper needs to see this information. --- bot/exts/recruitment/talentpool/_cog.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 01a2f20e2..aaafff973 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -291,18 +291,7 @@ class TalentPool(Cog, name="Talentpool"): if await self.autoreview_enabled() and user.id not in self.reviewer: self.reviewer.schedule_review(user.id) - history = await self.bot.api_client.get( - 'bot/nominations', - params={ - "user__id": str(user.id), - "active": "false", - "ordering": "-inserted_at" - } - ) - msg = f"✅ The nomination for {user.mention} has been added to the talent pool" - if history: - msg += f"\n\n({len(history)} previous nominations in total)" await ctx.send(msg) -- cgit v1.2.3 From f699c56d8321004adee51df00b5105a4d8b1d3b7 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 8 Sep 2021 15:20:54 +0100 Subject: Use - rather than 0 for number of nominees when cache isn't ready --- bot/exts/info/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index d44886969..be67910a6 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -72,7 +72,7 @@ class Information(Cog): """Return additional server info only visible in moderation channels.""" talentpool_info = "" if cog := self.bot.get_cog("Talentpool"): - num_nominated = len(cog.cache) if cog.cache else 0 + num_nominated = len(cog.cache) if cog.cache else "-" talentpool_info = f"Nominated: {num_nominated}\n" bb_info = "" -- cgit v1.2.3 From 108fb5d3dfc76c78d51126660ce891f9d1ee10eb Mon Sep 17 00:00:00 2001 From: Janine vN Date: Thu, 9 Sep 2021 10:31:48 -0400 Subject: Add string-formatting tag Adds a tag to show the string formatting mini language --- bot/resources/tags/string-formatting.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 bot/resources/tags/string-formatting.md diff --git a/bot/resources/tags/string-formatting.md b/bot/resources/tags/string-formatting.md new file mode 100644 index 000000000..fba5577b8 --- /dev/null +++ b/bot/resources/tags/string-formatting.md @@ -0,0 +1,27 @@ +**String Formatting Mini Language** +The String Formatting Language in Python is a powerful way to tailor the display of strings and other data structures. This string formatting mini language works for f-strings and .format(). + +Take a look at some of these examples! +```py +my_num = 2134234523 +print(f"{my_num:,}") + +my_smaller_num = -30.0532234 +print(f"{my_smaller_num:=09.2f}") + +my_str = "Center me!" +print(f"{my_str:^20}") + +repr_str = "Spam \t Ham" +print(f"{repr_str!r}") +``` +Results: +``` +2,134,234,523 +-00030.05 + Center me! +'Spam \t Ham' +``` +**Full Specification & Resources** +[String Formatting Mini Language Specification](https://docs.python.org/3/library/string.html#format-specification-mini-language) +[pyformat.info](https://pyformat.info/) \ No newline at end of file -- cgit v1.2.3 From 693a91ea12b6f856da6c3a2ad6e17bcf12779d53 Mon Sep 17 00:00:00 2001 From: Janine vN Date: Thu, 9 Sep 2021 10:52:14 -0400 Subject: Add required newline to end of file --- bot/resources/tags/string-formatting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/string-formatting.md b/bot/resources/tags/string-formatting.md index fba5577b8..272b96af3 100644 --- a/bot/resources/tags/string-formatting.md +++ b/bot/resources/tags/string-formatting.md @@ -24,4 +24,4 @@ Results: ``` **Full Specification & Resources** [String Formatting Mini Language Specification](https://docs.python.org/3/library/string.html#format-specification-mini-language) -[pyformat.info](https://pyformat.info/) \ No newline at end of file +[pyformat.info](https://pyformat.info/) -- cgit v1.2.3 From 3d6ba16691512292c239e76c0a9ae373b3982bc2 Mon Sep 17 00:00:00 2001 From: Janine vN Date: Thu, 9 Sep 2021 12:28:13 -0400 Subject: Change formatting of examples After some discussion back and forth, I've adjusted how to display the examples and code bock to be of a more REPL-style. Additionally, a filler character for the "Center Me!" string is added to illustrate how exactly in centers it. This commit also adds some small styling changes. --- bot/resources/tags/string-formatting.md | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/bot/resources/tags/string-formatting.md b/bot/resources/tags/string-formatting.md index 272b96af3..707d19c90 100644 --- a/bot/resources/tags/string-formatting.md +++ b/bot/resources/tags/string-formatting.md @@ -1,25 +1,22 @@ -**String Formatting Mini Language** -The String Formatting Language in Python is a powerful way to tailor the display of strings and other data structures. This string formatting mini language works for f-strings and .format(). +**String Formatting Mini-Language** +The String Formatting Language in Python is a powerful way to tailor the display of strings and other data structures. This string formatting mini language works for f-strings and `.format()`. Take a look at some of these examples! ```py -my_num = 2134234523 -print(f"{my_num:,}") +>>> my_num = 2134234523 +>>> print(f"{my_num:,}") +2,134,234,523 -my_smaller_num = -30.0532234 -print(f"{my_smaller_num:=09.2f}") +>>> my_smaller_num = -30.0532234 +>>> print(f"{my_smaller_num:=09.2f}") +-00030.05 -my_str = "Center me!" -print(f"{my_str:^20}") +>>> my_str = "Center me!" +>>> print(f"{my_str:-^20}") +-----Center me!----- -repr_str = "Spam \t Ham" -print(f"{repr_str!r}") -``` -Results: -``` -2,134,234,523 --00030.05 - Center me! +>>> repr_str = "Spam \t Ham" +>>> print(f"{repr_str!r}") 'Spam \t Ham' ``` **Full Specification & Resources** -- cgit v1.2.3 From e026c3e7d87a48fd8ae719a2fb92a34d7824258e Mon Sep 17 00:00:00 2001 From: Izan Date: Thu, 2 Sep 2021 11:39:23 +0100 Subject: Apply infractions before DMing Bot now attempts to infract before DMing, with the exception of kicks/bans, in order to prevent the user getting sent a message stating they were infracted when the infraction failed (and so they aren't). --- bot/exts/moderation/infraction/_scheduler.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 6ba4e74e9..9c1fb44e1 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -149,22 +149,19 @@ class InfractionScheduler: else: expiry_msg = f" until {expiry}" if expiry else " permanently" - dm_result = "" - dm_log_text = "" + dm_result = constants.Emojis.failmail + dm_log_text = "\nDM: **Failed**" expiry_log_text = f"\nExpires: {expiry}" if expiry else "" log_title = "applied" log_content = None failed = False - # DM the user about the infraction if it's not a shadow/hidden infraction. + # DM the user about the infraction if it's a ban/kick and not a shadow/hidden infraction. # This needs to happen before we apply the infraction, as the bot cannot # send DMs to user that it doesn't share a guild with. If we were to # apply kick/ban infractions first, this would mean that we'd make it # impossible for us to deliver a DM. See python-discord/bot#982. - if not infraction["hidden"]: - dm_result = f"{constants.Emojis.failmail} " - dm_log_text = "\nDM: **Failed**" - + if not infraction["hidden"] and infr_type in ("ban", "kick"): # Accordingly display whether the user was successfully notified via DM. if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon): dm_result = ":incoming_envelope: " @@ -226,7 +223,13 @@ class InfractionScheduler: log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.") infr_message = "" else: + log.info(f"Applied {purge}{infr_type} infraction #{id_} to {user}.") infr_message = f" **{purge}{' '.join(infr_type.split('_'))}** to {user.mention}{expiry_msg}{end_msg}" + if infr_type not in ("ban", "kick"): # If we haven't already tried to send the DM + # Accordingly display whether the user was successfully notified via DM. + if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon): + dm_result = ":incoming_envelope: " + dm_log_text = "\nDM: Sent" # Send a confirmation message to the invoking context. log.trace(f"Sending infraction #{id_} confirmation message.") @@ -250,7 +253,6 @@ class InfractionScheduler: footer=f"ID {infraction['id']}" ) - log.info(f"Applied {purge}{infr_type} infraction #{id_} to {user}.") return not failed async def pardon_infraction( -- cgit v1.2.3 From 13e53790515da14703c204908513be33ab28539c Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sat, 11 Sep 2021 15:09:33 +0200 Subject: Raise InvalidHeaderError if inventory has invalid header If the requested inventory file has an invalid header, the resource has most likely moved or the url was invalid. In case of adding a new inventory, this error can be handled and displayed to the user. For automatic fetching it'll stop the rescheduling as the request succeeded but returned invalid content. --- bot/exts/info/doc/_cog.py | 9 +++++++-- bot/exts/info/doc/_inventory_parser.py | 22 +++++++++++++++++----- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index a2119a53d..ce3f4c95b 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -21,7 +21,7 @@ from bot.utils.lock import SharedEvent, lock from bot.utils.messages import send_denial, wait_for_deletion from bot.utils.scheduling import Scheduler from . import NAMESPACE, PRIORITY_PACKAGES, _batch_parser, doc_cache -from ._inventory_parser import InventoryDict, fetch_inventory +from ._inventory_parser import InvalidHeaderError, InventoryDict, fetch_inventory log = logging.getLogger(__name__) @@ -135,7 +135,12 @@ class DocCog(commands.Cog): The first attempt is rescheduled to execute in `FETCH_RESCHEDULE_DELAY.first` minutes, the subsequent attempts in `FETCH_RESCHEDULE_DELAY.repeated` minutes. """ - package = await fetch_inventory(inventory_url) + try: + package = await fetch_inventory(inventory_url) + except InvalidHeaderError as e: + # Do not reschedule if the header is invalid, as the request went through but the contents are invalid. + log.warning(f"Invalid inventory header at {inventory_url}. Reason: {e}") + return if not package: if api_package_name in self.inventory_scheduler: diff --git a/bot/exts/info/doc/_inventory_parser.py b/bot/exts/info/doc/_inventory_parser.py index 80d5841a0..61924d070 100644 --- a/bot/exts/info/doc/_inventory_parser.py +++ b/bot/exts/info/doc/_inventory_parser.py @@ -16,6 +16,10 @@ _V2_LINE_RE = re.compile(r'(?x)(.+?)\s+(\S*:\S*)\s+(-?\d+)\s+?(\S*)\s+(.*)') InventoryDict = DefaultDict[str, List[Tuple[str, str]]] +class InvalidHeaderError(Exception): + """Raised when an inventory file has an invalid header.""" + + class ZlibStreamReader: """Class used for decoding zlib data of a stream line by line.""" @@ -80,19 +84,25 @@ async def _fetch_inventory(url: str) -> InventoryDict: stream = response.content inventory_header = (await stream.readline()).decode().rstrip() - inventory_version = int(inventory_header[-1:]) - await stream.readline() # skip project name - await stream.readline() # skip project version + try: + inventory_version = int(inventory_header[-1:]) + except ValueError: + raise InvalidHeaderError("Unable to convert inventory version header.") + + has_project_header = (await stream.readline()).startswith(b"# Project") + has_version_header = (await stream.readline()).startswith(b"# Version") + if not (has_project_header and has_version_header): + raise InvalidHeaderError("Inventory missing project or version header.") if inventory_version == 1: return await _load_v1(stream) elif inventory_version == 2: if b"zlib" not in await stream.readline(): - raise ValueError(f"Invalid inventory file at url {url}.") + raise InvalidHeaderError("'zlib' not found in header of compressed inventory.") return await _load_v2(stream) - raise ValueError(f"Invalid inventory file at url {url}.") + raise InvalidHeaderError("Incompatible inventory version.") async def fetch_inventory(url: str) -> Optional[InventoryDict]: @@ -115,6 +125,8 @@ async def fetch_inventory(url: str) -> Optional[InventoryDict]: f"Failed to get inventory from {url}; " f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." ) + except InvalidHeaderError: + raise except Exception: log.exception( f"An unexpected error has occurred during fetching of {url}; " -- cgit v1.2.3 From ba33608246a4262463f90dd04c91b5fb1acb99c2 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sat, 11 Sep 2021 15:34:05 +0200 Subject: Display BadArgument to user when invalid header is raised in converter --- bot/converters.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 18bb6e4e5..d7946b86c 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -235,11 +235,16 @@ class Inventory(Converter): async def convert(ctx: Context, url: str) -> t.Tuple[str, _inventory_parser.InventoryDict]: """Convert url to Intersphinx inventory URL.""" await ctx.trigger_typing() - if (inventory := await _inventory_parser.fetch_inventory(url)) is None: - raise BadArgument( - f"Failed to fetch inventory file after {_inventory_parser.FAILED_REQUEST_ATTEMPTS} attempts." - ) - return url, inventory + try: + inventory = await _inventory_parser.fetch_inventory(url) + except _inventory_parser.InvalidHeaderError: + raise BadArgument("Unable to parse inventory because of invalid header, check if URL is correct.") + else: + if inventory is None: + raise BadArgument( + f"Failed to fetch inventory file after {_inventory_parser.FAILED_REQUEST_ATTEMPTS} attempts." + ) + return url, inventory class Snowflake(IDConverter): -- cgit v1.2.3 From f4658f8468cbe055e67d795cc6aa4b171c8c0b0f Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 11 Sep 2021 20:15:21 +0300 Subject: Handle Regex converter errors Handle cases where there are no enclosing backticks, and where the regex pattern is invalid. --- bot/exts/moderation/clean.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index f12550ab6..af79d5a35 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -44,16 +44,21 @@ class CleanChannels(Converter): class Regex(Converter): - """A converter that takes a string in the form `.+` and returns the contents of the inline code.""" + """A converter that takes a string in the form `.+` and returns the contents of the inline code compiled.""" - async def convert(self, ctx: Context, argument: str) -> str: - """Strips the backticks from the string.""" - return re.fullmatch(r"`(.+?)`", argument).group(1) + async def convert(self, ctx: Context, argument: str) -> re.Pattern: + """Strips the backticks from the string and compiles it to a regex pattern.""" + if not (match := re.fullmatch(r"`(.+?)`", argument)): + raise BadArgument("Regex pattern missing wrapping backticks") + try: + return re.compile(match.group(1), re.IGNORECASE + re.DOTALL) + except re.error as e: + raise BadArgument(f"Regex error: {e.msg}") if TYPE_CHECKING: CleanChannels = Union[Literal["*"], list[TextChannel]] # noqa: F811 - Regex = str # noqa: F811 + Regex = re.Pattern # noqa: F811 class Clean(Cog): @@ -120,7 +125,7 @@ class Clean(Cog): def _build_predicate( bots_only: bool = False, users: list[User] = None, - regex: Optional[str] = None, + regex: Optional[re.Pattern] = None, first_limit: Optional[datetime] = None, second_limit: Optional[datetime] = None, ) -> Predicate: @@ -151,7 +156,7 @@ class Clean(Cog): content = "\n".join(attr for attr in content if attr) # Now let's see if there's a regex match - return bool(re.search(regex, content, re.IGNORECASE + re.DOTALL)) + return bool(regex.search(content)) def predicate_range(message: Message) -> bool: """Check if the message age is between the two limits.""" @@ -346,7 +351,7 @@ class Clean(Cog): channels: CleanChannels, bots_only: bool = False, users: list[User] = None, - regex: Optional[str] = None, + regex: Optional[re.Pattern] = None, first_limit: Optional[CleanLimit] = None, second_limit: Optional[CleanLimit] = None, use_cache: Optional[bool] = True -- cgit v1.2.3 From 6fb6967783bff17f2b248098f803bb21b1f770d9 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sat, 11 Sep 2021 19:06:13 +0200 Subject: Use scheduling create_task util instead of creating from loop directly The util attaches an error logging callback instead of relying on python's exception logging which only occurs when the task is destroyed --- bot/async_stats.py | 4 +++- bot/exts/backend/branding/_cog.py | 3 ++- bot/exts/backend/config_verifier.py | 4 ++-- bot/exts/backend/logging.py | 4 ++-- bot/exts/backend/sync/_cog.py | 3 ++- bot/exts/filters/antispam.py | 6 +++++- bot/exts/filters/filter_lists.py | 3 ++- bot/exts/filters/filtering.py | 6 +++--- bot/exts/fun/duck_pond.py | 3 ++- bot/exts/fun/off_topic_names.py | 5 +++-- bot/exts/help_channels/_cog.py | 2 +- bot/exts/info/codeblock/_cog.py | 4 ++-- bot/exts/info/doc/_batch_parser.py | 5 +++-- bot/exts/info/doc/_cog.py | 6 ++++-- bot/exts/info/pep.py | 3 ++- bot/exts/info/python_news.py | 5 +++-- bot/exts/moderation/defcon.py | 3 ++- bot/exts/moderation/incidents.py | 5 +++-- bot/exts/moderation/infraction/_scheduler.py | 2 +- bot/exts/moderation/metabase.py | 4 ++-- bot/exts/moderation/modpings.py | 7 ++++++- bot/exts/moderation/silence.py | 3 ++- bot/exts/moderation/stream.py | 6 +++--- bot/exts/moderation/watchchannels/_watchchannel.py | 11 +++++++---- bot/exts/utils/reminders.py | 3 ++- bot/exts/utils/snekbox.py | 4 ++-- tests/bot/exts/backend/sync/test_cog.py | 7 ++++--- tests/helpers.py | 5 ++++- 28 files changed, 79 insertions(+), 47 deletions(-) diff --git a/bot/async_stats.py b/bot/async_stats.py index 58a80f528..2af832e5b 100644 --- a/bot/async_stats.py +++ b/bot/async_stats.py @@ -3,6 +3,8 @@ import socket from statsd.client.base import StatsClientBase +from bot.utils import scheduling + class AsyncStatsClient(StatsClientBase): """An async transport method for statsd communication.""" @@ -32,7 +34,7 @@ class AsyncStatsClient(StatsClientBase): def _send(self, data: str) -> None: """Start an async task to send data to statsd.""" - self._loop.create_task(self._async_send(data)) + scheduling.create_task(self._async_send(data), event_loop=self._loop) async def _async_send(self, data: str) -> None: """Send data to the statsd server using the async transport.""" diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py index 0ba146635..ab0a761ff 100644 --- a/bot/exts/backend/branding/_cog.py +++ b/bot/exts/backend/branding/_cog.py @@ -17,6 +17,7 @@ from bot.bot import Bot from bot.constants import Branding as BrandingConfig, Channels, Colours, Guild, MODERATION_ROLES from bot.decorators import mock_in_debug from bot.exts.backend.branding._repository import BrandingRepository, Event, RemoteObject +from bot.utils import scheduling log = logging.getLogger(__name__) @@ -126,7 +127,7 @@ class Branding(commands.Cog): self.bot = bot self.repository = BrandingRepository(bot) - self.bot.loop.create_task(self.maybe_start_daemon()) # Start depending on cache. + scheduling.create_task(self.maybe_start_daemon(), event_loop=self.bot.loop) # Start depending on cache. # region: Internal logic & state management diff --git a/bot/exts/backend/config_verifier.py b/bot/exts/backend/config_verifier.py index d72c6c22e..c24cb324f 100644 --- a/bot/exts/backend/config_verifier.py +++ b/bot/exts/backend/config_verifier.py @@ -4,7 +4,7 @@ from discord.ext.commands import Cog from bot import constants from bot.bot import Bot - +from bot.utils import scheduling log = logging.getLogger(__name__) @@ -14,7 +14,7 @@ class ConfigVerifier(Cog): def __init__(self, bot: Bot): self.bot = bot - self.channel_verify_task = self.bot.loop.create_task(self.verify_channels()) + self.channel_verify_task = scheduling.create_task(self.verify_channels(), event_loop=self.bot.loop) async def verify_channels(self) -> None: """ diff --git a/bot/exts/backend/logging.py b/bot/exts/backend/logging.py index 823f14ea4..8f1b8026f 100644 --- a/bot/exts/backend/logging.py +++ b/bot/exts/backend/logging.py @@ -5,7 +5,7 @@ from discord.ext.commands import Cog from bot.bot import Bot from bot.constants import Channels, DEBUG_MODE - +from bot.utils import scheduling log = logging.getLogger(__name__) @@ -16,7 +16,7 @@ class Logging(Cog): def __init__(self, bot: Bot): self.bot = bot - self.bot.loop.create_task(self.startup_greeting()) + scheduling.create_task(self.startup_greeting(), event_loop=self.bot.loop) async def startup_greeting(self) -> None: """Announce our presence to the configured devlog channel.""" diff --git a/bot/exts/backend/sync/_cog.py b/bot/exts/backend/sync/_cog.py index 48d2b6f02..f88dcf538 100644 --- a/bot/exts/backend/sync/_cog.py +++ b/bot/exts/backend/sync/_cog.py @@ -9,6 +9,7 @@ from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot from bot.exts.backend.sync import _syncers +from bot.utils import scheduling log = logging.getLogger(__name__) @@ -18,7 +19,7 @@ class Sync(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot - self.bot.loop.create_task(self.sync_guild()) + scheduling.create_task(self.sync_guild(), event_loop=self.bot.loop) async def sync_guild(self) -> None: """Syncs the roles/users of the guild with the database.""" diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 8c075fa95..6808bfa03 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -129,7 +129,11 @@ class AntiSpam(Cog): self.max_interval = max_interval_config['interval'] self.cache = MessageCache(AntiSpamConfig.cache_size, newest_first=True) - self.bot.loop.create_task(self.alert_on_validation_error(), name="AntiSpam.alert_on_validation_error") + scheduling.create_task( + self.alert_on_validation_error(), + name="AntiSpam.alert_on_validation_error", + event_loop=self.bot.loop, + ) @property def mod_log(self) -> ModLog: diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py index 232c1e48b..a06437f3d 100644 --- a/bot/exts/filters/filter_lists.py +++ b/bot/exts/filters/filter_lists.py @@ -9,6 +9,7 @@ from bot.api import ResponseCodeError from bot.bot import Bot from bot.converters import ValidDiscordServerInvite, ValidFilterListType from bot.pagination import LinePaginator +from bot.utils import scheduling log = logging.getLogger(__name__) @@ -27,7 +28,7 @@ class FilterLists(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot - self.bot.loop.create_task(self._amend_docstrings()) + scheduling.create_task(self._amend_docstrings(), event_loop=self.bot.loop) async def _amend_docstrings(self) -> None: """Add the valid FilterList types to the docstrings, so they'll appear in !help invocations.""" diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 7e698880f..64f3b82af 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -21,9 +21,9 @@ from bot.constants import ( ) from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.exts.moderation.modlog import ModLog +from bot.utils import scheduling from bot.utils.messages import format_user from bot.utils.regex import INVITE_RE -from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) @@ -64,7 +64,7 @@ class Filtering(Cog): def __init__(self, bot: Bot): self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) + self.scheduler = scheduling.Scheduler(self.__class__.__name__) self.name_lock = asyncio.Lock() staff_mistake_str = "If you believe this was a mistake, please let staff know!" @@ -133,7 +133,7 @@ class Filtering(Cog): }, } - self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) + scheduling.create_task(self.reschedule_offensive_msg_deletion(), event_loop=self.bot.loop) def cog_unload(self) -> None: """Cancel scheduled tasks.""" diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 7f7e4585c..8ced6922c 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -9,6 +9,7 @@ from discord.ext.commands import Cog, Context, command from bot import constants from bot.bot import Bot from bot.converters import MemberOrUser +from bot.utils import scheduling from bot.utils.checks import has_any_role from bot.utils.messages import count_unique_users_reaction, send_attachments from bot.utils.webhooks import send_webhook @@ -24,7 +25,7 @@ class DuckPond(Cog): self.webhook_id = constants.Webhooks.duck_pond self.webhook = None self.ducked_messages = [] - self.bot.loop.create_task(self.fetch_webhook()) + scheduling.create_task(self.fetch_webhook(), event_loop=self.bot.loop) self.relay_lock = None async def fetch_webhook(self) -> None: diff --git a/bot/exts/fun/off_topic_names.py b/bot/exts/fun/off_topic_names.py index 845b8175c..2f56aa5ba 100644 --- a/bot/exts/fun/off_topic_names.py +++ b/bot/exts/fun/off_topic_names.py @@ -11,6 +11,7 @@ from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES from bot.converters import OffTopicName from bot.pagination import LinePaginator +from bot.utils import scheduling CHANNELS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2) log = logging.getLogger(__name__) @@ -50,7 +51,7 @@ class OffTopicNames(Cog): self.bot = bot self.updater_task = None - self.bot.loop.create_task(self.init_offtopic_updater()) + scheduling.create_task(self.init_offtopic_updater(), event_loop=self.bot.loop) def cog_unload(self) -> None: """Cancel any running updater tasks on cog unload.""" @@ -62,7 +63,7 @@ class OffTopicNames(Cog): await self.bot.wait_until_guild_available() if self.updater_task is None: coro = update_names(self.bot) - self.updater_task = self.bot.loop.create_task(coro) + self.updater_task = scheduling.create_task(coro, event_loop=self.bot.loop) @group(name='otname', aliases=('otnames', 'otn'), invoke_without_command=True) @has_any_role(*MODERATION_ROLES) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index cfc9cf477..40fb9429c 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -82,7 +82,7 @@ class HelpChannels(commands.Cog): # Asyncio stuff self.queue_tasks: t.List[asyncio.Task] = [] - self.init_task = self.bot.loop.create_task(self.init_cog()) + self.init_task = scheduling.create_task(self.init_cog(), event_loop=self.bot.loop) def cog_unload(self) -> None: """Cancel the init task and scheduled tasks when the cog unloads.""" diff --git a/bot/exts/info/codeblock/_cog.py b/bot/exts/info/codeblock/_cog.py index 9a0705d2b..f63a459ff 100644 --- a/bot/exts/info/codeblock/_cog.py +++ b/bot/exts/info/codeblock/_cog.py @@ -11,7 +11,7 @@ from bot.bot import Bot from bot.exts.filters.token_remover import TokenRemover from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE from bot.exts.info.codeblock._instructions import get_instructions -from bot.utils import has_lines +from bot.utils import has_lines, scheduling from bot.utils.channel import is_help_channel from bot.utils.messages import wait_for_deletion @@ -114,7 +114,7 @@ class CodeBlockCog(Cog, name="Code Block"): bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed) self.codeblock_message_ids[message.id] = bot_message.id - self.bot.loop.create_task(wait_for_deletion(bot_message, (message.author.id,))) + scheduling.create_task(wait_for_deletion(bot_message, (message.author.id,)), event_loop=self.bot.loop) # Increase amount of codeblock correction in stats self.bot.stats.incr("codeblock_corrections") diff --git a/bot/exts/info/doc/_batch_parser.py b/bot/exts/info/doc/_batch_parser.py index 369bb462c..51ee29b68 100644 --- a/bot/exts/info/doc/_batch_parser.py +++ b/bot/exts/info/doc/_batch_parser.py @@ -24,9 +24,10 @@ class StaleInventoryNotifier: """Handle sending notifications about stale inventories through `DocItem`s to dev log.""" def __init__(self): - self._init_task = bot.instance.loop.create_task( + self._init_task = scheduling.create_task( self._init_channel(), - name="StaleInventoryNotifier channel init" + name="StaleInventoryNotifier channel init", + event_loop=bot.instance.loop, ) self._warned_urls = set() diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index a2119a53d..6cc1723cd 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -17,6 +17,7 @@ from bot.bot import Bot from bot.constants import MODERATION_ROLES, RedirectOutput from bot.converters import Inventory, PackageName, ValidURL, allowed_strings from bot.pagination import LinePaginator +from bot.utils import scheduling from bot.utils.lock import SharedEvent, lock from bot.utils.messages import send_denial, wait_for_deletion from bot.utils.scheduling import Scheduler @@ -75,9 +76,10 @@ class DocCog(commands.Cog): self.refresh_event.set() self.symbol_get_event = SharedEvent() - self.init_refresh_task = self.bot.loop.create_task( + self.init_refresh_task = scheduling.create_task( self.init_refresh_inventory(), - name="Doc inventory init" + name="Doc inventory init", + event_loop=self.bot.loop, ) @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) diff --git a/bot/exts/info/pep.py b/bot/exts/info/pep.py index b11b34db0..bbd112911 100644 --- a/bot/exts/info/pep.py +++ b/bot/exts/info/pep.py @@ -9,6 +9,7 @@ from discord.ext.commands import Cog, Context, command from bot.bot import Bot from bot.constants import Keys +from bot.utils import scheduling from bot.utils.caching import AsyncCache log = logging.getLogger(__name__) @@ -32,7 +33,7 @@ class PythonEnhancementProposals(Cog): self.peps: Dict[int, str] = {} # To avoid situations where we don't have last datetime, set this to now. self.last_refreshed_peps: datetime = datetime.now() - self.bot.loop.create_task(self.refresh_peps_urls()) + scheduling.create_task(self.refresh_peps_urls(), event_loop=self.bot.loop) async def refresh_peps_urls(self) -> None: """Refresh PEP URLs listing in every 3 hours.""" diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index 63eb4ac17..58dcd3a02 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -11,6 +11,7 @@ from discord.ext.tasks import loop from bot import constants from bot.bot import Bot +from bot.utils import scheduling from bot.utils.webhooks import send_webhook PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/" @@ -33,8 +34,8 @@ class PythonNews(Cog): self.webhook_names = {} self.webhook: t.Optional[discord.Webhook] = None - self.bot.loop.create_task(self.get_webhook_names()) - self.bot.loop.create_task(self.get_webhook_and_channel()) + scheduling.create_task(self.get_webhook_names(), event_loop=self.bot.loop) + scheduling.create_task(self.get_webhook_and_channel(), event_loop=self.bot.loop) async def start_tasks(self) -> None: """Start the tasks for fetching new PEPs and mailing list messages.""" diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 6ac077b93..053e8ae57 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -17,6 +17,7 @@ from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_ROLES, Roles from bot.converters import DurationDelta, Expiry from bot.exts.moderation.modlog import ModLog +from bot.utils import scheduling from bot.utils.messages import format_user from bot.utils.scheduling import Scheduler from bot.utils.time import ( @@ -69,7 +70,7 @@ class Defcon(Cog): self.scheduler = Scheduler(self.__class__.__name__) - self.bot.loop.create_task(self._sync_settings()) + scheduling.create_task(self._sync_settings(), event_loop=self.bot.loop) @property def mod_log(self) -> ModLog: diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 561e0251e..a3d90e3fe 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -9,6 +9,7 @@ from discord.ext.commands import Cog from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Guild, Webhooks +from bot.utils import scheduling from bot.utils.messages import sub_clyde log = logging.getLogger(__name__) @@ -190,7 +191,7 @@ class Incidents(Cog): self.bot = bot self.event_lock = asyncio.Lock() - self.crawl_task = self.bot.loop.create_task(self.crawl_incidents()) + self.crawl_task = scheduling.create_task(self.crawl_incidents(), event_loop=self.bot.loop) async def crawl_incidents(self) -> None: """ @@ -275,7 +276,7 @@ class Incidents(Cog): return payload.message_id == incident.id coroutine = self.bot.wait_for(event="raw_message_delete", check=check, timeout=timeout) - return self.bot.loop.create_task(coroutine) + return scheduling.create_task(coroutine, event_loop=self.bot.loop) async def process_event(self, reaction: str, incident: discord.Message, member: discord.Member) -> None: """ diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 6ba4e74e9..8e844822d 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -29,7 +29,7 @@ class InfractionScheduler: self.bot = bot self.scheduler = scheduling.Scheduler(self.__class__.__name__) - self.bot.loop.create_task(self.reschedule_infractions(supported_infractions)) + scheduling.create_task(self.reschedule_infractions(supported_infractions), event_loop=self.bot.loop) def cog_unload(self) -> None: """Cancel scheduled tasks.""" diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py index 9eeeec074..6eadd4bad 100644 --- a/bot/exts/moderation/metabase.py +++ b/bot/exts/moderation/metabase.py @@ -14,7 +14,7 @@ from discord.ext.commands import Cog, Context, group, has_any_role from bot.bot import Bot from bot.constants import Metabase as MetabaseConfig, Roles from bot.converters import allowed_strings -from bot.utils import send_to_paste_service +from bot.utils import scheduling, send_to_paste_service from bot.utils.channel import is_mod_channel from bot.utils.scheduling import Scheduler @@ -40,7 +40,7 @@ class Metabase(Cog): self.exports: Dict[int, List[Dict]] = {} # Saves the output of each question, so internal eval can access it - self.init_task = self.bot.loop.create_task(self.init_cog()) + self.init_task = scheduling.create_task(self.init_cog(), event_loop=self.bot.loop) async def cog_command_error(self, ctx: Context, error: Exception) -> None: """Handle ClientResponseError errors locally to invalidate token if needed.""" diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 80c9f0c38..d775cdedf 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -9,6 +9,7 @@ from discord.ext.commands import Cog, Context, group, has_any_role from bot.bot import Bot from bot.constants import Colours, Emojis, Guild, Icons, MODERATION_ROLES, Roles from bot.converters import Expiry +from bot.utils import scheduling from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) @@ -29,7 +30,11 @@ class ModPings(Cog): self.guild = None self.moderators_role = None - self.reschedule_task = self.bot.loop.create_task(self.reschedule_roles(), name="mod-pings-reschedule") + self.reschedule_task = scheduling.create_task( + self.reschedule_roles(), + name="mod-pings-reschedule", + event_loop=self.bot.loop, + ) async def reschedule_roles(self) -> None: """Reschedule moderators role re-apply times.""" diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 95e2792c3..2ee6496df 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -13,6 +13,7 @@ from discord.ext.commands import Context from bot import constants from bot.bot import Bot from bot.converters import HushDurationConverter +from bot.utils import scheduling from bot.utils.lock import LockedResourceError, lock, lock_arg from bot.utils.scheduling import Scheduler @@ -104,7 +105,7 @@ class Silence(commands.Cog): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) - self._init_task = self.bot.loop.create_task(self._async_init()) + self._init_task = scheduling.create_task(self._async_init(), event_loop=self.bot.loop) async def _async_init(self) -> None: """Set instance attributes once the guild is available and reschedule unsilences.""" diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index 01d2614b0..b5bd62a71 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -15,7 +15,7 @@ from bot.constants import ( ) from bot.converters import Expiry from bot.pagination import LinePaginator -from bot.utils.scheduling import Scheduler +from bot.utils import scheduling from bot.utils.time import discord_timestamp, format_infraction_with_duration log = logging.getLogger(__name__) @@ -30,8 +30,8 @@ class Stream(commands.Cog): def __init__(self, bot: Bot): self.bot = bot - self.scheduler = Scheduler(self.__class__.__name__) - self.reload_task = self.bot.loop.create_task(self._reload_tasks_from_redis()) + self.scheduler = scheduling.Scheduler(self.__class__.__name__) + self.reload_task = scheduling.create_task(self._reload_tasks_from_redis(), event_loop=self.bot.loop) def cog_unload(self) -> None: """Cancel all scheduled tasks.""" diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index 146426569..a42e1f518 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -18,7 +18,7 @@ from bot.exts.filters.token_remover import TokenRemover from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator -from bot.utils import CogABCMeta, messages +from bot.utils import CogABCMeta, messages, scheduling from bot.utils.time import get_time_delta log = logging.getLogger(__name__) @@ -69,7 +69,7 @@ class WatchChannel(metaclass=CogABCMeta): self.message_history = MessageHistory() self.disable_header = disable_header - self._start = self.bot.loop.create_task(self.start_watchchannel()) + self._start = scheduling.create_task(self.start_watchchannel(), event_loop=self.bot.loop) @property def modlog(self) -> ModLog: @@ -169,7 +169,7 @@ class WatchChannel(metaclass=CogABCMeta): """Queues up messages sent by watched users.""" if msg.author.id in self.watched_users: if not self.consuming_messages: - self._consume_task = self.bot.loop.create_task(self.consume_messages()) + self._consume_task = scheduling.create_task(self.consume_messages(), event_loop=self.bot.loop) self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)") self.message_queue[msg.author.id][msg.channel.id].append(msg) @@ -199,7 +199,10 @@ class WatchChannel(metaclass=CogABCMeta): if self.message_queue: self.log.trace("Channel queue not empty: Continuing consuming queues") - self._consume_task = self.bot.loop.create_task(self.consume_messages(delay_consumption=False)) + self._consume_task = scheduling.create_task( + self.consume_messages(delay_consumption=False), + event_loop=self.bot.loop, + ) else: self.log.trace("Done consuming messages.") diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 41b6cac5c..00eb930b5 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -17,6 +17,7 @@ from bot.constants import ( ) from bot.converters import Duration, UnambiguousUser from bot.pagination import LinePaginator +from bot.utils import scheduling from bot.utils.checks import has_any_role_check, has_no_roles_check from bot.utils.lock import lock_arg from bot.utils.messages import send_denial @@ -40,7 +41,7 @@ class Reminders(Cog): self.bot = bot self.scheduler = Scheduler(self.__class__.__name__) - self.bot.loop.create_task(self.reschedule_reminders()) + scheduling.create_task(self.reschedule_reminders(), event_loop=self.bot.loop) def cog_unload(self) -> None: """Cancel scheduled tasks.""" diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index b1f1ba6a8..5fb10a25b 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -14,7 +14,7 @@ from discord.ext.commands import Cog, Context, command, guild_only from bot.bot import Bot from bot.constants import Categories, Channels, Roles, URLs from bot.decorators import redirect_output -from bot.utils import send_to_paste_service +from bot.utils import scheduling, send_to_paste_service from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) @@ -219,7 +219,7 @@ class Snekbox(Cog): response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") else: response = await ctx.send(msg) - self.bot.loop.create_task(wait_for_deletion(response, (ctx.author.id,))) + scheduling.create_task(wait_for_deletion(response, (ctx.author.id,)), event_loop=self.bot.loop) log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") return response diff --git a/tests/bot/exts/backend/sync/test_cog.py b/tests/bot/exts/backend/sync/test_cog.py index 22a07313e..fdd0ab74a 100644 --- a/tests/bot/exts/backend/sync/test_cog.py +++ b/tests/bot/exts/backend/sync/test_cog.py @@ -60,13 +60,13 @@ class SyncCogTestCase(unittest.IsolatedAsyncioTestCase): class SyncCogTests(SyncCogTestCase): """Tests for the Sync cog.""" + @mock.patch("bot.utils.scheduling.create_task") @mock.patch.object(Sync, "sync_guild", new_callable=mock.MagicMock) - def test_sync_cog_init(self, sync_guild): + def test_sync_cog_init(self, sync_guild, create_task): """Should instantiate syncers and run a sync for the guild.""" # Reset because a Sync cog was already instantiated in setUp. self.RoleSyncer.reset_mock() self.UserSyncer.reset_mock() - self.bot.loop.create_task = mock.MagicMock() mock_sync_guild_coro = mock.MagicMock() sync_guild.return_value = mock_sync_guild_coro @@ -74,7 +74,8 @@ class SyncCogTests(SyncCogTestCase): Sync(self.bot) sync_guild.assert_called_once_with() - self.bot.loop.create_task.assert_called_once_with(mock_sync_guild_coro) + create_task.assert_called_once() + self.assertEqual(create_task.call_args.args[0], mock_sync_guild_coro) async def test_sync_cog_sync_guild(self): """Roles and users should be synced only if a guild is successfully retrieved.""" diff --git a/tests/helpers.py b/tests/helpers.py index 3978076ed..47f06f292 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -278,7 +278,10 @@ def _get_mock_loop() -> unittest.mock.Mock: # Since calling `create_task` on our MockBot does not actually schedule the coroutine object # as a task in the asyncio loop, this `side_effect` calls `close()` on the coroutine object # to prevent "has not been awaited"-warnings. - loop.create_task.side_effect = lambda coroutine: coroutine.close() + def mock_create_task(coroutine, **kwargs): + coroutine.close() + return unittest.mock.Mock() + loop.create_task.side_effect = mock_create_task return loop -- cgit v1.2.3 From f6a02d8435048d00e1bda31085ea25557f08a228 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sat, 11 Sep 2021 19:11:12 +0200 Subject: Use create_task util instead of asyncio.create_task The util attaches an error logging callback instead of relying on python's exception logging which only occurs when the task is destroyed --- bot/decorators.py | 4 ++-- bot/exts/help_channels/_cog.py | 2 +- bot/exts/info/doc/_cog.py | 2 +- bot/exts/moderation/defcon.py | 3 +-- bot/exts/utils/reminders.py | 3 +-- 5 files changed, 6 insertions(+), 8 deletions(-) diff --git a/bot/decorators.py b/bot/decorators.py index f65ec4103..ee210be26 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -10,7 +10,7 @@ from discord.ext import commands from discord.ext.commands import Cog, Context from bot.constants import Channels, DEBUG_MODE, RedirectOutput -from bot.utils import function +from bot.utils import function, scheduling from bot.utils.checks import ContextCheckFailure, in_whitelist_check from bot.utils.function import command_wraps @@ -154,7 +154,7 @@ def redirect_output( if ping_user: await ctx.send(f"Here's the output of your command, {ctx.author.mention}") - asyncio.create_task(func(self, ctx, *args, **kwargs)) + scheduling.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: " diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 40fb9429c..8612f9866 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -507,7 +507,7 @@ class HelpChannels(commands.Cog): """Wait for a dormant channel to become available in the queue and return it.""" log.trace("Waiting for a dormant channel.") - task = asyncio.create_task(self.channel_queue.get()) + task = scheduling.create_task(self.channel_queue.get()) self.queue_tasks.append(task) channel = await task diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 6cc1723cd..1624c50f6 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -458,4 +458,4 @@ class DocCog(commands.Cog): """Clear scheduled inventories, queued symbols and cleanup task on cog unload.""" self.inventory_scheduler.cancel_all() self.init_refresh_task.cancel() - asyncio.create_task(self.item_fetcher.clear(), name="DocCog.item_fetcher unload clear") + scheduling.create_task(self.item_fetcher.clear(), name="DocCog.item_fetcher unload clear") diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 053e8ae57..ac813d6ba 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -1,4 +1,3 @@ -import asyncio import logging import traceback from collections import namedtuple @@ -206,7 +205,7 @@ class Defcon(Cog): new_topic = f"{BASE_CHANNEL_TOPIC}\n(Threshold: {humanize_delta(self.threshold) if self.threshold else '-'})" self.mod_log.ignore(Event.guild_channel_update, Channels.defcon) - asyncio.create_task(self.channel.edit(topic=new_topic)) + scheduling.create_task(self.channel.edit(topic=new_topic)) @defcon_settings.atomic_transaction async def _update_threshold(self, author: User, threshold: relativedelta, expiry: Optional[Expiry] = None) -> None: diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 00eb930b5..1030357fd 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -1,4 +1,3 @@ -import asyncio import logging import random import textwrap @@ -81,7 +80,7 @@ class Reminders(Cog): f"Reminder {reminder['id']} invalid: " f"User {reminder['author']}={user}, Channel {reminder['channel_id']}={channel}." ) - asyncio.create_task(self.bot.api_client.delete(f"bot/reminders/{reminder['id']}")) + scheduling.create_task(self.bot.api_client.delete(f"bot/reminders/{reminder['id']}")) return is_valid, user, channel -- cgit v1.2.3 From e215fb03552f822e6e702e741956ee17d07f6117 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 11 Sep 2021 20:34:50 +0300 Subject: End clean on unexpected errors Added a cog_command_error method that sets cleaning to False when a command ends on an exception. I don't have anything in mind that might cause this, but it will ensure that in any case the cog will still be usable. --- bot/exts/moderation/clean.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index af79d5a35..3fb2c2870 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -588,6 +588,10 @@ class Clean(Cog): """Only allow moderators to invoke the commands in this cog.""" return await has_any_role(*MODERATION_ROLES).predicate(ctx) + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Safely end the cleaning operation on unexpected errors.""" + self.cleaning = False + def setup(bot: Bot) -> None: """Load the Clean cog.""" -- cgit v1.2.3 From 71ce990ba28dca3edf713ac28eff81065155f793 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 11 Sep 2021 23:39:41 +0400 Subject: Adds Core Dev Voting To Changelog Blacklist Adds the channel used for voting on contributors to the message changelog blacklist. Signed-off-by: Hassan Abouelela --- config-default.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/config-default.yml b/config-default.yml index a18fdafa5..3405934e0 100644 --- a/config-default.yml +++ b/config-default.yml @@ -157,9 +157,10 @@ guild: reddit: &REDDIT_CHANNEL 458224812528238616 # Development - dev_contrib: &DEV_CONTRIB 635950537262759947 - dev_core: &DEV_CORE 411200599653351425 - dev_log: &DEV_LOG 622895325144940554 + dev_contrib: &DEV_CONTRIB 635950537262759947 + dev_core: &DEV_CORE 411200599653351425 + dev_voting: &DEV_CORE_VOTING 839162966519447552 + dev_log: &DEV_LOG 622895325144940554 # Discussion meta: 429409067623251969 @@ -251,6 +252,7 @@ guild: - *MESSAGE_LOG - *MOD_LOG - *STAFF_VOICE + - *DEV_CORE_VOTING reminder_whitelist: - *BOT_CMD -- cgit v1.2.3 From 31a7a671ece7bd069c9862e53ed3be3c278299fb Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sun, 12 Sep 2021 15:50:58 +0200 Subject: Modlog: respect blacklist with threads --- bot/exts/moderation/modlog.py | 55 ++++++++++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 18a0cb463..b8e53bd50 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -9,7 +9,7 @@ from itertools import zip_longest import discord from dateutil.relativedelta import relativedelta from deepdiff import DeepDiff -from discord import Colour +from discord import Colour, Message, Thread from discord.abc import GuildChannel from discord.ext.commands import Cog, Context from discord.utils import escape_markdown @@ -519,17 +519,44 @@ class ModLog(Cog, name="ModLog"): channel_id=Channels.user_log ) + @staticmethod + def is_message_blacklisted(message: Message) -> bool: + """Return true if the message is in a blacklisted thread or channel.""" + # Ignore DMs or messages outside of the main guild + if not message.guild or message.guild.id != GuildConstant.id: + return True + + # Ignore bots + if message.author.bot: + return True + + # Look at the parent channel of a thread + if isinstance(message.channel, Thread): + return message.channel.parent.id in GuildConstant.modlog_blacklist + + return message.channel.id in GuildConstant.modlog_blacklist + + def is_raw_message_blacklisted(self, guild_id: t.Optional[int], channel_id: int) -> bool: + """Return true if the message constructed from raw parameter is in a blacklisted thread or channel.""" + # Ignore DMs or messages outside of the main guild + if not guild_id or guild_id != GuildConstant.id: + return True + + channel = self.bot.get_channel(channel_id) + + # Look at the parent channel of a thread + if isinstance(channel, Thread): + return channel.parent.id in GuildConstant.modlog_blacklist + + return channel.id in GuildConstant.modlog_blacklist + @Cog.listener() async def on_message_delete(self, message: discord.Message) -> None: """Log message delete event to message change log.""" channel = message.channel author = message.author - # Ignore DMs. - if not message.guild: - return - - if message.guild.id != GuildConstant.id or channel.id in GuildConstant.modlog_blacklist: + if self.is_message_blacklisted(message): return self._cached_deletes.append(message.id) @@ -584,7 +611,7 @@ class ModLog(Cog, name="ModLog"): @Cog.listener() async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None: """Log raw message delete event to message change log.""" - if event.guild_id != GuildConstant.id or event.channel_id in GuildConstant.modlog_blacklist: + if self.is_raw_message_blacklisted(event.guild_id, event.channel_id): return await asyncio.sleep(1) # Wait here in case the normal event was fired @@ -625,12 +652,7 @@ class ModLog(Cog, name="ModLog"): @Cog.listener() async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None: """Log message edit event to message change log.""" - if ( - not msg_before.guild - or msg_before.guild.id != GuildConstant.id - or msg_before.channel.id in GuildConstant.modlog_blacklist - or msg_before.author.bot - ): + if self.is_message_blacklisted(msg_before): return self._cached_edits.append(msg_before.id) @@ -707,12 +729,7 @@ class ModLog(Cog, name="ModLog"): except discord.NotFound: # Was deleted before we got the event return - if ( - not message.guild - or message.guild.id != GuildConstant.id - or message.channel.id in GuildConstant.modlog_blacklist - or message.author.bot - ): + if self.is_message_blacklisted(message): return await asyncio.sleep(1) # Wait here in case the normal event was fired -- cgit v1.2.3 From 3c80c3a63cc06dffa1d2436cf2cf3aaea8a391fc Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sun, 12 Sep 2021 16:02:23 +0200 Subject: Silence: remove thread related permissions --- bot/exts/moderation/silence.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index bf553f847..909373cdc 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -238,7 +238,13 @@ class Silence(commands.Cog): if isinstance(channel, TextChannel): role = self._everyone_role overwrite = channel.overwrites_for(role) - prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) + prev_overwrites = dict( + send_messages=overwrite.send_messages, + add_reactions=overwrite.add_reactions, + create_private_threads=overwrite.create_private_threads, + create_public_threads=overwrite.create_public_threads, + send_messages_in_threads=overwrite.send_messages_in_threads + ) else: role = self._verified_voice_role @@ -338,7 +344,15 @@ class Silence(commands.Cog): # Check if old overwrites were not stored if prev_overwrites is None: log.info(f"Missing previous overwrites for #{channel} ({channel.id}); defaulting to None.") - overwrite.update(send_messages=None, add_reactions=None, speak=None, connect=None) + overwrite.update( + send_messages=None, + add_reactions=None, + create_private_threads=None, + create_public_threads=None, + send_messages_in_threads=None, + speak=None, + connect=None + ) else: overwrite.update(**json.loads(prev_overwrites)) -- cgit v1.2.3 From 26a15dcdf6de5b9c0d73760e88d61523e5562690 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 13 Sep 2021 01:48:06 +0200 Subject: Return formatted list instead of paginating directly in tag list methods --- bot/exts/info/tags.py | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index bcffb3b80..d11782d03 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -131,6 +131,8 @@ def _fuzzy_search(search: str, target: str) -> float: class Tags(Cog): """Fetch tags by name or content.""" + PAGINATOR_DEFAULTS = dict(max_lines=15, empty=False, footer_text=FOOTER_TEXT) + def __init__(self, bot: Bot): self.bot = bot self.tags: dict[TagIdentifier, Tag] = {} @@ -228,9 +230,7 @@ class Tags(Cog): ), ctx, embed, - footer_text=FOOTER_TEXT, - empty=False, - max_lines=15 + **self.PAGINATOR_DEFAULTS, ) @group(name="tags", aliases=("tag", "t"), invoke_without_command=True) @@ -310,8 +310,8 @@ class Tags(Cog): description=suggested_tags_text ) - async def list_all_tags(self, ctx: Context) -> None: - """Send a paginator with all loaded tags accessible by `ctx.author`, groups first, and alphabetically sorted.""" + def list_all_tags(self, user: Member) -> list[str]: + """Return a formatted list of tags that are accessible by `user`; groups first, and alphabetically sorted.""" def tag_sort_key(tag_item: tuple[TagIdentifier, Tag]) -> str: group, name = tag_item[0] if group is None: @@ -338,22 +338,19 @@ class Tags(Cog): else: result_lines.append("\n\N{BULLET}") - if tag.accessible_by(ctx.author): + if tag.accessible_by(user): result_lines.append(f"**\N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK}** {identifier.name}") group_accessible = True - embed = Embed(title="Current tags") - await LinePaginator.paginate(result_lines, ctx, embed, max_lines=15, empty=False, footer_text=FOOTER_TEXT) + return result_lines - async def list_tags_in_group(self, ctx: Context, group: str) -> None: - """Send a paginator with all tags under `group`, that are accessible by `ctx.author`.""" - embed = Embed(title=f"Tags under *{group}*") - tag_lines = sorted( + def list_tags_in_group(self, group: str, user: discord.Member) -> list[str]: + """Return a formatted list of tags in `group`, that are accessible by `user`.""" + return sorted( f"**\N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK}** {identifier}" for identifier, tag in self.tags.items() - if identifier.group == group and tag.accessible_by(ctx.author) + if identifier.group == group and tag.accessible_by(user) ) - await LinePaginator.paginate(tag_lines, ctx, embed, footer_text=FOOTER_TEXT, empty=False, max_lines=15) @tags_group.command(name="get", aliases=("show", "g")) async def get_command( @@ -372,18 +369,19 @@ class Tags(Cog): """ # noqa: D205, D415 if tag_name_or_group is None and tag_name is None: if self.tags: - await self.list_all_tags(ctx) + await LinePaginator.paginate( + self.list_all_tags(ctx.author), ctx, Embed(title="Current tags"), **self.PAGINATOR_DEFAULTS + ) return True else: await ctx.send(embed=Embed(description="**There are no tags!**")) return True elif tag_name is None: - if any( - tag_name_or_group == identifier.group and tag.accessible_by(ctx.author) - for identifier, tag in self.tags.items() - ): - await self.list_tags_in_group(ctx, tag_name_or_group) + if group_tags := self.list_tags_in_group(tag_name_or_group, ctx.author): + await LinePaginator.paginate( + group_tags, ctx, Embed(title=f"Tags under *{tag_name_or_group}*"), **self.PAGINATOR_DEFAULTS + ) return True else: tag_name = tag_name_or_group -- cgit v1.2.3 From c12c0f7240b877cab0978f1e08d9230e5d04f55e Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 13 Sep 2021 01:48:40 +0200 Subject: remove redundant returns on both branches --- bot/exts/info/tags.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index d11782d03..d474d65be 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -372,10 +372,9 @@ class Tags(Cog): await LinePaginator.paginate( self.list_all_tags(ctx.author), ctx, Embed(title="Current tags"), **self.PAGINATOR_DEFAULTS ) - return True else: await ctx.send(embed=Embed(description="**There are no tags!**")) - return True + return True elif tag_name is None: if group_tags := self.list_tags_in_group(tag_name_or_group, ctx.author): -- cgit v1.2.3 From 1cfeaa649e4e8fab7b3508d061cdc1a30aa70c3f Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 13 Sep 2021 01:49:38 +0200 Subject: Rename methods to better reflect their new behaviour --- bot/exts/info/tags.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index d474d65be..56a952f97 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -310,7 +310,7 @@ class Tags(Cog): description=suggested_tags_text ) - def list_all_tags(self, user: Member) -> list[str]: + def accessible_tags(self, user: Member) -> list[str]: """Return a formatted list of tags that are accessible by `user`; groups first, and alphabetically sorted.""" def tag_sort_key(tag_item: tuple[TagIdentifier, Tag]) -> str: group, name = tag_item[0] @@ -344,7 +344,7 @@ class Tags(Cog): return result_lines - def list_tags_in_group(self, group: str, user: discord.Member) -> list[str]: + def accessible_tags_in_group(self, group: str, user: discord.Member) -> list[str]: """Return a formatted list of tags in `group`, that are accessible by `user`.""" return sorted( f"**\N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK}** {identifier}" @@ -370,14 +370,14 @@ class Tags(Cog): if tag_name_or_group is None and tag_name is None: if self.tags: await LinePaginator.paginate( - self.list_all_tags(ctx.author), ctx, Embed(title="Current tags"), **self.PAGINATOR_DEFAULTS + self.accessible_tags(ctx.author), ctx, Embed(title="Current tags"), **self.PAGINATOR_DEFAULTS ) else: await ctx.send(embed=Embed(description="**There are no tags!**")) return True elif tag_name is None: - if group_tags := self.list_tags_in_group(tag_name_or_group, ctx.author): + if group_tags := self.accessible_tags_in_group(tag_name_or_group, ctx.author): await LinePaginator.paginate( group_tags, ctx, Embed(title=f"Tags under *{tag_name_or_group}*"), **self.PAGINATOR_DEFAULTS ) -- cgit v1.2.3 From 43b18506d0dde27469a136676737826482e07fd7 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 13 Sep 2021 01:50:21 +0200 Subject: Reword all tags embed title --- bot/exts/info/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 56a952f97..f098d56c9 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -370,7 +370,7 @@ class Tags(Cog): if tag_name_or_group is None and tag_name is None: if self.tags: await LinePaginator.paginate( - self.accessible_tags(ctx.author), ctx, Embed(title="Current tags"), **self.PAGINATOR_DEFAULTS + self.accessible_tags(ctx.author), ctx, Embed(title="Available tags"), **self.PAGINATOR_DEFAULTS ) else: await ctx.send(embed=Embed(description="**There are no tags!**")) -- cgit v1.2.3 From 7ce571cc855f516e6a59d79ced241b0074aca389 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 15 Sep 2021 10:32:10 +0200 Subject: Silence tests: use thread perms --- tests/bot/exts/moderation/test_silence.py | 64 ++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 59a5893ef..78a12b6d2 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -438,7 +438,13 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): asyncio.run(self.cog._async_init()) # Populate instance attributes. self.text_channel = MockTextChannel() - self.text_overwrite = PermissionOverwrite(send_messages=True, add_reactions=False) + self.text_overwrite = PermissionOverwrite( + send_messages=True, + add_reactions=False, + create_private_threads=True, + create_public_threads=False, + send_messages_in_threads=True + ) self.text_channel.overwrites_for.return_value = self.text_overwrite self.voice_channel = MockVoiceChannel() @@ -509,9 +515,39 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_skipped_already_silenced(self): """Permissions were not set and `False` was returned for an already silenced channel.""" subtests = ( - (False, MockTextChannel(), PermissionOverwrite(send_messages=False, add_reactions=False)), - (True, MockTextChannel(), PermissionOverwrite(send_messages=True, add_reactions=True)), - (True, MockTextChannel(), PermissionOverwrite(send_messages=False, add_reactions=False)), + ( + False, + MockTextChannel(), + PermissionOverwrite( + send_messages=False, + add_reactions=False, + create_private_threads=False, + create_public_threads=False, + send_messages_in_threads=False + ) + ), + ( + True, + MockTextChannel(), + PermissionOverwrite( + send_messages=True, + add_reactions=True, + create_private_threads=True, + create_public_threads=True, + send_messages_in_threads=True + ) + ), + ( + True, + MockTextChannel(), + PermissionOverwrite( + send_messages=False, + add_reactions=False, + create_private_threads=False, + create_public_threads=False, + send_messages_in_threads=False + ) + ), (False, MockVoiceChannel(), PermissionOverwrite(connect=False, speak=False)), (True, MockVoiceChannel(), PermissionOverwrite(connect=True, speak=True)), (True, MockVoiceChannel(), PermissionOverwrite(connect=False, speak=False)), @@ -559,11 +595,16 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): await self.cog._set_silence_overwrites(self.text_channel) new_overwrite_dict = dict(self.text_overwrite) - # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method. - del prev_overwrite_dict['send_messages'] - del prev_overwrite_dict['add_reactions'] - del new_overwrite_dict['send_messages'] - del new_overwrite_dict['add_reactions'] + # Remove related permission keys because they were changed by the method. + for perm_name in ( + "send_messages", + "add_reactions", + "create_private_threads", + "create_public_threads", + "send_messages_in_threads" + ): + del prev_overwrite_dict[perm_name] + del new_overwrite_dict[perm_name] self.assertDictEqual(prev_overwrite_dict, new_overwrite_dict) @@ -601,7 +642,10 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): async def test_cached_previous_overwrites(self): """Channel's previous overwrites were cached.""" - overwrite_json = '{"send_messages": true, "add_reactions": false}' + overwrite_json = ( + '{"send_messages": true, "add_reactions": false, "create_private_threads": true, ' + '"create_public_threads": false, "send_messages_in_threads": true}' + ) await self.cog._set_silence_overwrites(self.text_channel) self.cog.previous_overwrites.set.assert_awaited_once_with(self.text_channel.id, overwrite_json) -- cgit v1.2.3 From 11147c59e2e2bfef3d4c17c688677b99abe2c900 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 15 Sep 2021 10:35:13 +0200 Subject: Defcon: add thread perms to (un)shutdown --- bot/exts/moderation/defcon.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 08032c543..f2bb0aa14 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -185,7 +185,14 @@ class Defcon(Cog): role = ctx.guild.default_role permissions = role.permissions - permissions.update(send_messages=False, add_reactions=False, connect=False) + permissions.update( + send_messages=False, + add_reactions=False, + create_private_threads=False, + create_public_threads=False, + send_messages_in_threads=False, + connect=False + ) await role.edit(reason="DEFCON shutdown", permissions=permissions) await ctx.send(f"{Action.SERVER_SHUTDOWN.value.emoji} Server shut down.") @@ -196,7 +203,14 @@ class Defcon(Cog): role = ctx.guild.default_role permissions = role.permissions - permissions.update(send_messages=True, add_reactions=True, connect=True) + permissions.update( + send_messages=True, + add_reactions=True, + create_private_threads=None, + create_public_threads=None, + send_messages_in_threads=True, + connect=True + ) await role.edit(reason="DEFCON unshutdown", permissions=permissions) await ctx.send(f"{Action.SERVER_OPEN.value.emoji} Server reopened.") -- cgit v1.2.3 From def3a5ceb4cdcc48f4b8368a53a15affcba84ef2 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 15 Sep 2021 11:36:28 +0200 Subject: Modlog: thread support --- bot/exts/moderation/modlog.py | 58 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index b8e53bd50..817b6af41 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -768,6 +768,64 @@ class ModLog(Cog, name="ModLog"): after_response, channel_id=Channels.message_log ) + @Cog.listener() + async def on_thread_update(self, before: Thread, after: Thread) -> None: + """Log thread archiving, un-archiving and name edits.""" + if before.name != after.name: + await self.send_log_message( + Icons.hash_blurple, + Colour.blurple(), + "Thread name edited", + ( + f"Thread {after.mention} (`{after.id}`) from {after.parent.mention} (`{after.parent.id}`): " + f"`{before.name}` -> `{after.name}`" + ) + ) + return + + if not before.archived and after.archived: + colour = Colour.red() + action = "archived" + icon = Icons.hash_red + elif before.archived and not after.archived: + colour = Colour.green() + action = "un-archived" + icon = Icons.hash_green + else: + return + + await self.send_log_message( + icon, + colour, + f"Thread {action}", + f"Thread {after.mention} (`{after.id}`) from {after.parent.mention} (`{after.parent.id}`) {action}" + ) + + @Cog.listener() + async def on_thread_delete(self, thread: Thread) -> None: + """Log thread deletion.""" + await self.send_log_message( + Icons.hash_red, + Colour.red(), + "Thread deleted", + f"Thread {thread.mention} (`{thread.id}`) from {thread.parent.mention} (`{thread.parent.id}`) deleted" + ) + + @Cog.listener() + async def on_thread_join(self, thread: Thread) -> None: + """Log thread creation.""" + # If we are in the thread already we can most probably assume we already logged it? + # We don't really have a better way of doing this since the API doesn't make any difference between the two + if thread.me: + return + + await self.send_log_message( + Icons.hash_green, + Colour.green(), + "Thread created", + f"Thread {thread.mention} (`{thread.id}`) from {thread.parent.mention} (`{thread.parent.id}`) created" + ) + @Cog.listener() async def on_voice_state_update( self, -- cgit v1.2.3 From 3c1cc75371fa8be3e1de3affa7b5a1f54b55da6b Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 17 Sep 2021 13:34:15 +0100 Subject: Add metricity to docker-compose By adding metricity to the compose, we allow it to migrate itself, rather than needing the site to do it. Defaulting 'USE_METRICITY' to false means that it will run migrations, but not actually start the bot. This means we don't add another service that needs to run all the time, which could impact some contribs on lower powered hardware. --- .gitignore | 1 + docker-compose.yml | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f74a142f3..177345908 100644 --- a/.gitignore +++ b/.gitignore @@ -116,6 +116,7 @@ log.* # Custom user configuration config.yml docker-compose.override.yml +metricity-config.toml # xmlrunner unittest XML reports TEST-**.xml diff --git a/docker-compose.yml b/docker-compose.yml index 0f0355dac..b3ca6baa4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,11 @@ services: POSTGRES_DB: pysite POSTGRES_PASSWORD: pysite POSTGRES_USER: pysite + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pysite"] + interval: 2s + timeout: 1s + retries: 5 redis: << : *logging @@ -31,6 +36,21 @@ services: ports: - "127.0.0.1:6379:6379" + metricity: + << : *logging + restart: on-failure # USE_METRICITY=false will stop the container, so this ensures it only restarts on error + depends_on: + postgres: + condition: service_healthy + image: ghcr.io/python-discord/metricity:latest + env_file: + - .env + environment: + DATABASE_URI: postgres://pysite:pysite@postgres/metricity + USE_METRICITY: ${USE_METRICITY-false} + volumes: + - .:/tmp/bot:ro + snekbox: << : *logging << : *restart_policy @@ -56,7 +76,7 @@ services: - "127.0.0.1:8000:8000" tty: true depends_on: - - postgres + - metricity environment: DATABASE_URL: postgres://pysite:pysite@postgres:5432/pysite METRICITY_DB_URL: postgres://pysite:pysite@postgres:5432/metricity -- cgit v1.2.3 From fe248cc05a3c50beb064c43aef224c0204512df9 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 17 Sep 2021 13:35:38 +0100 Subject: Remove duplicate roles when defining allowed_mentions --- bot/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/bot.py b/bot/bot.py index 914da9c98..db3d651a3 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -109,7 +109,7 @@ class Bot(commands.Bot): def create(cls) -> "Bot": """Create and return an instance of a Bot.""" loop = asyncio.get_event_loop() - allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES] + allowed_roles = list({discord.Object(id_) for id_ in constants.MODERATION_ROLES}) intents = discord.Intents.all() intents.presences = False -- cgit v1.2.3 From 50243809d73323688a2320bbb11ec6cdd0e4b8aa Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sun, 19 Sep 2021 15:24:34 +0200 Subject: Modlog: reuse logic for the message blacklist Co-authored-by: Bluenix --- bot/exts/moderation/modlog.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 817b6af41..ae71a18a3 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -519,22 +519,13 @@ class ModLog(Cog, name="ModLog"): channel_id=Channels.user_log ) - @staticmethod - def is_message_blacklisted(message: Message) -> bool: + def is_message_blacklisted(self, message: Message) -> bool: """Return true if the message is in a blacklisted thread or channel.""" - # Ignore DMs or messages outside of the main guild - if not message.guild or message.guild.id != GuildConstant.id: - return True - - # Ignore bots - if message.author.bot: + # Ignore bots or DMs + if message.author.bot or not message.guild: return True - # Look at the parent channel of a thread - if isinstance(message.channel, Thread): - return message.channel.parent.id in GuildConstant.modlog_blacklist - - return message.channel.id in GuildConstant.modlog_blacklist + return self.is_raw_message_blacklisted(message.guild.id, message.channel.id) def is_raw_message_blacklisted(self, guild_id: t.Optional[int], channel_id: int) -> bool: """Return true if the message constructed from raw parameter is in a blacklisted thread or channel.""" -- cgit v1.2.3 From fe12eaa0d8a1213adb0cc6323c21a4ca2793e392 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sun, 19 Sep 2021 15:25:43 +0200 Subject: Bot: update comment on joining threads Co-authored-by: Bluenix --- bot/exts/utils/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py index f3a7206fc..f6a67799c 100644 --- a/bot/exts/utils/bot.py +++ b/bot/exts/utils/bot.py @@ -26,7 +26,7 @@ class BotCog(Cog, name="Bot"): We want our bots to automatically join threads in order to answer commands using their prefixes. """ if thread.me: - # Already in this thread, return early + # We have already joined this thread return with suppress(Forbidden): -- cgit v1.2.3 From b61a019d64497d4138c754c5f30b491a3b2b0524 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sun, 19 Sep 2021 15:26:19 +0200 Subject: Bot: remove inaccurate docstrings --- bot/exts/utils/bot.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py index f6a67799c..caad98c90 100644 --- a/bot/exts/utils/bot.py +++ b/bot/exts/utils/bot.py @@ -23,7 +23,6 @@ class BotCog(Cog, name="Bot"): Try to join newly created threads. Despite the event name being misleading, this is dispatched when new threads are created. - We want our bots to automatically join threads in order to answer commands using their prefixes. """ if thread.me: # We have already joined this thread -- cgit v1.2.3 From 18f0f36a713c32176440cb113ad5814da9020717 Mon Sep 17 00:00:00 2001 From: Izan Date: Mon, 20 Sep 2021 11:35:34 +0100 Subject: Fix typos and grammar in tags --- bot/resources/tags/async-await.md | 8 ++++---- bot/resources/tags/traceback.md | 12 ++++++------ bot/resources/tags/windows-path.md | 8 ++++---- bot/resources/tags/xy-problem.md | 4 ++-- bot/resources/tags/ytdl.md | 2 +- bot/resources/tags/zip.md | 2 +- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/bot/resources/tags/async-await.md b/bot/resources/tags/async-await.md index ff71ace07..4ab8a76fc 100644 --- a/bot/resources/tags/async-await.md +++ b/bot/resources/tags/async-await.md @@ -2,18 +2,18 @@ Python provides the ability to run multiple tasks and coroutines simultaneously with the use of the `asyncio` library, which is included in the Python standard library. -This works by running these coroutines in an event loop, where the context of which coroutine is being run is switches periodically to allow all of them to run, giving the appearance of running at the same time. This is different to using threads or processes in that all code is run in the main process and thread, although it is possible to run coroutines in threads. +This works by running these coroutines in an event loop, where the context of the running coroutine switches periodically to allow all the coroutines to run, thus giving the appearance of running at the same time. This is different to using threads or processes in that all code runs in the main process and thread, although it is possible to run coroutines in threads. To call an async function we can either `await` it, or run it in an event loop which we get from `asyncio`. -To create a coroutine that can be used with asyncio we need to define a function using the async keyword: +To create a coroutine that can be used with asyncio we need to define a function using the `async` keyword: ```py async def main(): await something_awaitable() ``` -Which means we can call `await something_awaitable()` directly from within the function. If this were a non-async function this would have raised an exception like: `SyntaxError: 'await' outside async function` +Which means we can call `await something_awaitable()` directly from within the function. If this were a non-async function, this would have raised the exception `SyntaxError: 'await' outside async function` -To run the top level async function from outside of the event loop we can get an event loop from `asyncio`, and then use that loop to run the function: +To run the top level async function from outside the event loop, we can get the event loop from `asyncio` and then use it to run the function: ```py from asyncio import get_event_loop diff --git a/bot/resources/tags/traceback.md b/bot/resources/tags/traceback.md index e770fa86d..be5a8bf90 100644 --- a/bot/resources/tags/traceback.md +++ b/bot/resources/tags/traceback.md @@ -1,4 +1,4 @@ -Please provide a full traceback to your exception in order for us to identify your issue. +Please provide the full traceback for your exception in order to help us identify your issue. A full traceback could look like: ```py @@ -6,13 +6,13 @@ Traceback (most recent call last): File "tiny", line 3, in do_something() File "tiny", line 2, in do_something - a = 6 / 0 + a = 6 / b ZeroDivisionError: integer division or modulo by zero ``` The best way to read your traceback is bottom to top. -• Identify the exception raised (e.g. ZeroDivisionError) -• Make note of the line number, and navigate there in your program. -• Try to understand why the error occurred. +• Identify the exception raised (e.g. `ZeroDivisionError`) +• Make note of the line number (in this case 2), and navigate there in your program. +• Try to understand why the error occurred (in this case because `b` must be `0`). -To read more about exceptions and errors, please refer to the [PyDis Wiki](https://pythondiscord.com/pages/asking-good-questions/#examining-tracebacks) or the [official Python tutorial.](https://docs.python.org/3.7/tutorial/errors.html) +To read more about exceptions and errors, please refer to the [PyDis Wiki](https://pythondiscord.com/pages/guides/pydis-guides/asking-good-questions/#examining-tracebacks) or the [official Python tutorial](https://docs.python.org/3.7/tutorial/errors.html). diff --git a/bot/resources/tags/windows-path.md b/bot/resources/tags/windows-path.md index da8edf685..3738d1bc8 100644 --- a/bot/resources/tags/windows-path.md +++ b/bot/resources/tags/windows-path.md @@ -1,10 +1,10 @@ **PATH on Windows** -If you have installed Python but you forgot to check the *Add Python to PATH* option during the installation you may still be able to access your installation with ease. +If you have installed Python, but you forgot to check the *Add Python to PATH* option during the installation, you may still be able to access your installation with ease. -If you did not uncheck the option to install the Python launcher then you will find a `py` command on your system. If you want to be able to open your Python installation by running `python` then your best option is to re-install Python. +If you did not uncheck the option to install the Python launcher then you will find a `py` command on your system. If you want to be able to open your Python installation by running `python`, then your best option is to re-install Python. -Otherwise, you can access your install using the `py` command in Command Prompt. Where you may type something with the `python` command like: +Otherwise, you can access your installation using the `py` command in Command Prompt, where you may type something with the `python` command such as: ``` C:\Users\Username> python3 my_application_file.py ``` @@ -24,7 +24,7 @@ You can also access different versions of Python using the version flag, like so C:\Users\Username> py -3.7 ... Python 3.7 starts ... C:\Users\Username> py -3.6 -... Python 3.6 stars ... +... Python 3.6 starts ... C:\Users\Username> py -2 ... Python 2 (any version installed) starts ... ``` diff --git a/bot/resources/tags/xy-problem.md b/bot/resources/tags/xy-problem.md index b77bd27e8..8c508f18c 100644 --- a/bot/resources/tags/xy-problem.md +++ b/bot/resources/tags/xy-problem.md @@ -1,7 +1,7 @@ **xy-problem** -Asking about your attempted solution rather than your actual problem. +The XY problem can be summarised as asking about your attempted solution, rather than your actual problem. Often programmers will get distracted with a potential solution they've come up with, and will try asking for help getting it to work. However, it's possible this solution either wouldn't work as they expect, or there's a much better solution instead. -For more information and examples: http://xyproblem.info/ +For more information and examples, see http://xyproblem.info/ diff --git a/bot/resources/tags/ytdl.md b/bot/resources/tags/ytdl.md index f96b7f853..68a0a0cdb 100644 --- a/bot/resources/tags/ytdl.md +++ b/bot/resources/tags/ytdl.md @@ -1,4 +1,4 @@ -Per [Python Discord's Rule 5](https://pythondiscord.com/pages/rules), we are unable to assist with questions related to youtube-dl, pytube, or other YouTube video downloaders as their usage violates YouTube's Terms of Service. +Per [Python Discord's Rule 5](https://pythondiscord.com/pages/rules), we are unable to assist with questions related to youtube-dl, pytube, or other YouTube video downloaders, as their usage violates YouTube's Terms of Service. For reference, this usage is covered by the following clauses in [YouTube's TOS](https://www.youtube.com/static?gl=GB&template=terms), as of 2021-03-17: ``` diff --git a/bot/resources/tags/zip.md b/bot/resources/tags/zip.md index 6b05f0282..6f3157f71 100644 --- a/bot/resources/tags/zip.md +++ b/bot/resources/tags/zip.md @@ -3,7 +3,7 @@ The zip function allows you to iterate through multiple iterables simultaneously ```py letters = 'abc' numbers = [1, 2, 3] -# zip(letters, numbers) --> [('a', 1), ('b', 2), ('c', 3)] +# list(zip(letters, numbers)) --> [('a', 1), ('b', 2), ('c', 3)] for letter, number in zip(letters, numbers): print(letter, number) ``` -- cgit v1.2.3 From 802fa0dd29f35d4cf02c7be88dcec01d9ab67729 Mon Sep 17 00:00:00 2001 From: Izan Date: Mon, 20 Sep 2021 18:55:49 +0100 Subject: Remove coveralls from lint-test --- .github/workflows/lint-test.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index 619544e1a..2f42f1895 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -121,13 +121,6 @@ jobs: - name: Run tests and generate coverage report run: pytest -n auto --cov --disable-warnings -q - # This step will publish the coverage reports coveralls.io and - # print a "job" link in the output of the GitHub Action - - name: Publish coverage report to coveralls.io - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: coveralls - # Prepare the Pull Request Payload artifact. If this fails, we # we fail silently using the `continue-on-error` option. It's # nice if this succeeds, but if it fails for any reason, it -- cgit v1.2.3 From c27226504b5d384023074eb070e37464b6c8749a Mon Sep 17 00:00:00 2001 From: mbaruh Date: Mon, 20 Sep 2021 23:11:01 +0300 Subject: Indentation, type-hint, and documentation fixes --- bot/exts/moderation/clean.py | 63 ++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 3fb2c2870..d5bfdb485 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -5,7 +5,7 @@ from collections import defaultdict from contextlib import suppress from datetime import datetime from itertools import islice -from typing import Any, Callable, DefaultDict, Iterable, Literal, Optional, TYPE_CHECKING, Union +from typing import Any, Callable, Iterable, Literal, Optional, TYPE_CHECKING, Union from discord import Colour, Message, NotFound, TextChannel, User, errors from discord.ext.commands import Cog, Context, Converter, Greedy, group, has_any_role @@ -86,11 +86,11 @@ class Clean(Cog): @staticmethod def _validate_input( traverse: int, - channels: CleanChannels, + channels: Optional[CleanChannels], bots_only: bool, - users: list[User], - first_limit: CleanLimit, - second_limit: CleanLimit, + users: Optional[list[User]], + first_limit: Optional[CleanLimit], + second_limit: Optional[CleanLimit], ) -> None: """Raise errors if an argument value or a combination of values is invalid.""" # Is this an acceptable amount of messages to traverse? @@ -124,7 +124,7 @@ class Clean(Cog): @staticmethod def _build_predicate( bots_only: bool = False, - users: list[User] = None, + users: Optional[list[User]] = None, regex: Optional[re.Pattern] = None, first_limit: Optional[datetime] = None, second_limit: Optional[datetime] = None, @@ -196,7 +196,7 @@ class Clean(Cog): # Invocation message has already been deleted log.info("Tried to delete invocation message, but it was already deleted.") - def _get_messages_from_cache(self, traverse: int, to_delete: Predicate) -> tuple[DefaultDict, list[int]]: + def _get_messages_from_cache(self, traverse: int, to_delete: Predicate) -> tuple[defaultdict[Any, list], list[int]]: """Helper function for getting messages from the cache.""" message_mappings = defaultdict(list) message_ids = [] @@ -348,9 +348,9 @@ class Clean(Cog): self, ctx: Context, traverse: int, - channels: CleanChannels, + channels: Optional[CleanChannels], bots_only: bool = False, - users: list[User] = None, + users: Optional[list[User]] = None, regex: Optional[re.Pattern] = None, first_limit: Optional[CleanLimit] = None, second_limit: Optional[CleanLimit] = None, @@ -423,24 +423,25 @@ class Clean(Cog): @group(invoke_without_command=True, name="clean", aliases=["clear", "purge"]) async def clean_group( - self, - ctx: Context, - users: Greedy[User] = None, - traverse: Optional[int] = None, - first_limit: Optional[CleanLimit] = None, - second_limit: Optional[CleanLimit] = None, - use_cache: Optional[bool] = None, - bots_only: Optional[bool] = False, - regex: Optional[Regex] = None, - *, - channels: CleanChannels = None # "Optional" with discord.py silently ignores incorrect input. + self, + ctx: Context, + users: Greedy[User] = None, + traverse: Optional[int] = None, + first_limit: Optional[CleanLimit] = None, + second_limit: Optional[CleanLimit] = None, + use_cache: Optional[bool] = None, + bots_only: Optional[bool] = False, + regex: Optional[Regex] = None, + *, + channels: CleanChannels = None # "Optional" with discord.py silently ignores incorrect input. ) -> None: """ Commands for cleaning messages in channels. If arguments are provided, will act as a master command from which all subcommands can be derived. • `users`: A series of user mentions, ID's, or names. - • `traverse`: The number of messages to look at in each channel. + • `traverse`: The number of messages to look at in each channel. If using the cache, will look at the first + `traverse` messages in the cache. • `first_limit` and `second_limit`: A message, a duration delta, or an ISO datetime. If a message is provided, cleaning will happen in that channel, and channels cannot be provided. If a limit is provided, multiple channels cannot be provided. @@ -474,7 +475,7 @@ class Clean(Cog): self, ctx: Context, user: User, - traverse: Optional[int] = 10, + traverse: Optional[int] = DEFAULT_TRAVERSE, use_cache: Optional[bool] = True, *, channels: CleanChannels = None @@ -527,10 +528,10 @@ class Clean(Cog): @clean_group.command(name="until") async def clean_until( - self, - ctx: Context, - until: CleanLimit, - channel: TextChannel = None + self, + ctx: Context, + until: CleanLimit, + channel: TextChannel = None ) -> None: """ Delete all messages until a certain limit. @@ -547,11 +548,11 @@ class Clean(Cog): @clean_group.command(name="between", aliases=["after-until", "from-to"]) async def clean_between( - self, - ctx: Context, - first_limit: CleanLimit, - second_limit: CleanLimit, - channel: TextChannel = None + self, + ctx: Context, + first_limit: CleanLimit, + second_limit: CleanLimit, + channel: TextChannel = None ) -> None: """ Delete all messages within range. -- cgit v1.2.3 From 9b82abda7e873864d48710cb92651b27e53cbead Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 21 Sep 2021 09:40:25 +0200 Subject: Modlog: change thread logging to past-tense Co-authored-by: Bluenix --- 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 ae71a18a3..5b8b7ef12 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -789,7 +789,7 @@ class ModLog(Cog, name="ModLog"): icon, colour, f"Thread {action}", - f"Thread {after.mention} (`{after.id}`) from {after.parent.mention} (`{after.parent.id}`) {action}" + f"Thread {after.mention} (`{after.id}`) from {after.parent.mention} (`{after.parent.id}`) was {action}" ) @Cog.listener() -- cgit v1.2.3 From eff7d7f2cf3f2cd91ed6654b97edd95db786fd47 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Tue, 21 Sep 2021 23:39:52 +0300 Subject: Add `cog` as an alias to extensions command --- bot/exts/utils/extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/extensions.py b/bot/exts/utils/extensions.py index f78664527..309126d0e 100644 --- a/bot/exts/utils/extensions.py +++ b/bot/exts/utils/extensions.py @@ -36,7 +36,7 @@ class Extensions(commands.Cog): def __init__(self, bot: Bot): self.bot = bot - @group(name="extensions", aliases=("ext", "exts", "c", "cogs"), invoke_without_command=True) + @group(name="extensions", aliases=("ext", "exts", "c", "cog", "cogs"), invoke_without_command=True) async def extensions_group(self, ctx: Context) -> None: """Load, unload, reload, and list loaded extensions.""" await ctx.send_help(ctx.command) -- cgit v1.2.3 From b56c3405c14537c85b1496977d6c3c89cc2debcb Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 13 Sep 2021 02:24:20 +0200 Subject: Handle argument parsing through identifier from_string instead of d.py This lets us skip on the logic of figuring out whether we received a tag name alone, or both a name and a group --- bot/exts/info/tags.py | 37 +++++++++++++------------------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index f098d56c9..06b0d4d5a 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -233,15 +233,10 @@ class Tags(Cog): **self.PAGINATOR_DEFAULTS, ) - @group(name="tags", aliases=("tag", "t"), invoke_without_command=True) - async def tags_group( - self, - ctx: Context, - tag_name_or_group: str = None, - tag_name: str = None, - ) -> None: + @group(name="tags", aliases=("tag", "t"), invoke_without_command=True, usage="[tag_group] [tag_name]") + async def tags_group(self, ctx: Context, *, argument_string: Optional[str]) -> None: """Show all known tags, a single tag, or run a subcommand.""" - await self.get_command(ctx, tag_name_or_group=tag_name_or_group, tag_name=tag_name) + await self.get_command(ctx, argument_string=argument_string) @tags_group.group(name="search", invoke_without_command=True) async def search_tag_content(self, ctx: Context, *, keywords: str) -> None: @@ -352,12 +347,8 @@ class Tags(Cog): if identifier.group == group and tag.accessible_by(user) ) - @tags_group.command(name="get", aliases=("show", "g")) - async def get_command( - self, ctx: Context, - tag_name_or_group: str = None, - tag_name: str = None, - ) -> bool: + @tags_group.command(name="get", aliases=("show", "g"), usage="[tag_group] [tag_name]") + async def get_command(self, ctx: Context, *, argument_string: Optional[str]) -> bool: """ If a single argument matching a group name is given, list all accessible tags from that group Otherwise display the tag if one was found for the given arguments, or try to display suggestions for that name. @@ -367,7 +358,7 @@ class Tags(Cog): Returns True if a message was sent, or if the tag is on cooldown. Returns False if no message was sent. """ # noqa: D205, D415 - if tag_name_or_group is None and tag_name is None: + if not argument_string: if self.tags: await LinePaginator.paginate( self.accessible_tags(ctx.author), ctx, Embed(title="Available tags"), **self.PAGINATOR_DEFAULTS @@ -376,19 +367,17 @@ class Tags(Cog): await ctx.send(embed=Embed(description="**There are no tags!**")) return True - elif tag_name is None: - if group_tags := self.accessible_tags_in_group(tag_name_or_group, ctx.author): + identifier = TagIdentifier.from_string(argument_string) + + if identifier.group is None: + # Try to find accessible tags from a group matching the identifier's name. + if group_tags := self.accessible_tags_in_group(identifier.name, ctx.author): await LinePaginator.paginate( - group_tags, ctx, Embed(title=f"Tags under *{tag_name_or_group}*"), **self.PAGINATOR_DEFAULTS + group_tags, ctx, Embed(title=f"Tags under *{identifier.name}*"), **self.PAGINATOR_DEFAULTS ) return True - else: - tag_name = tag_name_or_group - tag_group = None - else: - tag_group = tag_name_or_group - embed = await self.get_tag_embed(ctx, TagIdentifier(tag_group, tag_name)) + embed = await self.get_tag_embed(ctx, identifier) if embed is None: return False -- cgit v1.2.3 From caa3bba3227e2343a86d98a95e5d647547d154bc Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 13 Sep 2021 02:27:35 +0200 Subject: Use new command interface that accepts direct content --- bot/exts/backend/error_handler.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 128e72c84..cf0bd3e12 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -10,7 +10,6 @@ from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Colours, Icons, MODERATION_ROLES from bot.errors import InvalidInfractedUserError, LockedResourceError -from bot.exts.info import tags from bot.utils.checks import ContextCheckFailure log = logging.getLogger(__name__) @@ -174,15 +173,7 @@ class ErrorHandler(Cog): await self.on_command_error(ctx, tag_error) return - tag_identifier = tags.TagIdentifier.from_string(ctx.message.content) - if tag_identifier.group is not None: - tag_name = tag_identifier.name - tag_name_or_group = tag_identifier.group - else: - tag_name = None - tag_name_or_group = tag_identifier.name - - if await ctx.invoke(tags_get_command, tag_name_or_group, tag_name): + if await ctx.invoke(tags_get_command, argument_string=ctx.message.content): return if not any(role.id in MODERATION_ROLES for role in ctx.author.roles): -- cgit v1.2.3 From 056b144a29b73b93e4eaa884edc86f7e1b09d74e Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 13 Sep 2021 02:47:34 +0200 Subject: Remove try_get_tag ctx args test The arguments are now parsed by the command itself so the test would only check if the mocked message was passed in. The only case where the errors would fail would be a change to the passed args, so it'd only restrict development as the tests would need to be changed anyway --- tests/bot/exts/backend/test_error_handler.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index ce59ee5fa..382194a63 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -360,18 +360,6 @@ class TryGetTagTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.try_get_tag(self.ctx)) self.cog.on_command_error.assert_awaited_once_with(self.ctx, err) - async def test_try_get_tag_ctx_invoke(self): - """Should call `ctx.invoke` with proper args/kwargs.""" - test_cases = ( - ("foo", ("foo", None)), - ("foo bar", ("foo", "bar")), - ) - for message_content, args in test_cases: - self.ctx.reset_mock() - self.ctx.message = MagicMock(content=message_content) - self.assertIsNone(await self.cog.try_get_tag(self.ctx)) - self.ctx.invoke.assert_awaited_once_with(self.tag.get_command, *args) - async def test_dont_call_suggestion_tag_sent(self): """Should never call command suggestion if tag is already sent.""" self.ctx.message = MagicMock(content="foo") -- cgit v1.2.3 From 18b5fd814377ba059a5ca2f8f17475a59d87fd8f Mon Sep 17 00:00:00 2001 From: Izan Date: Thu, 23 Sep 2021 10:29:04 +0100 Subject: Use full paste link --- bot/resources/tags/paste.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/paste.md b/bot/resources/tags/paste.md index 2ed51def7..8c3c2985d 100644 --- a/bot/resources/tags/paste.md +++ b/bot/resources/tags/paste.md @@ -1,6 +1,6 @@ **Pasting large amounts of code** If your code is too long to fit in a codeblock in discord, you can paste your code here: -https://paste.pydis.com/ +https://paste.pythondiscord.com/ After pasting your code, **save** it by clicking the floppy disk icon in the top right, or by typing `ctrl + S`. After doing that, the URL should **change**. Copy the URL and post it here so others can see it. -- cgit v1.2.3 From 3846da7cfbd33844bf04846d7d4e9ea67a5cb0a3 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 24 Sep 2021 00:10:07 +0100 Subject: Direct users to the appeals server when banned This is a new appeals process we are trialing. Users who get banned join this server and DM a modmail bot, who relays the message to the main server for mods to discuss. I have updated the shortening logic to allow for extra information to be included at the end of the embed, while still staying under the limit. --- bot/exts/moderation/infraction/_utils.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index b20ef1d06..e93174ea3 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -27,16 +27,18 @@ RULES_URL = "https://pythondiscord.com/pages/rules" # Type aliases Infraction = t.Dict[str, t.Union[str, int, bool]] -APPEAL_EMAIL = "appeals@pythondiscord.com" +APPEAL_SERVER_INVITE = "https://discord.gg/WXrCJxWBnm" INFRACTION_TITLE = "Please review our rules" -INFRACTION_APPEAL_EMAIL_FOOTER = f"To appeal this infraction, send an e-mail to {APPEAL_EMAIL}" +INFRACTION_APPEAL_SERVER_FOOTER = f"\n\nTo appeal this infraction, join our [appeals server]({APPEAL_SERVER_INVITE})." INFRACTION_APPEAL_MODMAIL_FOOTER = ( - 'If you would like to discuss or appeal this infraction, ' - 'send a message to the ModMail bot' + '\n\nIf you would like to discuss or appeal this infraction, ' + 'send a message to the ModMail bot.' ) INFRACTION_AUTHOR_NAME = "Infraction information" +LONGEST_EXTRAS = max(len(INFRACTION_APPEAL_SERVER_FOOTER), len(INFRACTION_APPEAL_MODMAIL_FOOTER)) + INFRACTION_DESCRIPTION_TEMPLATE = ( "**Type:** {type}\n" "**Expires:** {expires}\n" @@ -170,8 +172,10 @@ async def notify_infraction( ) # For case when other fields than reason is too long and this reach limit, then force-shorten string - if len(text) > 4096: - text = f"{text[:4093]}..." + if len(text) > 4096 - LONGEST_EXTRAS: + text = f"{text[:4093-LONGEST_EXTRAS]}..." + + text += INFRACTION_APPEAL_SERVER_FOOTER if infr_type.lower() == 'ban' else INFRACTION_APPEAL_MODMAIL_FOOTER embed = discord.Embed( description=text, @@ -182,10 +186,6 @@ async def notify_infraction( embed.title = INFRACTION_TITLE embed.url = RULES_URL - embed.set_footer( - text=INFRACTION_APPEAL_EMAIL_FOOTER if infr_type == 'Ban' else INFRACTION_APPEAL_MODMAIL_FOOTER - ) - return await send_private_embed(user, embed) -- cgit v1.2.3 From a95885210b0caa693bdc916bf3aa42c97e8ef071 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 24 Sep 2021 00:28:36 +0100 Subject: Update infraction DM tests to reflect new output --- tests/bot/exts/moderation/infraction/test_utils.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index eb256f1fd..72eebb254 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -139,14 +139,14 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): type="Ban", expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="No reason provided." - ), + ) + utils.INFRACTION_APPEAL_SERVER_FOOTER, colour=Colours.soft_red, url=utils.RULES_URL ).set_author( name=utils.INFRACTION_AUTHOR_NAME, url=utils.RULES_URL, icon_url=Icons.token_removed - ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER), + ), "send_result": True }, { @@ -157,14 +157,14 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): type="Warning", expires="N/A", reason="Test reason." - ), + ) + utils.INFRACTION_APPEAL_MODMAIL_FOOTER, colour=Colours.soft_red, url=utils.RULES_URL ).set_author( name=utils.INFRACTION_AUTHOR_NAME, url=utils.RULES_URL, icon_url=Icons.token_removed - ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER), + ), "send_result": False }, # Note that this test case asserts that the DM that *would* get sent to the user is formatted @@ -177,14 +177,14 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): type="Note", expires="N/A", reason="No reason provided." - ), + ) + utils.INFRACTION_APPEAL_MODMAIL_FOOTER, colour=Colours.soft_red, url=utils.RULES_URL ).set_author( name=utils.INFRACTION_AUTHOR_NAME, url=utils.RULES_URL, icon_url=Icons.defcon_denied - ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER), + ), "send_result": False }, { @@ -195,14 +195,14 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): type="Mute", expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="Test" - ), + ) + utils.INFRACTION_APPEAL_MODMAIL_FOOTER, colour=Colours.soft_red, url=utils.RULES_URL ).set_author( name=utils.INFRACTION_AUTHOR_NAME, url=utils.RULES_URL, icon_url=Icons.defcon_denied - ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER), + ), "send_result": False }, { @@ -213,14 +213,14 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): type="Mute", expires="N/A", reason="foo bar" * 4000 - )[:4093] + "...", + )[:4093-utils.LONGEST_EXTRAS] + "..." + utils.INFRACTION_APPEAL_MODMAIL_FOOTER, colour=Colours.soft_red, url=utils.RULES_URL ).set_author( name=utils.INFRACTION_AUTHOR_NAME, url=utils.RULES_URL, icon_url=Icons.defcon_denied - ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER), + ), "send_result": True } ] -- cgit v1.2.3 From e082596ff49b22dbb47d3bf8aa75ae98e2264620 Mon Sep 17 00:00:00 2001 From: Izan Date: Fri, 24 Sep 2021 16:17:36 +0100 Subject: Add handling for when `message.author` is a `discord.User` NB: Will give a sentry warning when this happens. --- bot/exts/help_channels/_cog.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index cfc9cf477..ecffc59fd 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -125,14 +125,19 @@ class HelpChannels(commands.Cog): """ log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.") await self.move_to_in_use(message.channel) - await self._handle_role_change(message.author, message.author.add_roles) - await _message.pin(message) + # Handle odd edge case of `message.author` being a `discord.User` (see bot#1839) + if isinstance(message.author, discord.User): + log.warning("`message.author` is a `discord.User` so not handling role change or sending DM.") + else: + await self._handle_role_change(message.author, message.author.add_roles) - try: - await _message.dm_on_open(message) - except Exception as e: - log.warning("Error occurred while sending DM:", exc_info=e) + try: + await _message.dm_on_open(message) + except Exception as e: + log.warning("Error occurred while sending DM:", exc_info=e) + + await _message.pin(message) # Add user with channel for dormant check. await _caches.claimants.set(message.channel.id, message.author.id) -- cgit v1.2.3 From 650e739fbb66eb30502675cc3b8bd257e1ee825d Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Fri, 24 Sep 2021 19:53:58 +0200 Subject: Suppress NotFound for batch deletion. I will copy my comment from the code, as it explains why we want this: In the rare case where we found messages matching the spam filter across multiple channels, it is possible that a single channel will only contain a single message to delete. If that should be the case, discord.py will use the "delete single message" endpoint instead of the bulk delete endpoint, and the single message deletion endpoint will complain if you give it that does not exist. As this means that we have no other message to delete in this channel (and message deletes work per-channel), we can just log an exception and carry on with business. --- bot/exts/filters/antispam.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 8c075fa95..72103c9fb 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -250,7 +250,20 @@ class AntiSpam(Cog): for message in messages: channel_messages[message.channel].append(message) for channel, messages in channel_messages.items(): - await channel.delete_messages(messages) + try: + await channel.delete_messages(messages) + except NotFound: + # In the rare case where we found messages matching the + # spam filter across multiple channels, it is possible + # that a single channel will only contain a single message + # to delete. If that should be the case, discord.py will + # use the "delete single message" endpoint instead of the + # bulk delete endpoint, and the single message deletion + # endpoint will complain if you give it that does not exist. + # As this means that we have no other message to delete in + # this channel (and message deletes work per-channel), + # we can just log an exception and carry on with business. + log.info(f"Tried to delete message `{messages[0].id}`, but message could not be found.") # Otherwise, the bulk delete endpoint will throw up. # Delete the message directly instead. -- cgit v1.2.3 From 33b4f42b481e6b5e1686f66803dc916595e4b457 Mon Sep 17 00:00:00 2001 From: Izan Date: Sat, 25 Sep 2021 21:58:44 +0100 Subject: Bluenix Review This commit should be squashed upon PR merge. - Made changes in `async-await.md`, including changing `run_until_complete()` to `asyncio.run()` - Minor changes in `traceback.md` - Rewriting `windows-path.md` --- bot/resources/tags/async-await.md | 11 +++++------ bot/resources/tags/traceback.md | 8 ++++---- bot/resources/tags/windows-path.md | 21 ++++----------------- 3 files changed, 13 insertions(+), 27 deletions(-) diff --git a/bot/resources/tags/async-await.md b/bot/resources/tags/async-await.md index 4ab8a76fc..a20332df6 100644 --- a/bot/resources/tags/async-await.md +++ b/bot/resources/tags/async-await.md @@ -2,7 +2,7 @@ Python provides the ability to run multiple tasks and coroutines simultaneously with the use of the `asyncio` library, which is included in the Python standard library. -This works by running these coroutines in an event loop, where the context of the running coroutine switches periodically to allow all the coroutines to run, thus giving the appearance of running at the same time. This is different to using threads or processes in that all code runs in the main process and thread, although it is possible to run coroutines in threads. +This works by running these coroutines in an event loop, where the context of the running coroutine switches periodically to allow all other coroutines to run, thus giving the appearance of running at the same time. This is different to using threads or processes in that all code runs in the main process and thread, although it is possible to run coroutines in threads. To call an async function we can either `await` it, or run it in an event loop which we get from `asyncio`. @@ -13,16 +13,15 @@ async def main(): ``` Which means we can call `await something_awaitable()` directly from within the function. If this were a non-async function, this would have raised the exception `SyntaxError: 'await' outside async function` -To run the top level async function from outside the event loop, we can get the event loop from `asyncio` and then use it to run the function: +To run the top level async function from outside the event loop we need to use `[asyncio.run()`](https://docs.python.org/3/library/asyncio-task.html#asyncio.run), like this: ```py -from asyncio import get_event_loop +import asyncio async def main(): await something_awaitable() -loop = get_event_loop() -loop.run_until_complete(main()) +asyncio.run(main()) ``` -Note that in the `run_until_complete()` where we appear to be calling `main()`, this does not execute the code in `main`, rather it returns a `coroutine` object which is then handled and run by the event loop via `run_until_complete()`. +Note that in the `asyncio.run()` where we appear to be calling `main()`, this does not execute the code in `main`, rather it returns a `coroutine` object which is then handled and run by the event loop via `asyncio.run`. To learn more about asyncio and its use, see the [asyncio documentation](https://docs.python.org/3/library/asyncio.html). diff --git a/bot/resources/tags/traceback.md b/bot/resources/tags/traceback.md index be5a8bf90..321737aac 100644 --- a/bot/resources/tags/traceback.md +++ b/bot/resources/tags/traceback.md @@ -7,12 +7,12 @@ Traceback (most recent call last): do_something() File "tiny", line 2, in do_something a = 6 / b -ZeroDivisionError: integer division or modulo by zero +ZeroDivisionError: division by zero ``` The best way to read your traceback is bottom to top. -• Identify the exception raised (e.g. `ZeroDivisionError`) -• Make note of the line number (in this case 2), and navigate there in your program. -• Try to understand why the error occurred (in this case because `b` must be `0`). +• Identify the exception raised (in this case `ZeroDivisionError`) +• Make note of the line number (in this case `2`), and navigate there in your program. +• Try to understand why the error occurred (in this case because `b` is `0`). To read more about exceptions and errors, please refer to the [PyDis Wiki](https://pythondiscord.com/pages/guides/pydis-guides/asking-good-questions/#examining-tracebacks) or the [official Python tutorial](https://docs.python.org/3.7/tutorial/errors.html). diff --git a/bot/resources/tags/windows-path.md b/bot/resources/tags/windows-path.md index 3738d1bc8..e8f5690fb 100644 --- a/bot/resources/tags/windows-path.md +++ b/bot/resources/tags/windows-path.md @@ -1,25 +1,12 @@ **PATH on Windows** -If you have installed Python, but you forgot to check the *Add Python to PATH* option during the installation, you may still be able to access your installation with ease. +If you have installed Python but forgot to check the *Add Python to PATH* option during the installation, you may still be able to access your installation with ease. -If you did not uncheck the option to install the Python launcher then you will find a `py` command on your system. If you want to be able to open your Python installation by running `python`, then your best option is to re-install Python. +If you did not uncheck the option to install the Python launcher, then you'll instead have a `py` command which can be used in the same way. If you want to be able to access your Python installation via the `python` command, then your best option is to re-install Python (remembering to tick the `add to PATH` checkbox). -Otherwise, you can access your installation using the `py` command in Command Prompt, where you may type something with the `python` command such as: -``` -C:\Users\Username> python3 my_application_file.py -``` - -You can achieve the same result using the `py` command like this: -``` -C:\Users\Username> py -3 my_application_file.py -``` - -You can pass any options to the Python interpreter after you specify a version, for example, to install a Python module using `pip` you can run: -``` -C:\Users\Username> py -3 -m pip install numpy -``` +You can pass any options to the Python interpreter, e.g. to install the `[numpy](https://pypi.org/project/numpy/)` module from PyPI you can run `py -3 -m pip install numpy` or `python -m pip install numpy`. -You can also access different versions of Python using the version flag, like so: +You can also access different versions of Python using the version flag of the `py` command, like so: ``` C:\Users\Username> py -3.7 ... Python 3.7 starts ... -- cgit v1.2.3 From 7ec98f71b6f650794e26d73a2503bf2e472e1228 Mon Sep 17 00:00:00 2001 From: Izan Date: Sat, 25 Sep 2021 23:51:04 +0100 Subject: Bluenix Review #2 This commit should be squashed upon PR merge. - Made some more fixes in `async-await.md` --- bot/resources/tags/async-await.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/resources/tags/async-await.md b/bot/resources/tags/async-await.md index a20332df6..e945240ba 100644 --- a/bot/resources/tags/async-await.md +++ b/bot/resources/tags/async-await.md @@ -2,7 +2,7 @@ Python provides the ability to run multiple tasks and coroutines simultaneously with the use of the `asyncio` library, which is included in the Python standard library. -This works by running these coroutines in an event loop, where the context of the running coroutine switches periodically to allow all other coroutines to run, thus giving the appearance of running at the same time. This is different to using threads or processes in that all code runs in the main process and thread, although it is possible to run coroutines in threads. +This works by running these coroutines in an event loop, where the context of the running coroutine switches periodically to allow all other coroutines to run, thus giving the appearance of running at the same time. This is different to using threads or processes in that all code runs in the main process and thread, although it is possible to run coroutines in other threads. To call an async function we can either `await` it, or run it in an event loop which we get from `asyncio`. @@ -11,7 +11,7 @@ To create a coroutine that can be used with asyncio we need to define a function async def main(): await something_awaitable() ``` -Which means we can call `await something_awaitable()` directly from within the function. If this were a non-async function, this would have raised the exception `SyntaxError: 'await' outside async function` +Which means we can call `await something_awaitable()` directly from within the function. If this were a non-async function, it would raise the exception `SyntaxError: 'await' outside async function` To run the top level async function from outside the event loop we need to use `[asyncio.run()`](https://docs.python.org/3/library/asyncio-task.html#asyncio.run), like this: ```py -- cgit v1.2.3 From 093fdaa2b8c89b770d7ff2b7889077dd9e21ad2d Mon Sep 17 00:00:00 2001 From: Izan Date: Sun, 26 Sep 2021 00:29:25 +0100 Subject: Bluenix Review #3 This commit should be squashed upon PR merge. - Another fix in `windows-path.md` --- bot/resources/tags/windows-path.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/resources/tags/windows-path.md b/bot/resources/tags/windows-path.md index e8f5690fb..b2b0da029 100644 --- a/bot/resources/tags/windows-path.md +++ b/bot/resources/tags/windows-path.md @@ -1,8 +1,8 @@ **PATH on Windows** -If you have installed Python but forgot to check the *Add Python to PATH* option during the installation, you may still be able to access your installation with ease. +If you have installed Python but forgot to check the `Add Python to PATH` option during the installation, you may still be able to access your installation with ease. -If you did not uncheck the option to install the Python launcher, then you'll instead have a `py` command which can be used in the same way. If you want to be able to access your Python installation via the `python` command, then your best option is to re-install Python (remembering to tick the `add to PATH` checkbox). +If you did not uncheck the option to install the `py launcher`, then you'll instead have a `py` command which can be used in the same way. If you want to be able to access your Python installation via the `python` command, then your best option is to re-install Python (remembering to tick the `Add Python to PATH` checkbox). You can pass any options to the Python interpreter, e.g. to install the `[numpy](https://pypi.org/project/numpy/)` module from PyPI you can run `py -3 -m pip install numpy` or `python -m pip install numpy`. -- cgit v1.2.3 From c04832f3cc5867de285f9e5c97d0c0c5191ac66d Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 27 Sep 2021 21:34:03 +0100 Subject: Add LT and GT to supported otn chars --- bot/converters.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 18bb6e4e5..8b0890830 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -392,7 +392,8 @@ class Duration(DurationDelta): class OffTopicName(Converter): """A converter that ensures an added off-topic name is valid.""" - ALLOWED_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-" + ALLOWED_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-<>" + TRANSLATED_CHARACTERS = "𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-<>" @classmethod def translate_name(cls, name: str, *, from_unicode: bool = True) -> str: @@ -402,9 +403,9 @@ class OffTopicName(Converter): If `from_unicode` is True, the name is translated from a discord-safe format, back to normalized text. """ if from_unicode: - table = str.maketrans(cls.ALLOWED_CHARACTERS, '𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-') + table = str.maketrans(cls.ALLOWED_CHARACTERS, cls.TRANSLATED_CHARACTERS) else: - table = str.maketrans('𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-', cls.ALLOWED_CHARACTERS) + table = str.maketrans(cls.TRANSLATED_CHARACTERS, cls.ALLOWED_CHARACTERS) return name.translate(table) -- cgit v1.2.3 From b9f41c5f54fc1f506561430cdafb07f14966cf68 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 20 Sep 2021 14:14:39 +0100 Subject: Add get_or_fetch_member util This is now needed, as we're a large server it's not guaranteed that the member cache will always be fully populated. --- bot/utils/members.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 bot/utils/members.py diff --git a/bot/utils/members.py b/bot/utils/members.py new file mode 100644 index 000000000..302fe6d63 --- /dev/null +++ b/bot/utils/members.py @@ -0,0 +1,24 @@ +import logging +import typing as t + +import discord + +log = logging.getLogger(__name__) + + +async def get_or_fetch_member(guild: discord.Guild, member_id: int) -> t.Optional[discord.Member]: + """ + Attempt to get a member from cache; on failure fetch from the API. + + Return `None` to indicate the member could not be found. + """ + if member := guild.get_member(member_id): + log.trace("%s retrieved from cache.", member) + else: + try: + member = await guild.fetch_member(member_id) + except discord.errors.NotFound: + log.trace("Failed to fetch %d from API.", member_id) + return None + log.trace("%s fetched from API.", member) + return member -- cgit v1.2.3 From 15a8e7e8f8b5682838613a035012a49f5be5034e Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 20 Sep 2021 14:30:39 +0100 Subject: Migrate all Guild.get_member calls to get_or_fetch_member This protects us against the guild cache not being fully populated with members. --- bot/exts/backend/sync/_syncers.py | 3 ++- bot/exts/events/code_jams/_cog.py | 7 ++++--- bot/exts/filters/token_remover.py | 7 ++++--- bot/exts/help_channels/_cog.py | 4 ++-- bot/exts/info/information.py | 9 +++++---- bot/exts/moderation/infraction/infractions.py | 5 +++-- bot/exts/moderation/infraction/management.py | 3 ++- bot/exts/moderation/infraction/superstarify.py | 3 ++- bot/exts/moderation/stream.py | 23 +++++++++------------- bot/exts/moderation/watchchannels/_watchchannel.py | 5 +++-- bot/exts/recruitment/talentpool/_cog.py | 9 +++++---- bot/exts/recruitment/talentpool/_review.py | 3 ++- bot/exts/utils/reminders.py | 18 +++++++++-------- 13 files changed, 53 insertions(+), 46 deletions(-) diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index c9f2d2da8..50016df0c 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -9,6 +9,7 @@ from more_itertools import chunked import bot from bot.api import ResponseCodeError +from bot.utils.members import get_or_fetch_member log = logging.getLogger(__name__) @@ -156,7 +157,7 @@ class UserSyncer(Syncer): if db_user[db_field] != guild_value: updated_fields[db_field] = guild_value - if guild_user := guild.get_member(db_user["id"]): + if guild_user := await get_or_fetch_member(guild, db_user["id"]): seen_guild_users.add(guild_user.id) maybe_update("name", guild_user.name) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index e099f7dfa..7b0831ab4 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -11,6 +11,7 @@ from discord.ext import commands from bot.bot import Bot from bot.constants import Emojis, Roles from bot.exts.events.code_jams import _channels +from bot.utils.members import get_or_fetch_member from bot.utils.services import send_to_paste_service log = logging.getLogger(__name__) @@ -59,7 +60,7 @@ class CodeJams(commands.Cog): reader = csv.DictReader(csv_file.splitlines()) for row in reader: - member = ctx.guild.get_member(int(row["Team Member Discord ID"])) + member = await get_or_fetch_member(ctx.guild, int(row["Team Member Discord ID"])) if member is None: log.trace(f"Got an invalid member ID: {row['Team Member Discord ID']}") @@ -69,8 +70,8 @@ class CodeJams(commands.Cog): team_leaders = await ctx.guild.create_role(name="Code Jam Team Leaders", colour=TEAM_LEADERS_COLOUR) - for team_name, members in teams.items(): - await _channels.create_team_channel(ctx.guild, team_name, members, team_leaders) + for team_name, team_members in teams.items(): + await _channels.create_team_channel(ctx.guild, team_name, team_members, team_leaders) await _channels.create_team_leader_channel(ctx.guild, team_leaders) await ctx.send(f"{Emojis.check_mark} Created Code Jam with {len(teams)} teams.") diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py index 93f1f3c33..6c86ff849 100644 --- a/bot/exts/filters/token_remover.py +++ b/bot/exts/filters/token_remover.py @@ -11,6 +11,7 @@ from bot import utils from bot.bot import Bot from bot.constants import Channels, Colours, Event, Icons from bot.exts.moderation.modlog import ModLog +from bot.utils.members import get_or_fetch_member from bot.utils.messages import format_user log = logging.getLogger(__name__) @@ -99,7 +100,7 @@ class TokenRemover(Cog): await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) log_message = self.format_log_message(msg, found_token) - userid_message, mention_everyone = self.format_userid_log_message(msg, found_token) + userid_message, mention_everyone = await self.format_userid_log_message(msg, found_token) log.debug(log_message) # Send pretty mod log embed to mod-alerts @@ -116,7 +117,7 @@ class TokenRemover(Cog): self.bot.stats.incr("tokens.removed_tokens") @classmethod - def format_userid_log_message(cls, msg: Message, token: Token) -> t.Tuple[str, bool]: + async def format_userid_log_message(cls, msg: Message, token: Token) -> t.Tuple[str, bool]: """ Format the portion of the log message that includes details about the detected user ID. @@ -128,7 +129,7 @@ class TokenRemover(Cog): Returns a tuple of (log_message, mention_everyone) """ user_id = cls.extract_user_id(token.user_id) - user = msg.guild.get_member(user_id) + user = await get_or_fetch_member(msg.guild, user_id) if user: return KNOWN_USER_LOG_MESSAGE.format( diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 8612f9866..a64ceac3a 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -14,7 +14,7 @@ from bot import constants from bot.bot import Bot from bot.constants import Channels, RedirectOutput from bot.exts.help_channels import _caches, _channel, _message, _name, _stats -from bot.utils import channel as channel_utils, lock, scheduling +from bot.utils import channel as channel_utils, lock, members, scheduling log = logging.getLogger(__name__) @@ -434,7 +434,7 @@ class HelpChannels(commands.Cog): await _caches.claimants.delete(channel.id) await _caches.session_participants.delete(channel.id) - claimant = self.bot.get_guild(constants.Guild.id).get_member(claimant_id) + claimant = await members.get_or_fetch_member(self.bot.get_guild(constants.Guild.id), claimant_id) if claimant is None: log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed") else: diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index be67910a6..c60fd2127 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -19,6 +19,7 @@ from bot.errors import NonExistentRoleError from bot.pagination import LinePaginator from bot.utils.channel import is_mod_channel, is_staff_channel from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check +from bot.utils.members import get_or_fetch_member from bot.utils.time import TimestampFormats, discord_timestamp, humanize_delta log = logging.getLogger(__name__) @@ -46,13 +47,13 @@ class Information(Cog): @staticmethod def join_role_stats(role_ids: list[int], guild: Guild, name: Optional[str] = None) -> dict[str, int]: """Return a dictionary with the number of `members` of each role given, and the `name` for this joined group.""" - members = 0 + member_count = 0 for role_id in role_ids: if (role := guild.get_role(role_id)) is not None: - members += len(role.members) + member_count += len(role.members) else: raise NonExistentRoleError(role_id) - return {name or role.name.title(): members} + return {name or role.name.title(): member_count} @staticmethod def get_member_counts(guild: Guild) -> dict[str, int]: @@ -244,7 +245,7 @@ class Information(Cog): async def create_user_embed(self, ctx: Context, user: MemberOrUser) -> Embed: """Creates an embed containing information on the `user`.""" - on_server = bool(ctx.guild.get_member(user.id)) + on_server = bool(await get_or_fetch_member(ctx.guild, user.id)) created = discord_timestamp(user.created_at, TimestampFormats.RELATIVE) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index eaba97703..b58b09250 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -14,6 +14,7 @@ from bot.converters import Duration, Expiry, MemberOrUser, UnambiguousMemberOrUs from bot.decorators import respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler +from bot.utils.members import get_or_fetch_member from bot.utils.messages import format_user log = logging.getLogger(__name__) @@ -422,7 +423,7 @@ class Infractions(InfractionScheduler, commands.Cog): notify: bool = True ) -> t.Dict[str, str]: """Remove a user's muted role, optionally DM them a notification, and return a log dict.""" - user = guild.get_member(user_id) + user = await get_or_fetch_member(guild, user_id) log_text = {} if user: @@ -470,7 +471,7 @@ class Infractions(InfractionScheduler, commands.Cog): notify: bool = True ) -> t.Dict[str, str]: """Optionally DM the user a pardon notification and return a log dict.""" - user = guild.get_member(user_id) + user = await get_or_fetch_member(guild, user_id) log_text = {} if user: diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index d72cf8f89..0cb2a8b60 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -19,6 +19,7 @@ from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator from bot.utils import messages, time from bot.utils.channel import is_mod_channel +from bot.utils.members import get_or_fetch_member from bot.utils.time import humanize_delta, until_expiration log = logging.getLogger(__name__) @@ -190,7 +191,7 @@ class ModManagement(commands.Cog): # Get information about the infraction's user user_id = new_infraction['user'] - user = ctx.guild.get_member(user_id) + user = await get_or_fetch_member(ctx.guild, user_id) if user: user_text = messages.format_user(user) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 05a2bbe10..aa2fd367b 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -14,6 +14,7 @@ from bot.bot import Bot from bot.converters import Duration, Expiry from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler +from bot.utils.members import get_or_fetch_member from bot.utils.messages import format_user from bot.utils.time import format_infraction @@ -198,7 +199,7 @@ class Superstarify(InfractionScheduler, Cog): return guild = self.bot.get_guild(constants.Guild.id) - user = guild.get_member(infraction["user"]) + user = await get_or_fetch_member(guild, infraction["user"]) # Don't bother sending a notification if the user left the guild. if not user: diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index b5bd62a71..a179a9acc 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -16,6 +16,7 @@ from bot.constants import ( from bot.converters import Expiry from bot.pagination import LinePaginator from bot.utils import scheduling +from bot.utils.members import get_or_fetch_member from bot.utils.time import discord_timestamp, format_infraction_with_duration log = logging.getLogger(__name__) @@ -47,23 +48,17 @@ class Stream(commands.Cog): """Reload outstanding tasks from redis on startup, delete the task if the member has since left the server.""" await self.bot.wait_until_guild_available() items = await self.task_cache.items() + guild = self.bot.get_guild(Guild.id) for key, value in items: - member = self.bot.get_guild(Guild.id).get_member(key) + member = await get_or_fetch_member(guild, key) if not member: - # Member isn't found in the cache - try: - member = await self.bot.get_guild(Guild.id).fetch_member(key) - except discord.errors.NotFound: - log.debug( - f"Member {key} left the guild before we could schedule " - "the revoking of their streaming permissions." - ) - await self.task_cache.delete(key) - continue - except discord.HTTPException: - log.exception(f"Exception while trying to retrieve member {key} from Discord.") - continue + log.debug( + "User with ID %d left the guild before their streaming permissions could be revoked.", + key + ) + await self.task_cache.delete(key) + continue revoke_time = Arrow.utcfromtimestamp(value) log.debug(f"Scheduling {member} ({member.id}) to have streaming permission revoked at {revoke_time}") diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index a42e1f518..3fafd097b 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -19,6 +19,7 @@ from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator from bot.utils import CogABCMeta, messages, scheduling +from bot.utils.members import get_or_fetch_member from bot.utils.time import get_time_delta log = logging.getLogger(__name__) @@ -281,7 +282,7 @@ class WatchChannel(metaclass=CogABCMeta): user_id = msg.author.id guild = self.bot.get_guild(GuildConfig.id) - actor = guild.get_member(self.watched_users[user_id]['actor']) + actor = await get_or_fetch_member(guild, self.watched_users[user_id]['actor']) actor = actor.display_name if actor else self.watched_users[user_id]['actor'] inserted_at = self.watched_users[user_id]['inserted_at'] @@ -355,7 +356,7 @@ class WatchChannel(metaclass=CogABCMeta): list_data["info"] = {} for user_id, user_data in watched_iter: - member = ctx.guild.get_member(user_id) + member = await get_or_fetch_member(ctx.guild, user_id) line = f"• `{user_id}`" if member: line += f" ({member.name}#{member.discriminator})" diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index aaafff973..38d7cfca1 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -16,6 +16,7 @@ from bot.converters import MemberOrUser from bot.exts.recruitment.talentpool._review import Reviewer from bot.pagination import LinePaginator from bot.utils import scheduling, time +from bot.utils.members import get_or_fetch_member from bot.utils.time import get_time_delta AUTOREVIEW_ENABLED_KEY = "autoreview_enabled" @@ -175,7 +176,7 @@ class TalentPool(Cog, name="Talentpool"): lines = [] for user_id, user_data in nominations: - member = ctx.guild.get_member(user_id) + member = await get_or_fetch_member(ctx.guild, user_id) line = f"• `{user_id}`" if member: line += f" ({member.name}#{member.discriminator})" @@ -314,7 +315,7 @@ class TalentPool(Cog, name="Talentpool"): title=f"Nominations for {user.display_name} `({user.id})`", color=Color.blue() ) - lines = [self._nomination_to_string(nomination) for nomination in result] + lines = [await self._nomination_to_string(nomination) for nomination in result] await LinePaginator.paginate( lines, ctx=ctx, @@ -495,13 +496,13 @@ class TalentPool(Cog, name="Talentpool"): return True - def _nomination_to_string(self, nomination_object: dict) -> str: + async def _nomination_to_string(self, nomination_object: dict) -> str: """Creates a string representation of a nomination.""" guild = self.bot.get_guild(Guild.id) entries = [] for site_entry in nomination_object["entries"]: actor_id = site_entry["actor"] - actor = guild.get_member(actor_id) + actor = await get_or_fetch_member(guild, actor_id) reason = site_entry["reason"] or "*None*" created = time.format_infraction(site_entry["inserted_at"]) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index f4aa73e75..14a8dd4c0 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -16,6 +16,7 @@ from discord.ext.commands import Context from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Guild +from bot.utils.members import get_or_fetch_member from bot.utils.messages import count_unique_users_reaction, pin_no_system_message from bot.utils.scheduling import Scheduler from bot.utils.time import get_time_delta, time_since @@ -111,7 +112,7 @@ class Reviewer: return "", None guild = self.bot.get_guild(Guild.id) - member = guild.get_member(user_id) + member = await get_or_fetch_member(guild, user_id) if not member: return ( diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 1030357fd..95f3661af 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -19,6 +19,7 @@ from bot.pagination import LinePaginator from bot.utils import scheduling from bot.utils.checks import has_any_role_check, has_no_roles_check from bot.utils.lock import lock_arg +from bot.utils.members import get_or_fetch_member from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler from bot.utils.time import TimestampFormats, discord_timestamp @@ -136,11 +137,12 @@ class Reminders(Cog): await send_denial(ctx, f"You can't mention other {disallowed_mentions} in your reminder!") return False - def get_mentionables(self, mention_ids: t.List[int]) -> t.Iterator[Mentionable]: + async def get_mentionables(self, mention_ids: t.List[int]) -> t.Iterator[Mentionable]: """Converts Role and Member ids to their corresponding objects if possible.""" guild = self.bot.get_guild(Guild.id) for mention_id in mention_ids: - if mentionable := (guild.get_member(mention_id) or guild.get_role(mention_id)): + member = await get_or_fetch_member(guild, mention_id) + if mentionable := (member or guild.get_role(mention_id)): yield mentionable def schedule_reminder(self, reminder: dict) -> None: @@ -194,9 +196,9 @@ class Reminders(Cog): embed.description = f"Here's your reminder: {reminder['content']}" # Here the jump URL is in the format of base_url/guild_id/channel_id/message_id - additional_mentions = ' '.join( - mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"]) - ) + additional_mentions = ' '.join([ + mentionable.mention async for mentionable in self.get_mentionables(reminder["mentions"]) + ]) jump_url = reminder.get("jump_url") embed.description += f"\n[Jump back to when you created the reminder]({jump_url})" @@ -337,10 +339,10 @@ class Reminders(Cog): remind_datetime = isoparse(remind_at).replace(tzinfo=None) time = discord_timestamp(remind_datetime, TimestampFormats.RELATIVE) - mentions = ", ".join( + mentions = ", ".join([ # Both Role and User objects have the `name` attribute - mention.name for mention in self.get_mentionables(mentions) - ) + mention.name async for mention in self.get_mentionables(mentions) + ]) mention_string = f"\n**Mentions:** {mentions}" if mentions else "" text = textwrap.dedent(f""" -- cgit v1.2.3 From 7a9828baf440155cdba92045b0903a76a18f2b2a Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 21 Sep 2021 18:33:27 +0100 Subject: Mock fetch_member in tests that user get_or_fetch now --- tests/bot/exts/backend/sync/test_users.py | 5 +++++ tests/bot/exts/filters/test_token_remover.py | 13 +++++++------ tests/bot/exts/moderation/infraction/test_infractions.py | 3 +++ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/bot/exts/backend/sync/test_users.py b/tests/bot/exts/backend/sync/test_users.py index 27932be95..88f1b2f52 100644 --- a/tests/bot/exts/backend/sync/test_users.py +++ b/tests/bot/exts/backend/sync/test_users.py @@ -1,6 +1,8 @@ import unittest from unittest import mock +from discord.errors import NotFound + from bot.exts.backend.sync._syncers import UserSyncer, _Diff from tests import helpers @@ -134,6 +136,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): self.get_mock_member(fake_user()), None ] + guild.fetch_member.side_effect = NotFound(mock.Mock(status=404), "Not found") actual_diff = await UserSyncer._get_diff(guild) expected_diff = ([], [{"id": 63, "in_guild": False}], None) @@ -158,6 +161,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): self.get_mock_member(updated_user), None ] + guild.fetch_member.side_effect = NotFound(mock.Mock(status=404), "Not found") actual_diff = await UserSyncer._get_diff(guild) expected_diff = ([new_user], [{"id": 55, "name": "updated"}, {"id": 63, "in_guild": False}], None) @@ -177,6 +181,7 @@ class UserSyncerDiffTests(unittest.IsolatedAsyncioTestCase): self.get_mock_member(fake_user()), None ] + guild.fetch_member.side_effect = NotFound(mock.Mock(status=404), "Not found") actual_diff = await UserSyncer._get_diff(guild) expected_diff = ([], [], None) diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py index 51feae9cb..05e790723 100644 --- a/tests/bot/exts/filters/test_token_remover.py +++ b/tests/bot/exts/filters/test_token_remover.py @@ -295,20 +295,21 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): ) @autospec("bot.exts.filters.token_remover", "UNKNOWN_USER_LOG_MESSAGE") - def test_format_userid_log_message_unknown(self, unknown_user_log_message): + async def test_format_userid_log_message_unknown(self, unknown_user_log_message,): """Should correctly format the user ID portion when the actual user it belongs to is unknown.""" token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") unknown_user_log_message.format.return_value = " Partner" msg = MockMessage(id=555, content="hello world") msg.guild.get_member.return_value = None + msg.guild.fetch_member.side_effect = NotFound(mock.Mock(status=404), "Not found") - return_value = TokenRemover.format_userid_log_message(msg, token) + return_value = await TokenRemover.format_userid_log_message(msg, token) self.assertEqual(return_value, (unknown_user_log_message.format.return_value, False)) unknown_user_log_message.format.assert_called_once_with(user_id=472265943062413332) @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE") - def test_format_userid_log_message_bot(self, known_user_log_message): + async def test_format_userid_log_message_bot(self, known_user_log_message): """Should correctly format the user ID portion when the ID belongs to a known bot.""" token = Token("NDcyMjY1OTQzMDYyNDEzMzMy", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") known_user_log_message.format.return_value = " Partner" @@ -316,7 +317,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): msg.guild.get_member.return_value.__str__.return_value = "Sam" msg.guild.get_member.return_value.bot = True - return_value = TokenRemover.format_userid_log_message(msg, token) + return_value = await TokenRemover.format_userid_log_message(msg, token) self.assertEqual(return_value, (known_user_log_message.format.return_value, True)) @@ -327,12 +328,12 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): ) @autospec("bot.exts.filters.token_remover", "KNOWN_USER_LOG_MESSAGE") - def test_format_log_message_user_token_user(self, user_token_message): + async def test_format_log_message_user_token_user(self, user_token_message): """Should correctly format the user ID portion when the ID belongs to a known user.""" token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") user_token_message.format.return_value = "Partner" - return_value = TokenRemover.format_userid_log_message(self.msg, token) + return_value = await TokenRemover.format_userid_log_message(self.msg, token) self.assertEqual(return_value, (user_token_message.format.return_value, True)) user_token_message.format.assert_called_once_with( diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index f844a9181..aeff734dc 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -3,6 +3,8 @@ import textwrap import unittest from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch +from discord.errors import NotFound + from bot.constants import Event from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction.infractions import Infractions @@ -195,6 +197,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): async def test_voice_unban_user_not_found(self): """Should include info to return dict when user was not found from guild.""" self.guild.get_member.return_value = None + self.guild.fetch_member.side_effect = NotFound(Mock(status=404), "Not found") result = await self.cog.pardon_voice_ban(self.user.id, self.guild) self.assertEqual(result, {"Info": "User was not found in the guild."}) -- cgit v1.2.3 From 6cf9f54c2f2fe51ca08ae44e98dc5dcea97b7ad8 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 21 Sep 2021 20:21:12 +0100 Subject: Rename channel helper to be consistent with other helpers --- bot/exts/help_channels/_channel.py | 4 ++-- bot/exts/help_channels/_cog.py | 6 +++--- bot/utils/channel.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index 0846b28c8..f1bcea171 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -10,7 +10,7 @@ from arrow import Arrow import bot from bot import constants from bot.exts.help_channels import _caches, _message -from bot.utils.channel import try_get_channel +from bot.utils.channel import get_or_fetch_channel log = logging.getLogger(__name__) @@ -133,7 +133,7 @@ async def move_to_bottom(channel: discord.TextChannel, category_id: int, **optio options should be avoided, as it may interfere with the category move we perform. """ # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. - category = await try_get_channel(category_id) + category = await get_or_fetch_channel(category_id) payload = [{"id": c.id, "position": c.position} for c in category.channels] diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index a64ceac3a..7c39bc132 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -278,13 +278,13 @@ class HelpChannels(commands.Cog): log.trace("Getting the CategoryChannel objects for the help categories.") try: - self.available_category = await channel_utils.try_get_channel( + self.available_category = await channel_utils.get_or_fetch_channel( constants.Categories.help_available ) - self.in_use_category = await channel_utils.try_get_channel( + self.in_use_category = await channel_utils.get_or_fetch_channel( constants.Categories.help_in_use ) - self.dormant_category = await channel_utils.try_get_channel( + self.dormant_category = await channel_utils.get_or_fetch_channel( constants.Categories.help_dormant ) except discord.HTTPException: diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 72603c521..6d2356679 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -53,7 +53,7 @@ def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: return getattr(channel, "category_id", None) == category_id -async def try_get_channel(channel_id: int) -> discord.abc.GuildChannel: +async def get_or_fetch_channel(channel_id: int) -> discord.abc.GuildChannel: """Attempt to get or fetch a channel and return it.""" log.trace(f"Getting the channel {channel_id}.") -- cgit v1.2.3 From e52db754d8758458fdbaff41eb2ac5dbaf83c9f7 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 21 Sep 2021 18:35:35 +0100 Subject: Add new poetry tasks for pytest Updated the test task to now run with --ff which runs failed tests from the last run first Added retest, which runs pytest with --lf this only runs the failed tests from the last test run --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 23cbba19b..4431a41c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,8 @@ precommit = "pre-commit install" build = "docker build -t ghcr.io/python-discord/bot:latest -f Dockerfile ." push = "docker push ghcr.io/python-discord/bot:latest" test-nocov = "pytest -n auto" -test = "pytest -n auto --cov-report= --cov" +test = "pytest -n auto --cov-report= --cov --ff" +retest = "pytest -n auto --cov-report= --cov --lf" html = "coverage html" report = "coverage report" -- cgit v1.2.3 From 58098b5121a4ebb06b6f5ed411143ec8d5766266 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 29 Sep 2021 23:14:13 +0100 Subject: python-news escape fixes (#1822) * Fix escapes in python-news posts No longer escapes markdown inside of codeblocks or pre-escaped markdown. Co-authored-by: Ryu18 * Add escaping to title of py-news posts * Fix typo Co-authored-by: Ryu18 --- bot/exts/info/python_news.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index 58dcd3a02..2a8b64f32 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -23,6 +23,14 @@ THREAD_URL = "https://mail.python.org/archives/list/{list}@python.org/thread/{id AVATAR_URL = "https://www.python.org/static/opengraph-icon-200x200.png" +# By first matching everything within a codeblock, +# when matching markdown it won't be within a codeblock +MARKDOWN_REGEX = re.compile( + r"(?P`.*?`)" # matches everything within a codeblock + r"|(?P(? str: - """Escape the markdown underlines and spoilers.""" - return re.sub(r"[_|]", lambda match: "\\" + match[0], content) + """Escape the markdown underlines and spoilers that aren't in codeblocks.""" + return MARKDOWN_REGEX.sub( + lambda match: match.group("codeblock") or "\\" + match.group("markdown"), + content + ) async def post_pep_news(self) -> None: """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" @@ -109,7 +120,7 @@ class PythonNews(Cog): # Build an embed and send a webhook embed = discord.Embed( - title=new["title"], + title=self.escape_markdown(new["title"]), description=self.escape_markdown(new["summary"]), timestamp=new_datetime, url=new["link"], @@ -129,7 +140,7 @@ class PythonNews(Cog): self.bot.stats.incr("python_news.posted.pep") if msg.channel.is_news(): - log.trace("Publishing PEP annnouncement because it was in a news channel") + log.trace("Publishing PEP announcement because it was in a news channel") await msg.publish() # Apply new sent news to DB to avoid duplicate sending @@ -179,7 +190,7 @@ class PythonNews(Cog): # Build an embed and send a message to the webhook embed = discord.Embed( - title=thread_information["subject"], + title=self.escape_markdown(thread_information["subject"]), description=content[:1000] + f"... [continue reading]({link})" if len(content) > 1000 else content, timestamp=new_date, url=link, -- cgit v1.2.3 From eeb70eff84530ca0939ec94e1343b7ad6bd703b7 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 21 Sep 2021 22:53:07 +0100 Subject: Allow helpers to edit their own nomination reason This change will allow helpers to run the edit reason command in the Talentpool cog. To ensure that non-mod helpers can only edit their own reasons the interface for the reason command has been changed slightly. If nominee_or_nomination_id is a member or user, then the command edits the currently active nomination for that person. If it's an int, then use this to look up that nomination ID to edit. If no nominator is specified, assume the invoker is editing their own nomination reason. Otherwise, edit the reason from that specific nominator. Raise a permission error if a non-mod staff member invokes this command on a specific nomination ID, or with an nominator other than themselves. --- bot/exts/recruitment/talentpool/_cog.py | 68 +++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index aaafff973..0b56ea8b8 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -7,12 +7,12 @@ from typing import Optional, Union import discord from async_rediscache import RedisCache from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent -from discord.ext.commands import Cog, Context, group, has_any_role +from discord.ext.commands import BadArgument, Cog, Context, group, has_any_role from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES -from bot.converters import MemberOrUser +from bot.converters import MemberOrUser, UnambiguousMemberOrUser from bot.exts.recruitment.talentpool._review import Reviewer from bot.pagination import LinePaginator from bot.utils import scheduling, time @@ -75,7 +75,7 @@ class TalentPool(Cog, name="Talentpool"): return True @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) - @has_any_role(*MODERATION_ROLES) + @has_any_role(*STAFF_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.send_help(ctx.command) @@ -342,18 +342,70 @@ class TalentPool(Cog, name="Talentpool"): await ctx.send(":x: The specified user does not have an active nomination") @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) - @has_any_role(*MODERATION_ROLES) + @has_any_role(*STAFF_ROLES) async def nomination_edit_group(self, ctx: Context) -> None: """Commands to edit nominations.""" await ctx.send_help(ctx.command) @nomination_edit_group.command(name='reason') - @has_any_role(*MODERATION_ROLES) - async def edit_reason_command(self, ctx: Context, nomination_id: int, actor: MemberOrUser, *, reason: str) -> None: - """Edits the reason of a specific nominator in a specific active nomination.""" + @has_any_role(*STAFF_ROLES) + async def edit_reason_command( + self, + ctx: Context, + nominee_or_nomination_id: Union[UnambiguousMemberOrUser, int], + nominator: Optional[UnambiguousMemberOrUser] = None, + *, + reason: str + ) -> None: + """ + Edit the nomination reason of a specific nominator for a given nomination. + + If nominee_or_nomination_id resolves to a member or user, edit the currently active nomination for that person. + Otherwise, if it's an int, look up that nomination ID to edit. + + If no nominator is specified, assume the invoker is editing their own nomination reason. + Otherwise, edit the reason from that specific nominator. + + Raise a permission error if a non-mod staff member invokes this command on a + specific nomination ID, or with an nominator other than themselves. + """ + # If not specified, assume the invoker is editing their own nomination reason. + nominator = nominator or ctx.author + + if nominator != ctx.author or isinstance(nominee_or_nomination_id, int): + # Invoker has specified another nominator, or a specific nomination id + if not any(role.id in MODERATION_ROLES for role in ctx.author.roles): + raise BadArgument( + "Only moderators can edit specific nomination IDs, " + "or the reason of a nominator other than themselves." + ) + + await self._edit_nomination_reason( + ctx, + target=nominee_or_nomination_id, + actor=nominator, + reason=reason + ) + + async def _edit_nomination_reason( + self, + ctx: Context, + target: Union[int, Member], + actor: MemberOrUser, + reason: str, + ) -> None: + """Edits a nomination reason in the database after validating the input.""" if len(reason) > REASON_MAX_CHARS: - await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.") + await ctx.send(f":x: Maximum allowed characters for the reason is {REASON_MAX_CHARS}.") return + if isinstance(target, Member): + if nomination := self.cache.get(target.id): + nomination_id = nomination["id"] + else: + await ctx.send("No active nomination found for that member.") + return + else: + nomination_id = target try: nomination = await self.bot.api_client.get(f"bot/nominations/{nomination_id}") -- cgit v1.2.3 From 77f76cb85f97e619509d121ba2147c54b8d822e8 Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Wed, 22 Sep 2021 18:59:14 +0100 Subject: Force kwargs when calling helper util Co-authored-by: Bluenix --- bot/exts/recruitment/talentpool/_cog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 0b56ea8b8..70535095d 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -390,11 +390,12 @@ class TalentPool(Cog, name="Talentpool"): async def _edit_nomination_reason( self, ctx: Context, + *, target: Union[int, Member], actor: MemberOrUser, reason: str, ) -> None: - """Edits a nomination reason in the database after validating the input.""" + """Edit a nomination reason in the database after validating the input.""" if len(reason) > REASON_MAX_CHARS: await ctx.send(f":x: Maximum allowed characters for the reason is {REASON_MAX_CHARS}.") return -- cgit v1.2.3 From 2f0b4215f0be46c62ea529f20ccba0ea967dcd21 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sun, 3 Oct 2021 14:51:01 +0100 Subject: Handle channel category being None in antispam/malware checks The code already handled the attribute not existing (e.g from a DM channel), but didn't handle TextChannels not in a category --- bot/exts/filters/antimalware.py | 2 +- bot/exts/filters/antispam.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index 0eedeb0fb..e708e5149 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/antimalware.py @@ -63,7 +63,7 @@ class AntiMalware(Cog): return # Ignore code jam channels - if hasattr(message.channel, "category") and message.channel.category.name == JAM_CATEGORY_NAME: + if getattr(message.channel, "category", None) and message.channel.category.name == JAM_CATEGORY_NAME: return # Check if user is staff, if is, return diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index fe79a5d62..70c1168bf 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -166,7 +166,7 @@ class AntiSpam(Cog): not message.guild or message.guild.id != GuildConfig.id or message.author.bot - or (hasattr(message.channel, "category") and message.channel.category.name == JAM_CATEGORY_NAME) + or (getattr(message.channel, "category", None) and message.channel.category.name == JAM_CATEGORY_NAME) or (message.channel.id in Filter.channel_whitelist and not DEBUG_MODE) or (any(role.id in Filter.role_whitelist for role in message.author.roles) and not DEBUG_MODE) ): -- cgit v1.2.3 From 0bd2f1e8ac7bc3daaad6a0151cec19eaeae6c758 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 3 Oct 2021 17:19:35 +0100 Subject: Block helpers from editing nomination reasons in channels other than #nominations --- bot/exts/recruitment/talentpool/_cog.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 70535095d..98e83f309 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -372,9 +372,13 @@ class TalentPool(Cog, name="Talentpool"): # If not specified, assume the invoker is editing their own nomination reason. nominator = nominator or ctx.author - if nominator != ctx.author or isinstance(nominee_or_nomination_id, int): - # Invoker has specified another nominator, or a specific nomination id - if not any(role.id in MODERATION_ROLES for role in ctx.author.roles): + if not any(role.id in MODERATION_ROLES for role in ctx.author.roles): + if ctx.channel.id != Channels.nominations: + await ctx.send(f":x: Nomination edits must be run in the <#{Channels.nominations}> channel") + return + + if nominator != ctx.author or isinstance(nominee_or_nomination_id, int): + # Invoker has specified another nominator, or a specific nomination id raise BadArgument( "Only moderators can edit specific nomination IDs, " "or the reason of a nominator other than themselves." -- cgit v1.2.3 From d444ebd8c3f9b10e021ecc94da8bbac482a1b236 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 3 Oct 2021 22:57:32 +0100 Subject: Check cache for Members and Users By swapping the isinstance to check for int, the else block now catches the case where target is Member or User, this allows for editting the nomination reason of members that are off server. --- bot/exts/recruitment/talentpool/_cog.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 98e83f309..193be2095 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -6,7 +6,7 @@ from typing import Optional, Union import discord from async_rediscache import RedisCache -from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent +from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent, User from discord.ext.commands import BadArgument, Cog, Context, group, has_any_role from bot.api import ResponseCodeError @@ -395,7 +395,7 @@ class TalentPool(Cog, name="Talentpool"): self, ctx: Context, *, - target: Union[int, Member], + target: Union[int, Member, User], actor: MemberOrUser, reason: str, ) -> None: @@ -403,14 +403,14 @@ class TalentPool(Cog, name="Talentpool"): if len(reason) > REASON_MAX_CHARS: await ctx.send(f":x: Maximum allowed characters for the reason is {REASON_MAX_CHARS}.") return - if isinstance(target, Member): + if isinstance(target, int): + nomination_id = target + else: if nomination := self.cache.get(target.id): nomination_id = nomination["id"] else: await ctx.send("No active nomination found for that member.") return - else: - nomination_id = target try: nomination = await self.bot.api_client.get(f"bot/nominations/{nomination_id}") -- cgit v1.2.3 From 17d100e2207b6b98c7b0cb6d9c378b15b1bb4c4e Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 4 Oct 2021 19:30:15 +0100 Subject: Monkey patch http.send_typing to catch 403s Sometimes discord turns off typing events by throwing 403's, so we should catch those --- bot/__init__.py | 4 +++- bot/typing.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 bot/typing.py diff --git a/bot/__init__.py b/bot/__init__.py index 8f880b8e6..70ff03fd4 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from discord.ext import commands -from bot import log +from bot import log, typing from bot.command import Command if TYPE_CHECKING: @@ -17,6 +17,8 @@ log.setup() if os.name == "nt": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) +typing.patch_typing() + # Monkey-patch discord.py decorators to use the Command subclass which supports root aliases. # Must be patched before any cogs are added. commands.command = partial(commands.command, cls=Command) diff --git a/bot/typing.py b/bot/typing.py new file mode 100644 index 000000000..4b1df3f2f --- /dev/null +++ b/bot/typing.py @@ -0,0 +1,32 @@ +import logging +from datetime import datetime, timedelta + +from discord import Forbidden, http + +log = logging.getLogger(__name__) + + +def patch_typing() -> None: + """ + Sometimes discord turns off typing events by throwing 403's. + + Handle those issues by patching the trigger_typing method so it ignores 403's in general. + """ + log.info("Patching send_typing, which should fix things breaking when discord disables typing events. Stay safe!") + + original = http.HTTPClient.send_typing + last_403 = None + + async def honeybadger_type(self, channel_id: int) -> None: # noqa: ANN001 + nonlocal last_403 + if last_403 and (datetime.now() - last_403) < timedelta(minutes=5): + log.warning("Not sending typing event, we got a 403 less than 5 minutes ago.") + return + try: + await original(self, channel_id) + except Forbidden: + last_403 = datetime.now() + log.warning("Got a 403 from typing event!") + pass + + http.HTTPClient.send_typing = honeybadger_type -- cgit v1.2.3 From 66dea7644d2f0572607105f85d80af9f39398e61 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 4 Oct 2021 19:37:33 +0100 Subject: Move all monkey patches to their own file --- bot/__init__.py | 9 ++++----- bot/command.py | 18 ------------------ bot/monkey_patches.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ bot/typing.py | 32 -------------------------------- 4 files changed, 54 insertions(+), 55 deletions(-) delete mode 100644 bot/command.py create mode 100644 bot/monkey_patches.py delete mode 100644 bot/typing.py diff --git a/bot/__init__.py b/bot/__init__.py index 70ff03fd4..a1c4466f1 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -5,8 +5,7 @@ from typing import TYPE_CHECKING from discord.ext import commands -from bot import log, typing -from bot.command import Command +from bot import log, monkey_patches if TYPE_CHECKING: from bot.bot import Bot @@ -17,11 +16,11 @@ log.setup() if os.name == "nt": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) -typing.patch_typing() +monkey_patches.patch_typing() # Monkey-patch discord.py decorators to use the Command subclass which supports root aliases. # Must be patched before any cogs are added. -commands.command = partial(commands.command, cls=Command) -commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=Command) +commands.command = partial(commands.command, cls=monkey_patches.Command) +commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=monkey_patches.Command) instance: "Bot" = None # Global Bot instance. diff --git a/bot/command.py b/bot/command.py deleted file mode 100644 index 0fb900f7b..000000000 --- a/bot/command.py +++ /dev/null @@ -1,18 +0,0 @@ -from discord.ext import commands - - -class Command(commands.Command): - """ - A `discord.ext.commands.Command` subclass which supports root aliases. - - A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as - top-level commands rather than being aliases of the command's group. It's stored as an attribute - also named `root_aliases`. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.root_aliases = kwargs.get("root_aliases", []) - - if not isinstance(self.root_aliases, (list, tuple)): - raise TypeError("Root aliases of a command must be a list or a tuple of strings.") diff --git a/bot/monkey_patches.py b/bot/monkey_patches.py new file mode 100644 index 000000000..9c0a22bfb --- /dev/null +++ b/bot/monkey_patches.py @@ -0,0 +1,50 @@ +import logging +from datetime import datetime, timedelta + +from discord import Forbidden, http +from discord.ext import commands + +log = logging.getLogger(__name__) + + +class Command(commands.Command): + """ + A `discord.ext.commands.Command` subclass which supports root aliases. + + A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as + top-level commands rather than being aliases of the command's group. It's stored as an attribute + also named `root_aliases`. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.root_aliases = kwargs.get("root_aliases", []) + + if not isinstance(self.root_aliases, (list, tuple)): + raise TypeError("Root aliases of a command must be a list or a tuple of strings.") + + +def patch_typing() -> None: + """ + Sometimes discord turns off typing events by throwing 403's. + + Handle those issues by patching the trigger_typing method so it ignores 403's in general. + """ + log.info("Patching send_typing, which should fix things breaking when discord disables typing events. Stay safe!") + + original = http.HTTPClient.send_typing + last_403 = None + + async def honeybadger_type(self, channel_id: int) -> None: # noqa: ANN001 + nonlocal last_403 + if last_403 and (datetime.now() - last_403) < timedelta(minutes=5): + log.warning("Not sending typing event, we got a 403 less than 5 minutes ago.") + return + try: + await original(self, channel_id) + except Forbidden: + last_403 = datetime.now() + log.warning("Got a 403 from typing event!") + pass + + http.HTTPClient.send_typing = honeybadger_type diff --git a/bot/typing.py b/bot/typing.py deleted file mode 100644 index 4b1df3f2f..000000000 --- a/bot/typing.py +++ /dev/null @@ -1,32 +0,0 @@ -import logging -from datetime import datetime, timedelta - -from discord import Forbidden, http - -log = logging.getLogger(__name__) - - -def patch_typing() -> None: - """ - Sometimes discord turns off typing events by throwing 403's. - - Handle those issues by patching the trigger_typing method so it ignores 403's in general. - """ - log.info("Patching send_typing, which should fix things breaking when discord disables typing events. Stay safe!") - - original = http.HTTPClient.send_typing - last_403 = None - - async def honeybadger_type(self, channel_id: int) -> None: # noqa: ANN001 - nonlocal last_403 - if last_403 and (datetime.now() - last_403) < timedelta(minutes=5): - log.warning("Not sending typing event, we got a 403 less than 5 minutes ago.") - return - try: - await original(self, channel_id) - except Forbidden: - last_403 = datetime.now() - log.warning("Got a 403 from typing event!") - pass - - http.HTTPClient.send_typing = honeybadger_type -- cgit v1.2.3 From 44e5f472d3338d4573f69354f8aa49023063d90a Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 4 Oct 2021 20:42:11 +0100 Subject: Use utcnow() and lower a logging level in patch_typing --- bot/monkey_patches.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/monkey_patches.py b/bot/monkey_patches.py index 9c0a22bfb..4dbdb5eab 100644 --- a/bot/monkey_patches.py +++ b/bot/monkey_patches.py @@ -30,20 +30,20 @@ def patch_typing() -> None: Handle those issues by patching the trigger_typing method so it ignores 403's in general. """ - log.info("Patching send_typing, which should fix things breaking when discord disables typing events. Stay safe!") + log.debug("Patching send_typing, which should fix things breaking when discord disables typing events. Stay safe!") original = http.HTTPClient.send_typing last_403 = None async def honeybadger_type(self, channel_id: int) -> None: # noqa: ANN001 nonlocal last_403 - if last_403 and (datetime.now() - last_403) < timedelta(minutes=5): + if last_403 and (datetime.utcnow() - last_403) < timedelta(minutes=5): log.warning("Not sending typing event, we got a 403 less than 5 minutes ago.") return try: await original(self, channel_id) except Forbidden: - last_403 = datetime.now() + last_403 = datetime.utcnow() log.warning("Got a 403 from typing event!") pass -- cgit v1.2.3 From 269ab7e1a0f35c4ecf14bffba93ab1695841364b Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Mon, 4 Oct 2021 21:00:59 +0100 Subject: Improvements to anti-spam log message - Removed redundant f-string - Made it so that messages with attachments will *always* upload to the log site. - Made it so that truncates messages will also upload to the paste site, for full viewing. - Fixed a previously unnoticed bug where truncating didn't account for extra characters of the ellipses. --- bot/exts/filters/antispam.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 70c1168bf..dc8d24bba 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -83,7 +83,11 @@ class DeletionContext: ) # For multiple messages or those with excessive newlines, use the logs API - if len(self.messages) > 1 or 'newlines' in self.rules: + if any(( + len(self.messages) > 1, + self.messages[0].attachments, + self.messages[0].count('\n') > 15 + )): url = await modlog.upload_log(self.messages.values(), actor_id, self.attachments) mod_alert_message += f"A complete log of the offending messages can be found [here]({url})" else: @@ -93,9 +97,11 @@ class DeletionContext: remaining_chars = 4080 - len(mod_alert_message) if len(content) > remaining_chars: - content = content[:remaining_chars] + "..." + url = await modlog.upload_log([message], actor_id, self.attachments) + log_site_msg = f"The full message can be found [here]({url})" + content = content[:remaining_chars - (3 + len(log_site_msg))] + "..." - mod_alert_message += f"{content}" + mod_alert_message += content *_, last_message = self.messages.values() await modlog.send_log_message( -- cgit v1.2.3 From 74493f202d3478d6159dcfc0dbe4ee27b6334fc6 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Tue, 5 Oct 2021 11:52:32 +0100 Subject: Make attachments check explicit Co-authored-by: Matteo Bertucci --- bot/exts/filters/antispam.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index dc8d24bba..7f4b5a627 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -85,7 +85,7 @@ class DeletionContext: # For multiple messages or those with excessive newlines, use the logs API if any(( len(self.messages) > 1, - self.messages[0].attachments, + len(self.messages[0].attachments) > 0, self.messages[0].count('\n') > 15 )): url = await modlog.upload_log(self.messages.values(), actor_id, self.attachments) -- cgit v1.2.3 From 86881fdde5cb12c3e013acb2aa0adf5c74b5cafe Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 5 Oct 2021 12:37:36 +0000 Subject: avatar.url -> display_avatar.url avatar can apparently be none, which means traceback and a very sad akarys --- bot/exts/filters/antispam.py | 2 +- bot/exts/filters/filtering.py | 4 ++-- bot/exts/filters/token_remover.py | 2 +- bot/exts/filters/webhook_remover.py | 2 +- bot/exts/fun/duck_pond.py | 4 ++-- bot/exts/info/information.py | 2 +- bot/exts/moderation/defcon.py | 2 +- bot/exts/moderation/incidents.py | 4 ++-- bot/exts/moderation/infraction/_scheduler.py | 6 +++--- bot/exts/moderation/infraction/management.py | 2 +- bot/exts/moderation/modlog.py | 12 ++++++------ bot/exts/moderation/watchchannels/_watchchannel.py | 6 +++--- bot/utils/messages.py | 2 +- tests/bot/exts/filters/test_token_remover.py | 4 ++-- tests/bot/exts/info/test_information.py | 2 +- 15 files changed, 28 insertions(+), 28 deletions(-) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 7c4e3e0f5..9d45f4620 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -103,7 +103,7 @@ class DeletionContext: colour=Colour(Colours.soft_red), title="Spam detected!", text=mod_alert_message, - thumbnail=last_message.author.avatar.url, + thumbnail=last_message.author.display_avatar.url, channel_id=Channels.mod_alerts, ping_everyone=AntiSpamConfig.ping_everyone ) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index f29ba4694..cff349e2f 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -226,7 +226,7 @@ class Filtering(Cog): title="Username filtering alert", text=log_string, channel_id=Channels.mod_alerts, - thumbnail=member.avatar.url + thumbnail=member.display_avatar.url ) # Update time when alert sent @@ -386,7 +386,7 @@ class Filtering(Cog): colour=Colour(Colours.soft_red), title=f"{_filter['type'].title()} triggered!", text=message, - thumbnail=msg.author.avatar.url, + thumbnail=msg.author.display_avatar.url, channel_id=Channels.mod_alerts, ping_everyone=ping_everyone, additional_embeds=stats.additional_embeds, diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py index 3c66a70f4..1757a1175 100644 --- a/bot/exts/filters/token_remover.py +++ b/bot/exts/filters/token_remover.py @@ -108,7 +108,7 @@ class TokenRemover(Cog): colour=Colour(Colours.soft_red), title="Token removed!", text=log_message + "\n" + userid_message, - thumbnail=msg.author.avatar.url, + thumbnail=msg.author.display_avatar.url, channel_id=Channels.mod_alerts, ping_everyone=mention_everyone, ) diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index cc639b5fb..2ea1eb1c8 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -63,7 +63,7 @@ class WebhookRemover(Cog): colour=Colour(Colours.soft_red), title="Discord webhook URL removed!", text=message, - thumbnail=msg.author.avatar.url, + thumbnail=msg.author.display_avatar.url, channel_id=Channels.mod_alerts ) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 2b32f7acc..aa8357ab0 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -93,7 +93,7 @@ class DuckPond(Cog): webhook=self.webhook, content=message.clean_content, username=message.author.display_name, - avatar_url=message.author.avatar.url + avatar_url=message.author.display_avatar.url ) if message.attachments: @@ -108,7 +108,7 @@ class DuckPond(Cog): webhook=self.webhook, embed=e, username=message.author.display_name, - avatar_url=message.author.avatar.url + avatar_url=message.author.display_avatar.url ) except discord.HTTPException: log.exception("Failed to send an attachment to the webhook") diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index aa7c72872..7361f3827 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -313,7 +313,7 @@ class Information(Cog): for field_name, field_content in fields: embed.add_field(name=field_name, value=field_content, inline=False) - embed.set_thumbnail(url=user.avatar.url) + embed.set_thumbnail(url=user.display_avatar.url) embed.colour = user.colour if user.colour != Colour.default() else Colour.blurple() return embed diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index f2bb0aa14..ebd0ab499 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -137,7 +137,7 @@ class Defcon(Cog): await self.mod_log.send_log_message( Icons.defcon_denied, Colours.soft_red, "Entry denied", - message, member.avatar.url + message, member.display_avatar.url ) @group(name='defcon', aliases=('dc',), invoke_without_command=True) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index fe7f234be..e2a90ae50 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -93,7 +93,7 @@ async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: di timestamp=datetime.utcnow(), colour=colour, ) - embed.set_footer(text=footer, icon_url=actioned_by.avatar.url) + embed.set_footer(text=footer, icon_url=actioned_by.display_avatar.url) if incident.attachments: attachment = incident.attachments[0] # User-sent messages can only contain one attachment @@ -252,7 +252,7 @@ class Incidents(Cog): await webhook.send( embed=embed, username=sub_clyde(incident.author.name), - avatar_url=incident.author.avatar.url, + avatar_url=incident.author.display_avatar.url, file=attachment_file, ) except Exception: diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 9f130f943..3f3c49852 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -239,7 +239,7 @@ class InfractionScheduler: icon_url=icon, colour=Colours.soft_red, title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}", - thumbnail=user.avatar.url, + thumbnail=user.display_avatar.url, text=textwrap.dedent(f""" Member: {messages.format_user(user)} Actor: {ctx.author.mention}{dm_log_text}{expiry_log_text} @@ -333,7 +333,7 @@ class InfractionScheduler: icon_url=_utils.INFRACTION_ICONS[infr_type][1], colour=Colours.soft_green, title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}", - thumbnail=user.avatar.url, + thumbnail=user.display_avatar.url, text="\n".join(f"{k}: {v}" for k, v in log_text.items()), footer=footer, content=log_content, @@ -450,7 +450,7 @@ class InfractionScheduler: log_title = "expiration failed" if "Failure" in log_text else "expired" user = self.bot.get_user(user_id) - avatar = user.avatar.url if user else None + avatar = user.display_avatar.url if user else None # Move reason to end so when reason is too long, this is not gonna cut out required items. log_text["Reason"] = log_text.pop("Reason") diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 2f6aafa71..722ddfcce 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -197,7 +197,7 @@ class ModManagement(commands.Cog): if user: user_text = messages.format_user(user) - thumbnail = user.avatar.url + thumbnail = user.display_avatar.url else: user_text = f"<@{user_id}>" thumbnail = None diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 5b8b7ef12..811bac135 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -394,7 +394,7 @@ class ModLog(Cog, name="ModLog"): await self.send_log_message( Icons.user_ban, Colours.soft_red, "User banned", format_user(member), - thumbnail=member.avatar.url, + thumbnail=member.display_avatar.url, channel_id=Channels.user_log ) @@ -415,7 +415,7 @@ class ModLog(Cog, name="ModLog"): await self.send_log_message( Icons.sign_in, Colours.soft_green, "User joined", message, - thumbnail=member.avatar.url, + thumbnail=member.display_avatar.url, channel_id=Channels.user_log ) @@ -432,7 +432,7 @@ class ModLog(Cog, name="ModLog"): await self.send_log_message( Icons.sign_out, Colours.soft_red, "User left", format_user(member), - thumbnail=member.avatar.url, + thumbnail=member.display_avatar.url, channel_id=Channels.user_log ) @@ -449,7 +449,7 @@ class ModLog(Cog, name="ModLog"): await self.send_log_message( Icons.user_unban, Colour.blurple(), "User unbanned", format_user(member), - thumbnail=member.avatar.url, + thumbnail=member.display_avatar.url, channel_id=Channels.mod_log ) @@ -515,7 +515,7 @@ class ModLog(Cog, name="ModLog"): colour=Colour.blurple(), title="Member updated", text=message, - thumbnail=after.avatar.url, + thumbnail=after.display_avatar.url, channel_id=Channels.user_log ) @@ -886,7 +886,7 @@ class ModLog(Cog, name="ModLog"): colour=colour, title="Voice state updated", text=message, - thumbnail=member.avatar.url, + thumbnail=member.display_avatar.url, channel_id=Channels.voice_log ) diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index 38a82e8a2..c9d3984ee 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -246,7 +246,7 @@ class WatchChannel(metaclass=CogABCMeta): await self.webhook_send( cleaned_content, username=msg.author.display_name, - avatar_url=msg.author.avatar.url + avatar_url=msg.author.display_avatar.url ) if msg.attachments: @@ -260,7 +260,7 @@ class WatchChannel(metaclass=CogABCMeta): await self.webhook_send( embed=e, username=msg.author.display_name, - avatar_url=msg.author.avatar.url + avatar_url=msg.author.display_avatar.url ) except discord.HTTPException as exc: self.log.exception( @@ -297,7 +297,7 @@ class WatchChannel(metaclass=CogABCMeta): embed = Embed(description=f"{msg.author.mention} {message_jump}") embed.set_footer(text=textwrap.shorten(footer, width=256, placeholder="...")) - await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar.url) + await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.display_avatar.url) async def list_watched_users( self, ctx: Context, oldest_first: bool = False, update_cache: bool = True diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 913dd72d4..f4aef7eb3 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -121,7 +121,7 @@ async def send_attachments( """ webhook_send_kwargs = { 'username': message.author.display_name, - 'avatar_url': message.author.avatar.url, + 'avatar_url': message.author.display_avatar.url, } webhook_send_kwargs.update(kwargs) webhook_send_kwargs['username'] = sub_clyde(webhook_send_kwargs['username']) diff --git a/tests/bot/exts/filters/test_token_remover.py b/tests/bot/exts/filters/test_token_remover.py index 47aae97c4..ed6c77c4a 100644 --- a/tests/bot/exts/filters/test_token_remover.py +++ b/tests/bot/exts/filters/test_token_remover.py @@ -26,7 +26,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.msg.guild.get_member.return_value.bot = False self.msg.guild.get_member.return_value.__str__.return_value = "Woody" self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) - self.msg.author.avatar.url = "picture-lemon.png" + self.msg.author.display_avatar.url = "picture-lemon.png" def test_extract_user_id_valid(self): """Should consider user IDs valid if they decode into an integer ID.""" @@ -375,7 +375,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): colour=Colour(constants.Colours.soft_red), title="Token removed!", text=log_msg + "\n" + userid_log_message, - thumbnail=self.msg.author.avatar.url, + thumbnail=self.msg.author.display_avatar.url, channel_id=constants.Channels.mod_alerts, ping_everyone=True, ) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 2e992c13b..4b50c3fd9 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -435,7 +435,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): ctx = helpers.MockContext() user = helpers.MockMember(id=217, colour=0) - user.avatar.url = "avatar url" + user.display_avatar.url = "avatar url" embed = await self.cog.create_user_embed(ctx, user) self.assertEqual(embed.thumbnail.url, "avatar url") -- cgit v1.2.3 From 62ec6f6c52921854e00e0af71490f050adce0749 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Tue, 5 Oct 2021 14:00:10 +0100 Subject: Fix if statement error Adds `messages_as_list` for easy indexing of `self.messages.values()` Also updated the comment above if statement and tidied some code --- bot/exts/filters/antispam.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 7f4b5a627..3b30817df 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -46,7 +46,6 @@ RULE_FUNCTION_MAPPING = { @dataclass class DeletionContext: """Represents a Deletion Context for a single spam event.""" - members: frozenset[Member] triggered_in: TextChannel channels: set[TextChannel] = field(default_factory=set) @@ -82,34 +81,34 @@ class DeletionContext: f"**Rules:** {', '.join(rule for rule in self.rules)}\n" ) - # For multiple messages or those with excessive newlines, use the logs API + messages_as_list = list(self.messages.values()) + first_message = messages_as_list[0] + # For multiple messages and those with attachments or excessive newlines, use the logs API if any(( - len(self.messages) > 1, - len(self.messages[0].attachments) > 0, - self.messages[0].count('\n') > 15 + len(messages_as_list) > 1, + len(first_message.attachments) > 0, + first_message.content.count('\n') > 15 )): url = await modlog.upload_log(self.messages.values(), actor_id, self.attachments) mod_alert_message += f"A complete log of the offending messages can be found [here]({url})" else: mod_alert_message += "Message:\n" - [message] = self.messages.values() - content = message.clean_content + content = first_message.clean_content remaining_chars = 4080 - len(mod_alert_message) if len(content) > remaining_chars: - url = await modlog.upload_log([message], actor_id, self.attachments) + url = await modlog.upload_log([first_message], actor_id, self.attachments) log_site_msg = f"The full message can be found [here]({url})" content = content[:remaining_chars - (3 + len(log_site_msg))] + "..." mod_alert_message += content - *_, last_message = self.messages.values() await modlog.send_log_message( icon_url=Icons.filtering, colour=Colour(Colours.soft_red), title="Spam detected!", text=mod_alert_message, - thumbnail=last_message.author.avatar_url_as(static_format="png"), + thumbnail=first_message.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, ping_everyone=AntiSpamConfig.ping_everyone ) -- cgit v1.2.3 From 85c30b572c77adc58f60cc96d42f523547f52820 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 5 Oct 2021 13:03:14 +0000 Subject: Defcon: ignore create thread perms --- bot/exts/moderation/defcon.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index ebd0ab499..f7697b3e8 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -188,8 +188,6 @@ class Defcon(Cog): permissions.update( send_messages=False, add_reactions=False, - create_private_threads=False, - create_public_threads=False, send_messages_in_threads=False, connect=False ) @@ -206,8 +204,6 @@ class Defcon(Cog): permissions.update( send_messages=True, add_reactions=True, - create_private_threads=None, - create_public_threads=None, send_messages_in_threads=True, connect=True ) -- cgit v1.2.3 From 1d886bb3b0c488f7681e8b74f88000e9ce4be50d Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 5 Oct 2021 13:08:32 +0000 Subject: Test incidents: correct avatar reference --- tests/bot/exts/moderation/test_incidents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index a356e245f..583cb8bb3 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -379,7 +379,7 @@ class TestArchive(TestIncidents): # Define our own `incident` to be archived incident = MockMessage( content="this is an incident", - author=MockUser(name="author_name", avatar=Mock(url="author_avatar")), + author=MockUser(name="author_name", display_avatar=Mock(url="author_avatar")), id=123, ) built_embed = MagicMock(discord.Embed, id=123) # We patch `make_embed` to return this -- cgit v1.2.3 From 025efcde7c5c478ecdc91ecd4c73f13bdfa6bdad Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Tue, 5 Oct 2021 16:17:20 +0100 Subject: Fix linting Reverted accidental line deletion --- bot/exts/filters/antispam.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 3b30817df..8bae159d2 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -46,6 +46,7 @@ RULE_FUNCTION_MAPPING = { @dataclass class DeletionContext: """Represents a Deletion Context for a single spam event.""" + members: frozenset[Member] triggered_in: TextChannel channels: set[TextChannel] = field(default_factory=set) -- cgit v1.2.3 From a9b02a5a85e0f558a0d9f0a2d0f0639045a421e5 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 5 Oct 2021 22:36:47 +0100 Subject: Use `isinstance` instead of `hasattr` to determine if `Member` --- bot/exts/moderation/infraction/infractions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 93a6b6c8b..fd47ff1c7 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -344,7 +344,7 @@ class Infractions(InfractionScheduler, commands.Cog): Will also remove the banned user from the Big Brother watch list if applicable. """ - if hasattr(user, 'top_role') and user.top_role >= ctx.me.top_role: + if isinstance(user, Member) and user.top_role >= ctx.me.top_role: await ctx.send(":x: I can't ban users above or equal to me in the role hierarchy.") return @@ -527,7 +527,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def cog_command_error(self, ctx: Context, error: Exception) -> None: """Send a notification to the invoking context on a Union failure.""" if isinstance(error, commands.BadUnionArgument): - if discord.User in error.converters or discord.Member in error.converters: + if discord.User in error.converters or Member in error.converters: await ctx.send(str(error.errors[0])) error.handled = True -- cgit v1.2.3 From c3eccadc9fbdc19b80afa0935c359775261318b3 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 6 Oct 2021 15:16:58 +0200 Subject: Revert "Apply infractions before DMing" --- bot/exts/moderation/infraction/_scheduler.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index ddd4c6366..8e844822d 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -149,19 +149,22 @@ class InfractionScheduler: else: expiry_msg = f" until {expiry}" if expiry else " permanently" - dm_result = constants.Emojis.failmail - dm_log_text = "\nDM: **Failed**" + dm_result = "" + dm_log_text = "" expiry_log_text = f"\nExpires: {expiry}" if expiry else "" log_title = "applied" log_content = None failed = False - # DM the user about the infraction if it's a ban/kick and not a shadow/hidden infraction. + # DM the user about the infraction if it's not a shadow/hidden infraction. # This needs to happen before we apply the infraction, as the bot cannot # send DMs to user that it doesn't share a guild with. If we were to # apply kick/ban infractions first, this would mean that we'd make it # impossible for us to deliver a DM. See python-discord/bot#982. - if not infraction["hidden"] and infr_type in ("ban", "kick"): + if not infraction["hidden"]: + dm_result = f"{constants.Emojis.failmail} " + dm_log_text = "\nDM: **Failed**" + # Accordingly display whether the user was successfully notified via DM. if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon): dm_result = ":incoming_envelope: " @@ -223,13 +226,7 @@ class InfractionScheduler: log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.") infr_message = "" else: - log.info(f"Applied {purge}{infr_type} infraction #{id_} to {user}.") infr_message = f" **{purge}{' '.join(infr_type.split('_'))}** to {user.mention}{expiry_msg}{end_msg}" - if infr_type not in ("ban", "kick"): # If we haven't already tried to send the DM - # Accordingly display whether the user was successfully notified via DM. - if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon): - dm_result = ":incoming_envelope: " - dm_log_text = "\nDM: Sent" # Send a confirmation message to the invoking context. log.trace(f"Sending infraction #{id_} confirmation message.") @@ -253,6 +250,7 @@ class InfractionScheduler: footer=f"ID {infraction['id']}" ) + log.info(f"Applied {purge}{infr_type} infraction #{id_} to {user}.") return not failed async def pardon_infraction( -- cgit v1.2.3 From acc3e6f15942f39d402764ffd308cd2b21476150 Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 6 Oct 2021 14:32:45 +0100 Subject: Attempt infraction before DMing Prevents users getting sent an infraction message when the infraction then fails. This time it won't DM for notes :p --- bot/exts/moderation/infraction/_scheduler.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index ddd4c6366..73403f10b 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -149,8 +149,8 @@ class InfractionScheduler: else: expiry_msg = f" until {expiry}" if expiry else " permanently" - dm_result = constants.Emojis.failmail - dm_log_text = "\nDM: **Failed**" + dm_result = "" + dm_log_text = "" expiry_log_text = f"\nExpires: {expiry}" if expiry else "" log_title = "applied" log_content = None @@ -166,6 +166,9 @@ class InfractionScheduler: if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon): dm_result = ":incoming_envelope: " dm_log_text = "\nDM: Sent" + else: + dm_result = f"{constants.Emojis.failmail} " + dm_log_text = "\nDM: **Failed**" end_msg = "" if infraction["actor"] == self.bot.user.id: @@ -225,11 +228,16 @@ class InfractionScheduler: else: log.info(f"Applied {purge}{infr_type} infraction #{id_} to {user}.") infr_message = f" **{purge}{' '.join(infr_type.split('_'))}** to {user.mention}{expiry_msg}{end_msg}" - if infr_type not in ("ban", "kick"): # If we haven't already tried to send the DM + + # If we need to DM and haven't already tried to + if not infraction["hidden"] and infr_type not in ("ban", "kick"): # Accordingly display whether the user was successfully notified via DM. if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon): dm_result = ":incoming_envelope: " dm_log_text = "\nDM: Sent" + else: + dm_result = f"{constants.Emojis.failmail} " + dm_log_text = "\nDM: **Failed**" # Send a confirmation message to the invoking context. log.trace(f"Sending infraction #{id_} confirmation message.") -- cgit v1.2.3 From 53e3d2267a71779787f8a9cb23806947beb26918 Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 6 Oct 2021 15:09:08 +0100 Subject: Distinguish between note/warning and expired infraction in error message --- bot/exts/moderation/infraction/management.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 0cb2a8b60..dd44f7dd0 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -141,10 +141,11 @@ class ModManagement(commands.Cog): log_text = "" if duration is not None and not infraction['active']: - if reason is None: + if (infr_type := infraction['type']) in ('note', 'warning'): + await ctx.send(f":x: Cannot edit the expiration of a {infr_type}.") + else: await ctx.send(":x: Cannot edit the expiration of an expired infraction.") - return - confirm_messages.append("expiry unchanged (infraction already expired)") + return elif isinstance(duration, str): request_data['expires_at'] = None confirm_messages.append("marked as permanent") -- cgit v1.2.3 From b40459a716c01c4d63a84ac886eab8884aeffefb Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 6 Oct 2021 22:11:45 +0100 Subject: Add appeal categories to mod categories This allows us to run moderation commands in the appeal categories --- config-default.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config-default.yml b/config-default.yml index 3405934e0..57bfc2687 100644 --- a/config-default.yml +++ b/config-default.yml @@ -144,6 +144,8 @@ guild: logs: &LOGS 468520609152892958 moderators: &MODS_CATEGORY 749736277464842262 modmail: &MODMAIL 714494672835444826 + appeals: &APPEALS 890331800025563216 + appeals2: &APPEALS2 895417395261341766 voice: 356013253765234688 summer_code_jam: 861692638540857384 @@ -238,6 +240,8 @@ guild: - *MODS_CATEGORY - *MODMAIL - *LOGS + - *APPEALS + - *APPEALS2 moderation_channels: - *ADMINS -- cgit v1.2.3 From a84cab132cc4de899aaf0a804d8bb11f0e3857a8 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Tue, 8 Jun 2021 00:34:28 +0200 Subject: Kill subdomains from configuration file. This will need a coordinated rollout with @jb3, in order to make the appropriate changes in Kubernetes beforehand. --- config-default.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config-default.yml b/config-default.yml index 3405934e0..943463bc0 100644 --- a/config-default.yml +++ b/config-default.yml @@ -357,14 +357,14 @@ urls: connect_max_retries: 3 connect_cooldown: 5 site: &DOMAIN "pythondiscord.com" - site_api: &API "pydis-api.default.svc.cluster.local" + site_api: &API "site.default.svc.cluster.local/api" site_api_schema: "http://" site_paste: &PASTE !JOIN ["paste.", *DOMAIN] site_schema: &SCHEMA "https://" - site_staff: &STAFF !JOIN ["staff.", *DOMAIN] + site_staff: &STAFF !JOIN [*SCHEMA, *DOMAIN, "/staff"] paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"] - site_logs_view: !JOIN [*SCHEMA, *STAFF, "/bot/logs"] + site_logs_view: !JOIN [*STAFF, "/bot/logs"] # Snekbox snekbox_eval_api: "http://snekbox.default.svc.cluster.local/eval" -- cgit v1.2.3 From a1dd2a942fb3a521dcb4c199ebfb3ea368649b10 Mon Sep 17 00:00:00 2001 From: Izan Date: Fri, 8 Oct 2021 10:07:11 +0100 Subject: Bluenix Review #4 --- bot/resources/tags/async-await.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/resources/tags/async-await.md b/bot/resources/tags/async-await.md index e945240ba..52d3e47e0 100644 --- a/bot/resources/tags/async-await.md +++ b/bot/resources/tags/async-await.md @@ -13,7 +13,7 @@ async def main(): ``` Which means we can call `await something_awaitable()` directly from within the function. If this were a non-async function, it would raise the exception `SyntaxError: 'await' outside async function` -To run the top level async function from outside the event loop we need to use `[asyncio.run()`](https://docs.python.org/3/library/asyncio-task.html#asyncio.run), like this: +To run the top level async function from outside the event loop we need to use [`asyncio.run()`](https://docs.python.org/3/library/asyncio-task.html#asyncio.run), like this: ```py import asyncio @@ -22,6 +22,6 @@ async def main(): asyncio.run(main()) ``` -Note that in the `asyncio.run()` where we appear to be calling `main()`, this does not execute the code in `main`, rather it returns a `coroutine` object which is then handled and run by the event loop via `asyncio.run`. +Note that in the `asyncio.run()`, where we appear to be calling `main()`, this does not execute the code in `main`. Rather, it creates and returns a new `coroutine` object (i.e `mmain() is not main()`) which is then handled and run by the event loop via `asyncio.run()`. To learn more about asyncio and its use, see the [asyncio documentation](https://docs.python.org/3/library/asyncio.html). -- cgit v1.2.3 From d63406c6b96093d74799a4e7cb681d3b94abf3c8 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 8 Oct 2021 11:18:06 +0100 Subject: Fix typo Co-authored-by: Bluenix --- bot/resources/tags/async-await.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/async-await.md b/bot/resources/tags/async-await.md index 52d3e47e0..01ab28fe3 100644 --- a/bot/resources/tags/async-await.md +++ b/bot/resources/tags/async-await.md @@ -22,6 +22,6 @@ async def main(): asyncio.run(main()) ``` -Note that in the `asyncio.run()`, where we appear to be calling `main()`, this does not execute the code in `main`. Rather, it creates and returns a new `coroutine` object (i.e `mmain() is not main()`) which is then handled and run by the event loop via `asyncio.run()`. +Note that in the `asyncio.run()`, where we appear to be calling `main()`, this does not execute the code in `main`. Rather, it creates and returns a new `coroutine` object (i.e `main() is not main()`) which is then handled and run by the event loop via `asyncio.run()`. To learn more about asyncio and its use, see the [asyncio documentation](https://docs.python.org/3/library/asyncio.html). -- cgit v1.2.3 From 016f65b4d450ff538a2b5deb21f8f03d678dac84 Mon Sep 17 00:00:00 2001 From: Richard Si <63936253+ichard26@users.noreply.github.com> Date: Sun, 10 Oct 2021 13:41:24 -0400 Subject: Handle 400 when setting pre-existing doc package If you run, for example: !doc setdoc black https://black.readthedocs/en/stable/objects.inv twice over. You'll get an unhelpful "According to the API, your request is malformed." error message back. This commit adds an error handler to catch the HTTP 400 and tell the user the package already exists. --- bot/exts/info/doc/_cog.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index e7710db24..7d63bc9a9 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -13,6 +13,7 @@ import aiohttp import discord from discord.ext import commands +from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import MODERATION_ROLES, RedirectOutput from bot.converters import Inventory, PackageName, ValidURL, allowed_strings @@ -395,7 +396,14 @@ class DocCog(commands.Cog): "base_url": base_url, "inventory_url": inventory_url } - await self.bot.api_client.post("bot/documentation-links", json=body) + try: + await self.bot.api_client.post("bot/documentation-links", json=body) + except ResponseCodeError as err: + if err.status == 400 and "already exists" in err.response_json.get("package", [""])[0]: + log.info(f"Ignoring HTTP 400 as package {package_name} has already been added.") + await ctx.send(f"Package {package_name} has already already added.") + return + raise log.info( f"User @{ctx.author} ({ctx.author.id}) added a new documentation package:\n" -- cgit v1.2.3 From 1df1527401169479a2e72d1a3b1838907954a73c Mon Sep 17 00:00:00 2001 From: Izan Date: Sun, 10 Oct 2021 11:02:46 +0100 Subject: Remove redundant `else` clauses --- bot/exts/moderation/infraction/_scheduler.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 6f1af86d3..f6fa5a3c3 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -165,13 +165,10 @@ class InfractionScheduler: dm_result = f"{constants.Emojis.failmail} " dm_log_text = "\nDM: **Failed**" - # Accordingly display whether the user was successfully notified via DM. + # Accordingly update whether the user was successfully notified via DM. if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon): dm_result = ":incoming_envelope: " dm_log_text = "\nDM: Sent" - else: - dm_result = f"{constants.Emojis.failmail} " - dm_log_text = "\nDM: **Failed**" end_msg = "" if infraction["actor"] == self.bot.user.id: @@ -233,13 +230,13 @@ class InfractionScheduler: # If we need to DM and haven't already tried to if not infraction["hidden"] and infr_type not in ("ban", "kick"): - # Accordingly display whether the user was successfully notified via DM. + dm_result = f"{constants.Emojis.failmail} " + dm_log_text = "\nDM: **Failed**" + + # Accordingly update whether the user was successfully notified via DM. if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon): dm_result = ":incoming_envelope: " dm_log_text = "\nDM: Sent" - else: - dm_result = f"{constants.Emojis.failmail} " - dm_log_text = "\nDM: **Failed**" # Send a confirmation message to the invoking context. log.trace(f"Sending infraction #{id_} confirmation message.") -- cgit v1.2.3 From 72c843e73831406e013b7478d4c12d08ec27021b Mon Sep 17 00:00:00 2001 From: Richard Si <63936253+ichard26@users.noreply.github.com> Date: Sun, 10 Oct 2021 17:08:34 -0400 Subject: Fix typo in bot/exts/info/doc/_cog.py it only took 5 people looking through the diff ... Co-authored-by: Bluenix --- bot/exts/info/doc/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 7d63bc9a9..ca6af946b 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -401,7 +401,7 @@ class DocCog(commands.Cog): except ResponseCodeError as err: if err.status == 400 and "already exists" in err.response_json.get("package", [""])[0]: log.info(f"Ignoring HTTP 400 as package {package_name} has already been added.") - await ctx.send(f"Package {package_name} has already already added.") + await ctx.send(f"Package {package_name} has already been added.") return raise -- cgit v1.2.3 From e9c2bcce3e00e88ccff35885e50c4ed3ecbd9e0f Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Wed, 3 Mar 2021 18:03:44 +0530 Subject: Send webhook embed containing information about the message if there is a message link in the incident report --- bot/constants.py | 1 + bot/exts/moderation/incidents.py | 73 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index f99913b17..33c911874 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -481,6 +481,7 @@ class Webhooks(metaclass=YAMLGetter): big_brother: int dev_log: int duck_pond: int + incidents: int incidents_archive: int diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index a3d90e3fe..0d63ef34f 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -1,16 +1,18 @@ import asyncio import logging +import re import typing as t from datetime import datetime from enum import Enum import discord +from async_rediscache import RedisCache from discord.ext.commands import Cog from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Guild, Webhooks from bot.utils import scheduling -from bot.utils.messages import sub_clyde +from bot.utils.messages import format_user, sub_clyde log = logging.getLogger(__name__) @@ -22,6 +24,10 @@ CRAWL_LIMIT = 50 # Seconds for `crawl_task` to sleep after adding reactions to a message CRAWL_SLEEP = 2 +DISCORD_MESSAGE_LINK_RE = re.compile( + r"discord(?:[\.,]|dot)com(?:\/|slash)channels(?:\/|slash)[0-9]{18}(?:\/|slash)[0-9]{18}(?:\/|slash)[0-9]{18}" +) + class Signal(Enum): """ @@ -114,9 +120,9 @@ def is_incident(message: discord.Message) -> bool: """True if `message` qualifies as an incident, False otherwise.""" conditions = ( message.channel.id == Channels.incidents, # Message sent in #incidents - not message.author.bot, # Not by a bot - not message.content.startswith("#"), # Doesn't start with a hash - not message.pinned, # And isn't header + not message.author.bot, # Not by a bot + not message.content.startswith("#"), # Doesn't start with a hash + not message.pinned, # And isn't header ) return all(conditions) @@ -131,6 +137,32 @@ def has_signals(message: discord.Message) -> bool: return ALL_SIGNALS.issubset(own_reactions(message)) +async def make_message_link_embed(incident: discord.Message, message_link: str) -> discord.Embed: + """ + Create an embed representation of discord message link contained in the incident report. + + The Embed would contain the following information --> + Author: @Jason Terror ♦ (736234578745884682) + Channel: Special/#bot-commands (814190307980607493) + Content: This is a very important message! + """ + channel_id = int(message_link.split("/")[3]) + msg_id = int(message_link.split("/")[4]) + + channel = incident.guild.get_channel(channel_id) + message = await channel.fetch_message(msg_id) + + text = message.content + channel = message.channel + description = ( + f"**Author:** {format_user(message.author)}\n" + f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" + f"**Content:** {text[:2045] + '...' if len(text) > 2048 else text}\n" + "\n" + ) + return discord.Embed(description=description) + + async def add_signals(incident: discord.Message) -> None: """ Add `Signal` member emoji to `incident` as reactions. @@ -186,6 +218,10 @@ class Incidents(Cog): Please refer to function docstrings for implementation details. """ + # This dictionary maps a incident message to the message link embeds(s) sent by it + # RedisCache[discord.Message.id, List[discord.Message.id]] + message_link_embeds_cache = RedisCache() + def __init__(self, bot: Bot) -> None: """Prepare `event_lock` and schedule `crawl_task` on start-up.""" self.bot = bot @@ -340,6 +376,12 @@ class Incidents(Cog): else: log.trace("Deletion was confirmed") + log.trace("Deleting discord links webhook message.") + webhook_msg_id = await self.message_link_embeds_cache.get(incident.id) + webhook = await self.bot.fetch_webhook(Webhooks.incidents) + await webhook.delete_message(webhook_msg_id) + log.trace("Successfully deleted discord links webhook message.") + async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: """ Get `discord.Message` for `message_id` from cache, or API. @@ -421,6 +463,29 @@ class Incidents(Cog): async def on_message(self, message: discord.Message) -> None: """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" if is_incident(message): + message_links = DISCORD_MESSAGE_LINK_RE.findall(message.content) + if message_links: + + embeds = [] + for message_link in message_links: + embeds.append( + await make_message_link_embed(message, message_link) + ) + + try: + webhook = await self.bot.fetch_webhook(Webhooks.incidents) + webhook_msg = await webhook.send( + embeds=embeds, + username=sub_clyde(message.author.name), + avatar_url=message.author.avatar_url, + wait=True + ) + except Exception: + log.exception(f"Failed to send message link embeds {message.id} to #incidents") + else: + log.trace("Message Link Embeds Sent successfully!") + await self.message_link_embeds_cache.set(message.id, webhook_msg.id) + await add_signals(message) -- cgit v1.2.3 From a2958fb30320b6f956a14a5f4669af527f97a523 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Wed, 3 Mar 2021 18:08:54 +0530 Subject: Add incidents webhook to default config template --- config-default.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config-default.yml b/config-default.yml index d77eacc7e..70e972086 100644 --- a/config-default.yml +++ b/config-default.yml @@ -308,6 +308,7 @@ guild: big_brother: 569133704568373283 dev_log: 680501655111729222 duck_pond: 637821475327311927 + incidents: 816650601844572212 incidents_archive: 720671599790915702 python_news: &PYNEWS_WEBHOOK 704381182279942324 -- cgit v1.2.3 From c32aee329016a8f9d9947d75a52ac26f8d90029c Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Thu, 4 Mar 2021 13:47:53 +0530 Subject: Send multiple webhook messages in case of more than 10 message links --- bot/exts/moderation/incidents.py | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 0d63ef34f..10a1f5fbd 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -8,6 +8,7 @@ from enum import Enum import discord from async_rediscache import RedisCache from discord.ext.commands import Cog +from more_itertools.recipes import grouper from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Guild, Webhooks @@ -377,9 +378,14 @@ class Incidents(Cog): log.trace("Deletion was confirmed") log.trace("Deleting discord links webhook message.") - webhook_msg_id = await self.message_link_embeds_cache.get(incident.id) + webhook_msg_ids = await self.message_link_embeds_cache.get(incident.id) + webhook_msg_ids = webhook_msg_ids.split(',') webhook = await self.bot.fetch_webhook(Webhooks.incidents) - await webhook.delete_message(webhook_msg_id) + + for x, msg in enumerate(webhook_msg_ids): + await webhook.delete_message(msg) + log.trace(f"Deleted discord links webhook message{x}/{len(webhook_msg_ids)}") + log.trace("Successfully deleted discord links webhook message.") async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: @@ -474,17 +480,24 @@ class Incidents(Cog): try: webhook = await self.bot.fetch_webhook(Webhooks.incidents) - webhook_msg = await webhook.send( - embeds=embeds, - username=sub_clyde(message.author.name), - avatar_url=message.author.avatar_url, - wait=True - ) + webhook_embed_list = list(grouper(embeds, 10)) + webhook_msg_ids = [] + + for x, embed in enumerate(webhook_embed_list): + webhook_msg = await webhook.send( + embeds=[x for x in embed if x is not None], + username=sub_clyde(message.author.name), + avatar_url=message.author.avatar_url, + wait=True + ) + webhook_msg_ids.append(webhook_msg.id) + log.trace(f"Message Link Embed {x+1}/{len(webhook_embed_list)} Sent Succesfully") + except Exception: log.exception(f"Failed to send message link embeds {message.id} to #incidents") else: + await self.message_link_embeds_cache.set(message.id, ','.join(map(str, webhook_msg_ids))) log.trace("Message Link Embeds Sent successfully!") - await self.message_link_embeds_cache.set(message.id, webhook_msg.id) await add_signals(message) -- cgit v1.2.3 From 7c4fdbaf49202c1b0b7340f3c53c88da2fb88330 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Thu, 4 Mar 2021 16:44:21 +0530 Subject: Use MessageConverter to find messages --- bot/exts/moderation/incidents.py | 60 +++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 10a1f5fbd..198224b83 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -7,10 +7,10 @@ from enum import Enum import discord from async_rediscache import RedisCache -from discord.ext.commands import Cog +from discord.ext.commands import Cog, Context, MessageConverter from more_itertools.recipes import grouper -from bot.bot import Bot +from bot import bot from bot.constants import Channels, Colours, Emojis, Guild, Webhooks from bot.utils import scheduling from bot.utils.messages import format_user, sub_clyde @@ -26,7 +26,8 @@ CRAWL_LIMIT = 50 CRAWL_SLEEP = 2 DISCORD_MESSAGE_LINK_RE = re.compile( - r"discord(?:[\.,]|dot)com(?:\/|slash)channels(?:\/|slash)[0-9]{18}(?:\/|slash)[0-9]{18}(?:\/|slash)[0-9]{18}" + r"http(?:s):\/\/discord(?:[\.,]|dot)com(?:\/|slash)channels(?:\/|slash)[0-9]{18}(?:\/|slash)[0-9]{18}" + r"(?:\/|slash)[0-9]{18}" ) @@ -138,7 +139,7 @@ def has_signals(message: discord.Message) -> bool: return ALL_SIGNALS.issubset(own_reactions(message)) -async def make_message_link_embed(incident: discord.Message, message_link: str) -> discord.Embed: +async def make_message_link_embed(ctx: Context, message_link: str) -> discord.Embed: """ Create an embed representation of discord message link contained in the incident report. @@ -147,21 +148,28 @@ async def make_message_link_embed(incident: discord.Message, message_link: str) Channel: Special/#bot-commands (814190307980607493) Content: This is a very important message! """ - channel_id = int(message_link.split("/")[3]) - msg_id = int(message_link.split("/")[4]) - - channel = incident.guild.get_channel(channel_id) - message = await channel.fetch_message(msg_id) - - text = message.content - channel = message.channel - description = ( - f"**Author:** {format_user(message.author)}\n" - f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" - f"**Content:** {text[:2045] + '...' if len(text) > 2048 else text}\n" - "\n" - ) - return discord.Embed(description=description) + embed = discord.Embed() + + try: + message_convert_object = MessageConverter() + message = await message_convert_object.convert(ctx, message_link) + + except Exception as e: + embed.title = f"{e}" + embed.colour = Colours.soft_red + + else: + text = message.content + channel = message.channel + + embed.description = ( + f"**Author:** {format_user(message.author)}\n" + f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" + f"**Content:** {text[:2045] + '...' if len(text) > 2048 else text}\n" + "\n" + ) + + return embed async def add_signals(incident: discord.Message) -> None: @@ -223,7 +231,7 @@ class Incidents(Cog): # RedisCache[discord.Message.id, List[discord.Message.id]] message_link_embeds_cache = RedisCache() - def __init__(self, bot: Bot) -> None: + def __init__(self, bot: bot.Bot) -> None: """Prepare `event_lock` and schedule `crawl_task` on start-up.""" self.bot = bot @@ -470,13 +478,13 @@ class Incidents(Cog): """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" if is_incident(message): message_links = DISCORD_MESSAGE_LINK_RE.findall(message.content) + print(message_links) if message_links: embeds = [] for message_link in message_links: - embeds.append( - await make_message_link_embed(message, message_link) - ) + ctx = await self.bot.get_context(message) + embeds.append(await make_message_link_embed(ctx, message_link)) try: webhook = await self.bot.fetch_webhook(Webhooks.incidents) @@ -491,17 +499,19 @@ class Incidents(Cog): wait=True ) webhook_msg_ids.append(webhook_msg.id) - log.trace(f"Message Link Embed {x+1}/{len(webhook_embed_list)} Sent Succesfully") + log.trace(f"Message Link Embed {x + 1}/{len(webhook_embed_list)} Sent Succesfully") except Exception: log.exception(f"Failed to send message link embeds {message.id} to #incidents") + else: await self.message_link_embeds_cache.set(message.id, ','.join(map(str, webhook_msg_ids))) log.trace("Message Link Embeds Sent successfully!") + log.trace(f"Skipping discord message link detection on {message.id}: message doesn't qualify.") await add_signals(message) -def setup(bot: Bot) -> None: +def setup(bot: bot.Bot) -> None: """Load the Incidents cog.""" bot.add_cog(Incidents(bot)) -- cgit v1.2.3 From d52127300289d1e054c931cc3493e239f914cf27 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Thu, 4 Mar 2021 16:46:47 +0530 Subject: Use str() when checking for message.content --- 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 198224b83..9ee1407d4 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -477,7 +477,7 @@ class Incidents(Cog): async def on_message(self, message: discord.Message) -> None: """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" if is_incident(message): - message_links = DISCORD_MESSAGE_LINK_RE.findall(message.content) + message_links = DISCORD_MESSAGE_LINK_RE.findall(str(message.content)) print(message_links) if message_links: -- cgit v1.2.3 From a6c609fcc745b9cb99ec1fcfc365b1f364e6ff31 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Fri, 5 Mar 2021 07:56:19 +0530 Subject: Fix tests according to the changes done to incidents.py --- bot/exts/moderation/incidents.py | 1 - tests/bot/exts/moderation/test_incidents.py | 21 +++++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 9ee1407d4..813b717a8 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -478,7 +478,6 @@ class Incidents(Cog): """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" if is_incident(message): message_links = DISCORD_MESSAGE_LINK_RE.findall(str(message.content)) - print(message_links) if message_links: embeds = [] diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index cbf7f7bcf..3c991dacc 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, call, patch import aiohttp import discord +from async_rediscache import RedisSession from bot.constants import Colours from bot.exts.moderation import incidents @@ -22,6 +23,22 @@ from tests.helpers import ( MockUser, ) +redis_session = None +redis_loop = asyncio.get_event_loop() + + +def setUpModule(): # noqa: N802 + """Create and connect to the fakeredis session.""" + global redis_session + redis_session = RedisSession(use_fakeredis=True) + redis_loop.run_until_complete(redis_session.connect()) + + +def tearDownModule(): # noqa: N802 + """Close the fakeredis session.""" + if redis_session: + redis_loop.run_until_complete(redis_session.close()) + class MockAsyncIterable: """ @@ -513,7 +530,7 @@ class TestProcessEvent(TestIncidents): with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task): await self.cog_instance.process_event( reaction=incidents.Signal.ACTIONED.value, - incident=MockMessage(), + incident=MockMessage(id=123), member=MockMember(roles=[MockRole(id=1)]) ) @@ -533,7 +550,7 @@ class TestProcessEvent(TestIncidents): with patch("bot.exts.moderation.incidents.Incidents.make_confirmation_task", mock_task): await self.cog_instance.process_event( reaction=incidents.Signal.ACTIONED.value, - incident=MockMessage(), + incident=MockMessage(id=123), member=MockMember(roles=[MockRole(id=1)]) ) except asyncio.TimeoutError: -- cgit v1.2.3 From e113b17f68452573b1b236f7577120dd3783f6da Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Fri, 5 Mar 2021 11:28:46 +0530 Subject: Rollback to changes which aren't required --- bot/exts/moderation/incidents.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 813b717a8..201c6d1ca 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -10,7 +10,7 @@ from async_rediscache import RedisCache from discord.ext.commands import Cog, Context, MessageConverter from more_itertools.recipes import grouper -from bot import bot +from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Guild, Webhooks from bot.utils import scheduling from bot.utils.messages import format_user, sub_clyde @@ -122,9 +122,9 @@ def is_incident(message: discord.Message) -> bool: """True if `message` qualifies as an incident, False otherwise.""" conditions = ( message.channel.id == Channels.incidents, # Message sent in #incidents - not message.author.bot, # Not by a bot - not message.content.startswith("#"), # Doesn't start with a hash - not message.pinned, # And isn't header + not message.author.bot, # Not by a bot + not message.content.startswith("#"), # Doesn't start with a hash + not message.pinned, # And isn't header ) return all(conditions) @@ -231,7 +231,7 @@ class Incidents(Cog): # RedisCache[discord.Message.id, List[discord.Message.id]] message_link_embeds_cache = RedisCache() - def __init__(self, bot: bot.Bot) -> None: + def __init__(self, bot: Bot) -> None: """Prepare `event_lock` and schedule `crawl_task` on start-up.""" self.bot = bot @@ -511,6 +511,6 @@ class Incidents(Cog): await add_signals(message) -def setup(bot: bot.Bot) -> None: +def setup(bot: Bot) -> None: """Load the Incidents cog.""" bot.add_cog(Incidents(bot)) -- cgit v1.2.3 From 93f25e91808b9ed83e0201e26c8abf8841caf10c Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Fri, 5 Mar 2021 11:48:26 +0530 Subject: If message content more than 500 characters shorten it done to 300 characters --- bot/exts/moderation/incidents.py | 58 +++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 201c6d1ca..1f1f20d6c 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -122,9 +122,9 @@ def is_incident(message: discord.Message) -> bool: """True if `message` qualifies as an incident, False otherwise.""" conditions = ( message.channel.id == Channels.incidents, # Message sent in #incidents - not message.author.bot, # Not by a bot - not message.content.startswith("#"), # Doesn't start with a hash - not message.pinned, # And isn't header + not message.author.bot, # Not by a bot + not message.content.startswith("#"), # Doesn't start with a hash + not message.pinned, # And isn't header ) return all(conditions) @@ -165,7 +165,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> discord.Em embed.description = ( f"**Author:** {format_user(message.author)}\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" - f"**Content:** {text[:2045] + '...' if len(text) > 2048 else text}\n" + f"**Content:** {text[:300] + '...' if len(text) > 500 else text}\n" "\n" ) @@ -485,31 +485,41 @@ class Incidents(Cog): ctx = await self.bot.get_context(message) embeds.append(await make_message_link_embed(ctx, message_link)) - try: - webhook = await self.bot.fetch_webhook(Webhooks.incidents) - webhook_embed_list = list(grouper(embeds, 10)) - webhook_msg_ids = [] + webhook = await self.bot.fetch_webhook(Webhooks.incidents) + webhook_embed_list = list(grouper(embeds, 10)) - for x, embed in enumerate(webhook_embed_list): - webhook_msg = await webhook.send( - embeds=[x for x in embed if x is not None], - username=sub_clyde(message.author.name), - avatar_url=message.author.avatar_url, - wait=True - ) - webhook_msg_ids.append(webhook_msg.id) - log.trace(f"Message Link Embed {x + 1}/{len(webhook_embed_list)} Sent Succesfully") - - except Exception: - log.exception(f"Failed to send message link embeds {message.id} to #incidents") - - else: - await self.message_link_embeds_cache.set(message.id, ','.join(map(str, webhook_msg_ids))) - log.trace("Message Link Embeds Sent successfully!") + await self.send_webhooks(webhook_embed_list, message, webhook) log.trace(f"Skipping discord message link detection on {message.id}: message doesn't qualify.") await add_signals(message) + async def send_webhooks( + self, + webhook_embed_list: t.List, + message: discord.Message, + webhook: discord.Webhook + ) -> t.List[int]: + webhook_msg_ids = [] + try: + for x, embed in enumerate(webhook_embed_list): + webhook_msg = await webhook.send( + embeds=[x for x in embed if x is not None], + username=sub_clyde(message.author.name), + avatar_url=message.author.avatar_url, + wait=True + ) + webhook_msg_ids.append(webhook_msg.id) + log.trace(f"Message Link Embed {x + 1}/{len(webhook_embed_list)} Sent Succesfully") + + except Exception: + log.exception(f"Failed to send message link embeds {message.id} to #incidents") + + else: + await self.message_link_embeds_cache.set(message.id, ','.join(map(str, webhook_msg_ids))) + log.trace("Message Link Embeds Sent successfully!") + + return webhook_msg_ids + def setup(bot: Bot) -> None: """Load the Incidents cog.""" -- cgit v1.2.3 From aaf62d36ba2bcd2593756f19534547f740b57f16 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Fri, 5 Mar 2021 12:32:26 +0530 Subject: Add a docstring to 'send_webhooks' function --- bot/exts/moderation/incidents.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 1f1f20d6c..8304df174 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -499,6 +499,15 @@ class Incidents(Cog): message: discord.Message, webhook: discord.Webhook ) -> t.List[int]: + """ + Send Message Link Embeds to #incidents channel. + + Uses the `webhook` passed in as parameter to send the embeds + in `webhook_embed_list` parameter. + + After sending each webhook it maps the `message.id` to the + `webhook_msg_ids` IDs in the async rediscache. + """ webhook_msg_ids = [] try: for x, embed in enumerate(webhook_embed_list): -- cgit v1.2.3 From 39116e698f48468bec19d03e946c271e0083ccf4 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 6 Mar 2021 16:48:23 +0530 Subject: Update regex to support all message links i.e. support for 'app', 'canary', 'ptb' --- bot/exts/moderation/incidents.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 8304df174..dabdaed2c 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -26,8 +26,9 @@ CRAWL_LIMIT = 50 CRAWL_SLEEP = 2 DISCORD_MESSAGE_LINK_RE = re.compile( - r"http(?:s):\/\/discord(?:[\.,]|dot)com(?:\/|slash)channels(?:\/|slash)[0-9]{18}(?:\/|slash)[0-9]{18}" - r"(?:\/|slash)[0-9]{18}" + r'(https?:\/\/(?:(ptb|canary|www)\.)?discord(?:app)?\.com\/channels\/' + r'[0-9]{15,21}' + r'\/[0-9]{15,21}\/[0-9]{15,21})' ) @@ -483,7 +484,7 @@ class Incidents(Cog): embeds = [] for message_link in message_links: ctx = await self.bot.get_context(message) - embeds.append(await make_message_link_embed(ctx, message_link)) + embeds.append(await make_message_link_embed(ctx, message_link[0])) webhook = await self.bot.fetch_webhook(Webhooks.incidents) webhook_embed_list = list(grouper(embeds, 10)) -- cgit v1.2.3 From 10a5909e39b3dcda901a5b50b4ef9327cbd62226 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 6 Mar 2021 16:54:33 +0530 Subject: Run webhook message deletion if webhook_msg_id var is True --- bot/exts/moderation/incidents.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index dabdaed2c..7f8a34a01 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -388,12 +388,14 @@ class Incidents(Cog): log.trace("Deleting discord links webhook message.") webhook_msg_ids = await self.message_link_embeds_cache.get(incident.id) - webhook_msg_ids = webhook_msg_ids.split(',') - webhook = await self.bot.fetch_webhook(Webhooks.incidents) - for x, msg in enumerate(webhook_msg_ids): - await webhook.delete_message(msg) - log.trace(f"Deleted discord links webhook message{x}/{len(webhook_msg_ids)}") + if webhook_msg_ids: + webhook_msg_ids = webhook_msg_ids.split(',') + webhook = await self.bot.fetch_webhook(Webhooks.incidents) + + for x, msg in enumerate(webhook_msg_ids): + await webhook.delete_message(msg) + log.trace(f"Deleted discord links webhook message{x}/{len(webhook_msg_ids)}") log.trace("Successfully deleted discord links webhook message.") -- cgit v1.2.3 From 2f8c63f88d1d5345be8b64eeda8fbc098c057a74 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 6 Mar 2021 17:10:27 +0530 Subject: Modify tests to support redis cache, done with the help @SebastiaanZ --- tests/bot/exts/moderation/test_incidents.py | 32 ++++++++++++++--------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index 3c991dacc..239f86e6f 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -23,22 +23,6 @@ from tests.helpers import ( MockUser, ) -redis_session = None -redis_loop = asyncio.get_event_loop() - - -def setUpModule(): # noqa: N802 - """Create and connect to the fakeredis session.""" - global redis_session - redis_session = RedisSession(use_fakeredis=True) - redis_loop.run_until_complete(redis_session.connect()) - - -def tearDownModule(): # noqa: N802 - """Close the fakeredis session.""" - if redis_session: - redis_loop.run_until_complete(redis_session.close()) - class MockAsyncIterable: """ @@ -300,6 +284,22 @@ class TestIncidents(unittest.IsolatedAsyncioTestCase): the instance as they wish. """ + session = None + + async def flush(self): + """Flush everything from the database to prevent carry-overs between tests.""" + with await self.session.pool as connection: + await connection.flushall() + + async def asyncSetUp(self): + self.session = RedisSession(use_fakeredis=True) + await self.session.connect() + await self.flush() + + async def asyncTearDown(self): + if self.session: + await self.session.close() + def setUp(self): """ Prepare a fresh `Incidents` instance for each test. -- cgit v1.2.3 From bb516c51bb811c51b0781d1898ac8e3d578fd4f7 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 6 Mar 2021 17:17:29 +0530 Subject: Allign comments to maintain readability --- bot/exts/moderation/incidents.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 7f8a34a01..be6708b83 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -123,9 +123,9 @@ def is_incident(message: discord.Message) -> bool: """True if `message` qualifies as an incident, False otherwise.""" conditions = ( message.channel.id == Channels.incidents, # Message sent in #incidents - not message.author.bot, # Not by a bot - not message.content.startswith("#"), # Doesn't start with a hash - not message.pinned, # And isn't header + not message.author.bot, # Not by a bot + not message.content.startswith("#"), # Doesn't start with a hash + not message.pinned, # And isn't header ) return all(conditions) -- cgit v1.2.3 From 6f3210dde67b1bcfa1c7c9c96c86f76d36af69f1 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 6 Mar 2021 17:28:32 +0530 Subject: Ignore N802 in 'asyncSetUp' and 'asyncTearDown' function in test_incidents.py --- tests/bot/exts/moderation/test_incidents.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index 239f86e6f..c015951b3 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -291,12 +291,12 @@ class TestIncidents(unittest.IsolatedAsyncioTestCase): with await self.session.pool as connection: await connection.flushall() - async def asyncSetUp(self): + async def asyncSetUp(self): # noqa: N802 self.session = RedisSession(use_fakeredis=True) await self.session.connect() await self.flush() - async def asyncTearDown(self): + async def asyncTearDown(self): # noqa: N802 if self.session: await self.session.close() -- cgit v1.2.3 From 9ad28b96db7fd0ebc3b0ee8b1d853de494077944 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 13 Mar 2021 06:37:13 +0530 Subject: Run black code formatter. --- bot/exts/moderation/incidents.py | 74 ++++++++++++++++++++++++++++------------ 1 file changed, 53 insertions(+), 21 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index be6708b83..6a2c8c4b0 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -26,9 +26,9 @@ CRAWL_LIMIT = 50 CRAWL_SLEEP = 2 DISCORD_MESSAGE_LINK_RE = re.compile( - r'(https?:\/\/(?:(ptb|canary|www)\.)?discord(?:app)?\.com\/channels\/' - r'[0-9]{15,21}' - r'\/[0-9]{15,21}\/[0-9]{15,21})' + r"(https?:\/\/(?:(ptb|canary|www)\.)?discord(?:app)?\.com\/channels\/" + r"[0-9]{15,21}" + r"\/[0-9]{15,21}\/[0-9]{15,21})" ) @@ -72,7 +72,11 @@ async def download_file(attachment: discord.Attachment) -> t.Optional[discord.Fi log.exception("Failed to download attachment") -async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> FileEmbed: +async def make_embed( + incident: discord.Message, + outcome: Signal, + actioned_by: discord.Member +) -> FileEmbed: """ Create an embed representation of `incident` for the #incidents-archive channel. @@ -110,9 +114,13 @@ async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: di file = await download_file(attachment) if file is not None: - embed.set_image(url=f"attachment://{attachment.filename}") # Embed displays the attached file + embed.set_image( + url=f"attachment://{attachment.filename}" + ) # Embed displays the attached file else: - embed.set_author(name="[Failed to relay attachment]", url=attachment.proxy_url) # Embed links the file + embed.set_author( + name="[Failed to relay attachment]", url=attachment.proxy_url + ) # Embed links the file else: file = None @@ -182,7 +190,9 @@ async def add_signals(incident: discord.Message) -> None: existing_reacts = own_reactions(incident) for signal_emoji in Signal: - if signal_emoji.value in existing_reacts: # This would not raise, but it is a superfluous API call + if ( + signal_emoji.value in existing_reacts + ): # This would not raise, but it is a superfluous API call log.trace(f"Skipping emoji as it's already been placed: {signal_emoji}") else: log.trace(f"Adding reaction: {signal_emoji}") @@ -270,7 +280,12 @@ class Incidents(Cog): log.debug("Crawl task finished!") - async def archive(self, incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> bool: + async def archive( + self, + incident: discord.Message, + outcome: Signal, + actioned_by: discord.Member + ) -> bool: """ Relay an embed representation of `incident` to the #incidents-archive channel. @@ -291,7 +306,9 @@ class Incidents(Cog): not all information was relayed, return False. This signals that the original message is not safe to be deleted, as we will lose some information. """ - log.info(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})") + log.info( + f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})" + ) embed, attachment_file = await make_embed(incident, outcome, actioned_by) try: @@ -316,7 +333,9 @@ class Incidents(Cog): If `timeout` passes, this will raise `asyncio.TimeoutError`, signaling that we haven't been able to confirm that the message was deleted. """ - log.trace(f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted") + log.trace( + f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted" + ) def check(payload: discord.RawReactionActionEvent) -> bool: return payload.message_id == incident.id @@ -324,7 +343,12 @@ class Incidents(Cog): coroutine = self.bot.wait_for(event="raw_message_delete", check=check, timeout=timeout) return scheduling.create_task(coroutine, event_loop=self.bot.loop) - async def process_event(self, reaction: str, incident: discord.Message, member: discord.Member) -> None: + async def process_event( + self, + reaction: str, + incident: discord.Message, + member: discord.Member + ) -> None: """ Process a `reaction_add` event in #incidents. @@ -366,7 +390,9 @@ class Incidents(Cog): relay_successful = await self.archive(incident, signal, actioned_by=member) if not relay_successful: - log.trace("Original message will not be deleted as we failed to relay it to the archive") + log.trace( + "Original message will not be deleted as we failed to relay it to the archive" + ) return timeout = 5 # Seconds @@ -390,7 +416,7 @@ class Incidents(Cog): webhook_msg_ids = await self.message_link_embeds_cache.get(incident.id) if webhook_msg_ids: - webhook_msg_ids = webhook_msg_ids.split(',') + webhook_msg_ids = webhook_msg_ids.split(",") webhook = await self.bot.fetch_webhook(Webhooks.incidents) for x, msg in enumerate(webhook_msg_ids): @@ -458,7 +484,9 @@ class Incidents(Cog): if payload.channel_id != Channels.incidents or payload.member.bot: return - log.trace(f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}") + log.trace( + f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}" + ) await self.crawl_task log.trace(f"Acquiring event lock: {self.event_lock.locked()=}") @@ -493,14 +521,16 @@ class Incidents(Cog): await self.send_webhooks(webhook_embed_list, message, webhook) - log.trace(f"Skipping discord message link detection on {message.id}: message doesn't qualify.") + log.trace( + f"Skipping discord message link detection on {message.id}: message doesn't qualify." + ) await add_signals(message) async def send_webhooks( - self, - webhook_embed_list: t.List, - message: discord.Message, - webhook: discord.Webhook + self, + webhook_embed_list: t.List, + message: discord.Message, + webhook: discord.Webhook ) -> t.List[int]: """ Send Message Link Embeds to #incidents channel. @@ -518,7 +548,7 @@ class Incidents(Cog): embeds=[x for x in embed if x is not None], username=sub_clyde(message.author.name), avatar_url=message.author.avatar_url, - wait=True + wait=True, ) webhook_msg_ids.append(webhook_msg.id) log.trace(f"Message Link Embed {x + 1}/{len(webhook_embed_list)} Sent Succesfully") @@ -527,7 +557,9 @@ class Incidents(Cog): log.exception(f"Failed to send message link embeds {message.id} to #incidents") else: - await self.message_link_embeds_cache.set(message.id, ','.join(map(str, webhook_msg_ids))) + await self.message_link_embeds_cache.set( + message.id, ",".join(map(str, webhook_msg_ids)) + ) log.trace("Message Link Embeds Sent successfully!") return webhook_msg_ids -- cgit v1.2.3 From fa54337286adcbb812a0e4a6c53fa730818f1f6c Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 20 Mar 2021 15:25:00 +0530 Subject: Apply grammar and style changes. --- bot/exts/moderation/incidents.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 6a2c8c4b0..b77fdfabe 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -1,6 +1,7 @@ import asyncio import logging import re +import textwrap import typing as t from datetime import datetime from enum import Enum @@ -150,7 +151,7 @@ def has_signals(message: discord.Message) -> bool: async def make_message_link_embed(ctx: Context, message_link: str) -> discord.Embed: """ - Create an embed representation of discord message link contained in the incident report. + Create an embedded representation of the discord message link contained in the incident report. The Embed would contain the following information --> Author: @Jason Terror ♦ (736234578745884682) @@ -174,7 +175,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> discord.Em embed.description = ( f"**Author:** {format_user(message.author)}\n" f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" - f"**Content:** {text[:300] + '...' if len(text) > 500 else text}\n" + f"**Content:** {textwrap.shorten(text, 300, placeholder='...')}\n" "\n" ) @@ -320,7 +321,9 @@ class Incidents(Cog): file=attachment_file, ) except Exception: - log.exception(f"Failed to archive incident {incident.id} to #incidents-archive") + log.exception( + f"Failed to archive incident {incident.id} to #incidents-archive" + ) return False else: log.trace("Message archived successfully!") @@ -498,19 +501,19 @@ class Incidents(Cog): return if not is_incident(message): - log.debug("Ignoring event for a non-incident message") + log.debug("Ignoring event for a non-incident message.") return await self.process_event(str(payload.emoji), message, payload.member) - log.trace("Releasing event lock") + log.trace("Releasing event lock.") @Cog.listener() async def on_message(self, message: discord.Message) -> None: """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" if is_incident(message): message_links = DISCORD_MESSAGE_LINK_RE.findall(str(message.content)) - if message_links: + if message_links: embeds = [] for message_link in message_links: ctx = await self.bot.get_context(message) @@ -530,16 +533,16 @@ class Incidents(Cog): self, webhook_embed_list: t.List, message: discord.Message, - webhook: discord.Webhook + webhook: discord.Webhook, ) -> t.List[int]: """ Send Message Link Embeds to #incidents channel. - Uses the `webhook` passed in as parameter to send the embeds - in `webhook_embed_list` parameter. + Uses the `webhook` passed in as a parameter to send + the embeds in the `webhook_embed_list` parameter. - After sending each webhook it maps the `message.id` to the - `webhook_msg_ids` IDs in the async rediscache. + After sending each webhook it maps the `message.id` + to the `webhook_msg_ids` IDs in the async redis-cache. """ webhook_msg_ids = [] try: @@ -551,10 +554,14 @@ class Incidents(Cog): wait=True, ) webhook_msg_ids.append(webhook_msg.id) - log.trace(f"Message Link Embed {x + 1}/{len(webhook_embed_list)} Sent Succesfully") + log.trace( + f"Message Link Embed {x + 1}/{len(webhook_embed_list)} sent successfully." + ) except Exception: - log.exception(f"Failed to send message link embeds {message.id} to #incidents") + log.exception( + f"Failed to send message link embeds {message.id} to #incidents." + ) else: await self.message_link_embeds_cache.set( -- cgit v1.2.3 From e8625daa99d9bbbd929d132be44164ce1254b74e Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Fri, 16 Apr 2021 16:49:32 +0530 Subject: Use `DiscordException` instead of broad exception clause. --- bot/exts/moderation/incidents.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index b77fdfabe..edf621e02 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -164,7 +164,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> discord.Em message_convert_object = MessageConverter() message = await message_convert_object.convert(ctx, message_link) - except Exception as e: + except discord.DiscordException as e: embed.title = f"{e}" embed.colour = Colours.soft_red @@ -558,7 +558,7 @@ class Incidents(Cog): f"Message Link Embed {x + 1}/{len(webhook_embed_list)} sent successfully." ) - except Exception: + except discord.DiscordException: log.exception( f"Failed to send message link embeds {message.id} to #incidents." ) -- cgit v1.2.3 From a1bb6f38738b50183ea9042c29d1fbc8b0b18bb7 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 17 Apr 2021 08:45:16 +0530 Subject: Refactors code. Earlier on message edit the message wasn't run through extract message links to see if new message links are added or if some got deleted. Similarly the cache was updated when a message got deleted. Now it makes extract message links a helper function and runs it on message edits and deletes in case there are some changes in the message links. This commit also updates the doc strings for functions according to the new changes done. --- bot/exts/moderation/incidents.py | 120 ++++++++++++++++++++++++++++++--------- 1 file changed, 94 insertions(+), 26 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index edf621e02..77017659e 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -207,6 +207,35 @@ async def add_signals(incident: discord.Message) -> None: return +async def extract_message_links(message: discord.Message) -> t.Optional[list]: + """ + Checks if there's any message links in the text content. + + Then passes the the message_link into `make_message_link_embed` to format a + embed for it containing information about the link. + + As discord only allows a max of 10 embeds in a single webhook we need to + group the embeds into group of 10 and then return the list. + + If no links are found for the message, it logs a trace statement. + """ + message_links = DISCORD_MESSAGE_LINK_RE.findall(str(message.content)) + + if message_links: + embeds = [] + for message_link in message_links: + ctx = await message.bot.get_context(message) + embeds.append(await make_message_link_embed(ctx, message_link[0])) + + webhook_embed_list = list(grouper(embeds, 10)) + + return webhook_embed_list + + log.trace( + f"Skipping discord message link detection on {message.id}: message doesn't qualify." + ) + + class Incidents(Cog): """ Automation for the #incidents channel. @@ -365,6 +394,9 @@ class Incidents(Cog): This ensures that if there is a racing event awaiting the lock, it will fail to find the message, and will abort. There is a `timeout` to ensure that this doesn't hold the lock forever should something go wrong. + + Deletes cache value (`message_link_embeds_cache`) of `msg_before` if it exists and removes the + webhook message for that particular link from the channel. """ members_roles: t.Set[int] = {role.id for role in member.roles} if not members_roles & ALLOWED_ROLES: # Intersection is truthy on at least 1 common element @@ -415,18 +447,8 @@ class Incidents(Cog): else: log.trace("Deletion was confirmed") - log.trace("Deleting discord links webhook message.") - webhook_msg_ids = await self.message_link_embeds_cache.get(incident.id) - - if webhook_msg_ids: - webhook_msg_ids = webhook_msg_ids.split(",") - webhook = await self.bot.fetch_webhook(Webhooks.incidents) - - for x, msg in enumerate(webhook_msg_ids): - await webhook.delete_message(msg) - log.trace(f"Deleted discord links webhook message{x}/{len(webhook_msg_ids)}") - - log.trace("Successfully deleted discord links webhook message.") + # Deletes the message link embeds found in cache from the channel and cache. + await self.delete_msg_link_embeds(incident) async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: """ @@ -509,25 +531,53 @@ class Incidents(Cog): @Cog.listener() async def on_message(self, message: discord.Message) -> None: - """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" + """ + If the message (`message`) is a incident then run it through `extract_message_links` + to get all the message link embeds (embeds which contain information about that particular + link), this message link embeds are then sent into the channel. + + Also passes the message into `add_signals` if the message is a incident. + """ if is_incident(message): - message_links = DISCORD_MESSAGE_LINK_RE.findall(str(message.content)) + webhook_embed_list = await extract_message_links(message) + webhook = await self.bot.fetch_webhook(Webhooks.incidents) + await self.send_webhooks(webhook_embed_list, message, webhook) - if message_links: - embeds = [] - for message_link in message_links: - ctx = await self.bot.get_context(message) - embeds.append(await make_message_link_embed(ctx, message_link[0])) + await add_signals(message) - webhook = await self.bot.fetch_webhook(Webhooks.incidents) - webhook_embed_list = list(grouper(embeds, 10)) + @Cog.listener() + async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None: + """ + Deletes cache value (`message_link_embeds_cache`) of `msg_before` if it exists and removes the + webhook message for that particular link from the channel. - await self.send_webhooks(webhook_embed_list, message, webhook) + If the message edit (`msg_after`) is a incident then run it through `extract_message_links` + to get all the message link embeds (embeds which contain information about that particular + link), this message link embeds are then sent into the channel. - log.trace( - f"Skipping discord message link detection on {message.id}: message doesn't qualify." - ) - await add_signals(message) + The edited message is also passed into `add_signals` if it is a incident message. + """ + if is_incident(msg_before): + if msg_before.id in self.message_link_embeds_cache.items: + # Deletes the message link embeds found in cache from the channel and cache. + await self.delete_msg_link_embeds(msg_before) + + if is_incident(msg_after): + webhook_embed_list = await extract_message_links(msg_after) + webhook = await self.bot.fetch_webhook(Webhooks.incidents) + await self.send_webhooks(webhook_embed_list, msg_after, webhook) + + await add_signals(msg_after) + + @Cog.listener() + async def on_message_delete(self, message: discord.Message) -> None: + """ + Deletes the message link embeds found in cache from the channel and cache if the message + is a incident and is found in msg link embeds cache. + """ + if is_incident(message): + if message.id in self.message_link_embeds_cache.items: + await self.delete_msg_link_embeds(message) async def send_webhooks( self, @@ -571,6 +621,24 @@ class Incidents(Cog): return webhook_msg_ids + async def delete_msg_link_embeds(self, message: discord.Message) -> None: + """Delete discord message links message found in cache for `message`.""" + log.trace("Deleting discord links webhook message.") + + webhook_msg_ids = await self.message_link_embeds_cache.get(message.id) + + if webhook_msg_ids: + webhook_msg_ids = webhook_msg_ids.split(",") + webhook = await self.bot.fetch_webhook(Webhooks.incidents) + + for x, msg in enumerate(webhook_msg_ids): + await webhook.delete_message(msg) + log.trace(f"Deleted discord links webhook message{x}/{len(webhook_msg_ids)}") + + await self.message_link_embeds_cache.delete(message.id) + + log.trace("Successfully deleted discord links webhook message.") + def setup(bot: Bot) -> None: """Load the Incidents cog.""" -- cgit v1.2.3 From 5406c45a08ba0532b10cc6609f1f54a9f0e80e3d Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 17 Apr 2021 08:48:33 +0530 Subject: Updates type hints for `message_link_embeds_cache`. --- bot/exts/moderation/incidents.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 77017659e..a5e2ef945 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -269,7 +269,13 @@ class Incidents(Cog): """ # This dictionary maps a incident message to the message link embeds(s) sent by it - # RedisCache[discord.Message.id, List[discord.Message.id]] + # + # Discord doesn't allow more than 10 embeds to be sent in a single webhook message + # hence the embeds need to be broken into groups of 10. Since we have multiple embeds + # and RedisCache doesn't allow storing lists, we need to join the list with commas to + # make it a string and then store it. + # + # RedisCache[discord.Message.id, str] message_link_embeds_cache = RedisCache() def __init__(self, bot: Bot) -> None: -- cgit v1.2.3 From 3ca726c0edd838647b99f5a16fe3f15956d59e64 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 17 Apr 2021 17:34:31 +0530 Subject: Revert changes done by black. --- bot/exts/moderation/incidents.py | 56 ++++++++++------------------------------ 1 file changed, 13 insertions(+), 43 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index a5e2ef945..c988c45bb 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -73,11 +73,7 @@ async def download_file(attachment: discord.Attachment) -> t.Optional[discord.Fi log.exception("Failed to download attachment") -async def make_embed( - incident: discord.Message, - outcome: Signal, - actioned_by: discord.Member -) -> FileEmbed: +async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> FileEmbed: """ Create an embed representation of `incident` for the #incidents-archive channel. @@ -115,13 +111,9 @@ async def make_embed( file = await download_file(attachment) if file is not None: - embed.set_image( - url=f"attachment://{attachment.filename}" - ) # Embed displays the attached file + embed.set_image(url=f"attachment://{attachment.filename}") # Embed displays the attached file else: - embed.set_author( - name="[Failed to relay attachment]", url=attachment.proxy_url - ) # Embed links the file + embed.set_author(name="[Failed to relay attachment]", url=attachment.proxy_url) # Embed links the file else: file = None @@ -191,9 +183,7 @@ async def add_signals(incident: discord.Message) -> None: existing_reacts = own_reactions(incident) for signal_emoji in Signal: - if ( - signal_emoji.value in existing_reacts - ): # This would not raise, but it is a superfluous API call + if signal_emoji.value in existing_reacts: # This would not raise, but it is a superfluous API call log.trace(f"Skipping emoji as it's already been placed: {signal_emoji}") else: log.trace(f"Adding reaction: {signal_emoji}") @@ -316,12 +306,7 @@ class Incidents(Cog): log.debug("Crawl task finished!") - async def archive( - self, - incident: discord.Message, - outcome: Signal, - actioned_by: discord.Member - ) -> bool: + async def archive(self, incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> bool: """ Relay an embed representation of `incident` to the #incidents-archive channel. @@ -342,9 +327,7 @@ class Incidents(Cog): not all information was relayed, return False. This signals that the original message is not safe to be deleted, as we will lose some information. """ - log.info( - f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})" - ) + log.info(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})") embed, attachment_file = await make_embed(incident, outcome, actioned_by) try: @@ -356,9 +339,7 @@ class Incidents(Cog): file=attachment_file, ) except Exception: - log.exception( - f"Failed to archive incident {incident.id} to #incidents-archive" - ) + log.exception(f"Failed to archive incident {incident.id} to #incidents-archive") return False else: log.trace("Message archived successfully!") @@ -371,9 +352,7 @@ class Incidents(Cog): If `timeout` passes, this will raise `asyncio.TimeoutError`, signaling that we haven't been able to confirm that the message was deleted. """ - log.trace( - f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted" - ) + log.trace(f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted") def check(payload: discord.RawReactionActionEvent) -> bool: return payload.message_id == incident.id @@ -381,12 +360,7 @@ class Incidents(Cog): coroutine = self.bot.wait_for(event="raw_message_delete", check=check, timeout=timeout) return scheduling.create_task(coroutine, event_loop=self.bot.loop) - async def process_event( - self, - reaction: str, - incident: discord.Message, - member: discord.Member - ) -> None: + async def process_event(self, reaction: str, incident: discord.Message, member: discord.Member) -> None: """ Process a `reaction_add` event in #incidents. @@ -431,9 +405,7 @@ class Incidents(Cog): relay_successful = await self.archive(incident, signal, actioned_by=member) if not relay_successful: - log.trace( - "Original message will not be deleted as we failed to relay it to the archive" - ) + log.trace("Original message will not be deleted as we failed to relay it to the archive") return timeout = 5 # Seconds @@ -515,9 +487,7 @@ class Incidents(Cog): if payload.channel_id != Channels.incidents or payload.member.bot: return - log.trace( - f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}" - ) + log.trace(f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}") await self.crawl_task log.trace(f"Acquiring event lock: {self.event_lock.locked()=}") @@ -529,11 +499,11 @@ class Incidents(Cog): return if not is_incident(message): - log.debug("Ignoring event for a non-incident message.") + log.debug("Ignoring event for a non-incident message") return await self.process_event(str(payload.emoji), message, payload.member) - log.trace("Releasing event lock.") + log.trace("Releasing event lock") @Cog.listener() async def on_message(self, message: discord.Message) -> None: -- cgit v1.2.3 From 23578ae0381b0d8d81b7be9d8eb3bc86a1557e0b Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 18 Apr 2021 06:19:06 +0530 Subject: Don't allow more than 10 embeds per report. If more than 10 embeds found, just get the first 10 and ignore the rest. --- bot/exts/moderation/incidents.py | 85 +++++++++++++++------------------------- 1 file changed, 32 insertions(+), 53 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index c988c45bb..032c15ca2 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -9,7 +9,6 @@ from enum import Enum import discord from async_rediscache import RedisCache from discord.ext.commands import Cog, Context, MessageConverter -from more_itertools.recipes import grouper from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Guild, Webhooks @@ -197,15 +196,15 @@ async def add_signals(incident: discord.Message) -> None: return -async def extract_message_links(message: discord.Message) -> t.Optional[list]: +async def extract_message_links(message: discord.Message, bot: Bot) -> t.Optional[list]: """ Checks if there's any message links in the text content. Then passes the the message_link into `make_message_link_embed` to format a embed for it containing information about the link. - As discord only allows a max of 10 embeds in a single webhook we need to - group the embeds into group of 10 and then return the list. + As discord only allows a max of 10 embeds in a single webhook, just send the + first 10 embeds and don't care about the rest. If no links are found for the message, it logs a trace statement. """ @@ -214,12 +213,10 @@ async def extract_message_links(message: discord.Message) -> t.Optional[list]: if message_links: embeds = [] for message_link in message_links: - ctx = await message.bot.get_context(message) + ctx = await bot.get_context(message) embeds.append(await make_message_link_embed(ctx, message_link[0])) - webhook_embed_list = list(grouper(embeds, 10)) - - return webhook_embed_list + return embeds[:10] log.trace( f"Skipping discord message link detection on {message.id}: message doesn't qualify." @@ -240,6 +237,7 @@ class Incidents(Cog): * See: `crawl_incidents` On message: + * Run message through `extract_message_links` and send them into the channel * Add `Signal` member emoji if message qualifies as an incident * Ignore messages starting with # * Use this if verbal communication is necessary @@ -253,18 +251,13 @@ class Incidents(Cog): * If `Signal.ACTIONED` or `Signal.NOT_ACTIONED` were chosen, attempt to relay the incident message to #incidents-archive * If relay successful, delete original message + * Search the cache for the webhook message for this message, if found delete it. * See: `on_raw_reaction_add` Please refer to function docstrings for implementation details. """ - # This dictionary maps a incident message to the message link embeds(s) sent by it - # - # Discord doesn't allow more than 10 embeds to be sent in a single webhook message - # hence the embeds need to be broken into groups of 10. Since we have multiple embeds - # and RedisCache doesn't allow storing lists, we need to join the list with commas to - # make it a string and then store it. - # + # This dictionary maps a incident message to the message link embeds sent by it # RedisCache[discord.Message.id, str] message_link_embeds_cache = RedisCache() @@ -426,7 +419,7 @@ class Incidents(Cog): log.trace("Deletion was confirmed") # Deletes the message link embeds found in cache from the channel and cache. - await self.delete_msg_link_embeds(incident) + await self.delete_msg_link_embed(incident) async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: """ @@ -515,7 +508,7 @@ class Incidents(Cog): Also passes the message into `add_signals` if the message is a incident. """ if is_incident(message): - webhook_embed_list = await extract_message_links(message) + webhook_embed_list = await extract_message_links(message, self.bot) webhook = await self.bot.fetch_webhook(Webhooks.incidents) await self.send_webhooks(webhook_embed_list, message, webhook) @@ -535,11 +528,11 @@ class Incidents(Cog): """ if is_incident(msg_before): if msg_before.id in self.message_link_embeds_cache.items: - # Deletes the message link embeds found in cache from the channel and cache. - await self.delete_msg_link_embeds(msg_before) + # Deletes the message link embed found in cache from the channel and cache. + await self.delete_msg_link_embed(msg_before) if is_incident(msg_after): - webhook_embed_list = await extract_message_links(msg_after) + webhook_embed_list = await extract_message_links(msg_after, self.bot) webhook = await self.bot.fetch_webhook(Webhooks.incidents) await self.send_webhooks(webhook_embed_list, msg_after, webhook) @@ -548,19 +541,19 @@ class Incidents(Cog): @Cog.listener() async def on_message_delete(self, message: discord.Message) -> None: """ - Deletes the message link embeds found in cache from the channel and cache if the message + Deletes the message link embed found in cache from the channel and cache if the message is a incident and is found in msg link embeds cache. """ if is_incident(message): if message.id in self.message_link_embeds_cache.items: - await self.delete_msg_link_embeds(message) + await self.delete_msg_link_embed(message) async def send_webhooks( self, webhook_embed_list: t.List, message: discord.Message, webhook: discord.Webhook, - ) -> t.List[int]: + ) -> t.Optional[int]: """ Send Message Link Embeds to #incidents channel. @@ -570,49 +563,35 @@ class Incidents(Cog): After sending each webhook it maps the `message.id` to the `webhook_msg_ids` IDs in the async redis-cache. """ - webhook_msg_ids = [] try: - for x, embed in enumerate(webhook_embed_list): - webhook_msg = await webhook.send( - embeds=[x for x in embed if x is not None], - username=sub_clyde(message.author.name), - avatar_url=message.author.avatar_url, - wait=True, - ) - webhook_msg_ids.append(webhook_msg.id) - log.trace( - f"Message Link Embed {x + 1}/{len(webhook_embed_list)} sent successfully." - ) + webhook_msg = await webhook.send( + embeds=[x for x in webhook_embed_list if x is not None], + username=sub_clyde(message.author.name), + avatar_url=message.author.avatar_url, + wait=True, + ) + log.trace(f"Message Link Embed sent successfully.") except discord.DiscordException: log.exception( - f"Failed to send message link embeds {message.id} to #incidents." + f"Failed to send message link embed {message.id} to #incidents." ) else: - await self.message_link_embeds_cache.set( - message.id, ",".join(map(str, webhook_msg_ids)) - ) - log.trace("Message Link Embeds Sent successfully!") - - return webhook_msg_ids + await self.message_link_embeds_cache.set(message.id, webhook_msg.id) + log.trace("Message Link Embed Sent successfully!") + return webhook_msg.id - async def delete_msg_link_embeds(self, message: discord.Message) -> None: - """Delete discord message links message found in cache for `message`.""" + async def delete_msg_link_embed(self, message: discord.Message) -> None: + """Delete discord message link message found in cache for `message`.""" log.trace("Deleting discord links webhook message.") + webhook_msg_id = await self.message_link_embeds_cache.get(message.id) - webhook_msg_ids = await self.message_link_embeds_cache.get(message.id) - - if webhook_msg_ids: - webhook_msg_ids = webhook_msg_ids.split(",") + if webhook_msg_id: webhook = await self.bot.fetch_webhook(Webhooks.incidents) - - for x, msg in enumerate(webhook_msg_ids): - await webhook.delete_message(msg) - log.trace(f"Deleted discord links webhook message{x}/{len(webhook_msg_ids)}") + await webhook.delete_message(webhook_msg_id) await self.message_link_embeds_cache.delete(message.id) - log.trace("Successfully deleted discord links webhook message.") -- cgit v1.2.3 From 691a63c8dcfee89f2cf8e5d2c9456b84789dfc9a Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 18 Apr 2021 06:22:00 +0530 Subject: Use str() rather than f string for single variable. Makes the intent much more clear. --- 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 032c15ca2..df8d08509 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -156,7 +156,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> discord.Em message = await message_convert_object.convert(ctx, message_link) except discord.DiscordException as e: - embed.title = f"{e}" + embed.title = str(e) embed.colour = Colours.soft_red else: -- cgit v1.2.3 From 00175a55784603c6030e83f2099e7e7daba02654 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 18 Apr 2021 06:27:02 +0530 Subject: Make incidents channel webhook a cog level attribute This would not fetch it everytime. --- bot/exts/moderation/incidents.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index df8d08509..a2548daca 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -265,6 +265,9 @@ class Incidents(Cog): """Prepare `event_lock` and schedule `crawl_task` on start-up.""" self.bot = bot + # Webhook to send message link embeds in #incidents + self.incidents_webhook = await self.bot.fetch_webhook(Webhooks.incidents) + self.event_lock = asyncio.Lock() self.crawl_task = scheduling.create_task(self.crawl_incidents(), event_loop=self.bot.loop) @@ -509,8 +512,7 @@ class Incidents(Cog): """ if is_incident(message): webhook_embed_list = await extract_message_links(message, self.bot) - webhook = await self.bot.fetch_webhook(Webhooks.incidents) - await self.send_webhooks(webhook_embed_list, message, webhook) + await self.send_webhooks(webhook_embed_list, message, self.incidents_webhook) await add_signals(message) @@ -533,8 +535,7 @@ class Incidents(Cog): if is_incident(msg_after): webhook_embed_list = await extract_message_links(msg_after, self.bot) - webhook = await self.bot.fetch_webhook(Webhooks.incidents) - await self.send_webhooks(webhook_embed_list, msg_after, webhook) + await self.send_webhooks(webhook_embed_list, msg_after, self.incidents_webhook) await add_signals(msg_after) @@ -588,8 +589,7 @@ class Incidents(Cog): webhook_msg_id = await self.message_link_embeds_cache.get(message.id) if webhook_msg_id: - webhook = await self.bot.fetch_webhook(Webhooks.incidents) - await webhook.delete_message(webhook_msg_id) + await self.incidents_webhook.delete_message(webhook_msg_id) await self.message_link_embeds_cache.delete(message.id) log.trace("Successfully deleted discord links webhook message.") -- cgit v1.2.3 From 593e5fe3172ac36de1f4875ce1eb734734a15d70 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 18 Apr 2021 06:53:07 +0530 Subject: On msg edits, edit the msg link embed rather than deleting it --- bot/exts/moderation/incidents.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index a2548daca..f6607e651 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -528,10 +528,15 @@ class Incidents(Cog): The edited message is also passed into `add_signals` if it is a incident message. """ - if is_incident(msg_before): - if msg_before.id in self.message_link_embeds_cache.items: - # Deletes the message link embed found in cache from the channel and cache. - await self.delete_msg_link_embed(msg_before) + + webhook_embed_list = await extract_message_links(msg_after, self.bot) + webhook_msg_id = self.message_link_embeds_cache.get(msg_before.id) + + if webhook_msg_id: + await self.incidents_webhook.edit_message( + message_id=webhook_msg_id, + embeds=[x for x in webhook_embed_list if x is not None], + ) if is_incident(msg_after): webhook_embed_list = await extract_message_links(msg_after, self.bot) -- cgit v1.2.3 From 91ffa412294a8bf63da132df19557fec54b02a00 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 18 Apr 2021 06:54:24 +0530 Subject: Use tasks to fetch incidents channel webhook. --- bot/exts/moderation/incidents.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index f6607e651..deaabcfa0 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -123,9 +123,9 @@ def is_incident(message: discord.Message) -> bool: """True if `message` qualifies as an incident, False otherwise.""" conditions = ( message.channel.id == Channels.incidents, # Message sent in #incidents - not message.author.bot, # Not by a bot - not message.content.startswith("#"), # Doesn't start with a hash - not message.pinned, # And isn't header + not message.author.bot, # Not by a bot + not message.content.startswith("#"), # Doesn't start with a hash + not message.pinned, # And isn't header ) return all(conditions) @@ -265,12 +265,16 @@ class Incidents(Cog): """Prepare `event_lock` and schedule `crawl_task` on start-up.""" self.bot = bot - # Webhook to send message link embeds in #incidents - self.incidents_webhook = await self.bot.fetch_webhook(Webhooks.incidents) + self.bot.loop.create_task(self.get_webhook()) self.event_lock = asyncio.Lock() self.crawl_task = scheduling.create_task(self.crawl_incidents(), event_loop=self.bot.loop) + async def get_webhook(self) -> None: + """Fetch and store message link embeds webhook, present in #incidents channel.""" + await self.bot.wait_until_guild_available() + self.incidents_webhook = await self.bot.fetch_webhook(Webhooks.incidents) + async def crawl_incidents(self) -> None: """ Crawl #incidents and add missing emoji where necessary. @@ -555,10 +559,10 @@ class Incidents(Cog): await self.delete_msg_link_embed(message) async def send_webhooks( - self, - webhook_embed_list: t.List, - message: discord.Message, - webhook: discord.Webhook, + self, + webhook_embed_list: t.List, + message: discord.Message, + webhook: discord.Webhook, ) -> t.Optional[int]: """ Send Message Link Embeds to #incidents channel. -- cgit v1.2.3 From a9ac92b19d6b4f562383e9eeab09eec8ef063d44 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 18 Apr 2021 07:02:19 +0530 Subject: Do required flake8 changes in docstrings. --- bot/exts/moderation/incidents.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index deaabcfa0..7ef7eb327 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -508,6 +508,8 @@ class Incidents(Cog): @Cog.listener() async def on_message(self, message: discord.Message) -> None: """ + Pass `message` to `add_signals` and `extract_message_links` if it satisfies `is_incident`. + If the message (`message`) is a incident then run it through `extract_message_links` to get all the message link embeds (embeds which contain information about that particular link), this message link embeds are then sent into the channel. @@ -523,6 +525,8 @@ class Incidents(Cog): @Cog.listener() async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None: """ + Pass `msg_after` to `extract_message_links` and edit `msg_before` webhook msg. + Deletes cache value (`message_link_embeds_cache`) of `msg_before` if it exists and removes the webhook message for that particular link from the channel. @@ -532,7 +536,6 @@ class Incidents(Cog): The edited message is also passed into `add_signals` if it is a incident message. """ - webhook_embed_list = await extract_message_links(msg_after, self.bot) webhook_msg_id = self.message_link_embeds_cache.get(msg_before.id) @@ -551,8 +554,9 @@ class Incidents(Cog): @Cog.listener() async def on_message_delete(self, message: discord.Message) -> None: """ - Deletes the message link embed found in cache from the channel and cache if the message - is a incident and is found in msg link embeds cache. + Delete message link embeds for `message`. + + Search through the cache for message, if found delete it from cache and channel. """ if is_incident(message): if message.id in self.message_link_embeds_cache.items: @@ -580,7 +584,7 @@ class Incidents(Cog): avatar_url=message.author.avatar_url, wait=True, ) - log.trace(f"Message Link Embed sent successfully.") + log.trace("Message Link Embed sent successfully.") except discord.DiscordException: log.exception( -- cgit v1.2.3 From 0f92fe11ffc8c6262c62bd7e9d0c4c81bd8da6f5 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 19 Apr 2021 05:40:45 +0530 Subject: Don't send errors, instead log them. Errors shouldn't be sent in #incidents. Instead, log them with log.exception and make the function return. --- bot/exts/moderation/incidents.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 7ef7eb327..a259db10d 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -123,9 +123,9 @@ def is_incident(message: discord.Message) -> bool: """True if `message` qualifies as an incident, False otherwise.""" conditions = ( message.channel.id == Channels.incidents, # Message sent in #incidents - not message.author.bot, # Not by a bot - not message.content.startswith("#"), # Doesn't start with a hash - not message.pinned, # And isn't header + not message.author.bot, # Not by a bot + not message.content.startswith("#"), # Doesn't start with a hash + not message.pinned, # And isn't header ) return all(conditions) @@ -140,7 +140,7 @@ def has_signals(message: discord.Message) -> bool: return ALL_SIGNALS.issubset(own_reactions(message)) -async def make_message_link_embed(ctx: Context, message_link: str) -> discord.Embed: +async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional[discord.Embed]: """ Create an embedded representation of the discord message link contained in the incident report. @@ -156,8 +156,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> discord.Em message = await message_convert_object.convert(ctx, message_link) except discord.DiscordException as e: - embed.title = str(e) - embed.colour = Colours.soft_red + log.exception(f"Failed to make message link embed for '{message_link}', raised exception: {e}") else: text = message.content @@ -169,8 +168,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> discord.Em f"**Content:** {textwrap.shorten(text, 300, placeholder='...')}\n" "\n" ) - - return embed + return embed async def add_signals(incident: discord.Message) -> None: @@ -214,7 +212,9 @@ async def extract_message_links(message: discord.Message, bot: Bot) -> t.Optiona embeds = [] for message_link in message_links: ctx = await bot.get_context(message) - embeds.append(await make_message_link_embed(ctx, message_link[0])) + embed = await make_message_link_embed(ctx, message_link[0]) + if embed: + embeds.append(embed) return embeds[:10] -- cgit v1.2.3 From 138dc2e5039fce0b267c9d47db6e387a832d3df0 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 19 Apr 2021 06:03:53 +0530 Subject: Bug fixes - `await` message link embeds cache get - don't double send webhook embeds (edit, send) on message edits --- bot/exts/moderation/incidents.py | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index a259db10d..413c9bcf9 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -517,11 +517,12 @@ class Incidents(Cog): Also passes the message into `add_signals` if the message is a incident. """ if is_incident(message): - webhook_embed_list = await extract_message_links(message, self.bot) - await self.send_webhooks(webhook_embed_list, message, self.incidents_webhook) - await add_signals(message) + webhook_embed_list = await extract_message_links(message, self.bot) + if webhook_embed_list: + await self.send_webhooks(webhook_embed_list, message, self.incidents_webhook) + @Cog.listener() async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None: """ @@ -536,20 +537,18 @@ class Incidents(Cog): The edited message is also passed into `add_signals` if it is a incident message. """ - webhook_embed_list = await extract_message_links(msg_after, self.bot) - webhook_msg_id = self.message_link_embeds_cache.get(msg_before.id) - - if webhook_msg_id: - await self.incidents_webhook.edit_message( - message_id=webhook_msg_id, - embeds=[x for x in webhook_embed_list if x is not None], - ) - if is_incident(msg_after): webhook_embed_list = await extract_message_links(msg_after, self.bot) - await self.send_webhooks(webhook_embed_list, msg_after, self.incidents_webhook) + webhook_msg_id = await self.message_link_embeds_cache.get(msg_before.id) - await add_signals(msg_after) + if webhook_msg_id: + await self.incidents_webhook.edit_message( + message_id=webhook_msg_id, + embeds=[x for x in webhook_embed_list if x is not None], + ) + return + + await self.send_webhooks(webhook_embed_list, msg_after, self.incidents_webhook) @Cog.listener() async def on_message_delete(self, message: discord.Message) -> None: @@ -559,8 +558,7 @@ class Incidents(Cog): Search through the cache for message, if found delete it from cache and channel. """ if is_incident(message): - if message.id in self.message_link_embeds_cache.items: - await self.delete_msg_link_embed(message) + await self.delete_msg_link_embed(message) async def send_webhooks( self, -- cgit v1.2.3 From b116d3c47d9c5c8e99b2557b37d0e402652b5ef3 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Wed, 21 Apr 2021 15:09:29 +0530 Subject: Rework message link embed. - Instead of default black colour, use gold to give it some shine! - Mention the channel also in the channel field. - Add message ID in footer, so it is easy to figure out for which message link is that embed. --- bot/exts/moderation/incidents.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 413c9bcf9..aebf22d00 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -149,8 +149,6 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional Channel: Special/#bot-commands (814190307980607493) Content: This is a very important message! """ - embed = discord.Embed() - try: message_convert_object = MessageConverter() message = await message_convert_object.convert(ctx, message_link) @@ -162,12 +160,16 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional text = message.content channel = message.channel - embed.description = ( - f"**Author:** {format_user(message.author)}\n" - f"**Channel:** {channel.category}/#{channel.name} (`{channel.id}`)\n" - f"**Content:** {textwrap.shorten(text, 300, placeholder='...')}\n" - "\n" + embed = discord.Embed( + colour=discord.Colour.gold(), + description=( + f"**Author:** {format_user(message.author)}\n" + f"**Channel:** <#{channel.id}> ({channel.category}/#{channel.name})\n" + f"**Content:** {textwrap.shorten(text, 300, placeholder='...')}\n" + ) ) + embed.set_footer(text=f"Message ID: {message.id}") + return embed -- cgit v1.2.3 From 5f57103b9dea3af864c916a24a8ffcc61d0106dc Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 26 Apr 2021 08:08:57 +0530 Subject: Remove redundant code --- bot/exts/moderation/incidents.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index aebf22d00..24cd21406 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -150,8 +150,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional Content: This is a very important message! """ try: - message_convert_object = MessageConverter() - message = await message_convert_object.convert(ctx, message_link) + message = await MessageConverter().convert(ctx, message_link) except discord.DiscordException as e: log.exception(f"Failed to make message link embed for '{message_link}', raised exception: {e}") @@ -164,7 +163,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional colour=discord.Colour.gold(), description=( f"**Author:** {format_user(message.author)}\n" - f"**Channel:** <#{channel.id}> ({channel.category}/#{channel.name})\n" + f"**Channel:** {channel.mention} ({channel.category}/#{channel.name})\n" f"**Content:** {textwrap.shorten(text, 300, placeholder='...')}\n" ) ) @@ -584,7 +583,6 @@ class Incidents(Cog): avatar_url=message.author.avatar_url, wait=True, ) - log.trace("Message Link Embed sent successfully.") except discord.DiscordException: log.exception( -- cgit v1.2.3 From e95b139593b9014638c187402343141967aba765 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 26 Apr 2021 08:13:19 +0530 Subject: Appy requested grammar changes. Co-authored-by: Boris Muratov <8bee278@gmail.com> --- bot/exts/moderation/incidents.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 24cd21406..b174ce668 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -258,8 +258,8 @@ class Incidents(Cog): Please refer to function docstrings for implementation details. """ - # This dictionary maps a incident message to the message link embeds sent by it - # RedisCache[discord.Message.id, str] + # This dictionary maps an incident report message to the message link embed's ID + # RedisCache[discord.Message.id, discord.Message.id] message_link_embeds_cache = RedisCache() def __init__(self, bot: Bot) -> None: @@ -511,11 +511,11 @@ class Incidents(Cog): """ Pass `message` to `add_signals` and `extract_message_links` if it satisfies `is_incident`. - If the message (`message`) is a incident then run it through `extract_message_links` + If the message (`message`) is an incident report, then run it through `extract_message_links` to get all the message link embeds (embeds which contain information about that particular - link), this message link embeds are then sent into the channel. + link).These message link embeds are then sent into the channel. - Also passes the message into `add_signals` if the message is a incident. + Also passes the message into `add_signals` if the message is an incident. """ if is_incident(message): await add_signals(message) -- cgit v1.2.3 From 1fa3ce5acc98dd3bea77881dfea1fdc0001feccb Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 26 Apr 2021 08:15:30 +0530 Subject: Rename 'send_webhooks' to 'send_message_link_embed' Co-authored-by: Boris Muratov <8bee278@gmail.com> --- bot/exts/moderation/incidents.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index b174ce668..da349f654 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -522,7 +522,7 @@ class Incidents(Cog): webhook_embed_list = await extract_message_links(message, self.bot) if webhook_embed_list: - await self.send_webhooks(webhook_embed_list, message, self.incidents_webhook) + await self.send_message_link_embeds(webhook_embed_list, message, self.incidents_webhook) @Cog.listener() async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None: @@ -549,7 +549,7 @@ class Incidents(Cog): ) return - await self.send_webhooks(webhook_embed_list, msg_after, self.incidents_webhook) + await self.send_message_link_embeds(webhook_embed_list, msg_after, self.incidents_webhook) @Cog.listener() async def on_message_delete(self, message: discord.Message) -> None: @@ -561,7 +561,7 @@ class Incidents(Cog): if is_incident(message): await self.delete_msg_link_embed(message) - async def send_webhooks( + async def send_message_link_embeds( self, webhook_embed_list: t.List, message: discord.Message, -- cgit v1.2.3 From b0c4cdbd3328e46f5a1d6dd6be3600e20e7f19aa Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 26 Apr 2021 08:17:39 +0530 Subject: Remove leading whitespace from msg link embed content --- 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 da349f654..9d3b0fe6f 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -164,7 +164,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional description=( f"**Author:** {format_user(message.author)}\n" f"**Channel:** {channel.mention} ({channel.category}/#{channel.name})\n" - f"**Content:** {textwrap.shorten(text, 300, placeholder='...')}\n" + f"**Content:** {textwrap.shorten(text.lstrip(), 300, placeholder='...')}\n" ) ) embed.set_footer(text=f"Message ID: {message.id}") -- cgit v1.2.3 From f74e894d3ba2d26130e31ba13a8a7ced2b63af4e Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 26 Apr 2021 08:20:24 +0530 Subject: Handle discord.errors.NotFound while deleting msg link webhook embeds --- bot/exts/moderation/incidents.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 9d3b0fe6f..840327cb6 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -600,7 +600,10 @@ class Incidents(Cog): webhook_msg_id = await self.message_link_embeds_cache.get(message.id) if webhook_msg_id: - await self.incidents_webhook.delete_message(webhook_msg_id) + try: + await self.incidents_webhook.delete_message(webhook_msg_id) + except discord.errors.NotFound: + log.trace(f"Incidents message link embed (`{webhook_msg_id}`) has already been deleted, skipping.") await self.message_link_embeds_cache.delete(message.id) log.trace("Successfully deleted discord links webhook message.") -- cgit v1.2.3 From 5ce51115f19f7ce13802701dc58d508ab5eb69f8 Mon Sep 17 00:00:00 2001 From: Shivansh Date: Tue, 27 Apr 2021 06:27:16 +0530 Subject: Fix truncation bug When you take a long message, just one word of 400 A's then the truncated wouldn't be able to handle it properly and just return the placeholder. This is a bug in the textwrap.shorten function. To solve this, I went the long way to use slicing on the list. This commit seems to have resolved the bug. --- bot/exts/moderation/incidents.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 840327cb6..235f7a0f7 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -156,15 +156,16 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional log.exception(f"Failed to make message link embed for '{message_link}', raised exception: {e}") else: - text = message.content + text = message.content.lstrip() channel = message.channel - + shortened_text = text[:300] + (text[300:] and '...') + embed = discord.Embed( colour=discord.Colour.gold(), description=( f"**Author:** {format_user(message.author)}\n" f"**Channel:** {channel.mention} ({channel.category}/#{channel.name})\n" - f"**Content:** {textwrap.shorten(text.lstrip(), 300, placeholder='...')}\n" + f"**Content:** {shortened_text}\n" ) ) embed.set_footer(text=f"Message ID: {message.id}") -- cgit v1.2.3 From a068ce561024ddb60677e6b6d6887102567dcf2e Mon Sep 17 00:00:00 2001 From: Shivansh Date: Sat, 1 May 2021 07:33:24 +0530 Subject: Write tests for this feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In short, I have written two tests, one which tests the whether `extract_message_links` is called on message edits or not. And the second one to test the regex of `extract_message_links` and assert the message link embeds sent by it. Special thanks to kwzrd💜#1198 for helping me out with it. --- bot/exts/moderation/incidents.py | 5 +-- tests/bot/exts/moderation/test_incidents.py | 64 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 235f7a0f7..a71cea45f 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -1,7 +1,6 @@ import asyncio import logging import re -import textwrap import typing as t from datetime import datetime from enum import Enum @@ -159,7 +158,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional text = message.content.lstrip() channel = message.channel shortened_text = text[:300] + (text[300:] and '...') - + embed = discord.Embed( colour=discord.Colour.gold(), description=( @@ -591,7 +590,7 @@ class Incidents(Cog): ) else: - await self.message_link_embeds_cache.set(message.id, webhook_msg.id) + await self.message_link_embeds_cache.set(int(message.id), int(webhook_msg.id)) log.trace("Message Link Embed Sent successfully!") return webhook_msg.id diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index c015951b3..4b2b652fc 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -3,6 +3,7 @@ import enum import logging import typing as t import unittest +from unittest import mock from unittest.mock import AsyncMock, MagicMock, call, patch import aiohttp @@ -11,6 +12,8 @@ from async_rediscache import RedisSession from bot.constants import Colours from bot.exts.moderation import incidents +from bot.exts.moderation.incidents import extract_message_links +from bot.utils.messages import format_user from tests.helpers import ( MockAsyncWebhook, MockAttachment, @@ -785,3 +788,64 @@ class TestOnMessage(TestIncidents): await self.cog_instance.on_message(MockMessage()) mock_add_signals.assert_not_called() + + +class TestMessageLinkEmbeds(TestIncidents): + """Tests for `extract_message_links` coroutine.""" + + async def extract_and_form_message_link_embeds(self): + """ + Extract message links from a mocked message and form the message link embed. + + Considers all types of message links, discord supports. + """ + self.guild_id_patcher = mock.patch("bot.exts.backend.sync._cog.constants.Guild.id", 5) + self.guild_id = self.guild_id_patcher.start() + + msg = MockMessage(id=555, content="Hello, World!" * 3000) + msg.channel.mention = "#lemonade-stand" + + msg_links = [ + # Valid Message links + f"https://discord.com/channels/{self.guild_id}/{msg.channel.discord_id}/{msg.discord_id}", + f"http://canary.discord.com/channels/{self.guild_id}/{msg.channel.discord_id}/{msg.discord_id}", + + # Invalid Message links + f"https://discord.com/channels/{msg.channel.discord_id}/{msg.discord_id}", + f"https://discord.com/channels/{self.guild_id}/{msg.channel.discord_id}000/{msg.discord_id}", + ] + + incident_msg = MockMessage( + id=777, + content=f"I would like to report the following messages, " + f"as they break our rules: \n{', '.join(msg_links)}" + ) + + embeds = await extract_message_links(incident_msg, self.cog_instance.bot) + description = ( + f"**Author:** {format_user(msg.author)}\n" + f"**Channel:** {msg.channel.mention} ({msg.channel.category}/#{msg.channel.name})\n" + f"**Content:** {('Hello, World!' * 3000)[:300] + '...'}\n" + ) + + # Check number of embeds returned with number of valid links + self.assertEqual( + self, len(embeds), 2 + ) + + # Check for the embed descriptions + for embed in embeds: + self.assertEqual( + self, embed.description, description + ) + + @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)) + async def test_incident_message_edit(self): + """Edit the incident message and check whether `extract_message_links` is called or not.""" + self.cog_instance.incidents_webhook = MockAsyncWebhook() # Patch in our webhook + + edited_msg = MockMessage(id=123) + with patch("bot.exts.moderation.incidents.extract_message_links", AsyncMock()) as mock_extract_message_links: + await self.cog_instance.on_message_edit(MockMessage(id=123), edited_msg) + + mock_extract_message_links.assert_awaited_once() -- cgit v1.2.3 From c8a8cec90b376ea5e2a191957b82bab8d519ff00 Mon Sep 17 00:00:00 2001 From: Shivansh Date: Mon, 3 May 2021 10:13:41 +0530 Subject: Rework text shortner. Explanation: It is unnecessary to show 300 characters, when there is only one word which is so long, so if there is only one word in the text, it would be truncated to 50 words. Also in some cases, there are messages of many lines with 1 word on each line(say), this would again make the embed big and polluting, so it would limit the number of lines to a maximum of 3. Rest of the feature is the same as before. This implementation has been inspired from the `format_output` function of snekbox cog. --- bot/exts/moderation/incidents.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index a71cea45f..18c229644 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -139,6 +139,23 @@ def has_signals(message: discord.Message) -> bool: return ALL_SIGNALS.issubset(own_reactions(message)) +async def shorten_text(text: str) -> str: + lines = text.count("\n") + if lines > 3: + text = "\n".join(line for line in text.split('\n')[:3]) + if len(text) >= 300: + text = f"{text[:300]}\n... (truncated - too long, too many lines)" + else: + text = f"{text}\n... (truncated - too many lines)" + elif len(text) >= 300: + if text.count(" ") < 1: + text = f"{text[:50]}\n... (truncated - single word)" + else: + text = f"{text[:300]}\n... (truncated - too long)" + + return text + + async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional[discord.Embed]: """ Create an embedded representation of the discord message link contained in the incident report. @@ -149,22 +166,20 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional Content: This is a very important message! """ try: - message = await MessageConverter().convert(ctx, message_link) + message: discord.Message = await MessageConverter().convert(ctx, message_link) except discord.DiscordException as e: log.exception(f"Failed to make message link embed for '{message_link}', raised exception: {e}") else: - text = message.content.lstrip() channel = message.channel - shortened_text = text[:300] + (text[300:] and '...') embed = discord.Embed( colour=discord.Colour.gold(), description=( f"**Author:** {format_user(message.author)}\n" f"**Channel:** {channel.mention} ({channel.category}/#{channel.name})\n" - f"**Content:** {shortened_text}\n" + f"**Content:** {await shorten_text(message.content)}\n" ) ) embed.set_footer(text=f"Message ID: {message.id}") -- cgit v1.2.3 From 95d14d30a29aeeb2ced0a90e6e01cb9fd0ad4f6e Mon Sep 17 00:00:00 2001 From: Shivansh Date: Thu, 6 May 2021 09:22:05 +0530 Subject: (incidents): Refactor text shortner --- bot/exts/moderation/incidents.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 18c229644..09712f5a0 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -140,18 +140,21 @@ def has_signals(message: discord.Message) -> bool: async def shorten_text(text: str) -> str: + """Truncate the text if there are over 3 lines or 300 characters, or if it is a single word.""" + original_length = len(text) lines = text.count("\n") + # Limit to a maximum of three lines if lines > 3: text = "\n".join(line for line in text.split('\n')[:3]) - if len(text) >= 300: - text = f"{text[:300]}\n... (truncated - too long, too many lines)" - else: - text = f"{text}\n... (truncated - too many lines)" - elif len(text) >= 300: - if text.count(" ") < 1: - text = f"{text[:50]}\n... (truncated - single word)" - else: - text = f"{text[:300]}\n... (truncated - too long)" + # If it is a single word, then truncate it to 50 characters + if text.count(" ") < 1: + text = text[:50] + # Truncate text to a maximum of 300 characters + if len(text) > 300: + text = text[:300] + # Add placeholder if the text was shortened + if len(text) < original_length: + text += "..." return text @@ -179,9 +182,12 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional description=( f"**Author:** {format_user(message.author)}\n" f"**Channel:** {channel.mention} ({channel.category}/#{channel.name})\n" - f"**Content:** {await shorten_text(message.content)}\n" ) ) + embed.add_field( + name="Content", + value=await shorten_text(message.content) + ) embed.set_footer(text=f"Message ID: {message.id}") return embed -- cgit v1.2.3 From 86988ac67ceaf6fb6fb5cfada0d964fef4b591e3 Mon Sep 17 00:00:00 2001 From: Shivansh Date: Fri, 7 May 2021 09:36:49 +0530 Subject: (incidents): Add test for text shortner Pass all 3 cases of text shortening to the test case and test them, the cases being: i. If the message is just one word, then shorten to 50 characters. ii. Maximum lines being 3. iii. Maximum characters being 300. This commit also removes a misc bug, of passing self, while asserting equal. --- bot/exts/moderation/incidents.py | 4 ++-- tests/bot/exts/moderation/test_incidents.py | 24 ++++++++++++++++-------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 09712f5a0..22b50625a 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -139,7 +139,7 @@ def has_signals(message: discord.Message) -> bool: return ALL_SIGNALS.issubset(own_reactions(message)) -async def shorten_text(text: str) -> str: +def shorten_text(text: str) -> str: """Truncate the text if there are over 3 lines or 300 characters, or if it is a single word.""" original_length = len(text) lines = text.count("\n") @@ -186,7 +186,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional ) embed.add_field( name="Content", - value=await shorten_text(message.content) + value=shorten_text(message.content) ) embed.set_footer(text=f"Message ID: {message.id}") diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index 4b2b652fc..3c5d8f47d 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -12,7 +12,6 @@ from async_rediscache import RedisSession from bot.constants import Colours from bot.exts.moderation import incidents -from bot.exts.moderation.incidents import extract_message_links from bot.utils.messages import format_user from tests.helpers import ( MockAsyncWebhook, @@ -793,6 +792,19 @@ class TestOnMessage(TestIncidents): class TestMessageLinkEmbeds(TestIncidents): """Tests for `extract_message_links` coroutine.""" + async def test_shorten_text(self): + """Test all cases of text shortening by mocking messages.""" + tests = { + "thisisasingleword"*10: ('thisisasingleword'*10)[:50]+"...", + "\n".join("Lets make a new line test".split()): "Lets\nmake\na"+"...", + 'Hello, World!' * 300: ('Hello, World!' * 300)[:300] + '...' + } + + for test, value in tests.items(): + self.assertEqual( + str(incidents.shorten_text(test)), value + ) + async def extract_and_form_message_link_embeds(self): """ Extract message links from a mocked message and form the message link embed. @@ -821,7 +833,7 @@ class TestMessageLinkEmbeds(TestIncidents): f"as they break our rules: \n{', '.join(msg_links)}" ) - embeds = await extract_message_links(incident_msg, self.cog_instance.bot) + embeds = await incidents.extract_message_links(incident_msg, self.cog_instance.bot) description = ( f"**Author:** {format_user(msg.author)}\n" f"**Channel:** {msg.channel.mention} ({msg.channel.category}/#{msg.channel.name})\n" @@ -829,15 +841,11 @@ class TestMessageLinkEmbeds(TestIncidents): ) # Check number of embeds returned with number of valid links - self.assertEqual( - self, len(embeds), 2 - ) + self.assertEqual(len(embeds), 2) # Check for the embed descriptions for embed in embeds: - self.assertEqual( - self, embed.description, description - ) + self.assertEqual(embed.description, description) @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)) async def test_incident_message_edit(self): -- cgit v1.2.3 From 24c7a975cf18b80ae4bb6d65f5a4950bae0ca4cb Mon Sep 17 00:00:00 2001 From: Shivansh Date: Mon, 10 May 2021 10:02:22 +0530 Subject: (incidents): Use subtests for test_shorten_text --- tests/bot/exts/moderation/test_incidents.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index 3c5d8f47d..875b76057 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -800,10 +800,10 @@ class TestMessageLinkEmbeds(TestIncidents): 'Hello, World!' * 300: ('Hello, World!' * 300)[:300] + '...' } - for test, value in tests.items(): - self.assertEqual( - str(incidents.shorten_text(test)), value - ) + for content, expected_conversion in tests.items(): + with self.subTest(content=content, expected_conversion=expected_conversion): + conversion = incidents.shorten_text(content) + self.assertEqual(conversion, expected_conversion) async def extract_and_form_message_link_embeds(self): """ -- cgit v1.2.3 From fc9a9d2cd01530444804b271ed00432cacf85353 Mon Sep 17 00:00:00 2001 From: Shivansh Date: Mon, 10 May 2021 10:07:48 +0530 Subject: (incidents):Log with error if webhook not found --- bot/exts/moderation/incidents.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 22b50625a..7d0984bd1 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -297,6 +297,9 @@ class Incidents(Cog): await self.bot.wait_until_guild_available() self.incidents_webhook = await self.bot.fetch_webhook(Webhooks.incidents) + if not self.incidents_webhook: + log.error(f"Failed to fetch incidents webhook with id `{Webhooks.incidents}`.") + async def crawl_incidents(self) -> None: """ Crawl #incidents and add missing emoji where necessary. -- cgit v1.2.3 From 23126ee86d2aa4d9357c41247faf46e1b2a8d138 Mon Sep 17 00:00:00 2001 From: Shivansh Date: Tue, 11 May 2021 10:39:19 +0530 Subject: Only process the first 10 message links --- bot/exts/moderation/incidents.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 7d0984bd1..9ce892024 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -232,14 +232,12 @@ async def extract_message_links(message: discord.Message, bot: Bot) -> t.Optiona if message_links: embeds = [] - for message_link in message_links: + for message_link in message_links[:10]: ctx = await bot.get_context(message) embed = await make_message_link_embed(ctx, message_link[0]) if embed: embeds.append(embed) - return embeds[:10] - log.trace( f"Skipping discord message link detection on {message.id}: message doesn't qualify." ) -- cgit v1.2.3 From fc8c0c121fe853baa3ee4ecd760229eac6689387 Mon Sep 17 00:00:00 2001 From: Shivansh Date: Tue, 11 May 2021 10:44:31 +0530 Subject: Apply requested changes to doc strings --- bot/exts/moderation/incidents.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 9ce892024..950d419c0 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -271,7 +271,7 @@ class Incidents(Cog): * If `Signal.ACTIONED` or `Signal.NOT_ACTIONED` were chosen, attempt to relay the incident message to #incidents-archive * If relay successful, delete original message - * Search the cache for the webhook message for this message, if found delete it. + * Delete quotation message if cached * See: `on_raw_reaction_add` Please refer to function docstrings for implementation details. @@ -533,9 +533,9 @@ class Incidents(Cog): """ Pass `message` to `add_signals` and `extract_message_links` if it satisfies `is_incident`. - If the message (`message`) is an incident report, then run it through `extract_message_links` - to get all the message link embeds (embeds which contain information about that particular - link).These message link embeds are then sent into the channel. + If `message` is an incident report, then run it through `extract_message_links` to get all + the message link embeds (embeds which contain information about that particular link). + These message link embeds are then sent into the channel. Also passes the message into `add_signals` if the message is an incident. """ -- cgit v1.2.3 From 19112affa86ceb2fbe55e0cf751ac675f24d725e Mon Sep 17 00:00:00 2001 From: Shivansh Date: Tue, 11 May 2021 10:46:35 +0530 Subject: Use better variable names This commit also adds a line which was got removed by mistake earlier. --- bot/exts/moderation/incidents.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 950d419c0..05c2ad6c9 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -238,6 +238,8 @@ async def extract_message_links(message: discord.Message, bot: Bot) -> t.Optiona if embed: embeds.append(embed) + return embeds + log.trace( f"Skipping discord message link detection on {message.id}: message doesn't qualify." ) @@ -567,7 +569,7 @@ class Incidents(Cog): if webhook_msg_id: await self.incidents_webhook.edit_message( message_id=webhook_msg_id, - embeds=[x for x in webhook_embed_list if x is not None], + embeds=[embed for embed in webhook_embed_list if embed is not None], ) return @@ -600,7 +602,7 @@ class Incidents(Cog): """ try: webhook_msg = await webhook.send( - embeds=[x for x in webhook_embed_list if x is not None], + embeds=[embed for embed in webhook_embed_list if embed is not None], username=sub_clyde(message.author.name), avatar_url=message.author.avatar_url, wait=True, -- cgit v1.2.3 From aa620fefd1dbf9f5cda19a72bf29483a61aa2a93 Mon Sep 17 00:00:00 2001 From: Shivansh Date: Wed, 12 May 2021 09:29:39 +0530 Subject: Make `extract_message_links` an instance method Since it was used cog's state (`self.bot`), it would be better to move it to the cog. --- bot/exts/moderation/incidents.py | 61 ++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 05c2ad6c9..197842034 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -216,35 +216,6 @@ async def add_signals(incident: discord.Message) -> None: return -async def extract_message_links(message: discord.Message, bot: Bot) -> t.Optional[list]: - """ - Checks if there's any message links in the text content. - - Then passes the the message_link into `make_message_link_embed` to format a - embed for it containing information about the link. - - As discord only allows a max of 10 embeds in a single webhook, just send the - first 10 embeds and don't care about the rest. - - If no links are found for the message, it logs a trace statement. - """ - message_links = DISCORD_MESSAGE_LINK_RE.findall(str(message.content)) - - if message_links: - embeds = [] - for message_link in message_links[:10]: - ctx = await bot.get_context(message) - embed = await make_message_link_embed(ctx, message_link[0]) - if embed: - embeds.append(embed) - - return embeds - - log.trace( - f"Skipping discord message link detection on {message.id}: message doesn't qualify." - ) - - class Incidents(Cog): """ Automation for the #incidents channel. @@ -544,7 +515,7 @@ class Incidents(Cog): if is_incident(message): await add_signals(message) - webhook_embed_list = await extract_message_links(message, self.bot) + webhook_embed_list = await self.extract_message_links(message) if webhook_embed_list: await self.send_message_link_embeds(webhook_embed_list, message, self.incidents_webhook) @@ -563,7 +534,7 @@ class Incidents(Cog): The edited message is also passed into `add_signals` if it is a incident message. """ if is_incident(msg_after): - webhook_embed_list = await extract_message_links(msg_after, self.bot) + webhook_embed_list = await self.extract_message_links(msg_after) webhook_msg_id = await self.message_link_embeds_cache.get(msg_before.id) if webhook_msg_id: @@ -585,6 +556,34 @@ class Incidents(Cog): if is_incident(message): await self.delete_msg_link_embed(message) + async def extract_message_links(self, message: discord.Message) -> t.Optional[t.List[discord.Embed]]: + """ + Checks if there's any message links in the text content. + + Then passes the the message_link into `make_message_link_embed` to format a + embed for it containing information about the link. + + As discord only allows a max of 10 embeds in a single webhook, just send the + first 10 embeds and don't care about the rest. + + If no links are found for the message, it logs a trace statement. + """ + message_links = DISCORD_MESSAGE_LINK_RE.findall(str(message.content)) + + if message_links: + embeds = [] + for message_link in message_links[:10]: + ctx = await self.bot.get_context(message) + embed = await make_message_link_embed(ctx, message_link[0]) + if embed: + embeds.append(embed) + + return embeds + + log.trace( + f"Skipping discord message link detection on {message.id}: message doesn't qualify." + ) + async def send_message_link_embeds( self, webhook_embed_list: t.List, -- cgit v1.2.3 From 682693bc07960db186cef95b0188031f934a360c Mon Sep 17 00:00:00 2001 From: Shivansh Date: Wed, 12 May 2021 09:35:40 +0530 Subject: Delete msg link embed if no link on edit Earlier, if we edited a message which contained message links originally but not now, then the webhook message wouldn't get deleted. This commits fixes that bug. --- bot/exts/moderation/incidents.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 197842034..7aad1df35 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -537,6 +537,10 @@ class Incidents(Cog): webhook_embed_list = await self.extract_message_links(msg_after) webhook_msg_id = await self.message_link_embeds_cache.get(msg_before.id) + if not webhook_embed_list: + await self.delete_msg_link_embed(msg_after) + return + if webhook_msg_id: await self.incidents_webhook.edit_message( message_id=webhook_msg_id, -- cgit v1.2.3 From ecfcc902fc619c2f07c449b40c4373a61b5abaf7 Mon Sep 17 00:00:00 2001 From: Shivansh Date: Wed, 12 May 2021 09:55:19 +0530 Subject: Use `on_raw_message_edit` Originally it was using `on_message_edit` which would have failed if the message was not in the bot' cache. Therefore we would have to use a raw listener. --- bot/exts/moderation/incidents.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 7aad1df35..0da4acaa2 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -520,9 +520,12 @@ class Incidents(Cog): await self.send_message_link_embeds(webhook_embed_list, message, self.incidents_webhook) @Cog.listener() - async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None: + async def on_raw_message_edit(self, payload: discord.RawMessageUpdateEvent) -> None: """ - Pass `msg_after` to `extract_message_links` and edit `msg_before` webhook msg. + Pass processed `payload` to `extract_message_links` and edit `msg_before` webhook msg. + + Fetch the message found in payload, if not found i.e. the message got deleted then delete its + webhook message and return. Deletes cache value (`message_link_embeds_cache`) of `msg_before` if it exists and removes the webhook message for that particular link from the channel. @@ -533,9 +536,15 @@ class Incidents(Cog): The edited message is also passed into `add_signals` if it is a incident message. """ + try: + channel = self.bot.get_channel(int(payload.data["channel_id"])) + msg_after = await channel.fetch_message(payload.message_id) + except discord.NotFound: # Was deleted before we got the event + return + if is_incident(msg_after): webhook_embed_list = await self.extract_message_links(msg_after) - webhook_msg_id = await self.message_link_embeds_cache.get(msg_before.id) + webhook_msg_id = await self.message_link_embeds_cache.get(payload.message_id) if not webhook_embed_list: await self.delete_msg_link_embed(msg_after) -- cgit v1.2.3 From 44d5481d23cf819e347d3940812be3450ead1934 Mon Sep 17 00:00:00 2001 From: Shivansh Date: Wed, 12 May 2021 10:07:01 +0530 Subject: Use raw message delete listener --- bot/exts/moderation/incidents.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 0da4acaa2..7ef4af3df 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -540,6 +540,7 @@ class Incidents(Cog): channel = self.bot.get_channel(int(payload.data["channel_id"])) msg_after = await channel.fetch_message(payload.message_id) except discord.NotFound: # Was deleted before we got the event + await self.delete_msg_link_embed(payload.message_id) return if is_incident(msg_after): @@ -547,7 +548,7 @@ class Incidents(Cog): webhook_msg_id = await self.message_link_embeds_cache.get(payload.message_id) if not webhook_embed_list: - await self.delete_msg_link_embed(msg_after) + await self.delete_msg_link_embed(msg_after.id) return if webhook_msg_id: @@ -560,14 +561,13 @@ class Incidents(Cog): await self.send_message_link_embeds(webhook_embed_list, msg_after, self.incidents_webhook) @Cog.listener() - async def on_message_delete(self, message: discord.Message) -> None: + async def on_raw_message_delete(self, payload: discord.RawMessageDeleteEvent) -> None: """ - Delete message link embeds for `message`. + Delete message link embeds for `payload.message_id`. Search through the cache for message, if found delete it from cache and channel. """ - if is_incident(message): - await self.delete_msg_link_embed(message) + await self.delete_msg_link_embed(payload.message_id) async def extract_message_links(self, message: discord.Message) -> t.Optional[t.List[discord.Embed]]: """ @@ -630,10 +630,10 @@ class Incidents(Cog): log.trace("Message Link Embed Sent successfully!") return webhook_msg.id - async def delete_msg_link_embed(self, message: discord.Message) -> None: + async def delete_msg_link_embed(self, message_id: int) -> None: """Delete discord message link message found in cache for `message`.""" log.trace("Deleting discord links webhook message.") - webhook_msg_id = await self.message_link_embeds_cache.get(message.id) + webhook_msg_id = await self.message_link_embeds_cache.get(int(message_id)) if webhook_msg_id: try: @@ -641,7 +641,7 @@ class Incidents(Cog): except discord.errors.NotFound: log.trace(f"Incidents message link embed (`{webhook_msg_id}`) has already been deleted, skipping.") - await self.message_link_embeds_cache.delete(message.id) + await self.message_link_embeds_cache.delete(message_id) log.trace("Successfully deleted discord links webhook message.") -- cgit v1.2.3 From 40a57a1dad45f0b32f2c5137e9c36d9c6df183fd Mon Sep 17 00:00:00 2001 From: Shivansh Date: Wed, 12 May 2021 11:19:59 +0530 Subject: Update tests for message link embeds This commit updates the test in accordance with 0b35f2a and 0c5561d. --- bot/exts/moderation/incidents.py | 2 +- tests/bot/exts/moderation/test_incidents.py | 42 +++++++++++++++++++---------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 7ef4af3df..97bb32591 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -422,7 +422,7 @@ class Incidents(Cog): log.trace("Deletion was confirmed") # Deletes the message link embeds found in cache from the channel and cache. - await self.delete_msg_link_embed(incident) + await self.delete_msg_link_embed(incident.id) async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: """ diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index 875b76057..6e97d31af 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -833,27 +833,41 @@ class TestMessageLinkEmbeds(TestIncidents): f"as they break our rules: \n{', '.join(msg_links)}" ) - embeds = await incidents.extract_message_links(incident_msg, self.cog_instance.bot) - description = ( - f"**Author:** {format_user(msg.author)}\n" - f"**Channel:** {msg.channel.mention} ({msg.channel.category}/#{msg.channel.name})\n" - f"**Content:** {('Hello, World!' * 3000)[:300] + '...'}\n" - ) + with patch( + "bot.exts.moderation.incidents.Incidents.extract_message_links", AsyncMock() + ) as mock_extract_message_links: + embeds = mock_extract_message_links(incident_msg) + description = ( + f"**Author:** {format_user(msg.author)}\n" + f"**Channel:** {msg.channel.mention} ({msg.channel.category}/#{msg.channel.name})\n" + f"**Content:** {('Hello, World!' * 3000)[:300] + '...'}\n" + ) - # Check number of embeds returned with number of valid links - self.assertEqual(len(embeds), 2) + # Check number of embeds returned with number of valid links + self.assertEqual(len(embeds), 2) - # Check for the embed descriptions - for embed in embeds: - self.assertEqual(embed.description, description) + # Check for the embed descriptions + for embed in embeds: + self.assertEqual(embed.description, description) @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)) async def test_incident_message_edit(self): """Edit the incident message and check whether `extract_message_links` is called or not.""" self.cog_instance.incidents_webhook = MockAsyncWebhook() # Patch in our webhook - edited_msg = MockMessage(id=123) - with patch("bot.exts.moderation.incidents.extract_message_links", AsyncMock()) as mock_extract_message_links: - await self.cog_instance.on_message_edit(MockMessage(id=123), edited_msg) + text_channel = MockTextChannel() + self.cog_instance.bot.get_channel = MagicMock(return_value=text_channel) + text_channel.fetch_message = AsyncMock(return_value=MockMessage()) + + payload = AsyncMock( + discord.RawMessageUpdateEvent, + channel_id=123, + message_id=456 + ) + + with patch( + "bot.exts.moderation.incidents.Incidents.extract_message_links", AsyncMock() + ) as mock_extract_message_links: + await self.cog_instance.on_raw_message_edit(payload) mock_extract_message_links.assert_awaited_once() -- cgit v1.2.3 From 43bed60ff788eefba704318f8b18e0b3f8b5eb4c Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 16 Aug 2021 14:26:14 +0530 Subject: Mock id,content attribute rather than type casting --- bot/exts/moderation/incidents.py | 4 ++-- tests/bot/exts/moderation/test_incidents.py | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 97bb32591..8d255071a 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -537,7 +537,7 @@ class Incidents(Cog): The edited message is also passed into `add_signals` if it is a incident message. """ try: - channel = self.bot.get_channel(int(payload.data["channel_id"])) + channel = self.bot.get_channel(payload.channel_id) msg_after = await channel.fetch_message(payload.message_id) except discord.NotFound: # Was deleted before we got the event await self.delete_msg_link_embed(payload.message_id) @@ -626,7 +626,7 @@ class Incidents(Cog): ) else: - await self.message_link_embeds_cache.set(int(message.id), int(webhook_msg.id)) + await self.message_link_embeds_cache.set(message.id, webhook_msg.id) log.trace("Message Link Embed Sent successfully!") return webhook_msg.id diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index 6e97d31af..06eafdde3 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -853,11 +853,12 @@ class TestMessageLinkEmbeds(TestIncidents): @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)) async def test_incident_message_edit(self): """Edit the incident message and check whether `extract_message_links` is called or not.""" - self.cog_instance.incidents_webhook = MockAsyncWebhook() # Patch in our webhook + self.cog_instance.incidents_webhook = MockAsyncWebhook(id=101) # Patch in our webhook + self.cog_instance.incidents_webhook.send = AsyncMock(return_value=MockMessage(id=191)) - text_channel = MockTextChannel() + text_channel = MockTextChannel(id=123) self.cog_instance.bot.get_channel = MagicMock(return_value=text_channel) - text_channel.fetch_message = AsyncMock(return_value=MockMessage()) + text_channel.fetch_message = AsyncMock(return_value=MockMessage(id=777, content="Did jason just screw up?")) payload = AsyncMock( discord.RawMessageUpdateEvent, -- cgit v1.2.3 From 6965c0868bb6230c35eca9dac4541e5e904b7575 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 16 Aug 2021 15:41:40 +0530 Subject: Correct log trace link to show the correct behaviour --- 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 8d255071a..2de7dd666 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -594,7 +594,7 @@ class Incidents(Cog): return embeds log.trace( - f"Skipping discord message link detection on {message.id}: message doesn't qualify." + f"No message links detected on incident message with id {message.id}." ) async def send_message_link_embeds( -- cgit v1.2.3 From b01cccec51d5c9df298a0380ad2ab516ee219c3d Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 16 Aug 2021 15:43:59 +0530 Subject: Remove unnecessary check for embed when sending --- 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 2de7dd666..dfc2e0bb0 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -614,7 +614,7 @@ class Incidents(Cog): """ try: webhook_msg = await webhook.send( - embeds=[embed for embed in webhook_embed_list if embed is not None], + embeds=[embed for embed in webhook_embed_list], username=sub_clyde(message.author.name), avatar_url=message.author.avatar_url, wait=True, -- cgit v1.2.3 From 1b52ccf00df8550b0e5df02d350fb8e0baef935d Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sat, 21 Aug 2021 07:26:11 +0530 Subject: Handle message not found specially Originally message not found would be passed into discord.Exceptions and then would be logged which was unnecessary for a `MessageNotFound` error. Now this has been handled, if the bot receives a deleted message it would look through the last 100 messages of mod_logs channel to check if a log entry exists for that message. If one exists, then the incident embed would have this log entry message linked to it, if it doesn't it would send a message not found error embed. In future, we could maybe do the modlog deleted message entry finding via the python discord API, rather than using hard coded values and checking if they are existing in the log entry. --- bot/exts/moderation/incidents.py | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index dfc2e0bb0..58777f8cc 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -7,7 +7,7 @@ from enum import Enum import discord from async_rediscache import RedisCache -from discord.ext.commands import Cog, Context, MessageConverter +from discord.ext.commands import Cog, Context, MessageConverter, MessageNotFound from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Guild, Webhooks @@ -168,9 +168,36 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional Channel: Special/#bot-commands (814190307980607493) Content: This is a very important message! """ + embed = None + try: message: discord.Message = await MessageConverter().convert(ctx, message_link) + except MessageNotFound: + mod_logs_channel = ctx.bot.get_channel(Channels.mod_log) + last_100_logs: list[discord.Message] = await mod_logs_channel.history(limit=100).flatten() + + for log_entry in last_100_logs: + log_embed: discord.Embed = log_entry.embeds[0] + if ( + log_embed.author.name == "Message deleted" + and f"[Jump to message]({message_link})" in log_embed.description + ): + embed = discord.Embed( + colour=discord.Colour.dark_gold(), + title="Deleted Message Link", + description=( + f"Found <#{Channels.mod_log}> entry for deleted message: " + f"[Jump to message]({log_entry.jump_url})." + ) + ) + if not embed: + embed = discord.Embed( + colour=discord.Colour.red(), + title="Bad Message Link", + description=f"Message {message_link} not found." + ) + except discord.DiscordException as e: log.exception(f"Failed to make message link embed for '{message_link}', raised exception: {e}") @@ -190,7 +217,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional ) embed.set_footer(text=f"Message ID: {message.id}") - return embed + return embed async def add_signals(incident: discord.Message) -> None: -- cgit v1.2.3 From 1724d323db69f2aebc9b74a28b6322ef5f784fe4 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 22 Aug 2021 14:14:12 +0530 Subject: Sends msg embeds for helper readable msgs only --- bot/exts/moderation/incidents.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 58777f8cc..81a1f0721 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -10,7 +10,7 @@ from async_rediscache import RedisCache from discord.ext.commands import Cog, Context, MessageConverter, MessageNotFound from bot.bot import Bot -from bot.constants import Channels, Colours, Emojis, Guild, Webhooks +from bot.constants import Channels, Colours, Emojis, Guild, Roles, Webhooks from bot.utils import scheduling from bot.utils.messages import format_user, sub_clyde @@ -203,6 +203,13 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional else: channel = message.channel + helpers_role = message.guild.get_role(Roles.helpers) + if not channel.overwrites_for(helpers_role).read_messages: + log.info( + f"Helpers don't have read permissions in #{channel.name}," + " not sending message link embed for {message_link}" + ) + return embed = discord.Embed( colour=discord.Colour.gold(), @@ -641,7 +648,7 @@ class Incidents(Cog): """ try: webhook_msg = await webhook.send( - embeds=[embed for embed in webhook_embed_list], + embeds=[embed for embed in webhook_embed_list if embed], username=sub_clyde(message.author.name), avatar_url=message.author.avatar_url, wait=True, -- cgit v1.2.3 From 842cf60966e4a568b20961d350fb8ceaee6d8d96 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Tue, 31 Aug 2021 05:26:06 +0530 Subject: Goodbye enhanced incidents edits Was discussed with Mr.Webscale (joe), Xithrius in dev-voice --- bot/exts/moderation/incidents.py | 41 ----------------------------- tests/bot/exts/moderation/test_incidents.py | 23 ---------------- 2 files changed, 64 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 81a1f0721..70f5272e0 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -553,47 +553,6 @@ class Incidents(Cog): if webhook_embed_list: await self.send_message_link_embeds(webhook_embed_list, message, self.incidents_webhook) - @Cog.listener() - async def on_raw_message_edit(self, payload: discord.RawMessageUpdateEvent) -> None: - """ - Pass processed `payload` to `extract_message_links` and edit `msg_before` webhook msg. - - Fetch the message found in payload, if not found i.e. the message got deleted then delete its - webhook message and return. - - Deletes cache value (`message_link_embeds_cache`) of `msg_before` if it exists and removes the - webhook message for that particular link from the channel. - - If the message edit (`msg_after`) is a incident then run it through `extract_message_links` - to get all the message link embeds (embeds which contain information about that particular - link), this message link embeds are then sent into the channel. - - The edited message is also passed into `add_signals` if it is a incident message. - """ - try: - channel = self.bot.get_channel(payload.channel_id) - msg_after = await channel.fetch_message(payload.message_id) - except discord.NotFound: # Was deleted before we got the event - await self.delete_msg_link_embed(payload.message_id) - return - - if is_incident(msg_after): - webhook_embed_list = await self.extract_message_links(msg_after) - webhook_msg_id = await self.message_link_embeds_cache.get(payload.message_id) - - if not webhook_embed_list: - await self.delete_msg_link_embed(msg_after.id) - return - - if webhook_msg_id: - await self.incidents_webhook.edit_message( - message_id=webhook_msg_id, - embeds=[embed for embed in webhook_embed_list if embed is not None], - ) - return - - await self.send_message_link_embeds(webhook_embed_list, msg_after, self.incidents_webhook) - @Cog.listener() async def on_raw_message_delete(self, payload: discord.RawMessageDeleteEvent) -> None: """ diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index 06eafdde3..3bdc9128c 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -849,26 +849,3 @@ class TestMessageLinkEmbeds(TestIncidents): # Check for the embed descriptions for embed in embeds: self.assertEqual(embed.description, description) - - @patch("bot.exts.moderation.incidents.is_incident", MagicMock(return_value=True)) - async def test_incident_message_edit(self): - """Edit the incident message and check whether `extract_message_links` is called or not.""" - self.cog_instance.incidents_webhook = MockAsyncWebhook(id=101) # Patch in our webhook - self.cog_instance.incidents_webhook.send = AsyncMock(return_value=MockMessage(id=191)) - - text_channel = MockTextChannel(id=123) - self.cog_instance.bot.get_channel = MagicMock(return_value=text_channel) - text_channel.fetch_message = AsyncMock(return_value=MockMessage(id=777, content="Did jason just screw up?")) - - payload = AsyncMock( - discord.RawMessageUpdateEvent, - channel_id=123, - message_id=456 - ) - - with patch( - "bot.exts.moderation.incidents.Incidents.extract_message_links", AsyncMock() - ) as mock_extract_message_links: - await self.cog_instance.on_raw_message_edit(payload) - - mock_extract_message_links.assert_awaited_once() -- cgit v1.2.3 From b8f2694b99e182f716cd3d241c34cfbfcc484954 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 11 Oct 2021 12:37:03 +0530 Subject: Apply requested grammar and style changes Co-authored-by: Bluenix Co-authored-by: Boris Muratov <8bee278@gmail.com> --- bot/exts/moderation/incidents.py | 67 +++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 70f5272e0..0d28490b3 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -172,7 +172,6 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional try: message: discord.Message = await MessageConverter().convert(ctx, message_link) - except MessageNotFound: mod_logs_channel = ctx.bot.get_channel(Channels.mod_log) last_100_logs: list[discord.Message] = await mod_logs_channel.history(limit=100).flatten() @@ -197,10 +196,8 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional title="Bad Message Link", description=f"Message {message_link} not found." ) - except discord.DiscordException as e: log.exception(f"Failed to make message link embed for '{message_link}', raised exception: {e}") - else: channel = message.channel helpers_role = message.guild.get_role(Roles.helpers) @@ -292,13 +289,13 @@ class Incidents(Cog): """Prepare `event_lock` and schedule `crawl_task` on start-up.""" self.bot = bot - self.bot.loop.create_task(self.get_webhook()) + self.bot.loop.create_task(self.fetch_webhook()) self.event_lock = asyncio.Lock() self.crawl_task = scheduling.create_task(self.crawl_incidents(), event_loop=self.bot.loop) - async def get_webhook(self) -> None: - """Fetch and store message link embeds webhook, present in #incidents channel.""" + async def fetch_webhook(self) -> None: + """Fetches the incidents webhook object, so we can post message link embeds to it.""" await self.bot.wait_until_guild_available() self.incidents_webhook = await self.bot.fetch_webhook(Webhooks.incidents) @@ -405,7 +402,7 @@ class Incidents(Cog): message, and will abort. There is a `timeout` to ensure that this doesn't hold the lock forever should something go wrong. - Deletes cache value (`message_link_embeds_cache`) of `msg_before` if it exists and removes the + Deletes cache value (`message_link_embeds_cache`) of `incident` if it exists. It then removes the webhook message for that particular link from the channel. """ members_roles: t.Set[int] = {role.id for role in member.roles} @@ -546,12 +543,12 @@ class Incidents(Cog): Also passes the message into `add_signals` if the message is an incident. """ - if is_incident(message): - await add_signals(message) + if not is_incident(message): + return - webhook_embed_list = await self.extract_message_links(message) - if webhook_embed_list: - await self.send_message_link_embeds(webhook_embed_list, message, self.incidents_webhook) + await add_signals(message) + if embed_list := await self.extract_message_links(message): + await self.send_message_link_embeds(embed_list, message, self.incidents_webhook) @Cog.listener() async def on_raw_message_delete(self, payload: discord.RawMessageDeleteEvent) -> None: @@ -564,31 +561,31 @@ class Incidents(Cog): async def extract_message_links(self, message: discord.Message) -> t.Optional[t.List[discord.Embed]]: """ - Checks if there's any message links in the text content. + Check if there's any message links in the text content. - Then passes the the message_link into `make_message_link_embed` to format a + Then pass the message_link into `make_message_link_embed` to format an embed for it containing information about the link. - As discord only allows a max of 10 embeds in a single webhook, just send the + As Discord only allows a max of 10 embeds in a single webhook, just send the first 10 embeds and don't care about the rest. - If no links are found for the message, it logs a trace statement. + If no links are found for the message, just log a trace statement. """ message_links = DISCORD_MESSAGE_LINK_RE.findall(str(message.content)) + if not message_links: + log.trace( + f"No message links detected on incident message with id {message.id}." + ) + return - if message_links: - embeds = [] - for message_link in message_links[:10]: - ctx = await self.bot.get_context(message) - embed = await make_message_link_embed(ctx, message_link[0]) - if embed: - embeds.append(embed) - - return embeds + embeds = [] + for message_link in message_links[:10]: + ctx = await self.bot.get_context(message) + embed = await make_message_link_embed(ctx, message_link[0]) + if embed: + embeds.append(embed) - log.trace( - f"No message links detected on incident message with id {message.id}." - ) + return embeds async def send_message_link_embeds( self, @@ -597,12 +594,12 @@ class Incidents(Cog): webhook: discord.Webhook, ) -> t.Optional[int]: """ - Send Message Link Embeds to #incidents channel. + Send message link embeds to #incidents channel. - Uses the `webhook` passed in as a parameter to send + Using the `webhook` passed in as a parameter to send the embeds in the `webhook_embed_list` parameter. - After sending each webhook it maps the `message.id` + After sending each embed it maps the `message.id to the `webhook_msg_ids` IDs in the async redis-cache. """ try: @@ -612,20 +609,18 @@ class Incidents(Cog): avatar_url=message.author.avatar_url, wait=True, ) - except discord.DiscordException: log.exception( f"Failed to send message link embed {message.id} to #incidents." ) - else: await self.message_link_embeds_cache.set(message.id, webhook_msg.id) - log.trace("Message Link Embed Sent successfully!") + log.trace("Message link embeds sent successfully to #incidents!") return webhook_msg.id async def delete_msg_link_embed(self, message_id: int) -> None: - """Delete discord message link message found in cache for `message`.""" - log.trace("Deleting discord links webhook message.") + """Delete the Discord message link message found in cache for `message_id`.""" + log.trace("Deleting Discord message link's webhook message.") webhook_msg_id = await self.message_link_embeds_cache.get(int(message_id)) if webhook_msg_id: -- cgit v1.2.3 From 863c8d76c66ea748af9ab29bad1d02d16e3888f2 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 11 Oct 2021 12:52:39 +0530 Subject: Refactor `shorten_text` utility function --- bot/exts/moderation/incidents.py | 10 ++++++---- tests/bot/exts/moderation/test_incidents.py | 19 ++++++++++++++----- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 0d28490b3..4a84d825e 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -142,16 +142,18 @@ def has_signals(message: discord.Message) -> bool: def shorten_text(text: str) -> str: """Truncate the text if there are over 3 lines or 300 characters, or if it is a single word.""" original_length = len(text) - lines = text.count("\n") + lines = text.split("\n") # Limit to a maximum of three lines - if lines > 3: - text = "\n".join(line for line in text.split('\n')[:3]) + if len(lines) > 3: + text = "\n".join(line for line in lines[:3]) + # If it is a single word, then truncate it to 50 characters if text.count(" ") < 1: text = text[:50] # Truncate text to a maximum of 300 characters - if len(text) > 300: + elif len(text) > 300: text = text[:300] + # Add placeholder if the text was shortened if len(text) < original_length: text += "..." diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index 3bdc9128c..8304af1c0 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -795,9 +795,16 @@ class TestMessageLinkEmbeds(TestIncidents): async def test_shorten_text(self): """Test all cases of text shortening by mocking messages.""" tests = { - "thisisasingleword"*10: ('thisisasingleword'*10)[:50]+"...", - "\n".join("Lets make a new line test".split()): "Lets\nmake\na"+"...", - 'Hello, World!' * 300: ('Hello, World!' * 300)[:300] + '...' + "thisisasingleword"*10: "thisisasinglewordthisisasinglewordthisisasinglewor...", + + "\n".join("Lets make a new line test".split()): "Lets\nmake\na...", + + 'Hello, World!' * 300: ( + "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!" + "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!" + "Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!" + "Hello, World!Hello, World!H..." + ) } for content, expected_conversion in tests.items(): @@ -829,8 +836,10 @@ class TestMessageLinkEmbeds(TestIncidents): incident_msg = MockMessage( id=777, - content=f"I would like to report the following messages, " - f"as they break our rules: \n{', '.join(msg_links)}" + content=( + f"I would like to report the following messages, " + f"as they break our rules: \n{', '.join(msg_links)}" + ) ) with patch( -- cgit v1.2.3 From 371869ad39628df306270ba16a76e96c6ccd3dad Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Mon, 11 Oct 2021 11:50:31 +0100 Subject: Only send DM before applying infraction for ban/kick --- bot/exts/moderation/infraction/_scheduler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index f6fa5a3c3..1bdfbb943 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -161,7 +161,7 @@ class InfractionScheduler: # send DMs to user that it doesn't share a guild with. If we were to # apply kick/ban infractions first, this would mean that we'd make it # impossible for us to deliver a DM. See python-discord/bot#982. - if not infraction["hidden"]: + if not infraction["hidden"] and infr_type in {"ban", "kick"}: dm_result = f"{constants.Emojis.failmail} " dm_log_text = "\nDM: **Failed**" @@ -229,7 +229,7 @@ class InfractionScheduler: infr_message = f" **{purge}{' '.join(infr_type.split('_'))}** to {user.mention}{expiry_msg}{end_msg}" # If we need to DM and haven't already tried to - if not infraction["hidden"] and infr_type not in ("ban", "kick"): + if not infraction["hidden"] and infr_type not in {"ban", "kick"}: dm_result = f"{constants.Emojis.failmail} " dm_log_text = "\nDM: **Failed**" -- cgit v1.2.3 From 7316463b54eeafef777468db872e81973663d435 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Tue, 12 Oct 2021 06:36:04 +0530 Subject: Correct discord message link regex The biggest size a Discord snowflake can be is 20. Co-authored-by: Bluenix --- bot/exts/moderation/incidents.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 4a84d825e..5d2c66d6c 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -26,8 +26,8 @@ CRAWL_SLEEP = 2 DISCORD_MESSAGE_LINK_RE = re.compile( r"(https?:\/\/(?:(ptb|canary|www)\.)?discord(?:app)?\.com\/channels\/" - r"[0-9]{15,21}" - r"\/[0-9]{15,21}\/[0-9]{15,21})" + r"[0-9]{15,20}" + r"\/[0-9]{15,20}\/[0-9]{15,20})" ) -- cgit v1.2.3 From 5e6e361ce5f08425d84e53fab611a3b8b685fcc0 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Tue, 12 Oct 2021 06:45:30 +0530 Subject: Add error handling on modlog channel fetch --- bot/exts/moderation/incidents.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 5d2c66d6c..14bec3877 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -175,7 +175,12 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional try: message: discord.Message = await MessageConverter().convert(ctx, message_link) except MessageNotFound: - mod_logs_channel = ctx.bot.get_channel(Channels.mod_log) + try: + mod_logs_channel = ctx.bot.get_channel(Channels.mod_log) + except discord.NotFound: + log.exception(f"Mod-logs (<#{Channels.mod_log}> channel not found.") + return + last_100_logs: list[discord.Message] = await mod_logs_channel.history(limit=100).flatten() for log_entry in last_100_logs: -- cgit v1.2.3 From f28fc6bdd57f6def0fcd8bf9e43af82e20f7fa1e Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Tue, 12 Oct 2021 06:47:50 +0530 Subject: Update typehints --- bot/exts/moderation/incidents.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 14bec3877..5c1554861 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -1,9 +1,9 @@ import asyncio import logging import re -import typing as t from datetime import datetime from enum import Enum +from typing import Optional import discord from async_rediscache import RedisCache @@ -45,17 +45,17 @@ class Signal(Enum): # Reactions from non-mod roles will be removed -ALLOWED_ROLES: t.Set[int] = set(Guild.moderation_roles) +ALLOWED_ROLES: set[int] = set(Guild.moderation_roles) # Message must have all of these emoji to pass the `has_signals` check -ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal} +ALL_SIGNALS: set[str] = {signal.value for signal in Signal} # An embed coupled with an optional file to be dispatched # If the file is not None, the embed attempts to show it in its body -FileEmbed = t.Tuple[discord.Embed, t.Optional[discord.File]] +FileEmbed = tuple[discord.Embed, Optional[discord.File]] -async def download_file(attachment: discord.Attachment) -> t.Optional[discord.File]: +async def download_file(attachment: discord.Attachment) -> Optional[discord.File]: """ Download & return `attachment` file. @@ -129,7 +129,7 @@ def is_incident(message: discord.Message) -> bool: return all(conditions) -def own_reactions(message: discord.Message) -> t.Set[str]: +def own_reactions(message: discord.Message) -> set[str]: """Get the set of reactions placed on `message` by the bot itself.""" return {str(reaction.emoji) for reaction in message.reactions if reaction.me} @@ -161,7 +161,7 @@ def shorten_text(text: str) -> str: return text -async def make_message_link_embed(ctx: Context, message_link: str) -> t.Optional[discord.Embed]: +async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[discord.Embed]: """ Create an embedded representation of the discord message link contained in the incident report. @@ -412,7 +412,7 @@ class Incidents(Cog): Deletes cache value (`message_link_embeds_cache`) of `incident` if it exists. It then removes the webhook message for that particular link from the channel. """ - members_roles: t.Set[int] = {role.id for role in member.roles} + members_roles: set[int] = {role.id for role in member.roles} if not members_roles & ALLOWED_ROLES: # Intersection is truthy on at least 1 common element log.debug(f"Removing invalid reaction: user {member} is not permitted to send signals") try: @@ -462,7 +462,7 @@ class Incidents(Cog): # Deletes the message link embeds found in cache from the channel and cache. await self.delete_msg_link_embed(incident.id) - async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: + async def resolve_message(self, message_id: int) -> Optional[discord.Message]: """ Get `discord.Message` for `message_id` from cache, or API. @@ -477,7 +477,7 @@ class Incidents(Cog): """ await self.bot.wait_until_guild_available() # First make sure that the cache is ready log.trace(f"Resolving message for: {message_id=}") - message: t.Optional[discord.Message] = self.bot._connection._get_message(message_id) + message: Optional[discord.Message] = self.bot._connection._get_message(message_id) if message is not None: log.trace("Message was found in cache") @@ -566,7 +566,7 @@ class Incidents(Cog): """ await self.delete_msg_link_embed(payload.message_id) - async def extract_message_links(self, message: discord.Message) -> t.Optional[t.List[discord.Embed]]: + async def extract_message_links(self, message: discord.Message) -> Optional[list[discord.Embed]]: """ Check if there's any message links in the text content. @@ -596,10 +596,10 @@ class Incidents(Cog): async def send_message_link_embeds( self, - webhook_embed_list: t.List, + webhook_embed_list: list, message: discord.Message, webhook: discord.Webhook, - ) -> t.Optional[int]: + ) -> Optional[int]: """ Send message link embeds to #incidents channel. -- cgit v1.2.3 From 9da71eddbf5641f2aa734a2fbe67fc444d390856 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Tue, 12 Oct 2021 06:49:52 +0530 Subject: Use scheduling create_task util instead of creating from loop directly --- bot/exts/moderation/incidents.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 5c1554861..65dc69ca6 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -296,7 +296,7 @@ class Incidents(Cog): """Prepare `event_lock` and schedule `crawl_task` on start-up.""" self.bot = bot - self.bot.loop.create_task(self.fetch_webhook()) + scheduling.create_task(self.fetch_webhook(), event_loop=self.bot.loop) self.event_lock = asyncio.Lock() self.crawl_task = scheduling.create_task(self.crawl_incidents(), event_loop=self.bot.loop) @@ -554,7 +554,9 @@ class Incidents(Cog): return await add_signals(message) - if embed_list := await self.extract_message_links(message): + + # Only use this feature if incidents webhook embed is found + if embed_list := await self.extract_message_links(message) and self.incidents_webhook: await self.send_message_link_embeds(embed_list, message, self.incidents_webhook) @Cog.listener() -- cgit v1.2.3 From bc0d5709f046fb0364bccd200982242ebe3ba510 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 14 Oct 2021 15:13:04 +0000 Subject: Add ISort to this project --- poetry.lock | 564 +++++++++++++++++++++++++++++++++++---------------------- pyproject.toml | 9 + 2 files changed, 353 insertions(+), 220 deletions(-) diff --git a/poetry.lock b/poetry.lock index 81b51b8da..11fda89e6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -140,14 +140,14 @@ testing = ["pytest (>=4.6)", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3 [[package]] name = "beautifulsoup4" -version = "4.9.3" +version = "4.10.0" description = "Screen-scraping library" category = "main" optional = false -python-versions = "*" +python-versions = ">3.0.0" [package.dependencies] -soupsieve = {version = ">1.2", markers = "python_version >= \"3.0\""} +soupsieve = ">1.2" [package.extras] html5lib = ["html5lib"] @@ -155,7 +155,7 @@ lxml = ["lxml"] [[package]] name = "certifi" -version = "2021.5.30" +version = "2021.10.8" description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = false @@ -190,7 +190,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "charset-normalizer" -version = "2.0.4" +version = "2.0.7" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "dev" optional = false @@ -279,7 +279,7 @@ voice = ["PyNaCl (>=1.3.0,<1.5)"] [[package]] name = "distlib" -version = "0.3.2" +version = "0.3.3" description = "Distribution utilities" category = "dev" optional = false @@ -317,13 +317,14 @@ testing = ["pre-commit"] [[package]] name = "fakeredis" -version = "1.6.0" +version = "1.6.1" description = "Fake implementation of redis API for testing purposes." category = "main" optional = false python-versions = ">=3.5" [package.dependencies] +packaging = "*" redis = "<3.6.0" six = ">=1.12" sortedcontainers = "*" @@ -345,11 +346,15 @@ sgmllib3k = "*" [[package]] name = "filelock" -version = "3.0.12" +version = "3.3.0" description = "A platform independent file lock." category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.6" + +[package.extras] +docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] +testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] [[package]] name = "flake8" @@ -366,14 +371,14 @@ pyflakes = ">=2.3.0,<2.4.0" [[package]] name = "flake8-annotations" -version = "2.6.2" +version = "2.7.0" description = "Flake8 Type Annotation Checks" category = "dev" optional = false -python-versions = ">=3.6.1,<4.0.0" +python-versions = ">=3.6.2,<4.0.0" [package.dependencies] -flake8 = ">=3.7,<4.0" +flake8 = ">=3.7,<5.0" [[package]] name = "flake8-bugbear" @@ -413,6 +418,22 @@ python-versions = "*" [package.dependencies] pycodestyle = "*" +[[package]] +name = "flake8-isort" +version = "4.0.0" +description = "flake8 plugin that integrates isort ." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +flake8 = ">=3.2.1,<4" +isort = ">=4.3.5,<6" +testfixtures = ">=6.8.0,<7" + +[package.extras] +test = ["pytest (>=4.0.2,<6)", "toml"] + [[package]] name = "flake8-polyfill" version = "1.0.2" @@ -437,14 +458,14 @@ flake8 = "*" [[package]] name = "flake8-tidy-imports" -version = "4.4.1" +version = "4.5.0" description = "A flake8 plugin that helps you write tidier imports." category = "dev" optional = false python-versions = ">=3.6" [package.dependencies] -flake8 = ">=3.8.0,<4" +flake8 = ">=3.8.0,<5" [[package]] name = "flake8-todo" @@ -467,18 +488,18 @@ python-versions = ">=3.6" [[package]] name = "humanfriendly" -version = "9.2" +version = "10.0" description = "Human friendly output for text interfaces using Python" category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.dependencies] -pyreadline = {version = "*", markers = "sys_platform == \"win32\""} +pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_version >= \"3.8\""} [[package]] name = "identify" -version = "2.2.13" +version = "2.3.0" description = "File identification library for Python" category = "dev" optional = false @@ -503,6 +524,20 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "isort" +version = "5.9.3" +description = "A Python utility / library to sort Python imports." +category = "dev" +optional = false +python-versions = ">=3.6.1,<4.0" + +[package.extras] +pipfile_deprecated_finder = ["pipreqs", "requirementslib"] +requirements_deprecated_finder = ["pipreqs", "pip-api"] +colors = ["colorama (>=0.4.3,<0.5.0)"] +plugins = ["setuptools"] + [[package]] name = "lxml" version = "4.6.3" @@ -539,7 +574,7 @@ python-versions = "*" [[package]] name = "more-itertools" -version = "8.8.0" +version = "8.10.0" description = "More routines for operating on iterables, beyond itertools" category = "main" optional = false @@ -555,7 +590,7 @@ python-versions = ">=3.5" [[package]] name = "multidict" -version = "5.1.0" +version = "5.2.0" description = "multidict implementation" category = "main" optional = false @@ -581,7 +616,7 @@ python-versions = ">=3.5" name = "packaging" version = "21.0" description = "Core utilities for Python packages" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -613,7 +648,7 @@ flake8-polyfill = ">=1.0.2,<2" [[package]] name = "pip-licenses" -version = "3.5.2" +version = "3.5.3" description = "Dump the software license list of Python packages installed with pip." category = "dev" optional = false @@ -627,7 +662,7 @@ test = ["docutils", "pytest-cov", "pytest-pycodestyle", "pytest-runner"] [[package]] name = "platformdirs" -version = "2.2.0" +version = "2.4.0" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false @@ -639,18 +674,19 @@ test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock [[package]] name = "pluggy" -version = "0.13.1" +version = "1.0.0" description = "plugin and hook calling mechanisms for python" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" [package.extras] dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "2.14.0" +version = "2.15.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -747,21 +783,21 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" name = "pyparsing" version = "2.4.7" description = "Python parsing module" -category = "dev" +category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] -name = "pyreadline" -version = "2.1" -description = "A python implmementation of GNU readline." +name = "pyreadline3" +version = "3.3" +description = "A python implementation of GNU readline." category = "main" optional = false python-versions = "*" [[package]] name = "pytest" -version = "6.2.4" +version = "6.2.5" description = "pytest: simple powerful testing with Python" category = "dev" optional = false @@ -773,7 +809,7 @@ attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} iniconfig = "*" packaging = "*" -pluggy = ">=0.12,<1.0.0a1" +pluggy = ">=0.12,<2.0" py = ">=1.8.2" toml = "*" @@ -873,11 +909,14 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [[package]] name = "rapidfuzz" -version = "1.5.0" +version = "1.7.1" description = "rapid fuzzy string matching" category = "main" optional = false -python-versions = ">=3.5" +python-versions = ">=2.7" + +[package.extras] +full = ["numpy"] [[package]] name = "redis" @@ -918,7 +957,7 @@ use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] [[package]] name = "sentry-sdk" -version = "1.3.1" +version = "1.4.3" description = "Python client for Sentry (https://sentry.io)" category = "main" optional = false @@ -1006,6 +1045,19 @@ mslex = ">=0.3.0,<0.4.0" psutil = ">=5.7.2,<6.0.0" toml = ">=0.10.0,<0.11.0" +[[package]] +name = "testfixtures" +version = "6.18.3" +description = "A collection of helpers and mock objects for unit tests and doc tests." +category = "dev" +optional = false +python-versions = "*" + +[package.extras] +build = ["setuptools-git", "wheel", "twine"] +docs = ["sphinx", "zope.component", "sybil", "twisted", "mock", "django (<2)", "django"] +test = ["pytest (>=3.6)", "pytest-cov", "pytest-django", "zope.component", "sybil", "twisted", "mock", "django (<2)", "django"] + [[package]] name = "toml" version = "0.10.2" @@ -1016,7 +1068,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "typing-extensions" -version = "3.10.0.0" +version = "3.10.0.2" description = "Backported and Experimental Type Hints for Python 3.5+" category = "main" optional = false @@ -1024,7 +1076,7 @@ python-versions = "*" [[package]] name = "urllib3" -version = "1.26.6" +version = "1.26.7" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false @@ -1037,7 +1089,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.7.2" +version = "20.8.1" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -1056,7 +1108,7 @@ testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", [[package]] name = "yarl" -version = "1.6.3" +version = "1.7.0" description = "Yet another URL library" category = "main" optional = false @@ -1069,7 +1121,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "ceddbb2621849f480f736985d71f37cebefd08a9b38bc3943a6f72706258b6ee" +content-hash = "4c8634e841913b01b832e4374dbd52a4cbbc7a177c0cd465c7a5361eeadbf009" [metadata.files] aio-pika = [ @@ -1152,13 +1204,12 @@ attrs = [ {file = "backports.entry_points_selectable-1.1.0.tar.gz", hash = "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a"}, ] beautifulsoup4 = [ - {file = "beautifulsoup4-4.9.3-py2-none-any.whl", hash = "sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35"}, - {file = "beautifulsoup4-4.9.3-py3-none-any.whl", hash = "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666"}, - {file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"}, + {file = "beautifulsoup4-4.10.0-py3-none-any.whl", hash = "sha256:9a315ce70049920ea4572a4055bc4bd700c940521d36fc858205ad4fcde149bf"}, + {file = "beautifulsoup4-4.10.0.tar.gz", hash = "sha256:c23ad23c521d818955a4151a67d81580319d4bf548d3d49f4223ae041ff98891"}, ] certifi = [ - {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, - {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, + {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, + {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, ] cffi = [ {file = "cffi-1.14.6-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:22b9c3c320171c108e903d61a3723b51e37aaa8c81255b5e7ce102775bd01e2c"}, @@ -1216,8 +1267,8 @@ chardet = [ {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, ] charset-normalizer = [ - {file = "charset-normalizer-2.0.4.tar.gz", hash = "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3"}, - {file = "charset_normalizer-2.0.4-py3-none-any.whl", hash = "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b"}, + {file = "charset-normalizer-2.0.7.tar.gz", hash = "sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0"}, + {file = "charset_normalizer-2.0.7-py3-none-any.whl", hash = "sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b"}, ] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, @@ -1294,8 +1345,8 @@ deepdiff = [ {file = "discord.py-1.7.3.tar.gz", hash = "sha256:462cd0fe307aef8b29cbfa8dd613e548ae4b2cb581d46da9ac0d46fb6ea19408"}, ] distlib = [ - {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, - {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"}, + {file = "distlib-0.3.3-py2.py3-none-any.whl", hash = "sha256:c8b54e8454e5bf6237cc84c20e8264c3e991e824ef27e8f1e81049867d861e31"}, + {file = "distlib-0.3.3.zip", hash = "sha256:d982d0751ff6eaaab5e2ec8e691d949ee80eddf01a62eaa96ddb11531fe16b05"}, ] docopt = [ {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, @@ -1308,24 +1359,24 @@ execnet = [ {file = "execnet-1.9.0.tar.gz", hash = "sha256:8f694f3ba9cc92cab508b152dcfe322153975c29bda272e2fd7f3f00f36e47c5"}, ] fakeredis = [ - {file = "fakeredis-1.6.0-py3-none-any.whl", hash = "sha256:3449b306f3a85102b28f8180c24722ef966fcb1e3c744758b6f635ec80321a5c"}, - {file = "fakeredis-1.6.0.tar.gz", hash = "sha256:11ccfc9769d718d37e45b382e64a6ba02586b622afa0371a6bd85766d72255f3"}, + {file = "fakeredis-1.6.1-py3-none-any.whl", hash = "sha256:5eb1516f1fe1813e9da8f6c482178fc067af09f53de587ae03887ef5d9d13024"}, + {file = "fakeredis-1.6.1.tar.gz", hash = "sha256:0d06a9384fb79da9f2164ce96e34eb9d4e2ea46215070805ea6fd3c174590b47"}, ] feedparser = [ {file = "feedparser-6.0.8-py3-none-any.whl", hash = "sha256:1b7f57841d9cf85074deb316ed2c795091a238adb79846bc46dccdaf80f9c59a"}, {file = "feedparser-6.0.8.tar.gz", hash = "sha256:5ce0410a05ab248c8c7cfca3a0ea2203968ee9ff4486067379af4827a59f9661"}, ] filelock = [ - {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, - {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, + {file = "filelock-3.3.0-py3-none-any.whl", hash = "sha256:bbc6a0382fe8ec4744ecdf6683a2e07f65eb10ff1aff53fc02a202565446cde0"}, + {file = "filelock-3.3.0.tar.gz", hash = "sha256:8c7eab13dc442dc249e95158bcc12dec724465919bdc9831fdbf0660f03d1785"}, ] flake8 = [ {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, ] flake8-annotations = [ - {file = "flake8-annotations-2.6.2.tar.gz", hash = "sha256:0d6cd2e770b5095f09689c9d84cc054c51b929c41a68969ea1beb4b825cac515"}, - {file = "flake8_annotations-2.6.2-py3-none-any.whl", hash = "sha256:d10c4638231f8a50c0a597c4efce42bd7b7d85df4f620a0ddaca526138936a4f"}, + {file = "flake8-annotations-2.7.0.tar.gz", hash = "sha256:52e53c05b0c06cac1c2dec192ea2c36e85081238add3bd99421d56f574b9479b"}, + {file = "flake8_annotations-2.7.0-py3-none-any.whl", hash = "sha256:3edfbbfb58e404868834fe6ec3eaf49c139f64f0701259f707d043185545151e"}, ] flake8-bugbear = [ {file = "flake8-bugbear-20.11.1.tar.gz", hash = "sha256:528020129fea2dea33a466b9d64ab650aa3e5f9ffc788b70ea4bc6cf18283538"}, @@ -1339,6 +1390,10 @@ flake8-import-order = [ {file = "flake8-import-order-0.18.1.tar.gz", hash = "sha256:a28dc39545ea4606c1ac3c24e9d05c849c6e5444a50fb7e9cdd430fc94de6e92"}, {file = "flake8_import_order-0.18.1-py2.py3-none-any.whl", hash = "sha256:90a80e46886259b9c396b578d75c749801a41ee969a235e163cfe1be7afd2543"}, ] +flake8-isort = [ + {file = "flake8-isort-4.0.0.tar.gz", hash = "sha256:2b91300f4f1926b396c2c90185844eb1a3d5ec39ea6138832d119da0a208f4d9"}, + {file = "flake8_isort-4.0.0-py2.py3-none-any.whl", hash = "sha256:729cd6ef9ba3659512dee337687c05d79c78e1215fdf921ed67e5fe46cce2f3c"}, +] flake8-polyfill = [ {file = "flake8-polyfill-1.0.2.tar.gz", hash = "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda"}, {file = "flake8_polyfill-1.0.2-py2.py3-none-any.whl", hash = "sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9"}, @@ -1348,8 +1403,8 @@ flake8-string-format = [ {file = "flake8_string_format-0.3.0-py2.py3-none-any.whl", hash = "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af"}, ] flake8-tidy-imports = [ - {file = "flake8-tidy-imports-4.4.1.tar.gz", hash = "sha256:c18b3351b998787db071e766e318da1f0bd9d5cecc69c4022a69e7aa2efb2c51"}, - {file = "flake8_tidy_imports-4.4.1-py3-none-any.whl", hash = "sha256:631a1ba9daaedbe8bb53f6086c5a92b390e98371205259e0e311a378df8c3dc8"}, + {file = "flake8-tidy-imports-4.5.0.tar.gz", hash = "sha256:ac637961d0f319012d099e49619f8c928e3221f74e00fe6eb89513bc64c40adb"}, + {file = "flake8_tidy_imports-4.5.0-py3-none-any.whl", hash = "sha256:87eed94ae6a2fda6a5918d109746feadf1311e0eb8274ab7a7920f6db00a41c9"}, ] flake8-todo = [ {file = "flake8-todo-0.7.tar.gz", hash = "sha256:6e4c5491ff838c06fe5a771b0e95ee15fc005ca57196011011280fc834a85915"}, @@ -1398,12 +1453,12 @@ hiredis = [ {file = "hiredis-2.0.0.tar.gz", hash = "sha256:81d6d8e39695f2c37954d1011c0480ef7cf444d4e3ae24bc5e89ee5de360139a"}, ] humanfriendly = [ - {file = "humanfriendly-9.2-py2.py3-none-any.whl", hash = "sha256:332da98c24cc150efcc91b5508b19115209272bfdf4b0764a56795932f854271"}, - {file = "humanfriendly-9.2.tar.gz", hash = "sha256:f7dba53ac7935fd0b4a2fc9a29e316ddd9ea135fb3052d3d0279d10c18ff9c48"}, + {file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"}, + {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"}, ] identify = [ - {file = "identify-2.2.13-py2.py3-none-any.whl", hash = "sha256:7199679b5be13a6b40e6e19ea473e789b11b4e3b60986499b1f589ffb03c217c"}, - {file = "identify-2.2.13.tar.gz", hash = "sha256:7bc6e829392bd017236531963d2d937d66fc27cadc643ac0aba2ce9f26157c79"}, + {file = "identify-2.3.0-py2.py3-none-any.whl", hash = "sha256:d1e82c83d063571bb88087676f81261a4eae913c492dafde184067c584bc7c05"}, + {file = "identify-2.3.0.tar.gz", hash = "sha256:fd08c97f23ceee72784081f1ce5125c8f53a02d3f2716dde79a6ab8f1039fea5"}, ] idna = [ {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, @@ -1413,6 +1468,10 @@ iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] +isort = [ + {file = "isort-5.9.3-py3-none-any.whl", hash = "sha256:e17d6e2b81095c9db0a03a8025a957f334d6ea30b26f9ec70805411e5c7c81f2"}, + {file = "isort-5.9.3.tar.gz", hash = "sha256:9c2ea1e62d871267b78307fe511c0838ba0da28698c5732d54e2790bf3ba9899"}, +] lxml = [ {file = "lxml-4.6.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2"}, {file = "lxml-4.6.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:1b7584d421d254ab86d4f0b13ec662a9014397678a7c4265a02a6d7c2b18a75f"}, @@ -1421,6 +1480,8 @@ lxml = [ {file = "lxml-4.6.3-cp27-cp27m-win_amd64.whl", hash = "sha256:8157dadbb09a34a6bd95a50690595e1fa0af1a99445e2744110e3dca7831c4ee"}, {file = "lxml-4.6.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7728e05c35412ba36d3e9795ae8995e3c86958179c9770e65558ec3fdfd3724f"}, {file = "lxml-4.6.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:4bff24dfeea62f2e56f5bab929b4428ae6caba2d1eea0c2d6eb618e30a71e6d4"}, + {file = "lxml-4.6.3-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:64812391546a18896adaa86c77c59a4998f33c24788cadc35789e55b727a37f4"}, + {file = "lxml-4.6.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c1a40c06fd5ba37ad39caa0b3144eb3772e813b5fb5b084198a985431c2f1e8d"}, {file = "lxml-4.6.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51"}, {file = "lxml-4.6.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f90ba11136bfdd25cae3951af8da2e95121c9b9b93727b1b896e3fa105b2f586"}, {file = "lxml-4.6.3-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:4c61b3a0db43a1607d6264166b230438f85bfed02e8cff20c22e564d0faff354"}, @@ -1470,51 +1531,86 @@ mccabe = [ {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] more-itertools = [ - {file = "more-itertools-8.8.0.tar.gz", hash = "sha256:83f0308e05477c68f56ea3a888172c78ed5d5b3c282addb67508e7ba6c8f813a"}, - {file = "more_itertools-8.8.0-py3-none-any.whl", hash = "sha256:2cf89ec599962f2ddc4d568a05defc40e0a587fbc10d5989713638864c36be4d"}, + {file = "more-itertools-8.10.0.tar.gz", hash = "sha256:1debcabeb1df793814859d64a81ad7cb10504c24349368ccf214c664c474f41f"}, + {file = "more_itertools-8.10.0-py3-none-any.whl", hash = "sha256:56ddac45541718ba332db05f464bebfb0768110111affd27f66e0051f276fa43"}, ] mslex = [ {file = "mslex-0.3.0-py2.py3-none-any.whl", hash = "sha256:380cb14abf8fabf40e56df5c8b21a6d533dc5cbdcfe42406bbf08dda8f42e42a"}, {file = "mslex-0.3.0.tar.gz", hash = "sha256:4a1ac3f25025cad78ad2fe499dd16d42759f7a3801645399cce5c404415daa97"}, ] multidict = [ - {file = "multidict-5.1.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:b7993704f1a4b204e71debe6095150d43b2ee6150fa4f44d6d966ec356a8d61f"}, - {file = "multidict-5.1.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:9dd6e9b1a913d096ac95d0399bd737e00f2af1e1594a787e00f7975778c8b2bf"}, - {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281"}, - {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:1ab820665e67373de5802acae069a6a05567ae234ddb129f31d290fc3d1aa56d"}, - {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:9436dc58c123f07b230383083855593550c4d301d2532045a17ccf6eca505f6d"}, - {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:830f57206cc96ed0ccf68304141fec9481a096c4d2e2831f311bde1c404401da"}, - {file = "multidict-5.1.0-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:2e68965192c4ea61fff1b81c14ff712fc7dc15d2bd120602e4a3494ea6584224"}, - {file = "multidict-5.1.0-cp36-cp36m-win32.whl", hash = "sha256:2f1a132f1c88724674271d636e6b7351477c27722f2ed789f719f9e3545a3d26"}, - {file = "multidict-5.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:3a4f32116f8f72ecf2a29dabfb27b23ab7cdc0ba807e8459e59a93a9be9506f6"}, - {file = "multidict-5.1.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:46c73e09ad374a6d876c599f2328161bcd95e280f84d2060cf57991dec5cfe76"}, - {file = "multidict-5.1.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:018132dbd8688c7a69ad89c4a3f39ea2f9f33302ebe567a879da8f4ca73f0d0a"}, - {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:4b186eb7d6ae7c06eb4392411189469e6a820da81447f46c0072a41c748ab73f"}, - {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:3a041b76d13706b7fff23b9fc83117c7b8fe8d5fe9e6be45eee72b9baa75f348"}, - {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:051012ccee979b2b06be928a6150d237aec75dd6bf2d1eeeb190baf2b05abc93"}, - {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:6a4d5ce640e37b0efcc8441caeea8f43a06addace2335bd11151bc02d2ee31f9"}, - {file = "multidict-5.1.0-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:5cf3443199b83ed9e955f511b5b241fd3ae004e3cb81c58ec10f4fe47c7dce37"}, - {file = "multidict-5.1.0-cp37-cp37m-win32.whl", hash = "sha256:f200755768dc19c6f4e2b672421e0ebb3dd54c38d5a4f262b872d8cfcc9e93b5"}, - {file = "multidict-5.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:05c20b68e512166fddba59a918773ba002fdd77800cad9f55b59790030bab632"}, - {file = "multidict-5.1.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:54fd1e83a184e19c598d5e70ba508196fd0bbdd676ce159feb412a4a6664f952"}, - {file = "multidict-5.1.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:0e3c84e6c67eba89c2dbcee08504ba8644ab4284863452450520dad8f1e89b79"}, - {file = "multidict-5.1.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:dc862056f76443a0db4509116c5cd480fe1b6a2d45512a653f9a855cc0517456"}, - {file = "multidict-5.1.0-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:0e929169f9c090dae0646a011c8b058e5e5fb391466016b39d21745b48817fd7"}, - {file = "multidict-5.1.0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:d81eddcb12d608cc08081fa88d046c78afb1bf8107e6feab5d43503fea74a635"}, - {file = "multidict-5.1.0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:585fd452dd7782130d112f7ddf3473ffdd521414674c33876187e101b588738a"}, - {file = "multidict-5.1.0-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:37e5438e1c78931df5d3c0c78ae049092877e5e9c02dd1ff5abb9cf27a5914ea"}, - {file = "multidict-5.1.0-cp38-cp38-win32.whl", hash = "sha256:07b42215124aedecc6083f1ce6b7e5ec5b50047afa701f3442054373a6deb656"}, - {file = "multidict-5.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:929006d3c2d923788ba153ad0de8ed2e5ed39fdbe8e7be21e2f22ed06c6783d3"}, - {file = "multidict-5.1.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b797515be8743b771aa868f83563f789bbd4b236659ba52243b735d80b29ed93"}, - {file = "multidict-5.1.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d5c65bdf4484872c4af3150aeebe101ba560dcfb34488d9a8ff8dbcd21079647"}, - {file = "multidict-5.1.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b47a43177a5e65b771b80db71e7be76c0ba23cc8aa73eeeb089ed5219cdbe27d"}, - {file = "multidict-5.1.0-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:806068d4f86cb06af37cd65821554f98240a19ce646d3cd24e1c33587f313eb8"}, - {file = "multidict-5.1.0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:46dd362c2f045095c920162e9307de5ffd0a1bfbba0a6e990b344366f55a30c1"}, - {file = "multidict-5.1.0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:ace010325c787c378afd7f7c1ac66b26313b3344628652eacd149bdd23c68841"}, - {file = "multidict-5.1.0-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:ecc771ab628ea281517e24fd2c52e8f31c41e66652d07599ad8818abaad38cda"}, - {file = "multidict-5.1.0-cp39-cp39-win32.whl", hash = "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80"}, - {file = "multidict-5.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:7df80d07818b385f3129180369079bd6934cf70469f99daaebfac89dca288359"}, - {file = "multidict-5.1.0.tar.gz", hash = "sha256:25b4e5f22d3a37ddf3effc0710ba692cfc792c2b9edfb9c05aefe823256e84d5"}, + {file = "multidict-5.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3822c5894c72e3b35aae9909bef66ec83e44522faf767c0ad39e0e2de11d3b55"}, + {file = "multidict-5.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:28e6d883acd8674887d7edc896b91751dc2d8e87fbdca8359591a13872799e4e"}, + {file = "multidict-5.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b61f85101ef08cbbc37846ac0e43f027f7844f3fade9b7f6dd087178caedeee7"}, + {file = "multidict-5.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9b668c065968c5979fe6b6fa6760bb6ab9aeb94b75b73c0a9c1acf6393ac3bf"}, + {file = "multidict-5.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:517d75522b7b18a3385726b54a081afd425d4f41144a5399e5abd97ccafdf36b"}, + {file = "multidict-5.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b4ac3ba7a97b35a5ccf34f41b5a8642a01d1e55454b699e5e8e7a99b5a3acf5"}, + {file = "multidict-5.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:df23c83398715b26ab09574217ca21e14694917a0c857e356fd39e1c64f8283f"}, + {file = "multidict-5.2.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e58a9b5cc96e014ddf93c2227cbdeca94b56a7eb77300205d6e4001805391747"}, + {file = "multidict-5.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f76440e480c3b2ca7f843ff8a48dc82446b86ed4930552d736c0bac507498a52"}, + {file = "multidict-5.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cfde464ca4af42a629648c0b0d79b8f295cf5b695412451716531d6916461628"}, + {file = "multidict-5.2.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:0fed465af2e0eb6357ba95795d003ac0bdb546305cc2366b1fc8f0ad67cc3fda"}, + {file = "multidict-5.2.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:b70913cbf2e14275013be98a06ef4b412329fe7b4f83d64eb70dce8269ed1e1a"}, + {file = "multidict-5.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a5635bcf1b75f0f6ef3c8a1ad07b500104a971e38d3683167b9454cb6465ac86"}, + {file = "multidict-5.2.0-cp310-cp310-win32.whl", hash = "sha256:77f0fb7200cc7dedda7a60912f2059086e29ff67cefbc58d2506638c1a9132d7"}, + {file = "multidict-5.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:9416cf11bcd73c861267e88aea71e9fcc35302b3943e45e1dbb4317f91a4b34f"}, + {file = "multidict-5.2.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:fd77c8f3cba815aa69cb97ee2b2ef385c7c12ada9c734b0f3b32e26bb88bbf1d"}, + {file = "multidict-5.2.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98ec9aea6223adf46999f22e2c0ab6cf33f5914be604a404f658386a8f1fba37"}, + {file = "multidict-5.2.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5283c0a00f48e8cafcecadebfa0ed1dac8b39e295c7248c44c665c16dc1138b"}, + {file = "multidict-5.2.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5f79c19c6420962eb17c7e48878a03053b7ccd7b69f389d5831c0a4a7f1ac0a1"}, + {file = "multidict-5.2.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e4a67f1080123de76e4e97a18d10350df6a7182e243312426d508712e99988d4"}, + {file = "multidict-5.2.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:94b117e27efd8e08b4046c57461d5a114d26b40824995a2eb58372b94f9fca02"}, + {file = "multidict-5.2.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:2e77282fd1d677c313ffcaddfec236bf23f273c4fba7cdf198108f5940ae10f5"}, + {file = "multidict-5.2.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:116347c63ba049c1ea56e157fa8aa6edaf5e92925c9b64f3da7769bdfa012858"}, + {file = "multidict-5.2.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:dc3a866cf6c13d59a01878cd806f219340f3e82eed514485e094321f24900677"}, + {file = "multidict-5.2.0-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:ac42181292099d91217a82e3fa3ce0e0ddf3a74fd891b7c2b347a7f5aa0edded"}, + {file = "multidict-5.2.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:f0bb0973f42ffcb5e3537548e0767079420aefd94ba990b61cf7bb8d47f4916d"}, + {file = "multidict-5.2.0-cp36-cp36m-win32.whl", hash = "sha256:ea21d4d5104b4f840b91d9dc8cbc832aba9612121eaba503e54eaab1ad140eb9"}, + {file = "multidict-5.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:e6453f3cbeb78440747096f239d282cc57a2997a16b5197c9bc839099e1633d0"}, + {file = "multidict-5.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d3def943bfd5f1c47d51fd324df1e806d8da1f8e105cc7f1c76a1daf0f7e17b0"}, + {file = "multidict-5.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35591729668a303a02b06e8dba0eb8140c4a1bfd4c4b3209a436a02a5ac1de11"}, + {file = "multidict-5.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8cacda0b679ebc25624d5de66c705bc53dcc7c6f02a7fb0f3ca5e227d80422"}, + {file = "multidict-5.2.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:baf1856fab8212bf35230c019cde7c641887e3fc08cadd39d32a421a30151ea3"}, + {file = "multidict-5.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a43616aec0f0d53c411582c451f5d3e1123a68cc7b3475d6f7d97a626f8ff90d"}, + {file = "multidict-5.2.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:25cbd39a9029b409167aa0a20d8a17f502d43f2efebfe9e3ac019fe6796c59ac"}, + {file = "multidict-5.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a2cbcfbea6dc776782a444db819c8b78afe4db597211298dd8b2222f73e9cd0"}, + {file = "multidict-5.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:3d2d7d1fff8e09d99354c04c3fd5b560fb04639fd45926b34e27cfdec678a704"}, + {file = "multidict-5.2.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a37e9a68349f6abe24130846e2f1d2e38f7ddab30b81b754e5a1fde32f782b23"}, + {file = "multidict-5.2.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:637c1896497ff19e1ee27c1c2c2ddaa9f2d134bbb5e0c52254361ea20486418d"}, + {file = "multidict-5.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:9815765f9dcda04921ba467957be543423e5ec6a1136135d84f2ae092c50d87b"}, + {file = "multidict-5.2.0-cp37-cp37m-win32.whl", hash = "sha256:8b911d74acdc1fe2941e59b4f1a278a330e9c34c6c8ca1ee21264c51ec9b67ef"}, + {file = "multidict-5.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:380b868f55f63d048a25931a1632818f90e4be71d2081c2338fcf656d299949a"}, + {file = "multidict-5.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e7d81ce5744757d2f05fc41896e3b2ae0458464b14b5a2c1e87a6a9d69aefaa8"}, + {file = "multidict-5.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2d1d55cdf706ddc62822d394d1df53573d32a7a07d4f099470d3cb9323b721b6"}, + {file = "multidict-5.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a4771d0d0ac9d9fe9e24e33bed482a13dfc1256d008d101485fe460359476065"}, + {file = "multidict-5.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da7d57ea65744d249427793c042094c4016789eb2562576fb831870f9c878d9e"}, + {file = "multidict-5.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdd68778f96216596218b4e8882944d24a634d984ee1a5a049b300377878fa7c"}, + {file = "multidict-5.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecc99bce8ee42dcad15848c7885197d26841cb24fa2ee6e89d23b8993c871c64"}, + {file = "multidict-5.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:067150fad08e6f2dd91a650c7a49ba65085303fcc3decbd64a57dc13a2733031"}, + {file = "multidict-5.2.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:78c106b2b506b4d895ddc801ff509f941119394b89c9115580014127414e6c2d"}, + {file = "multidict-5.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e6c4fa1ec16e01e292315ba76eb1d012c025b99d22896bd14a66628b245e3e01"}, + {file = "multidict-5.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:b227345e4186809d31f22087d0265655114af7cda442ecaf72246275865bebe4"}, + {file = "multidict-5.2.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:06560fbdcf22c9387100979e65b26fba0816c162b888cb65b845d3def7a54c9b"}, + {file = "multidict-5.2.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:7878b61c867fb2df7a95e44b316f88d5a3742390c99dfba6c557a21b30180cac"}, + {file = "multidict-5.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:246145bff76cc4b19310f0ad28bd0769b940c2a49fc601b86bfd150cbd72bb22"}, + {file = "multidict-5.2.0-cp38-cp38-win32.whl", hash = "sha256:c30ac9f562106cd9e8071c23949a067b10211917fdcb75b4718cf5775356a940"}, + {file = "multidict-5.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:f19001e790013ed580abfde2a4465388950728861b52f0da73e8e8a9418533c0"}, + {file = "multidict-5.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c1ff762e2ee126e6f1258650ac641e2b8e1f3d927a925aafcfde943b77a36d24"}, + {file = "multidict-5.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bd6c9c50bf2ad3f0448edaa1a3b55b2e6866ef8feca5d8dbec10ec7c94371d21"}, + {file = "multidict-5.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fc66d4016f6e50ed36fb39cd287a3878ffcebfa90008535c62e0e90a7ab713ae"}, + {file = "multidict-5.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9acb76d5f3dd9421874923da2ed1e76041cb51b9337fd7f507edde1d86535d6"}, + {file = "multidict-5.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dfc924a7e946dd3c6360e50e8f750d51e3ef5395c95dc054bc9eab0f70df4f9c"}, + {file = "multidict-5.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fdba7333eb2351fee2596b756d730d62b5827d5e1ab2f84e6cbb287cc67fe0"}, + {file = "multidict-5.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b9aad49466b8d828b96b9e3630006234879c8d3e2b0a9d99219b3121bc5cdb17"}, + {file = "multidict-5.2.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:93de39267c4c676c9ebb2057e98a8138bade0d806aad4d864322eee0803140a0"}, + {file = "multidict-5.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f9bef5cff994ca3026fcc90680e326d1a19df9841c5e3d224076407cc21471a1"}, + {file = "multidict-5.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:5f841c4f14331fd1e36cbf3336ed7be2cb2a8f110ce40ea253e5573387db7621"}, + {file = "multidict-5.2.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:38ba256ee9b310da6a1a0f013ef4e422fca30a685bcbec86a969bd520504e341"}, + {file = "multidict-5.2.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:3bc3b1621b979621cee9f7b09f024ec76ec03cc365e638126a056317470bde1b"}, + {file = "multidict-5.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6ee908c070020d682e9b42c8f621e8bb10c767d04416e2ebe44e37d0f44d9ad5"}, + {file = "multidict-5.2.0-cp39-cp39-win32.whl", hash = "sha256:1c7976cd1c157fa7ba5456ae5d31ccdf1479680dc9b8d8aa28afabc370df42b8"}, + {file = "multidict-5.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:c9631c642e08b9fff1c6255487e62971d8b8e821808ddd013d8ac058087591ac"}, + {file = "multidict-5.2.0.tar.gz", hash = "sha256:0dd1c93edb444b33ba2274b66f63def8a327d607c6c790772f448a53b6ea59ce"}, ] nodeenv = [ {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, @@ -1536,20 +1632,20 @@ pep8-naming = [ {file = "pep8_naming-0.12.1-py2.py3-none-any.whl", hash = "sha256:4a8daeaeb33cfcde779309fc0c9c0a68a3bbe2ad8a8308b763c5068f86eb9f37"}, ] pip-licenses = [ - {file = "pip-licenses-3.5.2.tar.gz", hash = "sha256:c5e984f461b34ad04dafa151d0048eb9d049e3d6439966c6440bb6b53ad077b6"}, - {file = "pip_licenses-3.5.2-py3-none-any.whl", hash = "sha256:62deafc82d5dccea1a4cab55172706e02f228abcd67f4d53e382fcb1497e9b62"}, + {file = "pip-licenses-3.5.3.tar.gz", hash = "sha256:f44860e00957b791c6c6005a3328f2d5eaeee96ddb8e7d87d4b0aa25b02252e4"}, + {file = "pip_licenses-3.5.3-py3-none-any.whl", hash = "sha256:59c148d6a03784bf945d232c0dc0e9de4272a3675acaa0361ad7712398ca86ba"}, ] platformdirs = [ - {file = "platformdirs-2.2.0-py3-none-any.whl", hash = "sha256:4666d822218db6a262bdfdc9c39d21f23b4cfdb08af331a81e92751daf6c866c"}, - {file = "platformdirs-2.2.0.tar.gz", hash = "sha256:632daad3ab546bd8e6af0537d09805cec458dce201bccfe23012df73332e181e"}, + {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"}, + {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"}, ] pluggy = [ - {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, - {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] pre-commit = [ - {file = "pre_commit-2.14.0-py2.py3-none-any.whl", hash = "sha256:ec3045ae62e1aa2eecfb8e86fa3025c2e3698f77394ef8d2011ce0aedd85b2d4"}, - {file = "pre_commit-2.14.0.tar.gz", hash = "sha256:2386eeb4cf6633712c7cc9ede83684d53c8cafca6b59f79c738098b51c6d206c"}, + {file = "pre_commit-2.15.0-py2.py3-none-any.whl", hash = "sha256:a4ed01000afcb484d9eb8d504272e642c4c4099bbad3a6b27e519bd6a3e928a6"}, + {file = "pre_commit-2.15.0.tar.gz", hash = "sha256:3c25add78dbdfb6a28a651780d5c311ac40dd17f160eb3954a0c59da40a505a7"}, ] psutil = [ {file = "psutil-5.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0066a82f7b1b37d334e68697faba68e5ad5e858279fd6351c8ca6024e8d6ba64"}, @@ -1643,14 +1739,13 @@ pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, ] -pyreadline = [ - {file = "pyreadline-2.1.win-amd64.exe", hash = "sha256:9ce5fa65b8992dfa373bddc5b6e0864ead8f291c94fbfec05fbd5c836162e67b"}, - {file = "pyreadline-2.1.win32.exe", hash = "sha256:65540c21bfe14405a3a77e4c085ecfce88724743a4ead47c66b84defcf82c32e"}, - {file = "pyreadline-2.1.zip", hash = "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1"}, +pyreadline3 = [ + {file = "pyreadline3-3.3-py3-none-any.whl", hash = "sha256:0003fd0079d152ecbd8111202c5a7dfa6a5569ffd65b235e45f3c2ecbee337b4"}, + {file = "pyreadline3-3.3.tar.gz", hash = "sha256:ff3b5a1ac0010d0967869f723e687d42cabc7dccf33b14934c92aa5168d260b3"}, ] pytest = [ - {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"}, - {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"}, + {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, + {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, ] pytest-cov = [ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, @@ -1708,67 +1803,57 @@ pyyaml = [ {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] rapidfuzz = [ - {file = "rapidfuzz-1.5.0-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:670a330e90e962de5823e01e8ae1b8903af788325fbce1ef3fd5ece4d22e0ba4"}, - {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:079afafa6e6b00ee799e16d9fc6c6522132cbd7742a7a9e78bd301321e1b5ad6"}, - {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:26cb066e79c9867d313450514bb70124d392ac457640c4ec090d29eb68b75541"}, - {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:542fbe8fb4403af36bfffd53e42cb1ff3f8d969a046208373d004804072b744c"}, - {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:407a5c4d2af813e803b828b004f8686300baf298e9bf90b3388a568b1637a8dc"}, - {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:662b4021951ac9edb9a0d026820529e891cea69c11f280188c5b80fefe6ee257"}, - {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2014_ppc64le.whl", hash = "sha256:03c97beb1c7ce5cb1d12bbb8eb87777e9a5fad23216dab78d6850cafdd3ecaf1"}, - {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2014_s390x.whl", hash = "sha256:eaafa0349d47850ed2c3ae121b62e078a63daf1d533b1cd43fca0c675a85a025"}, - {file = "rapidfuzz-1.5.0-cp35-cp35m-win32.whl", hash = "sha256:f0b7e15209208ee74bc264b97e111a3c73e19336eda7255c406e56cc6fbbd384"}, - {file = "rapidfuzz-1.5.0-cp35-cp35m-win_amd64.whl", hash = "sha256:0679af3d85082dcb27e75ea30c5047dbcc99340f38490c7d4769ae16909c246a"}, - {file = "rapidfuzz-1.5.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3a3ef319fd1162e7e38bf11259d86fc6ea3885d2abae6359e5b4dafad62592db"}, - {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:60ea1cee33a5a847aeac91a35865c6f7f35a87613df282bda2e7f984e91526f5"}, - {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2ba6ffe8ac66dbeae91a0b2cb50f4836ec16920f58746eaf46ff3e9c4f9c0ad8"}, - {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:7c101bafb27436affcaa14c631e2bf99d6a7a7860a201ce17ee98447c9c0e7f4"}, - {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:a8f3f374b4e8e80516b955a1da6364c526d480311a5c6be48264cf7dc06d2fba"}, - {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:f2fe161526cce52eae224c2af9ae1b9c475ae3e1001fe76024603b290bc8f719"}, - {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:8b086b2f70571c9bf16ead5f65976414f8e75a1c680220a839b8ddf005743060"}, - {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:814cd474c31db0383c69eed5b457571f63521f38829955c842b141b4835f067f"}, - {file = "rapidfuzz-1.5.0-cp36-cp36m-win32.whl", hash = "sha256:0a901aa223a4b051846cb828c33967a6f9c66b8fe0ba7e2a4dc70f6612006988"}, - {file = "rapidfuzz-1.5.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f03a5fa9fe38d7f8d566bff0b66600f488d56700469bf1e5e36078f4b58290b6"}, - {file = "rapidfuzz-1.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:122b7c25792eb27ca59ab23623a922a7290d881d296556d0c23da63ed1691cd5"}, - {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:73509dbfcf556233d62683aed0e5f23282ec7138eeedc3ecda2938ad8e8c969d"}, - {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6e8c4fd87361699e0cf5cf7ff075e4cd70a2698e9f914368f0c3e198c77c755c"}, - {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d627ec73d324d804af4c95909e2fa30b0e59f7efaf69264e553a0e498034404b"}, - {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:c57f3b74942ae0d0869336e613cbd0760de61a462ff441095eb5fca6575cf964"}, - {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:075b8bf76dd4bbc9ccb5177806c9867424d365898415433bf88e7b8e88dc4dfe"}, - {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:8049a500b431724d283ddf97d67fe48aa67b4523d617a203c22fd9da3a496223"}, - {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:a2d84fde07c32514758d283dd1227453db3ed5372a3e9eae85d0c29b2953f252"}, - {file = "rapidfuzz-1.5.0-cp37-cp37m-win32.whl", hash = "sha256:0e35b9b92a955018ebd09d4d9d70f8e81a0106fe1ed04bc82e3a05166cd04ea5"}, - {file = "rapidfuzz-1.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:8ae7bf62f0382d13e9b36babc897742bac5e7ee04b4e5e94cd67085bfccfd2fd"}, - {file = "rapidfuzz-1.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:466d9c644fa235278ef376eefb1fc4382107b07764fbc3c7280533ad9ce49bb4"}, - {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:d04a8465738363d0b9ee39abb3b289e1198d1f3cbc98bc43b8e21ec8e0b21774"}, - {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2c1ce8e8419ac8462289a6e021b8802701ea0f111ebde7607ba3c9588c3d6f30"}, - {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:f44564a29e96af0925e68733859d8247a692968034e1b37407d9cfa746d3a853"}, - {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d2d1bea50f54387bc1e82b93f6e3a433084e0fa538a7ada8e4d4d7200bae4b83"}, - {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:b409f0f86a316b6132253258185c7b011e779ed2170d1ad83c79515fea7d78c8"}, - {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:bf5a6f4f2eb44f32271e9c2d1e46b657764dbd1b933dd84d7c0433eab48741f8"}, - {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bbdee2e3c2cee9c59e1d1a3f351760a1b510e96379d14ba2fa2484a79f56d0ea"}, - {file = "rapidfuzz-1.5.0-cp38-cp38-win32.whl", hash = "sha256:575a0eceaf84632f2014fd55a42a0621e448115adf6fcbc2b0e5c7ae1c18b501"}, - {file = "rapidfuzz-1.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:cd6603b94e2a3d56d143a5100f8f3c1d29ad8f5416bdc2a25b079f96eee3c306"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3fa261479e3828eff1f3d0265def8d0d893f2e2f90692d5dae96b3f4ae44d69e"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7a386fe0aad7e89b5017768492ea085d241c32f6dc5a6774b0a309d28f61e720"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68156a67d541bb4584cb31e366fb7de9326f5b77ed07f9882e9b9aaa40b2e5b8"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:b62b2a2d2532d357d1b970107a90e85305bdd8e302995dd251f67a19495033f5"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:190b48ba8e3fbcb1cfc522300dbd6a007f50c13cd71002c95bd3946a63b749f6"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:51f9ac3316e713b4a10554a4d6b75fe6f802dd9b4073082cc98968ace6377cac"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:e00198aa7ca8408616d9821501ff90157c429c952d55a2a53987a9b064f73d49"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:5784c24e2de539064d8d5ce3f68756630b54fc33af31e054373a65bbed68823a"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:712a4d510c466d6ca75138dad53a1cbd8db0da4bbfa5fc431fcebb0a426e5323"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:2647e00e2211ed741aecb4e676461b7202ce46d536c3439ede911b088432b7a4"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-win32.whl", hash = "sha256:0b77ca0dacb129e878c2583295b76e12da890bd091115417d23b4049b02c2566"}, - {file = "rapidfuzz-1.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:dec0d429d117ffd7df1661e5f6ca56bfb6806e117be0b75b5d414df43aa4b6d5"}, - {file = "rapidfuzz-1.5.0-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a533d17d177d11b7c177c849adb728035621462f6ce2baaeb9cf1f42ba3e326c"}, - {file = "rapidfuzz-1.5.0-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:ac9a2d5a47a4a4eab060882a162d3626889abdec69f899a59fe7b9e01ce122c9"}, - {file = "rapidfuzz-1.5.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:0e6e2f02bb67a35d75a5613509bb49f0050c0ec4471a9af14da3ad5488d6d5ff"}, - {file = "rapidfuzz-1.5.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:8c61ced6729146e695ecad403165bf3a07e60b8e8a18df91962b3abf72aae6d5"}, - {file = "rapidfuzz-1.5.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:360415125e967d8682291f00bcea311c738101e0aee4cb90e5572d7e54483f0d"}, - {file = "rapidfuzz-1.5.0-pp37-pypy37_pp73-manylinux1_x86_64.whl", hash = "sha256:2fb9d47fc16a2e8f5e900c8334d823a7307148ea764321f861b876f85a880d57"}, - {file = "rapidfuzz-1.5.0-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:2134ac91e8951d42c9a7de131d767580b8ac50820475221024e5bd63577a376f"}, - {file = "rapidfuzz-1.5.0-pp37-pypy37_pp73-win32.whl", hash = "sha256:04c4fd372e858f25e0898ba27b5bb7ed8dc528b0915b7aa02d20237e9cdd4feb"}, - {file = "rapidfuzz-1.5.0.tar.gz", hash = "sha256:141ee381c16f7e58640ef1f1dbf76beb953d248297a7165f7ba25d81ac1161c7"}, + {file = "rapidfuzz-1.7.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1ca9888e867aed2bb8d51571270e5f8393d718bb189fe1a7c0b047b8fd72bad3"}, + {file = "rapidfuzz-1.7.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:f336cd32a2a72eb9d7694618c9065ef3a2af330ab7e54bc0ec69d3b2eb08080e"}, + {file = "rapidfuzz-1.7.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:76124767ac3d3213a1aad989f80b156b225defef8addc825a5b631d3164c3213"}, + {file = "rapidfuzz-1.7.1-cp27-cp27m-win32.whl", hash = "sha256:c1090deb95e5369fff47c223c0ed3472644efc56817e288ebeaaa34822a1235c"}, + {file = "rapidfuzz-1.7.1-cp27-cp27m-win_amd64.whl", hash = "sha256:83f94c89e8f16679e0def3c7afa6c9ba477d837fd01250d6a1e3fea12267ce24"}, + {file = "rapidfuzz-1.7.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:cdd5962bd009b1457e280b5619d312cd6305b5b8afeff6c27869f98fee839c36"}, + {file = "rapidfuzz-1.7.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:2940960e212b66f00fc58f9b4a13e6f80221141dcbaee9c51f97e0a1f30ff1ab"}, + {file = "rapidfuzz-1.7.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:5ed4304a91043d27b92fe9af5eb87d1586548da6d03cbda5bbc98b00fee227cb"}, + {file = "rapidfuzz-1.7.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:be18495bd84bf2bd3e888270a3cd4dea868ff4b9b8ec6e540f0e195cda554140"}, + {file = "rapidfuzz-1.7.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:d5779e6f548b6f3edfbdfbeeda4158286684dcb2bae3515ce68c510ea48e1b4d"}, + {file = "rapidfuzz-1.7.1-cp35-cp35m-win32.whl", hash = "sha256:80d780c4f6da08eb6801489df54fdbdc5ef2b882bd73f9585ef6e0cf09f1690d"}, + {file = "rapidfuzz-1.7.1-cp35-cp35m-win_amd64.whl", hash = "sha256:3b205c63b8606c2b8595ba8403a8c3ebd39de9f7f44631a2f651f3efe106ae9a"}, + {file = "rapidfuzz-1.7.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8f96588a8a7d021debb4c60d82b15a80995daa99159bbeddd8a37f68f75ee06c"}, + {file = "rapidfuzz-1.7.1-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b8139116a937691dde17f27aafe774647808339305f4683b3a6d9bae6518aa2a"}, + {file = "rapidfuzz-1.7.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba574801c8410cc1f2d690ef65f898f6a660bba22ec8213e0f34dd0f0590bc71"}, + {file = "rapidfuzz-1.7.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2d5194e3cb638af0cc7c02daa61cef07e332fd3f790ec113006302131be9afa6"}, + {file = "rapidfuzz-1.7.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dd9d8eaae888b966422cbcba954390a63b4933d8c513ea0056fd6e42d421d08"}, + {file = "rapidfuzz-1.7.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3725c61b9cf57b6b7a765b92046e7d9e5ccce845835b523954b410a70dc32692"}, + {file = "rapidfuzz-1.7.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e417961e5ca450d6c7448accc5a7e4e9ab0dd3c63729f76215d5e672785920fc"}, + {file = "rapidfuzz-1.7.1-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:26d756284c8c6274b5d558e759415bfb4016fcdf168159b34702c346875d8cc0"}, + {file = "rapidfuzz-1.7.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4887766f0dcc5df43fe4315df4b3c642829e06dc60d5bcb5e682fb76657e8ed1"}, + {file = "rapidfuzz-1.7.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec0a29671d59998b97998b757ab1c636dd3b7721eda41746ae897abe709681a9"}, + {file = "rapidfuzz-1.7.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dff55750fecd8c0f07bc199e48427c86873be2d0e6a3a80df98972847287f5d3"}, + {file = "rapidfuzz-1.7.1-cp37-cp37m-win32.whl", hash = "sha256:e113f741bb18b0ddd14d714d80ce9c6d5322724f3023b920708e82491e7aef28"}, + {file = "rapidfuzz-1.7.1-cp37-cp37m-win_amd64.whl", hash = "sha256:ef20654be0aed240ee44c98ce02639c37422adc3e144d28c4b6d3da043d9fd20"}, + {file = "rapidfuzz-1.7.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:9e27eb57745a4d2a390b056f6f490b712c2f54250c5d2c794dd76062065a8aef"}, + {file = "rapidfuzz-1.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:de2b0ebb67ee0b78973141dba91f574a325a3425664dbdbad37fd7aca7b28cab"}, + {file = "rapidfuzz-1.7.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:88c65d91dcd3c0595112d16555536c60ac5bcab1a43e517e155a242a39525057"}, + {file = "rapidfuzz-1.7.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:afd525a9b593cc1099f0210e116bcb4d9fc5585728d7bd929e6a4133dacd2d59"}, + {file = "rapidfuzz-1.7.1-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e6d77f104a8d67c01ae4248ced6f0d4ef05e63931afdf49c20decf962318877f"}, + {file = "rapidfuzz-1.7.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7db9d6ad0ab80e9e0f66f157b8e31b1d04ce5fa767b936ca1c212b98092572b1"}, + {file = "rapidfuzz-1.7.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0195c57f4beea0e7691594f59faf62a4be3c818c1955a8b9b712f37adc479d2d"}, + {file = "rapidfuzz-1.7.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9ffca8c8b74d12cd36c051e9befa7c4eb2d34624ce71f22dbfc659af15bf4a1e"}, + {file = "rapidfuzz-1.7.1-cp38-cp38-win32.whl", hash = "sha256:234cb75aa1e21cabad6a8c0718f84e2bfafdd4756b5232d5739545f97e343e59"}, + {file = "rapidfuzz-1.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:058977e93ab736071fcd8828fc6289ec026e9ca4a19f2a0967f9260e63910da8"}, + {file = "rapidfuzz-1.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9d02bb0724326826b1884cc9b9d9fd97ac352c18213f45e465a39ef069a33115"}, + {file = "rapidfuzz-1.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:212d6fa5b824aaa49a921c81d7cdc1d079b3545a30563ae14dc88e17918e76bf"}, + {file = "rapidfuzz-1.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a0cd8117deba10e2a1d6dccb6ff44a4c737adda3048dc45860c5f53cf64db14f"}, + {file = "rapidfuzz-1.7.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:61faa47b6b5d5a0cbe9fa6369df44d3f9435c4cccdb4d38d9de437f18b69dc4d"}, + {file = "rapidfuzz-1.7.1-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1daa756be52a7ee60d553ba667cda3a188ee811c92a9c21df43a4cdadb1eb8ca"}, + {file = "rapidfuzz-1.7.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c98ac10782dadf507e922963c8b8456a79151b4f10dbb08cfc86c1572db366dc"}, + {file = "rapidfuzz-1.7.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:358d80061ca107df6c3e1f67fa7af0f94a62827cb9c44ac09a16e78b38f7c3d5"}, + {file = "rapidfuzz-1.7.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5f90fc31d54fcd74a97d175892555786a8214a3cff43077463915b8a45a191d"}, + {file = "rapidfuzz-1.7.1-cp39-cp39-win32.whl", hash = "sha256:55dffdcdccea6f077a4f09164039411f01f621633be5883c58ceaf94f007a688"}, + {file = "rapidfuzz-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:d712a7f680d2074b587650f81865ca838c04fcc6b77c9d2d742de0853aaa24ce"}, + {file = "rapidfuzz-1.7.1-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:729d73a8db5a2b444a19d4aa2be009b2e628d207d7c754f6d280e3c6a59b94cb"}, + {file = "rapidfuzz-1.7.1-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:a1cabbc645395b6175cad79164d9ec621866a004b476e44cac534020b9f6bddb"}, + {file = "rapidfuzz-1.7.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ae697294f456f7f76e5bd30db5a65e8b855e7e09f9a65e144efa1e2c5009553c"}, + {file = "rapidfuzz-1.7.1-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e8ae51c1cf1f034f15216fec2e1eef658c8b3a9cbdcc1a053cc7133ede9d616d"}, + {file = "rapidfuzz-1.7.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:dccc072f2a0eeb98d46a79427ef793836ebc5184b1fe544b34607be10705ddc3"}, + {file = "rapidfuzz-1.7.1.tar.gz", hash = "sha256:99495c679174b2a02641f7dc2364a208135cacca77fc4825a86efbfe1e23b0ff"}, ] redis = [ {file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"}, @@ -1822,8 +1907,8 @@ requests = [ {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"}, ] sentry-sdk = [ - {file = "sentry-sdk-1.3.1.tar.gz", hash = "sha256:ebe99144fa9618d4b0e7617e7929b75acd905d258c3c779edcd34c0adfffe26c"}, - {file = "sentry_sdk-1.3.1-py2.py3-none-any.whl", hash = "sha256:f33d34c886d0ba24c75ea8885a8b3a172358853c7cbde05979fc99c29ef7bc52"}, + {file = "sentry-sdk-1.4.3.tar.gz", hash = "sha256:b9844751e40710e84a457c5bc29b21c383ccb2b63d76eeaad72f7f1c808c8828"}, + {file = "sentry_sdk-1.4.3-py2.py3-none-any.whl", hash = "sha256:c091cc7115ff25fe3a0e410dbecd7a996f81a3f6137d2272daef32d6c3cfa6dc"}, ] sgmllib3k = [ {file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"}, @@ -1852,59 +1937,98 @@ taskipy = [ {file = "taskipy-1.7.0-py3-none-any.whl", hash = "sha256:9e284c10898e9dee01a3e72220b94b192b1daa0f560271503a6df1da53d03844"}, {file = "taskipy-1.7.0.tar.gz", hash = "sha256:960e480b1004971e76454ecd1a0484e640744a30073a1069894a311467f85ed8"}, ] +testfixtures = [ + {file = "testfixtures-6.18.3-py2.py3-none-any.whl", hash = "sha256:6ddb7f56a123e1a9339f130a200359092bd0a6455e31838d6c477e8729bb7763"}, + {file = "testfixtures-6.18.3.tar.gz", hash = "sha256:2600100ae96ffd082334b378e355550fef8b4a529a6fa4c34f47130905c7426d"}, +] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] typing-extensions = [ - {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, - {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, - {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, + {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, + {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, + {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, ] urllib3 = [ - {file = "urllib3-1.26.6-py2.py3-none-any.whl", hash = "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4"}, - {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"}, + {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"}, + {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"}, ] virtualenv = [ - {file = "virtualenv-20.7.2-py2.py3-none-any.whl", hash = "sha256:e4670891b3a03eb071748c569a87cceaefbf643c5bac46d996c5a45c34aa0f06"}, - {file = "virtualenv-20.7.2.tar.gz", hash = "sha256:9ef4e8ee4710826e98ff3075c9a4739e2cb1040de6a2a8d35db0055840dc96a0"}, + {file = "virtualenv-20.8.1-py2.py3-none-any.whl", hash = "sha256:10062e34c204b5e4ec5f62e6ef2473f8ba76513a9a617e873f1f8fb4a519d300"}, + {file = "virtualenv-20.8.1.tar.gz", hash = "sha256:bcc17f0b3a29670dd777d6f0755a4c04f28815395bca279cdcb213b97199a6b8"}, ] yarl = [ - {file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"}, - {file = "yarl-1.6.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:bafb450deef6861815ed579c7a6113a879a6ef58aed4c3a4be54400ae8871478"}, - {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:547f7665ad50fa8563150ed079f8e805e63dd85def6674c97efd78eed6c224a6"}, - {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:63f90b20ca654b3ecc7a8d62c03ffa46999595f0167d6450fa8383bab252987e"}, - {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:97b5bdc450d63c3ba30a127d018b866ea94e65655efaf889ebeabc20f7d12406"}, - {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:d8d07d102f17b68966e2de0e07bfd6e139c7c02ef06d3a0f8d2f0f055e13bb76"}, - {file = "yarl-1.6.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:15263c3b0b47968c1d90daa89f21fcc889bb4b1aac5555580d74565de6836366"}, - {file = "yarl-1.6.3-cp36-cp36m-win32.whl", hash = "sha256:b5dfc9a40c198334f4f3f55880ecf910adebdcb2a0b9a9c23c9345faa9185721"}, - {file = "yarl-1.6.3-cp36-cp36m-win_amd64.whl", hash = "sha256:b2e9a456c121e26d13c29251f8267541bd75e6a1ccf9e859179701c36a078643"}, - {file = "yarl-1.6.3-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:ce3beb46a72d9f2190f9e1027886bfc513702d748047b548b05dab7dfb584d2e"}, - {file = "yarl-1.6.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2ce4c621d21326a4a5500c25031e102af589edb50c09b321049e388b3934eec3"}, - {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:d26608cf178efb8faa5ff0f2d2e77c208f471c5a3709e577a7b3fd0445703ac8"}, - {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:4c5bcfc3ed226bf6419f7a33982fb4b8ec2e45785a0561eb99274ebbf09fdd6a"}, - {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:4736eaee5626db8d9cda9eb5282028cc834e2aeb194e0d8b50217d707e98bb5c"}, - {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:68dc568889b1c13f1e4745c96b931cc94fdd0defe92a72c2b8ce01091b22e35f"}, - {file = "yarl-1.6.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:7356644cbed76119d0b6bd32ffba704d30d747e0c217109d7979a7bc36c4d970"}, - {file = "yarl-1.6.3-cp37-cp37m-win32.whl", hash = "sha256:00d7ad91b6583602eb9c1d085a2cf281ada267e9a197e8b7cae487dadbfa293e"}, - {file = "yarl-1.6.3-cp37-cp37m-win_amd64.whl", hash = "sha256:69ee97c71fee1f63d04c945f56d5d726483c4762845400a6795a3b75d56b6c50"}, - {file = "yarl-1.6.3-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:e46fba844f4895b36f4c398c5af062a9808d1f26b2999c58909517384d5deda2"}, - {file = "yarl-1.6.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:31ede6e8c4329fb81c86706ba8f6bf661a924b53ba191b27aa5fcee5714d18ec"}, - {file = "yarl-1.6.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71"}, - {file = "yarl-1.6.3-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:72a660bdd24497e3e84f5519e57a9ee9220b6f3ac4d45056961bf22838ce20cc"}, - {file = "yarl-1.6.3-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:324ba3d3c6fee56e2e0b0d09bf5c73824b9f08234339d2b788af65e60040c959"}, - {file = "yarl-1.6.3-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:e6b5460dc5ad42ad2b36cca524491dfcaffbfd9c8df50508bddc354e787b8dc2"}, - {file = "yarl-1.6.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:6d6283d8e0631b617edf0fd726353cb76630b83a089a40933043894e7f6721e2"}, - {file = "yarl-1.6.3-cp38-cp38-win32.whl", hash = "sha256:9ede61b0854e267fd565e7527e2f2eb3ef8858b301319be0604177690e1a3896"}, - {file = "yarl-1.6.3-cp38-cp38-win_amd64.whl", hash = "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a"}, - {file = "yarl-1.6.3-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:329412812ecfc94a57cd37c9d547579510a9e83c516bc069470db5f75684629e"}, - {file = "yarl-1.6.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:c49ff66d479d38ab863c50f7bb27dee97c6627c5fe60697de15529da9c3de724"}, - {file = "yarl-1.6.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f040bcc6725c821a4c0665f3aa96a4d0805a7aaf2caf266d256b8ed71b9f041c"}, - {file = "yarl-1.6.3-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:d5c32c82990e4ac4d8150fd7652b972216b204de4e83a122546dce571c1bdf25"}, - {file = "yarl-1.6.3-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:d597767fcd2c3dc49d6eea360c458b65643d1e4dbed91361cf5e36e53c1f8c96"}, - {file = "yarl-1.6.3-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:8aa3decd5e0e852dc68335abf5478a518b41bf2ab2f330fe44916399efedfae0"}, - {file = "yarl-1.6.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:73494d5b71099ae8cb8754f1df131c11d433b387efab7b51849e7e1e851f07a4"}, - {file = "yarl-1.6.3-cp39-cp39-win32.whl", hash = "sha256:5b883e458058f8d6099e4420f0cc2567989032b5f34b271c0827de9f1079a424"}, - {file = "yarl-1.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:4953fb0b4fdb7e08b2f3b3be80a00d28c5c8a2056bb066169de00e6501b986b6"}, - {file = "yarl-1.6.3.tar.gz", hash = "sha256:8a9066529240171b68893d60dca86a763eae2139dd42f42106b03cf4b426bf10"}, + {file = "yarl-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e35d8230e4b08d86ea65c32450533b906a8267a87b873f2954adeaecede85169"}, + {file = "yarl-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb4b3f277880c314e47720b4b6bb2c85114ab3c04c5442c9bc7006b3787904d8"}, + {file = "yarl-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7015dcedb91d90a138eebdc7e432aec8966e0147ab2a55f2df27b1904fa7291"}, + {file = "yarl-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb3e478175e15e00d659fb0354a6a8db71a7811a2a5052aed98048bc972e5d2b"}, + {file = "yarl-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b8c409aa3a7966647e7c1c524846b362a6bcbbe120bf8a176431f940d2b9a2e"}, + {file = "yarl-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b22ea41c7e98170474a01e3eded1377d46b2dfaef45888a0005c683eaaa49285"}, + {file = "yarl-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a7dfc46add4cfe5578013dbc4127893edc69fe19132d2836ff2f6e49edc5ecd6"}, + {file = "yarl-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:82ff6f85f67500a4f74885d81659cd270eb24dfe692fe44e622b8a2fd57e7279"}, + {file = "yarl-1.7.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f3cd2158b2ed0fb25c6811adfdcc47224efe075f2d68a750071dacc03a7a66e4"}, + {file = "yarl-1.7.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:59c0f13f9592820c51280d1cf811294d753e4a18baf90f0139d1dc93d4b6fc5f"}, + {file = "yarl-1.7.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7f7655ad83d1a8afa48435a449bf2f3009293da1604f5dd95b5ddcf5f673bd69"}, + {file = "yarl-1.7.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:aa9f0d9b62d15182341b3e9816582f46182cab91c1a57b2d308b9a3c4e2c4f78"}, + {file = "yarl-1.7.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fdd1b90c225a653b1bd1c0cae8edf1957892b9a09c8bf7ee6321eeb8208eac0f"}, + {file = "yarl-1.7.0-cp310-cp310-win32.whl", hash = "sha256:7c8d0bb76eabc5299db203e952ec55f8f4c53f08e0df4285aac8c92bd9e12675"}, + {file = "yarl-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:622a36fa779efb4ff9eff5fe52730ff17521431379851a31e040958fc251670c"}, + {file = "yarl-1.7.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3d461b7a8e139b9e4b41f62eb417ffa0b98d1c46d4caf14c845e6a3b349c0bb1"}, + {file = "yarl-1.7.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81cfacdd1e40bc931b5519499342efa388d24d262c30a3d31187bfa04f4a7001"}, + {file = "yarl-1.7.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:821b978f2152be7695d4331ef0621d207aedf9bbd591ba23a63412a3efc29a01"}, + {file = "yarl-1.7.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b64bd24c8c9a487f4a12260dc26732bf41028816dbf0c458f17864fbebdb3131"}, + {file = "yarl-1.7.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:98c9ddb92b60a83c21be42c776d3d9d5ec632a762a094c41bda37b7dfbd2cd83"}, + {file = "yarl-1.7.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a532d75ca74431c053a88a802e161fb3d651b8bf5821a3440bc3616e38754583"}, + {file = "yarl-1.7.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:053e09817eafb892e94e172d05406c1b3a22a93bc68f6eff5198363a3d764459"}, + {file = "yarl-1.7.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:98c51f02d542945d306c8e934aa2c1e66ba5e9c1c86b5bf37f3a51c8a747067e"}, + {file = "yarl-1.7.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:15ec41a5a5fdb7bace6d7b16701f9440007a82734f69127c0fbf6d87e10f4a1e"}, + {file = "yarl-1.7.0-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:a7f08819dba1e1255d6991ed37448a1bf4b1352c004bcd899b9da0c47958513d"}, + {file = "yarl-1.7.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8e3ffab21db0542ffd1887f3b9575ddd58961f2cf61429cb6458afc00c4581e0"}, + {file = "yarl-1.7.0-cp36-cp36m-win32.whl", hash = "sha256:50127634f519b2956005891507e3aa4ac345f66a7ea7bbc2d7dcba7401f41898"}, + {file = "yarl-1.7.0-cp36-cp36m-win_amd64.whl", hash = "sha256:36ec44f15193f6d5288d42ebb8e751b967ebdfb72d6830983838d45ab18edb4f"}, + {file = "yarl-1.7.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ec1b5a25a25c880c976d0bb3d107def085bb08dbb3db7f4442e0a2b980359d24"}, + {file = "yarl-1.7.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b36f5a63c891f813c6f04ef19675b382efc190fd5ce7e10ab19386d2548bca06"}, + {file = "yarl-1.7.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38173b8c3a29945e7ecade9a3f6ff39581eee8201338ee6a2c8882db5df3e806"}, + {file = "yarl-1.7.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ba402f32184f0b405fb281b93bd0d8ab7e3257735b57b62a6ed2e94cdf4fe50"}, + {file = "yarl-1.7.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:be52bc5208d767cdd8308a9e93059b3b36d1e048fecbea0e0346d0d24a76adc0"}, + {file = "yarl-1.7.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:08c2044a956f4ef30405f2f433ce77f1f57c2c773bf81ae43201917831044d5a"}, + {file = "yarl-1.7.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:484d61c047c45670ef5967653a1d0783e232c54bf9dd786a7737036828fa8d54"}, + {file = "yarl-1.7.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b7de92a4af85cfcaf4081f8aa6165b1d63ee5de150af3ee85f954145f93105a7"}, + {file = "yarl-1.7.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:376e41775aab79c5575534924a386c8e0f1a5d91db69fc6133fd27a489bcaf10"}, + {file = "yarl-1.7.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:8a8b10d0e7bac154f959b709fcea593cda527b234119311eb950096653816a86"}, + {file = "yarl-1.7.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:f46cd4c43e6175030e2a56def8f1d83b64e6706eeb2bb9ab0ef4756f65eab23f"}, + {file = "yarl-1.7.0-cp37-cp37m-win32.whl", hash = "sha256:b28cfb46140efe1a6092b8c5c4994a1fe70dc83c38fbcea4992401e0c6fb9cce"}, + {file = "yarl-1.7.0-cp37-cp37m-win_amd64.whl", hash = "sha256:9624154ec9c02a776802da1086eed7f5034bd1971977f5146233869c2ac80297"}, + {file = "yarl-1.7.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:69945d13e1bbf81784a9bc48824feb9cd66491e6a503d4e83f6cd7c7cc861361"}, + {file = "yarl-1.7.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:46a742ed9e363bd01be64160ce7520e92e11989bd4cb224403cfd31c101cc83d"}, + {file = "yarl-1.7.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cb4ff1ac7cb4500f43581b3f4cbd627d702143aa6be1fdc1fa3ebffaf4dc1be5"}, + {file = "yarl-1.7.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ad51e17cd65ea3debb0e10f0120cf8dd987c741fe423ed2285087368090b33d"}, + {file = "yarl-1.7.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7e37786ea89a5d3ffbbf318ea9790926f8dfda83858544f128553c347ad143c6"}, + {file = "yarl-1.7.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c63c1e208f800daad71715786bfeb1cecdc595d87e2e9b1cd234fd6e597fd71d"}, + {file = "yarl-1.7.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:91cbe24300c11835ef186436363352b3257db7af165e0a767f4f17aa25761388"}, + {file = "yarl-1.7.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e510dbec7c59d32eaa61ffa48173d5e3d7170a67f4a03e8f5e2e9e3971aca622"}, + {file = "yarl-1.7.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3def6e681cc02397e5d8141ee97b41d02932b2bcf0fb34532ad62855eab7c60e"}, + {file = "yarl-1.7.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:263c81b94e6431942b27f6f671fa62f430a0a5c14bb255f2ab69eeb9b2b66ff7"}, + {file = "yarl-1.7.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:e78c91faefe88d601ddd16e3882918dbde20577a2438e2320f8239c8b7507b8f"}, + {file = "yarl-1.7.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:22b2430c49713bfb2f0a0dd4a8d7aab218b28476ba86fd1c78ad8899462cbcf2"}, + {file = "yarl-1.7.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2e7ad9db939082f5d0b9269cfd92c025cb8f2fbbb1f1b9dc5a393c639db5bd92"}, + {file = "yarl-1.7.0-cp38-cp38-win32.whl", hash = "sha256:3a31e4a8dcb1beaf167b7e7af61b88cb961b220db8d3ba1c839723630e57eef7"}, + {file = "yarl-1.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:d579957439933d752358c6a300c93110f84aae67b63dd0c19dde6ecbf4056f6b"}, + {file = "yarl-1.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:87721b549505a546eb003252185103b5ec8147de6d3ad3714d148a5a67b6fe53"}, + {file = "yarl-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1fa866fa24d9f4108f9e58ea8a2135655419885cdb443e36b39a346e1181532"}, + {file = "yarl-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1d3b8449dfedfe94eaff2b77954258b09b24949f6818dfa444b05dbb05ae1b7e"}, + {file = "yarl-1.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db2372e350794ce8b9f810feb094c606b7e0e4aa6807141ac4fadfe5ddd75bb0"}, + {file = "yarl-1.7.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a06d9d0b9a97fa99b84fee71d9dd11e69e21ac8a27229089f07b5e5e50e8d63c"}, + {file = "yarl-1.7.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3455c2456d6307bcfa80bc1157b8603f7d93573291f5bdc7144489ca0df4628"}, + {file = "yarl-1.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d30d67e3486aea61bb2cbf7cf81385364c2e4f7ce7469a76ed72af76a5cdfe6b"}, + {file = "yarl-1.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c18a4b286e8d780c3a40c31d7b79836aa93b720f71d5743f20c08b7e049ca073"}, + {file = "yarl-1.7.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d54c925396e7891666cabc0199366ca55b27d003393465acef63fd29b8b7aa92"}, + {file = "yarl-1.7.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:64773840952de17851a1c7346ad7f71688c77e74248d1f0bc230e96680f84028"}, + {file = "yarl-1.7.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:acbf1756d9dc7cd0ae943d883be72e84e04396f6c2ff93a6ddeca929d562039f"}, + {file = "yarl-1.7.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:2e48f27936aa838939c798f466c851ba4ae79e347e8dfce43b009c64b930df12"}, + {file = "yarl-1.7.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1beef4734ca1ad40a9d8c6b20a76ab46e3a2ed09f38561f01e4aa2ea82cafcef"}, + {file = "yarl-1.7.0-cp39-cp39-win32.whl", hash = "sha256:8ee78c9a5f3c642219d4607680a4693b59239c27a3aa608b64ef79ddc9698039"}, + {file = "yarl-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:d750503682605088a14d29a4701548c15c510da4f13c8b17409c4097d5b04c52"}, + {file = "yarl-1.7.0.tar.gz", hash = "sha256:8e7ebaf62e19c2feb097ffb7c94deb0f0c9fab52590784c8cd679d30ab009162"}, ] diff --git a/pyproject.toml b/pyproject.toml index 4431a41c5..14e81af05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ flake8-import-order = "~=0.18" flake8-string-format = "~=0.2" flake8-tidy-imports = "~=4.0" flake8-todo = "~=0.7" +flake8-isort = "~=4.0" pep8-naming = "~=0.9" pre-commit = "~=2.1" taskipy = "~=1.7.0" @@ -66,8 +67,16 @@ test = "pytest -n auto --cov-report= --cov --ff" retest = "pytest -n auto --cov-report= --cov --lf" html = "coverage html" report = "coverage report" +isort = "isort ." [tool.coverage.run] branch = true source_pkgs = ["bot"] source = ["tests"] + +[tool.isort] +multi_line_output = 6 +order_by_type = false +case_sensitive = true +combine_as_imports = true +line_length = 120 -- cgit v1.2.3 From 1fcd57257be3012190c39e9b565b1c2d63b26976 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 14 Oct 2021 15:13:29 +0000 Subject: Precommit: add ISort --- .pre-commit-config.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a9412f07d..d8a90ac00 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,6 +13,11 @@ repos: rev: v1.5.1 hooks: - id: python-check-blanket-noqa + - repo: https://github.com/pycqa/isort + rev: 5.8.0 + hooks: + - id: isort + name: isort (python) - repo: local hooks: - id: flake8 -- cgit v1.2.3 From 939f301a4aaa2e27fd219051cbd1072a5eb571f5 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 14 Oct 2021 15:18:18 +0000 Subject: Remove flake8-import-order Replaced by flake8-isort now that we use ISort --- poetry.lock | 130 ++++++++++++++++++++++++++------------------------------- pyproject.toml | 1 - 2 files changed, 60 insertions(+), 71 deletions(-) diff --git a/poetry.lock b/poetry.lock index 11fda89e6..5e3f575d3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -163,7 +163,7 @@ python-versions = "*" [[package]] name = "cffi" -version = "1.14.6" +version = "1.15.0" description = "Foreign Function Interface for Python calling C code." category = "main" optional = false @@ -407,32 +407,21 @@ python-versions = "*" flake8 = ">=3" pydocstyle = ">=2.1" -[[package]] -name = "flake8-import-order" -version = "0.18.1" -description = "Flake8 and pylama plugin that checks the ordering of import statements." -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -pycodestyle = "*" - [[package]] name = "flake8-isort" -version = "4.0.0" +version = "4.1.1" description = "flake8 plugin that integrates isort ." category = "dev" optional = false python-versions = "*" [package.dependencies] -flake8 = ">=3.2.1,<4" +flake8 = ">=3.2.1,<5" isort = ">=4.3.5,<6" testfixtures = ">=6.8.0,<7" [package.extras] -test = ["pytest (>=4.0.2,<6)", "toml"] +test = ["pytest-cov"] [[package]] name = "flake8-polyfill" @@ -510,7 +499,7 @@ license = ["editdistance-s"] [[package]] name = "idna" -version = "3.2" +version = "3.3" description = "Internationalized Domain Names in Applications (IDNA)" category = "main" optional = false @@ -1121,7 +1110,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "4c8634e841913b01b832e4374dbd52a4cbbc7a177c0cd465c7a5361eeadbf009" +content-hash = "24a2142956e96706dced0172955c0338cb48fb4c067451301613014e23a82d62" [metadata.files] aio-pika = [ @@ -1212,51 +1201,56 @@ certifi = [ {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, ] cffi = [ - {file = "cffi-1.14.6-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:22b9c3c320171c108e903d61a3723b51e37aaa8c81255b5e7ce102775bd01e2c"}, - {file = "cffi-1.14.6-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:f0c5d1acbfca6ebdd6b1e3eded8d261affb6ddcf2186205518f1428b8569bb99"}, - {file = "cffi-1.14.6-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:99f27fefe34c37ba9875f224a8f36e31d744d8083e00f520f133cab79ad5e819"}, - {file = "cffi-1.14.6-cp27-cp27m-win32.whl", hash = "sha256:55af55e32ae468e9946f741a5d51f9896da6b9bf0bbdd326843fec05c730eb20"}, - {file = "cffi-1.14.6-cp27-cp27m-win_amd64.whl", hash = "sha256:7bcac9a2b4fdbed2c16fa5681356d7121ecabf041f18d97ed5b8e0dd38a80224"}, - {file = "cffi-1.14.6-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ed38b924ce794e505647f7c331b22a693bee1538fdf46b0222c4717b42f744e7"}, - {file = "cffi-1.14.6-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e22dcb48709fc51a7b58a927391b23ab37eb3737a98ac4338e2448bef8559b33"}, - {file = "cffi-1.14.6-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:aedb15f0a5a5949ecb129a82b72b19df97bbbca024081ed2ef88bd5c0a610534"}, - {file = "cffi-1.14.6-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:48916e459c54c4a70e52745639f1db524542140433599e13911b2f329834276a"}, - {file = "cffi-1.14.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f627688813d0a4140153ff532537fbe4afea5a3dffce1f9deb7f91f848a832b5"}, - {file = "cffi-1.14.6-cp35-cp35m-win32.whl", hash = "sha256:f0010c6f9d1a4011e429109fda55a225921e3206e7f62a0c22a35344bfd13cca"}, - {file = "cffi-1.14.6-cp35-cp35m-win_amd64.whl", hash = "sha256:57e555a9feb4a8460415f1aac331a2dc833b1115284f7ded7278b54afc5bd218"}, - {file = "cffi-1.14.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e8c6a99be100371dbb046880e7a282152aa5d6127ae01783e37662ef73850d8f"}, - {file = "cffi-1.14.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:19ca0dbdeda3b2615421d54bef8985f72af6e0c47082a8d26122adac81a95872"}, - {file = "cffi-1.14.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d950695ae4381ecd856bcaf2b1e866720e4ab9a1498cba61c602e56630ca7195"}, - {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9dc245e3ac69c92ee4c167fbdd7428ec1956d4e754223124991ef29eb57a09d"}, - {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8661b2ce9694ca01c529bfa204dbb144b275a31685a075ce123f12331be790b"}, - {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b315d709717a99f4b27b59b021e6207c64620790ca3e0bde636a6c7f14618abb"}, - {file = "cffi-1.14.6-cp36-cp36m-win32.whl", hash = "sha256:80b06212075346b5546b0417b9f2bf467fea3bfe7352f781ffc05a8ab24ba14a"}, - {file = "cffi-1.14.6-cp36-cp36m-win_amd64.whl", hash = "sha256:a9da7010cec5a12193d1af9872a00888f396aba3dc79186604a09ea3ee7c029e"}, - {file = "cffi-1.14.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4373612d59c404baeb7cbd788a18b2b2a8331abcc84c3ba40051fcd18b17a4d5"}, - {file = "cffi-1.14.6-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:f10afb1004f102c7868ebfe91c28f4a712227fe4cb24974350ace1f90e1febbf"}, - {file = "cffi-1.14.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fd4305f86f53dfd8cd3522269ed7fc34856a8ee3709a5e28b2836b2db9d4cd69"}, - {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d6169cb3c6c2ad50db5b868db6491a790300ade1ed5d1da29289d73bbe40b56"}, - {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d4b68e216fc65e9fe4f524c177b54964af043dde734807586cf5435af84045c"}, - {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33791e8a2dc2953f28b8d8d300dde42dd929ac28f974c4b4c6272cb2955cb762"}, - {file = "cffi-1.14.6-cp37-cp37m-win32.whl", hash = "sha256:0c0591bee64e438883b0c92a7bed78f6290d40bf02e54c5bf0978eaf36061771"}, - {file = "cffi-1.14.6-cp37-cp37m-win_amd64.whl", hash = "sha256:8eb687582ed7cd8c4bdbff3df6c0da443eb89c3c72e6e5dcdd9c81729712791a"}, - {file = "cffi-1.14.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ba6f2b3f452e150945d58f4badd92310449876c4c954836cfb1803bdd7b422f0"}, - {file = "cffi-1.14.6-cp38-cp38-manylinux1_i686.whl", hash = "sha256:64fda793737bc4037521d4899be780534b9aea552eb673b9833b01f945904c2e"}, - {file = "cffi-1.14.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9f3e33c28cd39d1b655ed1ba7247133b6f7fc16fa16887b120c0c670e35ce346"}, - {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26bb2549b72708c833f5abe62b756176022a7b9a7f689b571e74c8478ead51dc"}, - {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb687a11f0a7a1839719edd80f41e459cc5366857ecbed383ff376c4e3cc6afd"}, - {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2ad4d668a5c0645d281dcd17aff2be3212bc109b33814bbb15c4939f44181cc"}, - {file = "cffi-1.14.6-cp38-cp38-win32.whl", hash = "sha256:487d63e1454627c8e47dd230025780e91869cfba4c753a74fda196a1f6ad6548"}, - {file = "cffi-1.14.6-cp38-cp38-win_amd64.whl", hash = "sha256:c33d18eb6e6bc36f09d793c0dc58b0211fccc6ae5149b808da4a62660678b156"}, - {file = "cffi-1.14.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:06c54a68935738d206570b20da5ef2b6b6d92b38ef3ec45c5422c0ebaf338d4d"}, - {file = "cffi-1.14.6-cp39-cp39-manylinux1_i686.whl", hash = "sha256:f174135f5609428cc6e1b9090f9268f5c8935fddb1b25ccb8255a2d50de6789e"}, - {file = "cffi-1.14.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f3ebe6e73c319340830a9b2825d32eb6d8475c1dac020b4f0aa774ee3b898d1c"}, - {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c8d896becff2fa653dc4438b54a5a25a971d1f4110b32bd3068db3722c80202"}, - {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4922cd707b25e623b902c86188aca466d3620892db76c0bdd7b99a3d5e61d35f"}, - {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c9e005e9bd57bc987764c32a1bee4364c44fdc11a3cc20a40b93b444984f2b87"}, - {file = "cffi-1.14.6-cp39-cp39-win32.whl", hash = "sha256:eb9e2a346c5238a30a746893f23a9535e700f8192a68c07c0258e7ece6ff3728"}, - {file = "cffi-1.14.6-cp39-cp39-win_amd64.whl", hash = "sha256:818014c754cd3dba7229c0f5884396264d51ffb87ec86e927ef0be140bfdb0d2"}, - {file = "cffi-1.14.6.tar.gz", hash = "sha256:c9a875ce9d7fe32887784274dd533c57909b7b1dcadcc128a2ac21331a9765dd"}, + {file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"}, + {file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"}, + {file = "cffi-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14"}, + {file = "cffi-1.15.0-cp27-cp27m-win32.whl", hash = "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474"}, + {file = "cffi-1.15.0-cp27-cp27m-win_amd64.whl", hash = "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6"}, + {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27"}, + {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023"}, + {file = "cffi-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2"}, + {file = "cffi-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382"}, + {file = "cffi-1.15.0-cp310-cp310-win32.whl", hash = "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55"}, + {file = "cffi-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0"}, + {file = "cffi-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605"}, + {file = "cffi-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e"}, + {file = "cffi-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc"}, + {file = "cffi-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7"}, + {file = "cffi-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66"}, + {file = "cffi-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029"}, + {file = "cffi-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6"}, + {file = "cffi-1.15.0-cp38-cp38-win32.whl", hash = "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c"}, + {file = "cffi-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443"}, + {file = "cffi-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a"}, + {file = "cffi-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8"}, + {file = "cffi-1.15.0-cp39-cp39-win32.whl", hash = "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a"}, + {file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"}, + {file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"}, ] cfgv = [ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, @@ -1386,13 +1380,9 @@ flake8-docstrings = [ {file = "flake8-docstrings-1.6.0.tar.gz", hash = "sha256:9fe7c6a306064af8e62a055c2f61e9eb1da55f84bb39caef2b84ce53708ac34b"}, {file = "flake8_docstrings-1.6.0-py2.py3-none-any.whl", hash = "sha256:99cac583d6c7e32dd28bbfbef120a7c0d1b6dde4adb5a9fd441c4227a6534bde"}, ] -flake8-import-order = [ - {file = "flake8-import-order-0.18.1.tar.gz", hash = "sha256:a28dc39545ea4606c1ac3c24e9d05c849c6e5444a50fb7e9cdd430fc94de6e92"}, - {file = "flake8_import_order-0.18.1-py2.py3-none-any.whl", hash = "sha256:90a80e46886259b9c396b578d75c749801a41ee969a235e163cfe1be7afd2543"}, -] flake8-isort = [ - {file = "flake8-isort-4.0.0.tar.gz", hash = "sha256:2b91300f4f1926b396c2c90185844eb1a3d5ec39ea6138832d119da0a208f4d9"}, - {file = "flake8_isort-4.0.0-py2.py3-none-any.whl", hash = "sha256:729cd6ef9ba3659512dee337687c05d79c78e1215fdf921ed67e5fe46cce2f3c"}, + {file = "flake8-isort-4.1.1.tar.gz", hash = "sha256:d814304ab70e6e58859bc5c3e221e2e6e71c958e7005239202fee19c24f82717"}, + {file = "flake8_isort-4.1.1-py3-none-any.whl", hash = "sha256:c4e8b6dcb7be9b71a02e6e5d4196cefcef0f3447be51e82730fb336fff164949"}, ] flake8-polyfill = [ {file = "flake8-polyfill-1.0.2.tar.gz", hash = "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda"}, @@ -1461,8 +1451,8 @@ identify = [ {file = "identify-2.3.0.tar.gz", hash = "sha256:fd08c97f23ceee72784081f1ce5125c8f53a02d3f2716dde79a6ab8f1039fea5"}, ] idna = [ - {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, - {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, diff --git a/pyproject.toml b/pyproject.toml index 14e81af05..d3a00e121 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,6 @@ flake8 = "~=3.8" flake8-annotations = "~=2.0" flake8-bugbear = "~=20.1" flake8-docstrings = "~=1.4" -flake8-import-order = "~=0.18" flake8-string-format = "~=0.2" flake8-tidy-imports = "~=4.0" flake8-todo = "~=0.7" -- cgit v1.2.3 From f9e9dca8edadb0029fcdac309115f833114ec536 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 14 Oct 2021 15:18:44 +0000 Subject: ISort: give the codebase a sort --- bot/converters.py | 1 + bot/exts/filters/antispam.py | 5 +---- bot/exts/filters/filtering.py | 5 +---- bot/exts/info/doc/__init__.py | 1 + bot/exts/info/doc/_batch_parser.py | 1 + bot/exts/info/doc/_cog.py | 1 + bot/exts/info/doc/_parsing.py | 2 ++ bot/exts/info/doc/_redis_cache.py | 1 + bot/exts/moderation/stream.py | 3 +-- bot/exts/moderation/voice_gate.py | 1 - bot/exts/utils/clean.py | 4 +--- bot/exts/utils/reminders.py | 5 +---- bot/rules/discord_emojis.py | 1 - bot/rules/links.py | 1 - bot/utils/checks.py | 12 ++---------- tests/__init__.py | 1 - tests/bot/exts/backend/sync/test_base.py | 1 - tests/bot/exts/events/test_code_jams.py | 4 ++-- tests/bot/exts/moderation/test_incidents.py | 11 ++--------- tests/bot/exts/moderation/test_silence.py | 9 +-------- tests/bot/test_converters.py | 8 +------- tests/test_base.py | 1 - 22 files changed, 20 insertions(+), 59 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index c96e2c984..7f6aa9c52 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -22,6 +22,7 @@ from bot.exts.info.doc import _inventory_parser from bot.utils.extensions import EXTENSIONS, unqualify from bot.utils.regex import INVITE_RE from bot.utils.time import parse_duration_string + if t.TYPE_CHECKING: from bot.exts.info.source import SourceType diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 8bae159d2..94aec6684 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -14,9 +14,7 @@ from discord.ext.commands import Cog from bot import rules from bot.bot import Bot from bot.constants import ( - AntiSpam as AntiSpamConfig, Channels, - Colours, DEBUG_MODE, Event, Filter, - Guild as GuildConfig, Icons, + AntiSpam as AntiSpamConfig, Channels, Colours, DEBUG_MODE, Event, Filter, Guild as GuildConfig, Icons ) from bot.converters import Duration from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME @@ -25,7 +23,6 @@ from bot.utils import lock, scheduling from bot.utils.message_cache import MessageCache from bot.utils.messages import format_user, send_attachments - log = logging.getLogger(__name__) RULE_FUNCTION_MAPPING = { diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 64f3b82af..916e3efff 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -15,10 +15,7 @@ from discord.utils import escape_markdown from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import ( - Channels, Colours, Filter, - Guild, Icons, URLs -) +from bot.constants import Channels, Colours, Filter, Guild, Icons, URLs from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.exts.moderation.modlog import ModLog from bot.utils import scheduling diff --git a/bot/exts/info/doc/__init__.py b/bot/exts/info/doc/__init__.py index 38a8975c0..facdf4d0b 100644 --- a/bot/exts/info/doc/__init__.py +++ b/bot/exts/info/doc/__init__.py @@ -1,4 +1,5 @@ from bot.bot import Bot + from ._redis_cache import DocRedisCache MAX_SIGNATURE_AMOUNT = 3 diff --git a/bot/exts/info/doc/_batch_parser.py b/bot/exts/info/doc/_batch_parser.py index 51ee29b68..c8e542ce7 100644 --- a/bot/exts/info/doc/_batch_parser.py +++ b/bot/exts/info/doc/_batch_parser.py @@ -14,6 +14,7 @@ from bs4 import BeautifulSoup import bot from bot.constants import Channels from bot.utils import scheduling + from . import _cog, doc_cache from ._parsing import get_symbol_markdown diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index ca6af946b..5a8aa8841 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -22,6 +22,7 @@ from bot.utils import scheduling from bot.utils.lock import SharedEvent, lock from bot.utils.messages import send_denial, wait_for_deletion from bot.utils.scheduling import Scheduler + from . import NAMESPACE, PRIORITY_PACKAGES, _batch_parser, doc_cache from ._inventory_parser import InvalidHeaderError, InventoryDict, fetch_inventory diff --git a/bot/exts/info/doc/_parsing.py b/bot/exts/info/doc/_parsing.py index 1a0d42c47..a1bf33de9 100644 --- a/bot/exts/info/doc/_parsing.py +++ b/bot/exts/info/doc/_parsing.py @@ -11,9 +11,11 @@ from bs4 import BeautifulSoup from bs4.element import NavigableString, Tag from bot.utils.helpers import find_nth_occurrence + from . import MAX_SIGNATURE_AMOUNT from ._html import get_dd_description, get_general_description, get_signatures from ._markdown import DocMarkdownConverter + if TYPE_CHECKING: from ._cog import DocItem diff --git a/bot/exts/info/doc/_redis_cache.py b/bot/exts/info/doc/_redis_cache.py index ad764816f..79648893a 100644 --- a/bot/exts/info/doc/_redis_cache.py +++ b/bot/exts/info/doc/_redis_cache.py @@ -4,6 +4,7 @@ import datetime from typing import Optional, TYPE_CHECKING from async_rediscache.types.base import RedisObject, namespace_lock + if TYPE_CHECKING: from ._cog import DocItem diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index a179a9acc..735cd21da 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -10,8 +10,7 @@ from discord.ext import commands from bot.bot import Bot from bot.constants import ( - Colours, Emojis, Guild, MODERATION_ROLES, Roles, - STAFF_PARTNERS_COMMUNITY_ROLES, VideoPermission + Colours, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES, VideoPermission ) from bot.converters import Expiry from bot.pagination import LinePaginator diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 8494a1e2e..2e8e8513a 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -8,7 +8,6 @@ from async_rediscache import RedisCache from discord import Colour, Member, VoiceState from discord.ext.commands import Cog, Context, command - from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels, Event, MODERATION_ROLES, Roles, VoiceGate as GateConf diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index cb662e852..764ebca15 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -8,9 +8,7 @@ from discord.ext import commands from discord.ext.commands import Cog, Context, group, has_any_role from bot.bot import Bot -from bot.constants import ( - Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES -) +from bot.constants import Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES from bot.exts.moderation.modlog import ModLog log = logging.getLogger(__name__) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 95f3661af..bf4fdf96b 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -10,10 +10,7 @@ from dateutil.parser import isoparse from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot -from bot.constants import ( - Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, - Roles, STAFF_PARTNERS_COMMUNITY_ROLES -) +from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES from bot.converters import Duration, UnambiguousUser from bot.pagination import LinePaginator from bot.utils import scheduling diff --git a/bot/rules/discord_emojis.py b/bot/rules/discord_emojis.py index 41faf7ee8..d979ac5e7 100644 --- a/bot/rules/discord_emojis.py +++ b/bot/rules/discord_emojis.py @@ -4,7 +4,6 @@ from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message from emoji import demojize - DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>|:\w+:") CODE_BLOCK_RE = re.compile(r"```.*?```", flags=re.DOTALL) diff --git a/bot/rules/links.py b/bot/rules/links.py index ec75a19c5..c46b783c5 100644 --- a/bot/rules/links.py +++ b/bot/rules/links.py @@ -3,7 +3,6 @@ from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message - LINK_RE = re.compile(r"(https?://[^\s]+)") diff --git a/bot/utils/checks.py b/bot/utils/checks.py index 3d0c8a50c..ff311010e 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -3,16 +3,8 @@ import logging from typing import Callable, Container, Iterable, Optional, Union from discord.ext.commands import ( - BucketType, - CheckFailure, - Cog, - Command, - CommandOnCooldown, - Context, - Cooldown, - CooldownMapping, - NoPrivateMessage, - has_any_role, + BucketType, CheckFailure, Cog, Command, CommandOnCooldown, Context, Cooldown, CooldownMapping, NoPrivateMessage, + has_any_role ) from bot import constants diff --git a/tests/__init__.py b/tests/__init__.py index 2228110ad..f5b83a261 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,5 +1,4 @@ import logging - log = logging.getLogger() log.setLevel(logging.CRITICAL) diff --git a/tests/bot/exts/backend/sync/test_base.py b/tests/bot/exts/backend/sync/test_base.py index 3ad9db9c3..9dc46005b 100644 --- a/tests/bot/exts/backend/sync/test_base.py +++ b/tests/bot/exts/backend/sync/test_base.py @@ -1,7 +1,6 @@ import unittest from unittest import mock - from bot.api import ResponseCodeError from bot.exts.backend.sync._syncers import Syncer from tests import helpers diff --git a/tests/bot/exts/events/test_code_jams.py b/tests/bot/exts/events/test_code_jams.py index b9ee1e363..0856546af 100644 --- a/tests/bot/exts/events/test_code_jams.py +++ b/tests/bot/exts/events/test_code_jams.py @@ -8,8 +8,8 @@ from bot.constants import Roles from bot.exts.events import code_jams from bot.exts.events.code_jams import _channels, _cog from tests.helpers import ( - MockAttachment, MockBot, MockCategoryChannel, MockContext, - MockGuild, MockMember, MockRole, MockTextChannel, autospec + MockAttachment, MockBot, MockCategoryChannel, MockContext, MockGuild, MockMember, MockRole, MockTextChannel, + autospec ) TEST_CSV = b"""\ diff --git a/tests/bot/exts/moderation/test_incidents.py b/tests/bot/exts/moderation/test_incidents.py index cbf7f7bcf..c98edf08a 100644 --- a/tests/bot/exts/moderation/test_incidents.py +++ b/tests/bot/exts/moderation/test_incidents.py @@ -11,15 +11,8 @@ import discord from bot.constants import Colours from bot.exts.moderation import incidents from tests.helpers import ( - MockAsyncWebhook, - MockAttachment, - MockBot, - MockMember, - MockMessage, - MockReaction, - MockRole, - MockTextChannel, - MockUser, + MockAsyncWebhook, MockAttachment, MockBot, MockMember, MockMessage, MockReaction, MockRole, MockTextChannel, + MockUser ) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index 59a5893ef..ef8394be8 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -12,14 +12,7 @@ from discord import PermissionOverwrite from bot.constants import Channels, Guild, MODERATION_ROLES, Roles from bot.exts.moderation import silence from tests.helpers import ( - MockBot, - MockContext, - MockGuild, - MockMember, - MockRole, - MockTextChannel, - MockVoiceChannel, - autospec + MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel, MockVoiceChannel, autospec ) redis_session = None diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index 6e3a6b898..ef6c8e19e 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -6,13 +6,7 @@ from unittest.mock import MagicMock, patch from dateutil.relativedelta import relativedelta from discord.ext.commands import BadArgument -from bot.converters import ( - Duration, - HushDurationConverter, - ISODateTime, - PackageName, - TagNameConverter, -) +from bot.converters import Duration, HushDurationConverter, ISODateTime, PackageName, TagNameConverter class ConverterTests(unittest.IsolatedAsyncioTestCase): diff --git a/tests/test_base.py b/tests/test_base.py index a7db4bf3e..7dd5dfac4 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -2,7 +2,6 @@ import logging import unittest import unittest.mock - from tests.base import LoggingTestsMixin, _CaptureLogHandler -- cgit v1.2.3 From 445bc4500494b7c31834b0c225387418464076e0 Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Fri, 15 Oct 2021 01:34:42 +0530 Subject: Send the user's mention in the `watch` command. (#1867) --- bot/exts/moderation/watchchannels/bigbrother.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py index 3aa253fea..b91b9a124 100644 --- a/bot/exts/moderation/watchchannels/bigbrother.py +++ b/bot/exts/moderation/watchchannels/bigbrother.py @@ -87,11 +87,11 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): return if not await self.fetch_user_cache(): - await ctx.send(f":x: Updating the user cache failed, can't watch user {user}") + await ctx.send(f":x: Updating the user cache failed, can't watch user {user.mention}") return if user.id in self.watched_users: - await ctx.send(f":x: {user} is already being watched.") + await ctx.send(f":x: {user.mention} is already being watched.") return # discord.User instances don't have a roles attribute @@ -103,7 +103,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): if response is not None: self.watched_users[user.id] = response - msg = f":white_check_mark: Messages sent by {user} will now be relayed to Big Brother." + msg = f":white_check_mark: Messages sent by {user.mention} will now be relayed to Big Brother." history = await self.bot.api_client.get( self.api_endpoint, @@ -156,7 +156,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): log.debug(f"Perma-banned user {user} was unwatched.") return log.trace("User is not banned. Sending message to channel") - message = f":white_check_mark: Messages sent by {user} will no longer be relayed." + message = f":white_check_mark: Messages sent by {user.mention} will no longer be relayed." else: log.trace("No active watches found for user.") -- cgit v1.2.3 From 1f4b65d4d06d3f22f05253eccd1abd0468217811 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Thu, 14 Oct 2021 21:52:01 +0100 Subject: Send `!defcon threshold` message in `channel` as well as #defcon. (#1856) Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/exts/moderation/defcon.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index ac813d6ba..1d6dbb49a 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -8,7 +8,7 @@ from typing import Optional, Union from aioredis import RedisError from async_rediscache import RedisCache from dateutil.relativedelta import relativedelta -from discord import Colour, Embed, Forbidden, Member, User +from discord import Colour, Embed, Forbidden, Member, TextChannel, User from discord.ext import tasks from discord.ext.commands import Cog, Context, group, has_any_role @@ -176,7 +176,7 @@ class Defcon(Cog): """ if isinstance(threshold, int): threshold = relativedelta(days=threshold) - await self._update_threshold(ctx.author, threshold=threshold, expiry=expiry) + await self._update_threshold(ctx.author, ctx.channel, threshold, expiry) @defcon_group.command() @has_any_role(Roles.admins) @@ -208,7 +208,13 @@ class Defcon(Cog): scheduling.create_task(self.channel.edit(topic=new_topic)) @defcon_settings.atomic_transaction - async def _update_threshold(self, author: User, threshold: relativedelta, expiry: Optional[Expiry] = None) -> None: + async def _update_threshold( + self, + author: User, + channel: TextChannel, + threshold: relativedelta, + expiry: Optional[Expiry] = None + ) -> None: """Update the new threshold in the cog, cache, defcon channel, and logs, and additionally schedule expiry.""" self.threshold = threshold if threshold == relativedelta(days=0): # If the threshold is 0, we don't need to schedule anything @@ -248,9 +254,13 @@ class Defcon(Cog): else: channel_message = "removed" - await self.channel.send( - f"{action.value.emoji} DEFCON threshold {channel_message}{error}." - ) + message = f"{action.value.emoji} DEFCON threshold {channel_message}{error}." + await self.channel.send(message) + + # If invoked outside of #defcon send to `ctx.channel` too + if channel != self.channel: + await channel.send(message) + await self._send_defcon_log(action, author) self._update_channel_topic() @@ -258,7 +268,7 @@ class Defcon(Cog): async def _remove_threshold(self) -> None: """Resets the threshold back to 0.""" - await self._update_threshold(self.bot.user, relativedelta(days=0)) + await self._update_threshold(self.bot.user, self.channel, relativedelta(days=0)) @staticmethod def _stringify_relativedelta(delta: relativedelta) -> str: -- cgit v1.2.3 From cbbaf2f0761fa5c3b61c16b50a9d4b772b92898c Mon Sep 17 00:00:00 2001 From: Janine vN Date: Thu, 14 Oct 2021 16:55:07 -0400 Subject: Add a contribute tag which explains how to contribute to PyDis projects * Add contribute tag * Adjust wording Adjusts the wording in the tag from feedback Co-authored-by: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Co-authored-by: ChrisJL --- bot/resources/tags/contribute.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 bot/resources/tags/contribute.md diff --git a/bot/resources/tags/contribute.md b/bot/resources/tags/contribute.md new file mode 100644 index 000000000..070975646 --- /dev/null +++ b/bot/resources/tags/contribute.md @@ -0,0 +1,12 @@ +**Contribute to Python Discord's Open Source Projects** +Looking to contribute to Open Source Projects for the first time? Want to add a feature or fix a bug on the bots on this server? We have on-going projects that people can contribute to, even if you've never contributed to open source before! + +**Projects to Contribute to** +• [Sir Lancebot](https://github.com/python-discord/sir-lancebot) - our fun, beginner-friendly bot +• [Python](https://github.com/python-discord/bot) - our utility & moderation bot +• [Site](https://github.com/python-discord/site) - resources, guides, and more + +**Where to start** +1. Read our [contributing guidelines](https://pythondiscord.com/pages/guides/pydis-guides/contributing/) +2. Chat with us in <#635950537262759947> if you're ready to jump in or have any questions +3. Open an issue or ask to be assigned to an issue to work on -- cgit v1.2.3 From 77bf28a7ba103e2ac407db19a1315fc7e14cc984 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 5 Oct 2021 20:17:19 +0100 Subject: Add `CustomLogger` to bot/log.py --- bot/log.py | 51 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/bot/log.py b/bot/log.py index 4e20c005e..74d8263c0 100644 --- a/bot/log.py +++ b/bot/log.py @@ -3,6 +3,7 @@ import os import sys from logging import Logger, handlers from pathlib import Path +from typing import Optional, TYPE_CHECKING, cast import coloredlogs import sentry_sdk @@ -14,11 +15,38 @@ from bot import constants TRACE_LEVEL = 5 +if TYPE_CHECKING: + LoggerClass = Logger +else: + LoggerClass = logging.getLoggerClass() + + +class CustomLogger(LoggerClass): + """Custom implementation of the `Logger` class with an added `trace` method.""" + + def trace(self, msg: str, *args, **kwargs) -> None: + """ + Log 'msg % args' with severity 'TRACE'. + + To pass exception information, use the keyword argument exc_info with + a true value, e.g. + + logger.trace("Houston, we have an %s", "interesting problem", exc_info=1) + """ + if self.isEnabledFor(TRACE_LEVEL): + self.log(TRACE_LEVEL, msg, *args, **kwargs) + + +def get_logger(name: Optional[str] = None) -> CustomLogger: + """Utility to make mypy recognise that logger is of type `CustomLogger`.""" + return cast(CustomLogger, logging.getLogger(name)) + + def setup() -> None: """Set up loggers.""" logging.TRACE = TRACE_LEVEL logging.addLevelName(TRACE_LEVEL, "TRACE") - Logger.trace = _monkeypatch_trace + logging.setLoggerClass(CustomLogger) format_string = "%(asctime)s | %(name)s | %(levelname)s | %(message)s" log_format = logging.Formatter(format_string) @@ -42,7 +70,7 @@ def setup() -> None: if "COLOREDLOGS_LOG_FORMAT" not in os.environ: coloredlogs.DEFAULT_LOG_FORMAT = format_string - coloredlogs.install(level=logging.TRACE, logger=root_log, stream=sys.stdout) + coloredlogs.install(level=TRACE_LEVEL, logger=root_log, stream=sys.stdout) root_log.setLevel(logging.DEBUG if constants.DEBUG_MODE else logging.INFO) logging.getLogger("discord").setLevel(logging.WARNING) @@ -73,19 +101,6 @@ def setup_sentry() -> None: ) -def _monkeypatch_trace(self: logging.Logger, msg: str, *args, **kwargs) -> None: - """ - Log 'msg % args' with severity 'TRACE'. - - To pass exception information, use the keyword argument exc_info with - a true value, e.g. - - logger.trace("Houston, we have an %s", "interesting problem", exc_info=1) - """ - if self.isEnabledFor(TRACE_LEVEL): - self._log(TRACE_LEVEL, msg, args, **kwargs) - - def _set_trace_loggers() -> None: """ Set loggers to the trace level according to the value from the BOT_TRACE_LOGGERS env var. @@ -101,13 +116,13 @@ def _set_trace_loggers() -> None: level_filter = constants.Bot.trace_loggers if level_filter: if level_filter.startswith("*"): - logging.getLogger().setLevel(logging.TRACE) + logging.getLogger().setLevel(TRACE_LEVEL) elif level_filter.startswith("!"): - logging.getLogger().setLevel(logging.TRACE) + logging.getLogger().setLevel(TRACE_LEVEL) for logger_name in level_filter.strip("!,").split(","): logging.getLogger(logger_name).setLevel(logging.DEBUG) else: for logger_name in level_filter.strip(",").split(","): - logging.getLogger(logger_name).setLevel(logging.TRACE) + logging.getLogger(logger_name).setLevel(TRACE_LEVEL) -- cgit v1.2.3 From 94b4ad61c203e5b8b33ace2ef639a9fb2fdea4e4 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 5 Oct 2021 21:18:45 +0100 Subject: Migrate to `bot.log.get_logger` function --- bot/__main__.py | 6 ++---- bot/api.py | 4 ++-- bot/bot.py | 4 ++-- bot/constants.py | 14 +++++--------- bot/converters.py | 5 +++-- bot/decorators.py | 4 ++-- bot/exts/backend/branding/_cog.py | 4 ++-- bot/exts/backend/branding/_repository.py | 4 ++-- bot/exts/backend/config_verifier.py | 5 ++--- bot/exts/backend/error_handler.py | 4 ++-- bot/exts/backend/logging.py | 5 ++--- bot/exts/backend/sync/_cog.py | 4 ++-- bot/exts/backend/sync/_syncers.py | 4 ++-- bot/exts/events/code_jams/_channels.py | 4 ++-- bot/exts/events/code_jams/_cog.py | 4 ++-- bot/exts/filters/antimalware.py | 4 ++-- bot/exts/filters/antispam.py | 5 ++--- bot/exts/filters/filter_lists.py | 4 ++-- bot/exts/filters/filtering.py | 4 ++-- bot/exts/filters/security.py | 5 ++--- bot/exts/filters/token_remover.py | 4 ++-- bot/exts/filters/webhook_remover.py | 4 ++-- bot/exts/fun/duck_pond.py | 4 ++-- bot/exts/fun/off_topic_names.py | 4 ++-- bot/exts/help_channels/__init__.py | 5 ++--- bot/exts/help_channels/_channel.py | 4 ++-- bot/exts/help_channels/_cog.py | 4 ++-- bot/exts/help_channels/_message.py | 4 ++-- bot/exts/help_channels/_name.py | 4 ++-- bot/exts/help_channels/_stats.py | 5 ++--- bot/exts/info/code_snippets.py | 3 ++- bot/exts/info/codeblock/_cog.py | 4 ++-- bot/exts/info/codeblock/_instructions.py | 4 ++-- bot/exts/info/codeblock/_parsing.py | 4 ++-- bot/exts/info/doc/_batch_parser.py | 4 ++-- bot/exts/info/doc/_cog.py | 4 ++-- bot/exts/info/doc/_html.py | 4 ++-- bot/exts/info/doc/_inventory_parser.py | 4 ++-- bot/exts/info/doc/_parsing.py | 5 +++-- bot/exts/info/help.py | 4 ++-- bot/exts/info/information.py | 4 ++-- bot/exts/info/pep.py | 4 ++-- bot/exts/info/pypi.py | 4 ++-- bot/exts/info/python_news.py | 4 ++-- bot/exts/info/site.py | 5 ++--- bot/exts/info/tags.py | 4 ++-- bot/exts/moderation/defcon.py | 4 ++-- bot/exts/moderation/dm_relay.py | 5 ++--- bot/exts/moderation/incidents.py | 4 ++-- bot/exts/moderation/infraction/_scheduler.py | 4 ++-- bot/exts/moderation/infraction/_utils.py | 4 ++-- bot/exts/moderation/infraction/infractions.py | 4 ++-- bot/exts/moderation/infraction/management.py | 4 ++-- bot/exts/moderation/infraction/superstarify.py | 4 ++-- bot/exts/moderation/metabase.py | 4 ++-- bot/exts/moderation/modlog.py | 4 ++-- bot/exts/moderation/modpings.py | 4 ++-- bot/exts/moderation/silence.py | 4 ++-- bot/exts/moderation/slowmode.py | 4 ++-- bot/exts/moderation/stream.py | 4 ++-- bot/exts/moderation/verification.py | 4 ++-- bot/exts/moderation/voice_gate.py | 5 ++--- bot/exts/moderation/watchchannels/_watchchannel.py | 7 ++++--- bot/exts/moderation/watchchannels/bigbrother.py | 4 ++-- bot/exts/recruitment/talentpool/_cog.py | 4 ++-- bot/exts/recruitment/talentpool/_review.py | 4 ++-- bot/exts/utils/bot.py | 4 ++-- bot/exts/utils/clean.py | 4 ++-- bot/exts/utils/extensions.py | 4 ++-- bot/exts/utils/internal.py | 4 ++-- bot/exts/utils/reminders.py | 4 ++-- bot/exts/utils/snekbox.py | 4 ++-- bot/exts/utils/utils.py | 4 ++-- bot/log.py | 20 ++++++++++---------- bot/monkey_patches.py | 5 +++-- bot/pagination.py | 4 ++-- bot/utils/channel.py | 5 ++--- bot/utils/checks.py | 4 ++-- bot/utils/function.py | 5 +++-- bot/utils/lock.py | 4 ++-- bot/utils/members.py | 5 +++-- bot/utils/messages.py | 4 ++-- bot/utils/scheduling.py | 7 ++++--- bot/utils/services.py | 4 ++-- bot/utils/webhooks.py | 4 ++-- tests/__init__.py | 3 ++- tests/base.py | 3 ++- tests/test_base.py | 11 +++++------ 88 files changed, 199 insertions(+), 206 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index 9317563c8..0d3fce180 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,11 +1,9 @@ -import logging - import aiohttp import bot from bot import constants from bot.bot import Bot, StartupError -from bot.log import setup_sentry +from bot.log import get_logger, setup_sentry setup_sentry() @@ -21,7 +19,7 @@ except StartupError as e: message = "Could not connect to Redis. Is it running?" # The exception is logged with an empty message so the actual message is visible at the bottom - log = logging.getLogger("bot") + log = get_logger("bot") log.fatal("", exc_info=e.exception) log.fatal(message) diff --git a/bot/api.py b/bot/api.py index 6ce9481f4..33e612641 100644 --- a/bot/api.py +++ b/bot/api.py @@ -1,13 +1,13 @@ import asyncio -import logging from typing import Optional from urllib.parse import quote as quote_url import aiohttp +from bot.log import get_logger from .constants import Keys, URLs -log = logging.getLogger(__name__) +log = get_logger(__name__) class ResponseCodeError(ValueError): diff --git a/bot/bot.py b/bot/bot.py index db3d651a3..94783a466 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -1,5 +1,4 @@ import asyncio -import logging import socket import warnings from collections import defaultdict @@ -14,8 +13,9 @@ from sentry_sdk import push_scope from bot import api, constants from bot.async_stats import AsyncStatsClient +from bot.log import get_logger -log = logging.getLogger('bot') +log = get_logger('bot') LOCALHOST = "127.0.0.1" diff --git a/bot/constants.py b/bot/constants.py index f99913b17..140dd12f9 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -9,8 +9,6 @@ the custom configuration. Any settings left out in the custom user configuration will stay their default values from `config-default.yml`. """ - -import logging import os from collections.abc import Mapping from enum import Enum @@ -25,8 +23,6 @@ try: except ModuleNotFoundError: pass -log = logging.getLogger(__name__) - def _env_var_constructor(loader, node): """ @@ -104,7 +100,7 @@ def _recursive_update(original, new): if Path("config.yml").exists(): - log.info("Found `config.yml` file, loading constants from it.") + print("Found `config.yml` file, loading constants from it.") with open("config.yml", encoding="UTF-8") as f: user_config = yaml.safe_load(f) _recursive_update(_CONFIG_YAML, user_config) @@ -123,11 +119,11 @@ def check_required_keys(keys): if lookup is None: raise KeyError(key) except KeyError: - log.critical( + raise ( f"A configuration for `{key_path}` is required, but was not found. " "Please set it in `config.yml` or setup an environment variable and try again." ) - raise + try: @@ -186,8 +182,8 @@ class YAMLGetter(type): (cls.section, cls.subsection, name) if cls.subsection is not None else (cls.section, name) ) - # Only an INFO log since this can be caught through `hasattr` or `getattr`. - log.info(f"Tried accessing configuration variable at `{dotted_path}`, but it could not be found.") + # Only an print since this can be caught through `hasattr` or `getattr`. + print(f"Tried accessing configuration variable at `{dotted_path}`, but it could not be found.") raise AttributeError(repr(name)) from e def __getitem__(cls, name): diff --git a/bot/converters.py b/bot/converters.py index c96e2c984..4d019691e 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging import re import typing as t from datetime import datetime @@ -19,13 +18,15 @@ from bot.api import ResponseCodeError from bot.constants import URLs from bot.errors import InvalidInfraction from bot.exts.info.doc import _inventory_parser +from bot.log import get_logger from bot.utils.extensions import EXTENSIONS, unqualify from bot.utils.regex import INVITE_RE from bot.utils.time import parse_duration_string + if t.TYPE_CHECKING: from bot.exts.info.source import SourceType -log = logging.getLogger(__name__) +log = get_logger(__name__) DISCORD_EPOCH_DT = datetime.utcfromtimestamp(DISCORD_EPOCH / 1000) RE_USER_MENTION = re.compile(r"<@!?([0-9]+)>$") diff --git a/bot/decorators.py b/bot/decorators.py index ee210be26..048a2a09a 100644 --- a/bot/decorators.py +++ b/bot/decorators.py @@ -1,6 +1,5 @@ import asyncio import functools -import logging import types import typing as t from contextlib import suppress @@ -10,11 +9,12 @@ from discord.ext import commands from discord.ext.commands import Cog, Context from bot.constants import Channels, DEBUG_MODE, RedirectOutput +from bot.log import get_logger from bot.utils import function, scheduling from bot.utils.checks import ContextCheckFailure, in_whitelist_check from bot.utils.function import command_wraps -log = logging.getLogger(__name__) +log = get_logger(__name__) def in_whitelist( diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py index ab0a761ff..9c5bdbb4e 100644 --- a/bot/exts/backend/branding/_cog.py +++ b/bot/exts/backend/branding/_cog.py @@ -1,6 +1,5 @@ import asyncio import contextlib -import logging import random import typing as t from datetime import timedelta @@ -17,9 +16,10 @@ from bot.bot import Bot from bot.constants import Branding as BrandingConfig, Channels, Colours, Guild, MODERATION_ROLES from bot.decorators import mock_in_debug from bot.exts.backend.branding._repository import BrandingRepository, Event, RemoteObject +from bot.log import get_logger from bot.utils import scheduling -log = logging.getLogger(__name__) +log = get_logger(__name__) class AssetType(Enum): diff --git a/bot/exts/backend/branding/_repository.py b/bot/exts/backend/branding/_repository.py index 7b09d4641..d88ea67f3 100644 --- a/bot/exts/backend/branding/_repository.py +++ b/bot/exts/backend/branding/_repository.py @@ -1,4 +1,3 @@ -import logging import typing as t from datetime import date, datetime @@ -7,6 +6,7 @@ import frontmatter from bot.bot import Bot from bot.constants import Keys from bot.errors import BrandingMisconfiguration +from bot.log import get_logger # Base URL for requests into the branding repository. BRANDING_URL = "https://api.github.com/repos/python-discord/branding/contents" @@ -25,7 +25,7 @@ ARBITRARY_YEAR = 2020 # Format used to parse date strings after we inject `ARBITRARY_YEAR` at the end. DATE_FMT = "%B %d %Y" # Ex: July 10 2020 -log = logging.getLogger(__name__) +log = get_logger(__name__) class RemoteObject: diff --git a/bot/exts/backend/config_verifier.py b/bot/exts/backend/config_verifier.py index c24cb324f..dc85a65a2 100644 --- a/bot/exts/backend/config_verifier.py +++ b/bot/exts/backend/config_verifier.py @@ -1,12 +1,11 @@ -import logging - from discord.ext.commands import Cog from bot import constants from bot.bot import Bot +from bot.log import get_logger from bot.utils import scheduling -log = logging.getLogger(__name__) +log = get_logger(__name__) class ConfigVerifier(Cog): diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 578c372c3..7644b93ae 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -1,5 +1,4 @@ import difflib -import logging import typing as t from discord import Embed @@ -11,9 +10,10 @@ from bot.bot import Bot from bot.constants import Colours, Icons, MODERATION_ROLES from bot.converters import TagNameConverter from bot.errors import InvalidInfractedUserError, LockedResourceError +from bot.log import get_logger from bot.utils.checks import ContextCheckFailure -log = logging.getLogger(__name__) +log = get_logger(__name__) class ErrorHandler(Cog): diff --git a/bot/exts/backend/logging.py b/bot/exts/backend/logging.py index 8f1b8026f..2d03cd580 100644 --- a/bot/exts/backend/logging.py +++ b/bot/exts/backend/logging.py @@ -1,13 +1,12 @@ -import logging - from discord import Embed from discord.ext.commands import Cog from bot.bot import Bot from bot.constants import Channels, DEBUG_MODE +from bot.log import get_logger from bot.utils import scheduling -log = logging.getLogger(__name__) +log = get_logger(__name__) class Logging(Cog): diff --git a/bot/exts/backend/sync/_cog.py b/bot/exts/backend/sync/_cog.py index f88dcf538..80f5750bc 100644 --- a/bot/exts/backend/sync/_cog.py +++ b/bot/exts/backend/sync/_cog.py @@ -1,4 +1,3 @@ -import logging from typing import Any, Dict from discord import Member, Role, User @@ -9,9 +8,10 @@ from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot from bot.exts.backend.sync import _syncers +from bot.log import get_logger from bot.utils import scheduling -log = logging.getLogger(__name__) +log = get_logger(__name__) class Sync(Cog): diff --git a/bot/exts/backend/sync/_syncers.py b/bot/exts/backend/sync/_syncers.py index 50016df0c..45301b098 100644 --- a/bot/exts/backend/sync/_syncers.py +++ b/bot/exts/backend/sync/_syncers.py @@ -1,5 +1,4 @@ import abc -import logging import typing as t from collections import namedtuple @@ -9,9 +8,10 @@ from more_itertools import chunked import bot from bot.api import ResponseCodeError +from bot.log import get_logger from bot.utils.members import get_or_fetch_member -log = logging.getLogger(__name__) +log = get_logger(__name__) CHUNK_SIZE = 1000 diff --git a/bot/exts/events/code_jams/_channels.py b/bot/exts/events/code_jams/_channels.py index 34ff0ad41..e8cf5f7bf 100644 --- a/bot/exts/events/code_jams/_channels.py +++ b/bot/exts/events/code_jams/_channels.py @@ -1,11 +1,11 @@ -import logging import typing as t import discord from bot.constants import Categories, Channels, Roles +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) MAX_CHANNELS = 50 CATEGORY_NAME = "Code Jam" diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index 7b0831ab4..b31d628d5 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -1,6 +1,5 @@ import asyncio import csv -import logging import typing as t from collections import defaultdict @@ -11,10 +10,11 @@ from discord.ext import commands from bot.bot import Bot from bot.constants import Emojis, Roles from bot.exts.events.code_jams import _channels +from bot.log import get_logger from bot.utils.members import get_or_fetch_member from bot.utils.services import send_to_paste_service -log = logging.getLogger(__name__) +log = get_logger(__name__) TEAM_LEADERS_COLOUR = 0x11806a DELETION_REACTION = "\U0001f4a5" diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index e708e5149..d727f7940 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/antimalware.py @@ -1,4 +1,3 @@ -import logging import typing as t from os.path import splitext @@ -8,8 +7,9 @@ from discord.ext.commands import Cog from bot.bot import Bot from bot.constants import Channels, Filter, URLs from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) PY_EMBED_DESCRIPTION = ( "It looks like you tried to attach a Python file - " diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 8bae159d2..d5883d6b4 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -1,5 +1,4 @@ import asyncio -import logging from collections import defaultdict from collections.abc import Mapping from dataclasses import dataclass, field @@ -21,12 +20,12 @@ from bot.constants import ( from bot.converters import Duration from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.exts.moderation.modlog import ModLog +from bot.log import get_logger from bot.utils import lock, scheduling from bot.utils.message_cache import MessageCache from bot.utils.messages import format_user, send_attachments - -log = logging.getLogger(__name__) +log = get_logger(__name__) RULE_FUNCTION_MAPPING = { 'attachments': rules.apply_attachments, diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py index a06437f3d..4b5200684 100644 --- a/bot/exts/filters/filter_lists.py +++ b/bot/exts/filters/filter_lists.py @@ -1,4 +1,3 @@ -import logging from typing import Optional from discord import Colour, Embed @@ -8,10 +7,11 @@ from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot from bot.converters import ValidDiscordServerInvite, ValidFilterListType +from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils import scheduling -log = logging.getLogger(__name__) +log = get_logger(__name__) class FilterLists(Cog): diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 64f3b82af..a8e3d11e3 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -1,5 +1,4 @@ import asyncio -import logging import re from datetime import datetime, timedelta from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple, Union @@ -21,11 +20,12 @@ from bot.constants import ( ) from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.exts.moderation.modlog import ModLog +from bot.log import get_logger from bot.utils import scheduling from bot.utils.messages import format_user from bot.utils.regex import INVITE_RE -log = logging.getLogger(__name__) +log = get_logger(__name__) # Regular expressions CODE_BLOCK_RE = re.compile( diff --git a/bot/exts/filters/security.py b/bot/exts/filters/security.py index c680c5e27..fe3918423 100644 --- a/bot/exts/filters/security.py +++ b/bot/exts/filters/security.py @@ -1,10 +1,9 @@ -import logging - from discord.ext.commands import Cog, Context, NoPrivateMessage from bot.bot import Bot +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) class Security(Cog): diff --git a/bot/exts/filters/token_remover.py b/bot/exts/filters/token_remover.py index 6c86ff849..f68d4b987 100644 --- a/bot/exts/filters/token_remover.py +++ b/bot/exts/filters/token_remover.py @@ -1,6 +1,5 @@ import base64 import binascii -import logging import re import typing as t @@ -11,10 +10,11 @@ from bot import utils from bot.bot import Bot from bot.constants import Channels, Colours, Event, Icons from bot.exts.moderation.modlog import ModLog +from bot.log import get_logger from bot.utils.members import get_or_fetch_member from bot.utils.messages import format_user -log = logging.getLogger(__name__) +log = get_logger(__name__) LOG_MESSAGE = ( "Censored a seemingly valid token sent by {author} in {channel}, " diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index 25e267426..40cb4e141 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -1,4 +1,3 @@ -import logging import re from discord import Colour, Message, NotFound @@ -7,6 +6,7 @@ from discord.ext.commands import Cog from bot.bot import Bot from bot.constants import Channels, Colours, Event, Icons from bot.exts.moderation.modlog import ModLog +from bot.log import get_logger from bot.utils.messages import format_user WEBHOOK_URL_RE = re.compile( @@ -21,7 +21,7 @@ ALERT_MESSAGE_TEMPLATE = ( "mistake, please let us know." ) -log = logging.getLogger(__name__) +log = get_logger(__name__) class WebhookRemover(Cog): diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 8ced6922c..2b5592530 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -1,5 +1,4 @@ import asyncio -import logging from typing import Union import discord @@ -9,12 +8,13 @@ from discord.ext.commands import Cog, Context, command from bot import constants from bot.bot import Bot from bot.converters import MemberOrUser +from bot.log import get_logger from bot.utils import scheduling from bot.utils.checks import has_any_role from bot.utils.messages import count_unique_users_reaction, send_attachments from bot.utils.webhooks import send_webhook -log = logging.getLogger(__name__) +log = get_logger(__name__) class DuckPond(Cog): diff --git a/bot/exts/fun/off_topic_names.py b/bot/exts/fun/off_topic_names.py index 2f56aa5ba..427667c66 100644 --- a/bot/exts/fun/off_topic_names.py +++ b/bot/exts/fun/off_topic_names.py @@ -1,5 +1,4 @@ import difflib -import logging from datetime import datetime, timedelta from discord import Colour, Embed @@ -10,11 +9,12 @@ from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES from bot.converters import OffTopicName +from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils import scheduling CHANNELS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2) -log = logging.getLogger(__name__) +log = get_logger(__name__) async def update_names(bot: Bot) -> None: diff --git a/bot/exts/help_channels/__init__.py b/bot/exts/help_channels/__init__.py index 781f40449..beba18aa6 100644 --- a/bot/exts/help_channels/__init__.py +++ b/bot/exts/help_channels/__init__.py @@ -1,10 +1,9 @@ -import logging - from bot import constants from bot.bot import Bot from bot.exts.help_channels._channel import MAX_CHANNELS_PER_CATEGORY +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) def validate_config() -> None: diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index f1bcea171..e43c1e789 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -1,4 +1,3 @@ -import logging import typing as t from datetime import timedelta from enum import Enum @@ -10,9 +9,10 @@ from arrow import Arrow import bot from bot import constants from bot.exts.help_channels import _caches, _message +from bot.log import get_logger from bot.utils.channel import get_or_fetch_channel -log = logging.getLogger(__name__) +log = get_logger(__name__) MAX_CHANNELS_PER_CATEGORY = 50 EXCLUDED_CHANNELS = (constants.Channels.cooldown,) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 7c39bc132..498305b47 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -1,5 +1,4 @@ import asyncio -import logging import random import typing as t from datetime import timedelta @@ -14,9 +13,10 @@ from bot import constants from bot.bot import Bot from bot.constants import Channels, RedirectOutput from bot.exts.help_channels import _caches, _channel, _message, _name, _stats +from bot.log import get_logger from bot.utils import channel as channel_utils, lock, members, scheduling -log = logging.getLogger(__name__) +log = get_logger(__name__) NAMESPACE = "help" HELP_CHANNEL_TOPIC = """ diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index 077b20b47..a52c67570 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -1,4 +1,3 @@ -import logging import textwrap import typing as t @@ -9,8 +8,9 @@ from arrow import Arrow import bot from bot import constants from bot.exts.help_channels import _caches +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" diff --git a/bot/exts/help_channels/_name.py b/bot/exts/help_channels/_name.py index 061f855ae..a9d9b2df1 100644 --- a/bot/exts/help_channels/_name.py +++ b/bot/exts/help_channels/_name.py @@ -1,5 +1,4 @@ import json -import logging import typing as t from collections import deque from pathlib import Path @@ -8,8 +7,9 @@ import discord from bot import constants from bot.exts.help_channels._channel import MAX_CHANNELS_PER_CATEGORY, get_category_channels +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) def create_name_queue(*categories: discord.CategoryChannel) -> deque: diff --git a/bot/exts/help_channels/_stats.py b/bot/exts/help_channels/_stats.py index eb34e75e1..4698c26de 100644 --- a/bot/exts/help_channels/_stats.py +++ b/bot/exts/help_channels/_stats.py @@ -1,12 +1,11 @@ -import logging - from more_itertools import ilen import bot from bot import constants from bot.exts.help_channels import _caches, _channel +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) def report_counts() -> None: diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 4a90a0668..07b1b8a2d 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -10,9 +10,10 @@ from discord.ext.commands import Cog from bot.bot import Bot from bot.constants import Channels +from bot.log import get_logger from bot.utils.messages import wait_for_deletion -log = logging.getLogger(__name__) +log = get_logger(__name__) GITHUB_RE = re.compile( r'https://github\.com/(?P[a-zA-Z0-9-]+/[\w.-]+)/blob/' diff --git a/bot/exts/info/codeblock/_cog.py b/bot/exts/info/codeblock/_cog.py index f63a459ff..a859d8cef 100644 --- a/bot/exts/info/codeblock/_cog.py +++ b/bot/exts/info/codeblock/_cog.py @@ -1,4 +1,3 @@ -import logging import time from typing import Optional @@ -11,11 +10,12 @@ from bot.bot import Bot from bot.exts.filters.token_remover import TokenRemover from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE from bot.exts.info.codeblock._instructions import get_instructions +from bot.log import get_logger from bot.utils import has_lines, scheduling from bot.utils.channel import is_help_channel from bot.utils.messages import wait_for_deletion -log = logging.getLogger(__name__) +log = get_logger(__name__) class CodeBlockCog(Cog, name="Code Block"): diff --git a/bot/exts/info/codeblock/_instructions.py b/bot/exts/info/codeblock/_instructions.py index dadb5e1ef..8fcadeec2 100644 --- a/bot/exts/info/codeblock/_instructions.py +++ b/bot/exts/info/codeblock/_instructions.py @@ -1,11 +1,11 @@ """This module generates and formats instructional messages about fixing Markdown code blocks.""" -import logging from typing import Optional from bot.exts.info.codeblock import _parsing +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) _EXAMPLE_PY = "{lang}\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. _EXAMPLE_CODE_BLOCKS = ( diff --git a/bot/exts/info/codeblock/_parsing.py b/bot/exts/info/codeblock/_parsing.py index 73fd11b94..3c193d6c5 100644 --- a/bot/exts/info/codeblock/_parsing.py +++ b/bot/exts/info/codeblock/_parsing.py @@ -1,15 +1,15 @@ """This module provides functions for parsing Markdown code blocks.""" import ast -import logging import re import textwrap from typing import NamedTuple, Optional, Sequence from bot import constants +from bot.log import get_logger from bot.utils import has_lines -log = logging.getLogger(__name__) +log = get_logger(__name__) BACKTICK = "`" PY_LANG_CODES = ("python-repl", "python", "pycon", "py") # Order is important; "py" is last cause it's a subset. diff --git a/bot/exts/info/doc/_batch_parser.py b/bot/exts/info/doc/_batch_parser.py index 51ee29b68..ac72e5e49 100644 --- a/bot/exts/info/doc/_batch_parser.py +++ b/bot/exts/info/doc/_batch_parser.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio import collections -import logging from collections import defaultdict from contextlib import suppress from operator import attrgetter @@ -13,11 +12,12 @@ from bs4 import BeautifulSoup import bot from bot.constants import Channels +from bot.log import get_logger from bot.utils import scheduling from . import _cog, doc_cache from ._parsing import get_symbol_markdown -log = logging.getLogger(__name__) +log = get_logger(__name__) class StaleInventoryNotifier: diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index ca6af946b..243e7e4e1 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import logging import sys import textwrap from collections import defaultdict @@ -17,6 +16,7 @@ from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import MODERATION_ROLES, RedirectOutput from bot.converters import Inventory, PackageName, ValidURL, allowed_strings +from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils import scheduling from bot.utils.lock import SharedEvent, lock @@ -25,7 +25,7 @@ from bot.utils.scheduling import Scheduler from . import NAMESPACE, PRIORITY_PACKAGES, _batch_parser, doc_cache from ._inventory_parser import InvalidHeaderError, InventoryDict, fetch_inventory -log = logging.getLogger(__name__) +log = get_logger(__name__) # symbols with a group contained here will get the group prefixed on duplicates FORCE_PREFIX_GROUPS = ( diff --git a/bot/exts/info/doc/_html.py b/bot/exts/info/doc/_html.py index 94efd81b7..0e524a6bb 100644 --- a/bot/exts/info/doc/_html.py +++ b/bot/exts/info/doc/_html.py @@ -1,4 +1,3 @@ -import logging import re from functools import partial from typing import Callable, Container, Iterable, List, Union @@ -6,9 +5,10 @@ from typing import Callable, Container, Iterable, List, Union from bs4 import BeautifulSoup from bs4.element import NavigableString, PageElement, SoupStrainer, Tag +from bot.log import get_logger from . import MAX_SIGNATURE_AMOUNT -log = logging.getLogger(__name__) +log = get_logger(__name__) _UNWANTED_SIGNATURE_SYMBOLS_RE = re.compile(r"\[source]|\\\\|¶") _SEARCH_END_TAG_ATTRS = ( diff --git a/bot/exts/info/doc/_inventory_parser.py b/bot/exts/info/doc/_inventory_parser.py index 61924d070..e69246d47 100644 --- a/bot/exts/info/doc/_inventory_parser.py +++ b/bot/exts/info/doc/_inventory_parser.py @@ -1,4 +1,3 @@ -import logging import re import zlib from collections import defaultdict @@ -7,8 +6,9 @@ from typing import AsyncIterator, DefaultDict, List, Optional, Tuple import aiohttp import bot +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) FAILED_REQUEST_ATTEMPTS = 3 _V2_LINE_RE = re.compile(r'(?x)(.+?)\s+(\S*:\S*)\s+(-?\d+)\s+?(\S*)\s+(.*)') diff --git a/bot/exts/info/doc/_parsing.py b/bot/exts/info/doc/_parsing.py index 1a0d42c47..1b98b122b 100644 --- a/bot/exts/info/doc/_parsing.py +++ b/bot/exts/info/doc/_parsing.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging import re import string import textwrap @@ -10,14 +9,16 @@ from typing import Collection, Iterable, Iterator, List, Optional, TYPE_CHECKING from bs4 import BeautifulSoup from bs4.element import NavigableString, Tag +from bot.log import get_logger from bot.utils.helpers import find_nth_occurrence from . import MAX_SIGNATURE_AMOUNT from ._html import get_dd_description, get_general_description, get_signatures from ._markdown import DocMarkdownConverter + if TYPE_CHECKING: from ._cog import DocItem -log = logging.getLogger(__name__) +log = get_logger(__name__) _WHITESPACE_AFTER_NEWLINES_RE = re.compile(r"(?<=\n\n)(\s+)") _PARAMETERS_RE = re.compile(r"\((.+)\)") diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 21a6cf752..f413caded 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -1,5 +1,4 @@ import itertools -import logging from collections import namedtuple from contextlib import suppress from typing import List, Union @@ -12,10 +11,11 @@ from rapidfuzz.utils import default_process from bot import constants from bot.constants import Channels, STAFF_PARTNERS_COMMUNITY_ROLES from bot.decorators import redirect_output +from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils.messages import wait_for_deletion -log = logging.getLogger(__name__) +log = get_logger(__name__) COMMANDS_PER_PAGE = 8 PREFIX = constants.Bot.prefix diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index c60fd2127..f27483af8 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -1,5 +1,4 @@ import colorsys -import logging import pprint import textwrap from collections import defaultdict @@ -16,13 +15,14 @@ from bot.bot import Bot from bot.converters import MemberOrUser from bot.decorators import in_whitelist from bot.errors import NonExistentRoleError +from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils.channel import is_mod_channel, is_staff_channel from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check from bot.utils.members import get_or_fetch_member from bot.utils.time import TimestampFormats, discord_timestamp, humanize_delta -log = logging.getLogger(__name__) +log = get_logger(__name__) class Information(Cog): diff --git a/bot/exts/info/pep.py b/bot/exts/info/pep.py index bbd112911..259095b50 100644 --- a/bot/exts/info/pep.py +++ b/bot/exts/info/pep.py @@ -1,4 +1,3 @@ -import logging from datetime import datetime, timedelta from email.parser import HeaderParser from io import StringIO @@ -9,10 +8,11 @@ from discord.ext.commands import Cog, Context, command from bot.bot import Bot from bot.constants import Keys +from bot.log import get_logger from bot.utils import scheduling from bot.utils.caching import AsyncCache -log = logging.getLogger(__name__) +log = get_logger(__name__) ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" BASE_PEP_URL = "http://www.python.org/dev/peps/pep-" diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py index 62498ce0b..c3d2e2a3c 100644 --- a/bot/exts/info/pypi.py +++ b/bot/exts/info/pypi.py @@ -1,5 +1,4 @@ import itertools -import logging import random import re from contextlib import suppress @@ -10,6 +9,7 @@ from discord.utils import escape_markdown from bot.bot import Bot from bot.constants import Colours, NEGATIVE_REPLIES, RedirectOutput +from bot.log import get_logger from bot.utils.messages import wait_for_deletion URL = "https://pypi.org/pypi/{package}/json" @@ -20,7 +20,7 @@ PYPI_COLOURS = itertools.cycle((Colours.yellow, Colours.blue, Colours.white)) ILLEGAL_CHARACTERS = re.compile(r"[^-_.a-zA-Z0-9]+") INVALID_INPUT_DELETE_DELAY = RedirectOutput.delete_delay -log = logging.getLogger(__name__) +log = get_logger(__name__) class PyPi(Cog): diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index 2a8b64f32..2fad9d2ab 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -1,4 +1,3 @@ -import logging import re import typing as t from datetime import date, datetime @@ -11,6 +10,7 @@ from discord.ext.tasks import loop from bot import constants from bot.bot import Bot +from bot.log import get_logger from bot.utils import scheduling from bot.utils.webhooks import send_webhook @@ -31,7 +31,7 @@ MARKDOWN_REGEX = re.compile( re.DOTALL # required to support multi-line codeblocks ) -log = logging.getLogger(__name__) +log = get_logger(__name__) class PythonNews(Cog): diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index 28eb558a6..e1f2f5153 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -1,13 +1,12 @@ -import logging - from discord import Colour, Embed from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import URLs +from bot.log import get_logger from bot.pagination import LinePaginator -log = logging.getLogger(__name__) +log = get_logger(__name__) BASE_URL = f"{URLs.site_schema}{URLs.site}" diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index bb91a8563..842647555 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -1,4 +1,3 @@ -import logging import re import time from pathlib import Path @@ -10,10 +9,11 @@ from discord.ext.commands import Cog, Context, group from bot import constants from bot.bot import Bot from bot.converters import TagNameConverter +from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils.messages import wait_for_deletion -log = logging.getLogger(__name__) +log = get_logger(__name__) TEST_CHANNELS = ( constants.Channels.bot_commands, diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 1d6dbb49a..56051d0e5 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -1,4 +1,3 @@ -import logging import traceback from collections import namedtuple from datetime import datetime @@ -16,6 +15,7 @@ from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_ROLES, Roles from bot.converters import DurationDelta, Expiry from bot.exts.moderation.modlog import ModLog +from bot.log import get_logger from bot.utils import scheduling from bot.utils.messages import format_user from bot.utils.scheduling import Scheduler @@ -23,7 +23,7 @@ from bot.utils.time import ( TimestampFormats, discord_timestamp, humanize_delta, parse_duration_string, relativedelta_to_timedelta ) -log = logging.getLogger(__name__) +log = get_logger(__name__) REJECTION_MESSAGE = """ Hi, {user} - Thanks for your interest in our server! diff --git a/bot/exts/moderation/dm_relay.py b/bot/exts/moderation/dm_relay.py index 0051db82f..566422e29 100644 --- a/bot/exts/moderation/dm_relay.py +++ b/bot/exts/moderation/dm_relay.py @@ -1,14 +1,13 @@ -import logging - import discord from discord.ext.commands import Cog, Context, command, has_any_role from bot.bot import Bot from bot.constants import Emojis, MODERATION_ROLES +from bot.log import get_logger from bot.utils.channel import is_mod_channel from bot.utils.services import send_to_paste_service -log = logging.getLogger(__name__) +log = get_logger(__name__) class DMRelay(Cog): diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index a3d90e3fe..4470b6dd6 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -1,5 +1,4 @@ import asyncio -import logging import typing as t from datetime import datetime from enum import Enum @@ -9,10 +8,11 @@ from discord.ext.commands import Cog from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Guild, Webhooks +from bot.log import get_logger from bot.utils import scheduling from bot.utils.messages import sub_clyde -log = logging.getLogger(__name__) +log = get_logger(__name__) # Amount of messages for `crawl_task` to process at most on start-up - limited to 50 # as in practice, there should never be this many messages, and if there are, diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 1bdfbb943..2a1ccb9d4 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -1,4 +1,3 @@ -import logging import textwrap import typing as t from abc import abstractmethod @@ -16,10 +15,11 @@ from bot.constants import Colours from bot.converters import MemberOrUser from bot.exts.moderation.infraction import _utils from bot.exts.moderation.modlog import ModLog +from bot.log import get_logger from bot.utils import messages, scheduling, time from bot.utils.channel import is_mod_channel -log = logging.getLogger(__name__) +log = get_logger(__name__) class InfractionScheduler: diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index b20ef1d06..89718c857 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -1,4 +1,3 @@ -import logging import typing as t from datetime import datetime @@ -9,8 +8,9 @@ from bot.api import ResponseCodeError from bot.constants import Colours, Icons from bot.converters import MemberOrUser from bot.errors import InvalidInfractedUserError +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) # apply icon, pardon icon INFRACTION_ICONS = { diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index a7f7dcb7f..e495a94b3 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -1,4 +1,3 @@ -import logging import textwrap import typing as t @@ -14,10 +13,11 @@ from bot.converters import Duration, Expiry, MemberOrUser, UnambiguousMemberOrUs from bot.decorators import respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler +from bot.log import get_logger from bot.utils.members import get_or_fetch_member from bot.utils.messages import format_user -log = logging.getLogger(__name__) +log = get_logger(__name__) class Infractions(InfractionScheduler, commands.Cog): diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index dd44f7dd0..a50339ee2 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -1,4 +1,3 @@ -import logging import textwrap import typing as t from datetime import datetime @@ -16,13 +15,14 @@ from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, Unambigu from bot.errors import InvalidInfraction from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog +from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils import messages, time from bot.utils.channel import is_mod_channel from bot.utils.members import get_or_fetch_member from bot.utils.time import humanize_delta, until_expiration -log = logging.getLogger(__name__) +log = get_logger(__name__) class ModManagement(commands.Cog): diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 17cde68f6..08c92b8f3 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -1,5 +1,4 @@ import json -import logging import random import textwrap import typing as t @@ -14,11 +13,12 @@ from bot.bot import Bot from bot.converters import Duration, Expiry from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler +from bot.log import get_logger from bot.utils.members import get_or_fetch_member from bot.utils.messages import format_user from bot.utils.time import format_infraction -log = logging.getLogger(__name__) +log = get_logger(__name__) NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy" SUPERSTARIFY_DEFAULT_DURATION = "1h" diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py index 6eadd4bad..ce9c220b3 100644 --- a/bot/exts/moderation/metabase.py +++ b/bot/exts/moderation/metabase.py @@ -1,6 +1,5 @@ import csv import json -import logging from datetime import timedelta from io import StringIO from typing import Dict, List, Optional @@ -14,11 +13,12 @@ from discord.ext.commands import Cog, Context, group, has_any_role from bot.bot import Bot from bot.constants import Metabase as MetabaseConfig, Roles from bot.converters import allowed_strings +from bot.log import get_logger from bot.utils import scheduling, send_to_paste_service from bot.utils.channel import is_mod_channel from bot.utils.scheduling import Scheduler -log = logging.getLogger(__name__) +log = get_logger(__name__) BASE_HEADERS = { "Content-Type": "application/json" diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index be2245650..fbb3684e7 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -1,7 +1,6 @@ import asyncio import difflib import itertools -import logging import typing as t from datetime import datetime from itertools import zip_longest @@ -16,10 +15,11 @@ from discord.utils import escape_markdown from bot.bot import Bot from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, Roles, URLs +from bot.log import get_logger from bot.utils.messages import format_user from bot.utils.time import humanize_delta -log = logging.getLogger(__name__) +log = get_logger(__name__) GUILD_CHANNEL = t.Union[discord.CategoryChannel, discord.TextChannel, discord.VoiceChannel] diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index d775cdedf..a7ccb8162 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -1,5 +1,4 @@ import datetime -import logging from async_rediscache import RedisCache from dateutil.parser import isoparse @@ -9,10 +8,11 @@ from discord.ext.commands import Cog, Context, group, has_any_role from bot.bot import Bot from bot.constants import Colours, Emojis, Guild, Icons, MODERATION_ROLES, Roles from bot.converters import Expiry +from bot.log import get_logger from bot.utils import scheduling from bot.utils.scheduling import Scheduler -log = logging.getLogger(__name__) +log = get_logger(__name__) class ModPings(Cog): diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 2ee6496df..133ebaba5 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -1,5 +1,4 @@ import json -import logging import typing from contextlib import suppress from datetime import datetime, timedelta, timezone @@ -13,11 +12,12 @@ from discord.ext.commands import Context from bot import constants from bot.bot import Bot from bot.converters import HushDurationConverter +from bot.log import get_logger from bot.utils import scheduling from bot.utils.lock import LockedResourceError, lock, lock_arg from bot.utils.scheduling import Scheduler -log = logging.getLogger(__name__) +log = get_logger(__name__) LOCK_NAMESPACE = "silence" diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py index d8baff76a..9583597e0 100644 --- a/bot/exts/moderation/slowmode.py +++ b/bot/exts/moderation/slowmode.py @@ -1,4 +1,3 @@ -import logging from typing import Optional from dateutil.relativedelta import relativedelta @@ -8,9 +7,10 @@ from discord.ext.commands import Cog, Context, group, has_any_role from bot.bot import Bot from bot.constants import Channels, Emojis, MODERATION_ROLES from bot.converters import DurationDelta +from bot.log import get_logger from bot.utils import time -log = logging.getLogger(__name__) +log = get_logger(__name__) SLOWMODE_MAX_DELAY = 21600 # seconds diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index a179a9acc..cf58bf5d9 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -1,4 +1,3 @@ -import logging from datetime import timedelta, timezone from operator import itemgetter @@ -14,12 +13,13 @@ from bot.constants import ( STAFF_PARTNERS_COMMUNITY_ROLES, VideoPermission ) from bot.converters import Expiry +from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils import scheduling from bot.utils.members import get_or_fetch_member from bot.utils.time import discord_timestamp, format_infraction_with_duration -log = logging.getLogger(__name__) +log = get_logger(__name__) class Stream(commands.Cog): diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index bfe9b74b4..ed5571d2a 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -1,4 +1,3 @@ -import logging import typing as t import discord @@ -7,9 +6,10 @@ from discord.ext.commands import Cog, Context, command, has_any_role from bot import constants from bot.bot import Bot from bot.decorators import in_whitelist +from bot.log import get_logger from bot.utils.checks import InWhitelistCheckFailure -log = logging.getLogger(__name__) +log = get_logger(__name__) # Sent via DMs once user joins the guild ON_JOIN_MESSAGE = """ diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 8494a1e2e..88733176f 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -1,5 +1,4 @@ import asyncio -import logging from contextlib import suppress from datetime import datetime, timedelta @@ -8,15 +7,15 @@ from async_rediscache import RedisCache from discord import Colour, Member, VoiceState from discord.ext.commands import Cog, Context, command - from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels, Event, MODERATION_ROLES, Roles, VoiceGate as GateConf from bot.decorators import has_no_roles, in_whitelist from bot.exts.moderation.modlog import ModLog +from bot.log import get_logger from bot.utils.checks import InWhitelistCheckFailure -log = logging.getLogger(__name__) +log = get_logger(__name__) # Flag written to the cog's RedisCache as a value when the Member's (key) notification # was already removed ~ this signals both that no further notifications should be sent, diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index 3fafd097b..3264a6d62 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -1,5 +1,4 @@ import asyncio -import logging import re import textwrap from abc import abstractmethod @@ -17,12 +16,14 @@ from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, from bot.exts.filters.token_remover import TokenRemover from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE from bot.exts.moderation.modlog import ModLog +from bot.log import CustomLogger +from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils import CogABCMeta, messages, scheduling from bot.utils.members import get_or_fetch_member from bot.utils.time import get_time_delta -log = logging.getLogger(__name__) +log = get_logger(__name__) URL_RE = re.compile(r"(https?://[^\s]+)") @@ -47,7 +48,7 @@ class WatchChannel(metaclass=CogABCMeta): webhook_id: int, api_endpoint: str, api_default_params: dict, - logger: logging.Logger, + logger: CustomLogger, *, disable_header: bool = False ) -> None: diff --git a/bot/exts/moderation/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py index b91b9a124..ab37b1b80 100644 --- a/bot/exts/moderation/watchchannels/bigbrother.py +++ b/bot/exts/moderation/watchchannels/bigbrother.py @@ -1,4 +1,3 @@ -import logging import textwrap from collections import ChainMap @@ -9,8 +8,9 @@ from bot.constants import Channels, MODERATION_ROLES, Webhooks from bot.converters import MemberOrUser from bot.exts.moderation.infraction._utils import post_infraction from bot.exts.moderation.watchchannels._watchchannel import WatchChannel +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) class BigBrother(WatchChannel, Cog, name="Big Brother"): diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index f9c836bbd..2fafaec97 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -1,4 +1,3 @@ -import logging import textwrap from collections import ChainMap, defaultdict from io import StringIO @@ -14,6 +13,7 @@ from bot.bot import Bot from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES from bot.converters import MemberOrUser, UnambiguousMemberOrUser from bot.exts.recruitment.talentpool._review import Reviewer +from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils import scheduling, time from bot.utils.members import get_or_fetch_member @@ -22,7 +22,7 @@ from bot.utils.time import get_time_delta AUTOREVIEW_ENABLED_KEY = "autoreview_enabled" REASON_MAX_CHARS = 1000 -log = logging.getLogger(__name__) +log = get_logger(__name__) class TalentPool(Cog, name="Talentpool"): diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 14a8dd4c0..dcf73c2cb 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -1,6 +1,5 @@ import asyncio import contextlib -import logging import random import re import textwrap @@ -16,6 +15,7 @@ from discord.ext.commands import Context from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Guild +from bot.log import get_logger from bot.utils.members import get_or_fetch_member from bot.utils.messages import count_unique_users_reaction, pin_no_system_message from bot.utils.scheduling import Scheduler @@ -24,7 +24,7 @@ from bot.utils.time import get_time_delta, time_since if typing.TYPE_CHECKING: from bot.exts.recruitment.talentpool._cog import TalentPool -log = logging.getLogger(__name__) +log = get_logger(__name__) # Maximum amount of days before an automatic review is posted. MAX_DAYS_IN_POOL = 30 diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py index d84709616..8f0094bc9 100644 --- a/bot/exts/utils/bot.py +++ b/bot/exts/utils/bot.py @@ -1,4 +1,3 @@ -import logging from typing import Optional from discord import Embed, TextChannel @@ -6,8 +5,9 @@ from discord.ext.commands import Cog, Context, command, group, has_any_role from bot.bot import Bot from bot.constants import Guild, MODERATION_ROLES, URLs +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) class BotCog(Cog, name="Bot"): diff --git a/bot/exts/utils/clean.py b/bot/exts/utils/clean.py index cb662e852..e3d346af5 100644 --- a/bot/exts/utils/clean.py +++ b/bot/exts/utils/clean.py @@ -1,4 +1,3 @@ -import logging import random import re from typing import Iterable, Optional @@ -12,8 +11,9 @@ from bot.constants import ( Channels, CleanMessages, Colours, Event, Icons, MODERATION_ROLES, NEGATIVE_REPLIES ) from bot.exts.moderation.modlog import ModLog +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) class Clean(Cog): diff --git a/bot/exts/utils/extensions.py b/bot/exts/utils/extensions.py index 309126d0e..fa5d38917 100644 --- a/bot/exts/utils/extensions.py +++ b/bot/exts/utils/extensions.py @@ -1,5 +1,4 @@ import functools -import logging import typing as t from enum import Enum @@ -11,10 +10,11 @@ from bot import exts from bot.bot import Bot from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs from bot.converters import Extension +from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils.extensions import EXTENSIONS -log = logging.getLogger(__name__) +log = get_logger(__name__) UNLOAD_BLACKLIST = {f"{exts.__name__}.utils.extensions", f"{exts.__name__}.moderation.modlog"} diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index 5d2cd7611..879735945 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -1,6 +1,5 @@ import contextlib import inspect -import logging import pprint import re import textwrap @@ -15,9 +14,10 @@ from discord.ext.commands import Cog, Context, group, has_any_role, is_owner from bot.bot import Bot from bot.constants import DEBUG_MODE, Roles +from bot.log import get_logger from bot.utils import find_nth_occurrence, send_to_paste_service -log = logging.getLogger(__name__) +log = get_logger(__name__) class Internal(Cog): diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 95f3661af..be5181297 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -1,4 +1,3 @@ -import logging import random import textwrap import typing as t @@ -15,6 +14,7 @@ from bot.constants import ( Roles, STAFF_PARTNERS_COMMUNITY_ROLES ) from bot.converters import Duration, UnambiguousUser +from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils import scheduling from bot.utils.checks import has_any_role_check, has_no_roles_check @@ -24,7 +24,7 @@ from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler from bot.utils.time import TimestampFormats, discord_timestamp -log = logging.getLogger(__name__) +log = get_logger(__name__) LOCK_NAMESPACE = "reminder" WHITELISTED_CHANNELS = Guild.reminder_whitelist diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index 5fb10a25b..fbfc58d0b 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -1,7 +1,6 @@ import asyncio import contextlib import datetime -import logging import re import textwrap from functools import partial @@ -14,10 +13,11 @@ from discord.ext.commands import Cog, Context, command, guild_only from bot.bot import Bot from bot.constants import Categories, Channels, Roles, URLs from bot.decorators import redirect_output +from bot.log import get_logger from bot.utils import scheduling, send_to_paste_service from bot.utils.messages import wait_for_deletion -log = logging.getLogger(__name__) +log = get_logger(__name__) ESCAPE_REGEX = re.compile("[`\u202E\u200B]{3,}") FORMATTED_CODE_REGEX = re.compile( diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 0139a6ad3..f69bab781 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -1,5 +1,4 @@ import difflib -import logging import re import unicodedata from typing import Tuple, Union @@ -12,11 +11,12 @@ from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES from bot.converters import Snowflake from bot.decorators import in_whitelist +from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils import messages from bot.utils.time import time_since -log = logging.getLogger(__name__) +log = get_logger(__name__) ZEN_OF_PYTHON = """\ Beautiful is better than ugly. diff --git a/bot/log.py b/bot/log.py index 74d8263c0..b3cecdcf2 100644 --- a/bot/log.py +++ b/bot/log.py @@ -56,7 +56,7 @@ def setup() -> None: file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8") file_handler.setFormatter(log_format) - root_log = logging.getLogger() + root_log = get_logger() root_log.addHandler(file_handler) if "COLOREDLOGS_LEVEL_STYLES" not in os.environ: @@ -73,13 +73,13 @@ def setup() -> None: coloredlogs.install(level=TRACE_LEVEL, logger=root_log, stream=sys.stdout) root_log.setLevel(logging.DEBUG if constants.DEBUG_MODE else logging.INFO) - logging.getLogger("discord").setLevel(logging.WARNING) - logging.getLogger("websockets").setLevel(logging.WARNING) - logging.getLogger("chardet").setLevel(logging.WARNING) - logging.getLogger("async_rediscache").setLevel(logging.WARNING) + get_logger("discord").setLevel(logging.WARNING) + get_logger("websockets").setLevel(logging.WARNING) + get_logger("chardet").setLevel(logging.WARNING) + get_logger("async_rediscache").setLevel(logging.WARNING) # Set back to the default of INFO even if asyncio's debug mode is enabled. - logging.getLogger("asyncio").setLevel(logging.INFO) + get_logger("asyncio").setLevel(logging.INFO) _set_trace_loggers() @@ -116,13 +116,13 @@ def _set_trace_loggers() -> None: level_filter = constants.Bot.trace_loggers if level_filter: if level_filter.startswith("*"): - logging.getLogger().setLevel(TRACE_LEVEL) + get_logger().setLevel(TRACE_LEVEL) elif level_filter.startswith("!"): - logging.getLogger().setLevel(TRACE_LEVEL) + get_logger().setLevel(TRACE_LEVEL) for logger_name in level_filter.strip("!,").split(","): - logging.getLogger(logger_name).setLevel(logging.DEBUG) + get_logger(logger_name).setLevel(logging.DEBUG) else: for logger_name in level_filter.strip(",").split(","): - logging.getLogger(logger_name).setLevel(TRACE_LEVEL) + get_logger(logger_name).setLevel(TRACE_LEVEL) diff --git a/bot/monkey_patches.py b/bot/monkey_patches.py index 4dbdb5eab..e56a19da2 100644 --- a/bot/monkey_patches.py +++ b/bot/monkey_patches.py @@ -1,10 +1,11 @@ -import logging from datetime import datetime, timedelta from discord import Forbidden, http from discord.ext import commands -log = logging.getLogger(__name__) +from bot.log import get_logger + +log = get_logger(__name__) class Command(commands.Command): diff --git a/bot/pagination.py b/bot/pagination.py index 26caa7db0..8f4353eb1 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -1,5 +1,4 @@ import asyncio -import logging import typing as t from contextlib import suppress from functools import partial @@ -9,6 +8,7 @@ from discord.abc import User from discord.ext.commands import Context, Paginator from bot import constants +from bot.log import get_logger from bot.utils import messages FIRST_EMOJI = "\u23EE" # [:track_previous:] @@ -19,7 +19,7 @@ DELETE_EMOJI = constants.Emojis.trashcan # [:trashcan:] PAGINATION_EMOJI = (FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMOJI) -log = logging.getLogger(__name__) +log = get_logger(__name__) class EmptyPaginatorEmbedError(Exception): diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 6d2356679..b9e234857 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -1,12 +1,11 @@ -import logging - import discord import bot from bot import constants from bot.constants import Categories +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) def is_help_channel(channel: discord.TextChannel) -> bool: diff --git a/bot/utils/checks.py b/bot/utils/checks.py index 3d0c8a50c..4a7f1d1b5 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -1,5 +1,4 @@ import datetime -import logging from typing import Callable, Container, Iterable, Optional, Union from discord.ext.commands import ( @@ -16,8 +15,9 @@ from discord.ext.commands import ( ) from bot import constants +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) class ContextCheckFailure(CheckFailure): diff --git a/bot/utils/function.py b/bot/utils/function.py index 9bc44e753..55115d7d3 100644 --- a/bot/utils/function.py +++ b/bot/utils/function.py @@ -2,11 +2,12 @@ import functools import inspect -import logging import types import typing as t -log = logging.getLogger(__name__) +from bot.log import get_logger + +log = get_logger(__name__) Argument = t.Union[int, str] BoundArgs = t.OrderedDict[str, t.Any] diff --git a/bot/utils/lock.py b/bot/utils/lock.py index ec6f92cd4..c039a4f25 100644 --- a/bot/utils/lock.py +++ b/bot/utils/lock.py @@ -1,6 +1,5 @@ import asyncio import inspect -import logging import types from collections import defaultdict from functools import partial @@ -8,10 +7,11 @@ from typing import Any, Awaitable, Callable, Hashable, Union from weakref import WeakValueDictionary from bot.errors import LockedResourceError +from bot.log import get_logger from bot.utils import function from bot.utils.function import command_wraps -log = logging.getLogger(__name__) +log = get_logger(__name__) __lock_dicts = defaultdict(WeakValueDictionary) _IdCallableReturn = Union[Hashable, Awaitable[Hashable]] diff --git a/bot/utils/members.py b/bot/utils/members.py index 302fe6d63..77ddf1696 100644 --- a/bot/utils/members.py +++ b/bot/utils/members.py @@ -1,9 +1,10 @@ -import logging import typing as t import discord -log = logging.getLogger(__name__) +from bot.log import get_logger + +log = get_logger(__name__) async def get_or_fetch_member(guild: discord.Guild, member_id: int) -> t.Optional[discord.Member]: diff --git a/bot/utils/messages.py b/bot/utils/messages.py index abeb04021..053750cc3 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -1,5 +1,4 @@ import asyncio -import logging import random import re from functools import partial @@ -11,9 +10,10 @@ from discord.ext.commands import Context import bot from bot.constants import Emojis, MODERATION_ROLES, NEGATIVE_REPLIES +from bot.log import get_logger from bot.utils import scheduling -log = logging.getLogger(__name__) +log = get_logger(__name__) def reaction_check( diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index bb83b5c0d..7b4c8e2de 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -1,11 +1,12 @@ import asyncio import contextlib import inspect -import logging import typing as t from datetime import datetime from functools import partial +from bot.log import get_logger + class Scheduler: """ @@ -27,7 +28,7 @@ class Scheduler: def __init__(self, name: str): self.name = name - self._log = logging.getLogger(f"{__name__}.{name}") + self._log = get_logger(f"{__name__}.{name}") self._scheduled_tasks: t.Dict[t.Hashable, asyncio.Task] = {} def __contains__(self, task_id: t.Hashable) -> bool: @@ -187,5 +188,5 @@ def _log_task_exception(task: asyncio.Task, *, suppressed_exceptions: t.Tuple[t. exception = task.exception() # Log the exception if one exists. if exception and not isinstance(exception, suppressed_exceptions): - log = logging.getLogger(__name__) + log = get_logger(__name__) log.error(f"Error in task {task.get_name()} {id(task)}!", exc_info=exception) diff --git a/bot/utils/services.py b/bot/utils/services.py index db9c93d0f..439c8d500 100644 --- a/bot/utils/services.py +++ b/bot/utils/services.py @@ -1,12 +1,12 @@ -import logging from typing import Optional from aiohttp import ClientConnectorError import bot from bot.constants import URLs +from bot.log import get_logger -log = logging.getLogger(__name__) +log = get_logger(__name__) FAILED_REQUEST_ATTEMPTS = 3 diff --git a/bot/utils/webhooks.py b/bot/utils/webhooks.py index 66f82ec66..9c916b63a 100644 --- a/bot/utils/webhooks.py +++ b/bot/utils/webhooks.py @@ -1,12 +1,12 @@ -import logging from typing import Optional import discord from discord import Embed +from bot.log import get_logger from bot.utils.messages import sub_clyde -log = logging.getLogger(__name__) +log = get_logger(__name__) async def send_webhook( diff --git a/tests/__init__.py b/tests/__init__.py index 2228110ad..c2b9d12dc 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,5 +1,6 @@ import logging +from bot.log import get_logger -log = logging.getLogger() +log = get_logger() log.setLevel(logging.CRITICAL) diff --git a/tests/base.py b/tests/base.py index d99b9ac31..ab9287e9a 100644 --- a/tests/base.py +++ b/tests/base.py @@ -6,6 +6,7 @@ from typing import Dict import discord from discord.ext import commands +from bot.log import get_logger from tests import helpers @@ -42,7 +43,7 @@ class LoggingTestsMixin: manager when we're testing under the assumption that no log records will be emitted. """ if not isinstance(logger, logging.Logger): - logger = logging.getLogger(logger) + logger = get_logger(logger) if level: level = logging._nameToLevel.get(level, level) diff --git a/tests/test_base.py b/tests/test_base.py index a7db4bf3e..365805a71 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,8 +1,7 @@ import logging -import unittest import unittest.mock - +from bot.log import get_logger from tests.base import LoggingTestsMixin, _CaptureLogHandler @@ -15,7 +14,7 @@ class LoggingTestCaseTests(unittest.TestCase): @classmethod def setUpClass(cls): - cls.log = logging.getLogger(__name__) + cls.log = get_logger(__name__) def test_assert_not_logs_does_not_raise_with_no_logs(self): """Test if LoggingTestCase.assertNotLogs does not raise when no logs were emitted.""" @@ -56,15 +55,15 @@ class LoggingTestCaseTests(unittest.TestCase): def test_logging_test_case_works_with_logger_instance(self): """Test if the LoggingTestCase captures logging for provided logger.""" - log = logging.getLogger("new_logger") + log = get_logger("new_logger") with self.assertRaises(AssertionError): with LoggingTestCase.assertNotLogs(self, logger=log): log.info("Hello, this should raise an AssertionError") def test_logging_test_case_respects_alternative_logger(self): """Test if LoggingTestCase only checks the provided logger.""" - log_one = logging.getLogger("log one") - log_two = logging.getLogger("log two") + log_one = get_logger("log one") + log_two = get_logger("log two") with LoggingTestCase.assertNotLogs(self, logger=log_one): log_two.info("Hello, this should not raise an AssertionError") -- cgit v1.2.3 From c9bfe510d83073f4ac943ff40721357d855087e7 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 5 Oct 2021 21:19:27 +0100 Subject: Fix newlines after imports and address review There's now always one blank line (no more, no less) after an import --- bot/constants.py | 4 +--- bot/exts/info/doc/_redis_cache.py | 1 + bot/rules/discord_emojis.py | 1 - bot/rules/links.py | 1 - tests/bot/exts/backend/sync/test_base.py | 1 - 5 files changed, 2 insertions(+), 6 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 140dd12f9..f704c9e6a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -119,13 +119,12 @@ def check_required_keys(keys): if lookup is None: raise KeyError(key) except KeyError: - raise ( + raise KeyError( f"A configuration for `{key_path}` is required, but was not found. " "Please set it in `config.yml` or setup an environment variable and try again." ) - try: required_keys = _CONFIG_YAML['config']['required_keys'] except KeyError: @@ -182,7 +181,6 @@ class YAMLGetter(type): (cls.section, cls.subsection, name) if cls.subsection is not None else (cls.section, name) ) - # Only an print since this can be caught through `hasattr` or `getattr`. print(f"Tried accessing configuration variable at `{dotted_path}`, but it could not be found.") raise AttributeError(repr(name)) from e diff --git a/bot/exts/info/doc/_redis_cache.py b/bot/exts/info/doc/_redis_cache.py index ad764816f..79648893a 100644 --- a/bot/exts/info/doc/_redis_cache.py +++ b/bot/exts/info/doc/_redis_cache.py @@ -4,6 +4,7 @@ import datetime from typing import Optional, TYPE_CHECKING from async_rediscache.types.base import RedisObject, namespace_lock + if TYPE_CHECKING: from ._cog import DocItem diff --git a/bot/rules/discord_emojis.py b/bot/rules/discord_emojis.py index 41faf7ee8..d979ac5e7 100644 --- a/bot/rules/discord_emojis.py +++ b/bot/rules/discord_emojis.py @@ -4,7 +4,6 @@ from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message from emoji import demojize - DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>|:\w+:") CODE_BLOCK_RE = re.compile(r"```.*?```", flags=re.DOTALL) diff --git a/bot/rules/links.py b/bot/rules/links.py index ec75a19c5..c46b783c5 100644 --- a/bot/rules/links.py +++ b/bot/rules/links.py @@ -3,7 +3,6 @@ from typing import Dict, Iterable, List, Optional, Tuple from discord import Member, Message - LINK_RE = re.compile(r"(https?://[^\s]+)") diff --git a/tests/bot/exts/backend/sync/test_base.py b/tests/bot/exts/backend/sync/test_base.py index 3ad9db9c3..9dc46005b 100644 --- a/tests/bot/exts/backend/sync/test_base.py +++ b/tests/bot/exts/backend/sync/test_base.py @@ -1,7 +1,6 @@ import unittest from unittest import mock - from bot.api import ResponseCodeError from bot.exts.backend.sync._syncers import Syncer from tests import helpers -- cgit v1.2.3 From 28bd771cfcc3640bfe65f54ba7679e05a1117c73 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Thu, 14 Oct 2021 22:41:57 +0100 Subject: Update `isinstance` check --- bot/exts/utils/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index be5181297..b4f88af05 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -118,7 +118,7 @@ class Reminders(Cog): if await has_no_roles_check(ctx, *STAFF_PARTNERS_COMMUNITY_ROLES): return False, "members/roles" elif await has_no_roles_check(ctx, *MODERATION_ROLES): - return all(isinstance(mention, discord.Member) for mention in mentions), "roles" + return all(isinstance(mention, (discord.User, discord.Member)) for mention in mentions), "roles" else: return True, "" -- cgit v1.2.3 From 6f6e192c0d68165f18059c1d2f88b00cb449c1a9 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 15 Oct 2021 09:19:07 +0000 Subject: Actions: do not check licenses of dev packages --- .github/workflows/lint-test.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index 2f42f1895..f2c9dfb6c 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -81,12 +81,14 @@ jobs: pip install poetry poetry install - # Check all the dependencies are compatible with the MIT license. + # Check all of our non-dev dependencies are compatible with the MIT license. # If you added a new dependencies that is being rejected, # please make sure it is compatible with the license for this project, # and add it to the ALLOWED_LICENSE variable - name: Check Dependencies License - run: pip-licenses --allow-only="$ALLOWED_LICENSE" + run: | + pip-licenses --allow-only="$ALLOWED_LICENSE" \ + --package $(poetry export -f requirements.txt --without-hashes | sed "s/==.*//g" | tr "\n" " ") # This step caches our pre-commit environment. To make sure we # do create a new environment when our pre-commit setup changes, -- cgit v1.2.3 From 8359c64e31a372fd5dc3bda2560fd1aaafb72ac5 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 15 Oct 2021 09:25:40 +0000 Subject: Isort: set atomic to true --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index d3a00e121..515514c7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,3 +79,4 @@ order_by_type = false case_sensitive = true combine_as_imports = true line_length = 120 +atomic = true -- cgit v1.2.3 From a791dac927ada417ee090f00a25c1ea2b5d53ada Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 15 Oct 2021 11:37:12 +0200 Subject: Defcon: remove tzinfo from member.created_at --- bot/exts/moderation/defcon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 173e81ff6..189f8eb31 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -111,7 +111,7 @@ class Defcon(Cog): if self.threshold: now = datetime.utcnow() - if now - member.created_at < relativedelta_to_timedelta(self.threshold): + if now - member.created_at.replace(tzinfo=None) < relativedelta_to_timedelta(self.threshold): log.info(f"Rejecting user {member}: Account is too new") message_sent = False -- cgit v1.2.3 From 574f610787696d2886a4b5229bb6533894f4f165 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 15 Oct 2021 11:38:34 +0200 Subject: Voice gate: set tzinfo to None in author.joined_at --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 8494a1e2e..45224277b 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -166,7 +166,7 @@ class VoiceGate(Cog): return checks = { - "joined_at": ctx.author.joined_at > datetime.utcnow() - timedelta(days=GateConf.minimum_days_member), + "joined_at": ctx.author.joined_at.replace(tzinfo=None) > datetime.utcnow() - timedelta(days=GateConf.minimum_days_member), "total_messages": data["total_messages"] < GateConf.minimum_messages, "voice_banned": data["voice_banned"], "activity_blocks": data["activity_blocks"] < GateConf.minimum_activity_blocks -- cgit v1.2.3 From 9325c79bb458136fd22424008041ef4c00cd63d4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 16 Oct 2021 09:31:05 +0300 Subject: Do not try to calculate expiry if infraction is permanent on reapply --- bot/exts/moderation/infraction/_scheduler.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 2a1ccb9d4..fc915016c 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -81,12 +81,16 @@ class InfractionScheduler: apply_coro: t.Optional[t.Awaitable] ) -> None: """Reapply an infraction if it's still active or deactivate it if less than 60 sec left.""" - # Calculate the time remaining, in seconds, for the mute. - expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) - delta = (expiry - datetime.utcnow()).total_seconds() + if infraction["expires_at"] is not None: + # Calculate the time remaining, in seconds, for the mute. + expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) + delta = (expiry - datetime.utcnow()).total_seconds() + else: + # If the infraction is permanent, it is not possible to get the time remaining. + delta = None - # Mark as inactive if less than a minute remains. - if delta < 60: + # Mark as inactive if the infraction is not permanent and less than a minute remains. + if delta is not None and delta < 60: log.info( "Infraction will be deactivated instead of re-applied " "because less than 1 minute remains." -- cgit v1.2.3 From 69935aaca8fe2ad7810d99204939fce787ad0011 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sun, 17 Oct 2021 10:28:06 +0200 Subject: Modlog: remove tzinfo from member_join (#1877) --- 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 7d80d4ba5..3e19083ac 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -405,7 +405,7 @@ class ModLog(Cog, name="ModLog"): return now = datetime.utcnow() - difference = abs(relativedelta(now, member.created_at)) + difference = abs(relativedelta(now, member.created_at.replace(tzinfo=None))) message = format_user(member) + "\n\n**Account age:** " + humanize_delta(difference) -- cgit v1.2.3 From 581bea0ed8918cb38180f48ebf5e2ecda57a7192 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sat, 16 Oct 2021 16:22:37 +0100 Subject: Formats Help Command Output Closes #1232 Modifies the docstring sent for per-command help to remove weird formatting issues mentioned in #1232. Removes newlines that are not used for paragraph breaks, after retrieving the docstring, and lets the embed handle it on the discord side. Allow overriding this behaviour via \u2003 to denote a non-escapable break. Co-authored-by: Hassan Abouelela <47495861+HassanAbouelela@users.noreply.github.com> --- bot/exts/info/help.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index f413caded..743dfdd3f 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -1,4 +1,5 @@ import itertools +import re from collections import namedtuple from contextlib import suppress from typing import List, Union @@ -179,7 +180,10 @@ class CustomHelpCommand(HelpCommand): except CommandError: command_details += NOT_ALLOWED_TO_RUN_MESSAGE - command_details += f"*{command.help or 'No details provided.'}*\n" + # Remove line breaks from docstrings, if not used to separate paragraphs. + # Allow overriding this behaviour via putting \u2003 at the start of a line. + formatted_doc = re.sub("(? Date: Thu, 14 Oct 2021 16:28:59 +0100 Subject: Ignore channels mod team can't view in modlog The main purpose of this is so that we don't need to keep adding to the modlog_blacklist for every new channel we want to ignore, since it now implicitly ignores channels mods don't have read perms to. This is done by making use of Discord.py 2.0's updated behaviour of Channel.permissions_for() where it can now accept a role and determines the permissions of users in that role for a given channel. This takes into account default permissions as well as channel overrides. Co-authored-by: Xithrius --- bot/exts/moderation/modlog.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 3e19083ac..375956dd3 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -527,17 +527,28 @@ class ModLog(Cog, name="ModLog"): return self.is_raw_message_blacklisted(message.guild.id, message.channel.id) - def is_raw_message_blacklisted(self, guild_id: t.Optional[int], channel_id: int) -> bool: - """Return true if the message constructed from raw parameter is in a blacklisted thread or channel.""" - # Ignore DMs or messages outside of the main guild - if not guild_id or guild_id != GuildConstant.id: - return True - + def is_raw_message_blacklisted(self, channel_id: int) -> bool: + """ + Return true if the channel, or parent channel in the case of threads, passed should be ignored by modlog. + + Currently ignored channels are: + 1. Channels not in the guild we care about (constants.Guild.id). + 2. Channels that mods do not have view permissions to + 3. Channels in constants.Guild.modlog_blacklist + """ channel = self.bot.get_channel(channel_id) - # Look at the parent channel of a thread + # Ignore not found channels, DMs, and messages outside of the main guild. + if not channel or channel.guild and channel.guild.id != GuildConstant.id: + return True + + # Look at the parent channel of a thread. if isinstance(channel, Thread): - return channel.parent.id in GuildConstant.modlog_blacklist + channel = channel.parent + + # Mod team doesn't have view permission to the channel. + if not channel.permissions_for(channel.guild.get_role(Roles.mod_team)).view_channel: + return True return channel.id in GuildConstant.modlog_blacklist -- cgit v1.2.3 From db7857d8afc55244a26fce6687c172fefc5dc47c Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 14 Oct 2021 16:30:01 +0100 Subject: Refactor: Update modlog ignore function name This new name better describes what this function actually does --- bot/exts/moderation/modlog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 375956dd3..0dc2e84b5 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -525,9 +525,9 @@ class ModLog(Cog, name="ModLog"): if message.author.bot or not message.guild: return True - return self.is_raw_message_blacklisted(message.guild.id, message.channel.id) + return self.is_channel_ignored(message.channel.id) - def is_raw_message_blacklisted(self, channel_id: int) -> bool: + def is_channel_ignored(self, channel_id: int) -> bool: """ Return true if the channel, or parent channel in the case of threads, passed should be ignored by modlog. @@ -613,7 +613,7 @@ class ModLog(Cog, name="ModLog"): @Cog.listener() async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None: """Log raw message delete event to message change log.""" - if self.is_raw_message_blacklisted(event.guild_id, event.channel_id): + if self.is_channel_ignored(event.channel_id): return await asyncio.sleep(1) # Wait here in case the normal event was fired -- cgit v1.2.3 From 457cd6e34212fae08ef74b1b08dba2984e57a665 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 14 Oct 2021 16:30:21 +0100 Subject: Use modlog ignore function for voice events too --- bot/exts/moderation/modlog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 0dc2e84b5..b09eb2d14 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -838,7 +838,8 @@ class ModLog(Cog, name="ModLog"): """Log member voice state changes to the voice log channel.""" if ( member.guild.id != GuildConstant.id - or (before.channel and before.channel.id in GuildConstant.modlog_blacklist) + or (before.channel and self.is_channel_ignored(before.channel.id)) + or (after.channel and self.is_channel_ignored(after.channel.id)) ): return -- cgit v1.2.3 From 2b8807b448415e9c08cff136e3343f15d81a3037 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 14 Oct 2021 16:31:25 +0100 Subject: Remove unneeded config entries Since channels that mods can't read are now implicitly ignored, there is no need to explicitly ignore them. --- config-default.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/config-default.yml b/config-default.yml index d77eacc7e..b61d9c99c 100644 --- a/config-default.yml +++ b/config-default.yml @@ -248,15 +248,13 @@ guild: - *ADMIN_SPAM - *MODS - # Modlog cog ignores events which occur in these channels + # Modlog cog explicitly ignores events which occur in these channels. + # This is on top of implicitly ignoring events in channels that the mod team cannot view. modlog_blacklist: - - *ADMINS - - *ADMINS_VOICE - *ATTACH_LOG - *MESSAGE_LOG - *MOD_LOG - *STAFF_VOICE - - *DEV_CORE_VOTING reminder_whitelist: - *BOT_CMD -- cgit v1.2.3 From 50d1cd96623f4d6423326b23e590f9378e69c14c Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 17 Oct 2021 14:33:21 +0530 Subject: Check for webhook availability before extracting msg links --- bot/exts/moderation/incidents.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 65dc69ca6..a02a38b24 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -556,8 +556,9 @@ class Incidents(Cog): await add_signals(message) # Only use this feature if incidents webhook embed is found - if embed_list := await self.extract_message_links(message) and self.incidents_webhook: - await self.send_message_link_embeds(embed_list, message, self.incidents_webhook) + if self.incidents_webhook: + if embed_list := await self.extract_message_links(message): + await self.send_message_link_embeds(embed_list, message, self.incidents_webhook) @Cog.listener() async def on_raw_message_delete(self, payload: discord.RawMessageDeleteEvent) -> None: @@ -608,7 +609,7 @@ class Incidents(Cog): Using the `webhook` passed in as a parameter to send the embeds in the `webhook_embed_list` parameter. - After sending each embed it maps the `message.id + After sending each embed it maps the `message.id` to the `webhook_msg_ids` IDs in the async redis-cache. """ try: -- cgit v1.2.3 From 0324f5ba6a547242dde7543dacc60e069d767cec Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 17 Oct 2021 14:38:11 +0530 Subject: Add incidents check in delete and reaction handlers also --- bot/exts/moderation/incidents.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index a02a38b24..92b4fd5cf 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -459,8 +459,9 @@ class Incidents(Cog): else: log.trace("Deletion was confirmed") - # Deletes the message link embeds found in cache from the channel and cache. - await self.delete_msg_link_embed(incident.id) + if self.incidents_webhook: + # Deletes the message link embeds found in cache from the channel and cache. + await self.delete_msg_link_embed(incident.id) async def resolve_message(self, message_id: int) -> Optional[discord.Message]: """ @@ -567,7 +568,8 @@ class Incidents(Cog): Search through the cache for message, if found delete it from cache and channel. """ - await self.delete_msg_link_embed(payload.message_id) + if self.incidents_webhook: + await self.delete_msg_link_embed(payload.message_id) async def extract_message_links(self, message: discord.Message) -> Optional[list[discord.Embed]]: """ -- cgit v1.2.3 From 77eaae2434aaa0894fe9dd888b7a208285c2665b Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sun, 17 Oct 2021 11:48:00 +0200 Subject: Regex: add a word boundary before .gg (#1817) * Regex: add a word boundary before .gg and use named groups Before this commit, `an-arbitrary-domain.gg/notaninvite` would trigger the filter. This solve the issue by adding a word boundary before this branch of the pattern. * Regex: replace the word boundary by a word char Co-authored-by: ChrisJL Co-authored-by: ChrisJL Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/converters.py | 4 ++-- bot/exts/filters/filtering.py | 2 +- bot/utils/regex.py | 18 +++++++++--------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 4a4d3b544..dd02f6ae6 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -71,10 +71,10 @@ class ValidDiscordServerInvite(Converter): async def convert(self, ctx: Context, server_invite: str) -> dict: """Check whether the string is a valid Discord server invite.""" - invite_code = INVITE_RE.search(server_invite) + invite_code = INVITE_RE.match(server_invite) if invite_code: response = await ctx.bot.http_session.get( - f"{URLs.discord_invite_api}/{invite_code[1]}" + f"{URLs.discord_invite_api}/{invite_code.group('invite')}" ) if response.status != 404: invite_data = await response.json() diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 7faf063b9..a151db1f0 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -507,7 +507,7 @@ class Filtering(Cog): # discord\.gg/gdudes-pony-farm text = text.replace("\\", "") - invites = INVITE_RE.findall(text) + invites = [m.group("invite") for m in INVITE_RE.finditer(text)] invite_data = dict() for invite in invites: if invite in invite_data: diff --git a/bot/utils/regex.py b/bot/utils/regex.py index 7bad1e627..d77f5950b 100644 --- a/bot/utils/regex.py +++ b/bot/utils/regex.py @@ -1,14 +1,14 @@ import re INVITE_RE = re.compile( - r"(?:discord(?:[\.,]|dot)gg|" # Could be discord.gg/ - r"discord(?:[\.,]|dot)com(?:\/|slash)invite|" # or discord.com/invite/ - r"discordapp(?:[\.,]|dot)com(?:\/|slash)invite|" # or discordapp.com/invite/ - r"discord(?:[\.,]|dot)me|" # or discord.me - r"discord(?:[\.,]|dot)li|" # or discord.li - r"discord(?:[\.,]|dot)io|" # or discord.io. - r"(?:[\.,]|dot)gg" # or .gg/ - r")(?:[\/]|slash)" # / or 'slash' - r"([a-zA-Z0-9\-]+)", # the invite code itself + r"(discord([\.,]|dot)gg|" # Could be discord.gg/ + r"discord([\.,]|dot)com(\/|slash)invite|" # or discord.com/invite/ + r"discordapp([\.,]|dot)com(\/|slash)invite|" # or discordapp.com/invite/ + r"discord([\.,]|dot)me|" # or discord.me + r"discord([\.,]|dot)li|" # or discord.li + r"discord([\.,]|dot)io|" # or discord.io. + r"((?[a-zA-Z0-9\-]+)", # the invite code itself flags=re.IGNORECASE ) -- cgit v1.2.3 From 77e83e01c3adbce4bd9ffac4eeb031cf8a283640 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sun, 17 Oct 2021 10:55:14 +0100 Subject: Fixed delayed logs and made some other minor refactors (#1863) * Run debug log before help command invocation to avoid delayed logs * Refactored other areas of code slightly, ensuring logging is done as soon as possible. Removed outdated comment * Ensured debug logs were sent for disabled commands Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/exts/backend/error_handler.py | 47 ++++++++++++++++----------------------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 7644b93ae..6ab6634a6 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -59,17 +59,23 @@ class ErrorHandler(Cog): log.trace(f"Command {command} had its error already handled locally; ignoring.") return + debug_message = ( + f"Command {command} invoked by {ctx.message.author} with error " + f"{e.__class__.__name__}: {e}" + ) + if isinstance(e, errors.CommandNotFound) and not getattr(ctx, "invoked_from_error_handler", False): if await self.try_silence(ctx): return - # Try to look for a tag with the command's name - await self.try_get_tag(ctx) - return # Exit early to avoid logging. + await self.try_get_tag(ctx) # Try to look for a tag with the command's name elif isinstance(e, errors.UserInputError): + log.debug(debug_message) await self.handle_user_input_error(ctx, e) elif isinstance(e, errors.CheckFailure): + log.debug(debug_message) await self.handle_check_failure(ctx, e) elif isinstance(e, errors.CommandOnCooldown): + log.debug(debug_message) await ctx.send(e) elif isinstance(e, errors.CommandInvokeError): if isinstance(e.original, ResponseCodeError): @@ -80,22 +86,16 @@ class ErrorHandler(Cog): await ctx.send(f"Cannot infract that user. {e.original.reason}") else: await self.handle_unexpected_error(ctx, e.original) - return # Exit early to avoid logging. elif isinstance(e, errors.ConversionError): if isinstance(e.original, ResponseCodeError): await self.handle_api_error(ctx, e.original) else: await self.handle_unexpected_error(ctx, e.original) - return # Exit early to avoid logging. - elif not isinstance(e, errors.DisabledCommand): + elif isinstance(e, errors.DisabledCommand): + log.debug(debug_message) + else: # MaxConcurrencyReached, ExtensionError await self.handle_unexpected_error(ctx, e) - return # Exit early to avoid logging. - - log.debug( - f"Command {command} invoked by {ctx.message.author} with error " - f"{e.__class__.__name__}: {e}" - ) @staticmethod def get_help_command(ctx: Context) -> t.Coroutine: @@ -188,9 +188,6 @@ class ErrorHandler(Cog): if not any(role.id in MODERATION_ROLES for role in ctx.author.roles): await self.send_command_suggestion(ctx, ctx.invoked_with) - # Return to not raise the exception - return - async def send_command_suggestion(self, ctx: Context, command_name: str) -> None: """Sends user similar commands if any can be found.""" # No similar tag found, or tag on cooldown - @@ -235,38 +232,32 @@ class ErrorHandler(Cog): """ if isinstance(e, errors.MissingRequiredArgument): embed = self._get_error_embed("Missing required argument", e.param.name) - await ctx.send(embed=embed) - await self.get_help_command(ctx) self.bot.stats.incr("errors.missing_required_argument") elif isinstance(e, errors.TooManyArguments): embed = self._get_error_embed("Too many arguments", str(e)) - await ctx.send(embed=embed) - await self.get_help_command(ctx) self.bot.stats.incr("errors.too_many_arguments") elif isinstance(e, errors.BadArgument): embed = self._get_error_embed("Bad argument", str(e)) - await ctx.send(embed=embed) - await self.get_help_command(ctx) self.bot.stats.incr("errors.bad_argument") elif isinstance(e, errors.BadUnionArgument): embed = self._get_error_embed("Bad argument", f"{e}\n{e.errors[-1]}") - await ctx.send(embed=embed) - await self.get_help_command(ctx) self.bot.stats.incr("errors.bad_union_argument") elif isinstance(e, errors.ArgumentParsingError): embed = self._get_error_embed("Argument parsing error", str(e)) await ctx.send(embed=embed) self.get_help_command(ctx).close() self.bot.stats.incr("errors.argument_parsing_error") + return else: embed = self._get_error_embed( "Input error", "Something about your input seems off. Check the arguments and try again." ) - await ctx.send(embed=embed) - await self.get_help_command(ctx) self.bot.stats.incr("errors.other_user_input_error") + await ctx.send(embed=embed) + await self.get_help_command(ctx) + @staticmethod async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: """ @@ -299,8 +290,8 @@ class ErrorHandler(Cog): async def handle_api_error(ctx: Context, e: ResponseCodeError) -> None: """Send an error message in `ctx` for ResponseCodeError and log it.""" if e.status == 404: - await ctx.send("There does not seem to be anything matching your query.") log.debug(f"API responded with 404 for command {ctx.command}") + await ctx.send("There does not seem to be anything matching your query.") ctx.bot.stats.incr("errors.api_error_404") elif e.status == 400: content = await e.response.json() @@ -308,12 +299,12 @@ class ErrorHandler(Cog): await ctx.send("According to the API, your request is malformed.") ctx.bot.stats.incr("errors.api_error_400") elif 500 <= e.status < 600: - await ctx.send("Sorry, there seems to be an internal issue with the API.") log.warning(f"API responded with {e.status} for command {ctx.command}") + await ctx.send("Sorry, there seems to be an internal issue with the API.") ctx.bot.stats.incr("errors.api_internal_server_error") else: - await ctx.send(f"Got an unexpected status code from the API (`{e.status}`).") log.warning(f"Unexpected API response for command {ctx.command}: {e.status}") + await ctx.send(f"Got an unexpected status code from the API (`{e.status}`).") ctx.bot.stats.incr(f"errors.api_error_{e.status}") @staticmethod -- cgit v1.2.3 From 976a0e92e3f45fc7051abfbcd95c52544eb2d18b Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 17 Oct 2021 12:51:28 +0100 Subject: Fix attr error in ModLog command Fixes #1881 Fixes BOT-1NX --- 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 b09eb2d14..900c3b610 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -539,7 +539,7 @@ class ModLog(Cog, name="ModLog"): channel = self.bot.get_channel(channel_id) # Ignore not found channels, DMs, and messages outside of the main guild. - if not channel or channel.guild and channel.guild.id != GuildConstant.id: + if not channel or not hasattr(channel, "guild") or channel.guild.id != GuildConstant.id: return True # Look at the parent channel of a thread. -- cgit v1.2.3 From a91b51dedf281bbc0c5ea0fae9cc2c150c4afb56 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 17 Oct 2021 15:06:08 +0100 Subject: Use MISSING sentinal rather than None for no files Fixes #1884 Fixes BOT-1NY Discord.py 2.0 changed how this works, webhooks now look for the MISSING sentinal, rather than None to determine whether files are being passed. This was updated in this commit: https://github.com/Rapptz/discord.py/commit/a6f7213c89e9d592c69ea3c631b0cb2bdab19577 --- 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 097fa36f1..e265e29d3 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -105,7 +105,7 @@ async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: di else: embed.set_author(name="[Failed to relay attachment]", url=attachment.proxy_url) # Embed links the file else: - file = None + file = discord.utils.MISSING return embed, file -- cgit v1.2.3 From 1895fe6555bf0143b8439696b04d53ffdb568ac5 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 17 Oct 2021 17:24:27 +0100 Subject: Fix attr error since asset attrs have changed in 2.0 Fixes #1886 Fixes BOT-1NZ This was updated with Discord.py 2.0. --- 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 900c3b610..b90480f0d 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -378,7 +378,7 @@ class ModLog(Cog, name="ModLog"): await self.send_log_message( Icons.guild_update, Colour.blurple(), "Guild updated", message, - thumbnail=after.icon_url_as(format="png") + thumbnail=after.icon.with_static_format("png") ) @Cog.listener() -- cgit v1.2.3 From e84ced79ade1a28a7e24c307d16126dc80a17a7b Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 17 Oct 2021 15:19:35 +0530 Subject: Refactor shorten_text utility function --- bot/exts/moderation/incidents.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 5c5efdb15..b62ba0629 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -142,21 +142,23 @@ def has_signals(message: discord.Message) -> bool: def shorten_text(text: str) -> str: """Truncate the text if there are over 3 lines or 300 characters, or if it is a single word.""" original_length = len(text) - lines = text.split("\n") + # Truncate text to a maximum of 300 characters + if len(text) > 300: + text = text[:300] + # Limit to a maximum of three lines - if len(lines) > 3: - text = "\n".join(line for line in lines[:3]) + text = "\n".join(line for line in text.split("\n", maxsplit=3)[:3]) # If it is a single word, then truncate it to 50 characters - if text.count(" ") < 1: + if text.find(" ") == -1: text = text[:50] - # Truncate text to a maximum of 300 characters - elif len(text) > 300: - text = text[:300] + + # Remove extra whitespaces from the `text` + text = text.strip() # Add placeholder if the text was shortened if len(text) < original_length: - text += "..." + text = f"{text}..." return text -- cgit v1.2.3 From f047a7013302b22308d213b66ed16412e96a36d8 Mon Sep 17 00:00:00 2001 From: Karlis Suvi <45097959+ks129@users.noreply.github.com> Date: Mon, 18 Oct 2021 07:57:07 +0300 Subject: Fix guild icon URL getting way in server command (#1888) --- bot/exts/info/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 1b3e28e79..0dcb8de11 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -200,7 +200,7 @@ class Information(Cog): f"\nRoles: {num_roles}" f"\nMember status: {member_status}" ) - embed.set_thumbnail(url=ctx.guild.icon_url) + embed.set_thumbnail(url=ctx.guild.icon.url) # Members total_members = f"{ctx.guild.member_count:,}" -- cgit v1.2.3 From 379303520b1cbb0d777d05a2362a9df4006e636d Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 18 Oct 2021 21:22:35 +0100 Subject: Migrate to on_socket_event_type event Discord.py 2.0 (Namely this commit https://github.com/Rapptz/discord.py/commit/e2250d402e8ad035b2653eb411c8e744cc9eb3bf) removed the socket_response event, and replaced it with the socket_event_type event, which just sends the type of event triggered on the websocket. Since this event was removed, no socket stats were being incremented, as the event never triggered. I have looked through the rest of the bot, and we do not use the socket_response event type anywhere else. --- bot/exts/utils/internal.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index 879735945..96664929b 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -37,11 +37,10 @@ class Internal(Cog): self.eval.add_check(is_owner().predicate) @Cog.listener() - async def on_socket_response(self, msg: dict) -> None: + async def on_socket_event_type(self, event_type: str) -> 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 + 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.""" -- cgit v1.2.3 From 67e304ec7eefad638fe264731f52e324bfd7fef0 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Tue, 19 Oct 2021 04:55:55 +0530 Subject: Removing config validation checks --- bot/exts/moderation/incidents.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index b62ba0629..805b516c9 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -177,11 +177,7 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[d try: message: discord.Message = await MessageConverter().convert(ctx, message_link) except MessageNotFound: - try: - mod_logs_channel = ctx.bot.get_channel(Channels.mod_log) - except discord.NotFound: - log.exception(f"Mod-logs (<#{Channels.mod_log}> channel not found.") - return + mod_logs_channel = ctx.bot.get_channel(Channels.mod_log) last_100_logs: list[discord.Message] = await mod_logs_channel.history(limit=100).flatten() -- cgit v1.2.3 From 17e6cb4174c102a5e77aaa8515ba77634356b3e4 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Tue, 19 Oct 2021 04:58:34 +0530 Subject: Make docstring clear about max length --- bot/exts/moderation/incidents.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 805b516c9..9a526fa9f 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -140,7 +140,11 @@ def has_signals(message: discord.Message) -> bool: def shorten_text(text: str) -> str: - """Truncate the text if there are over 3 lines or 300 characters, or if it is a single word.""" + """ + Truncate the text if there are over 3 lines or 300 characters, or if it is a single word. + + The maximum length of the string would be 303 characters across 3 lines at maximum. + """ original_length = len(text) # Truncate text to a maximum of 300 characters if len(text) > 300: @@ -301,7 +305,7 @@ class Incidents(Cog): self.crawl_task = scheduling.create_task(self.crawl_incidents(), event_loop=self.bot.loop) async def fetch_webhook(self) -> None: - """Fetches the incidents webhook object, so we can post message link embeds to it.""" + """Fetch the incidents webhook object, so we can post message link embeds to it.""" await self.bot.wait_until_guild_available() self.incidents_webhook = await self.bot.fetch_webhook(Webhooks.incidents) -- cgit v1.2.3 From 792e05cf87cf52dde38031ca24de511623736c75 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Tue, 19 Oct 2021 05:03:12 +0530 Subject: Add message creation timestamp to message link embed --- bot/exts/moderation/incidents.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 9a526fa9f..0b6b7ad9a 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -222,7 +222,8 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[d description=( f"**Author:** {format_user(message.author)}\n" f"**Channel:** {channel.mention} ({channel.category}/#{channel.name})\n" - ) + ), + timestamp=message.created_at ) embed.add_field( name="Content", -- cgit v1.2.3 From 6402cc893833c2a03f8aca0048e674226e02cb72 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Tue, 19 Oct 2021 05:04:48 +0530 Subject: Fix incident webhook fetch validation --- bot/exts/moderation/incidents.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 0b6b7ad9a..693c01c81 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -308,9 +308,10 @@ class Incidents(Cog): async def fetch_webhook(self) -> None: """Fetch the incidents webhook object, so we can post message link embeds to it.""" await self.bot.wait_until_guild_available() - self.incidents_webhook = await self.bot.fetch_webhook(Webhooks.incidents) - if not self.incidents_webhook: + try: + self.incidents_webhook = await self.bot.fetch_webhook(Webhooks.incidents) + except discord.HTTPException: log.error(f"Failed to fetch incidents webhook with id `{Webhooks.incidents}`.") async def crawl_incidents(self) -> None: -- cgit v1.2.3 From b53bf178db221740c36c920a6ca95f53ccdcff83 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Tue, 19 Oct 2021 06:03:38 +0530 Subject: Use discord timestamps for showing worktime --- bot/exts/moderation/modpings.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 6cc46ad26..65372c312 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -12,6 +12,7 @@ from bot.converters import Expiry from bot.log import get_logger from bot.utils import scheduling from bot.utils.scheduling import Scheduler +from bot.utils.time import TimestampFormats, discord_timestamp log = get_logger(__name__) @@ -83,7 +84,7 @@ class ModPings(Cog): start_timestamp, work_time = schedule.split("|") start = datetime.datetime.fromtimestamp(float(start_timestamp)) - mod = self.bot.fetch_user(mod_id) + mod = await self.bot.fetch_user(mod_id) self._modpings_scheduler.schedule_at( start, mod_id, @@ -114,7 +115,7 @@ class ModPings(Cog): log.trace(f"Skipping adding moderator role to mod with ID {mod.id} - found in pings off cache.") else: log.trace(f"Applying moderator role to mod with ID {mod.id}") - await mod.add_roles(self.moderators_role, reason="Moderator schedule time started!") + await mod.add_roles(self.moderators_role, reason="Moderator scheduled time started!") log.trace(f"Sleeping for {work_time} seconds, worktime for mod with ID {mod.id}") await asyncio.sleep(work_time) @@ -216,7 +217,6 @@ class ModPings(Cog): # otherwise the scheduler would schedule it immediately start += datetime.timedelta(days=1) - start, end = start.replace(tzinfo=None), end.replace(tzinfo=None) work_time = (end - start).total_seconds() await self.modpings_schedule.set(ctx.author.id, f"{start.timestamp()}|{work_time}") @@ -232,7 +232,8 @@ class ModPings(Cog): await ctx.send( f"{Emojis.ok_hand} {ctx.author.mention} Scheduled mod pings from " - f"{start: %H:%M} to {end: %H:%M} UTC Timing!" + f"{discord_timestamp(start, TimestampFormats.TIME)} to " + f"{discord_timestamp(end, TimestampFormats.TIME)}!" ) @schedule_modpings.command(name='delete', aliases=('del', 'd')) -- cgit v1.2.3 From b2e8bfdc47339714ec014a13e2018e03c0931fe4 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 19 Oct 2021 09:11:51 +0100 Subject: Invert `isinstance` check as per review --- bot/exts/help_channels/_cog.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index ecffc59fd..b3da1e315 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -126,9 +126,12 @@ class HelpChannels(commands.Cog): log.info(f"Channel #{message.channel} was claimed by `{message.author.id}`.") await self.move_to_in_use(message.channel) - # Handle odd edge case of `message.author` being a `discord.User` (see bot#1839) - if isinstance(message.author, discord.User): - log.warning("`message.author` is a `discord.User` so not handling role change or sending DM.") + # Handle odd edge case of `message.author` not being a `discord.Member` (see bot#1839) + if not isinstance(message.author, discord.Member): + log.warning( + f"`message.author` ({message.author} / {message.author.id}) isn't a `discord.Member` so not handling " + "role change or sending DM." + ) else: await self._handle_role_change(message.author, message.author.add_roles) -- cgit v1.2.3 From 5b7d8c41c88d3d0333f16dabd45efa770da87c82 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 19 Oct 2021 09:31:32 +0100 Subject: Update log message for when author isn't `discord.Member` --- bot/exts/help_channels/_cog.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index b3da1e315..770a6360a 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -129,8 +129,7 @@ class HelpChannels(commands.Cog): # Handle odd edge case of `message.author` not being a `discord.Member` (see bot#1839) if not isinstance(message.author, discord.Member): log.warning( - f"`message.author` ({message.author} / {message.author.id}) isn't a `discord.Member` so not handling " - "role change or sending DM." + f"{message.author} ({message.author.id}) isn't a member. Not giving cooldown role or sending DM." ) else: await self._handle_role_change(message.author, message.author.add_roles) -- cgit v1.2.3 From acc79870d55ca257a234a9dc49bfa4a7c85da2ca Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 19 Oct 2021 11:17:31 +0000 Subject: Filtering: add autoban on specific reasons Due to the increase in typo-squatting based phishing, we want to automatically ban users sending specific domain names. For that, this commit will automatically ban any user that trigger a filter which has `[autoban]` in its reason. That's it! --- bot/exts/filters/filtering.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 7faf063b9..ad67f3469 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -42,6 +42,10 @@ ZALGO_RE = regex.compile(rf"[\p{{NONSPACING MARK}}\p{{ENCLOSING MARK}}--[{VARIAT # Other constants. DAYS_BETWEEN_ALERTS = 3 OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) +AUTO_BAN_REASON = ( + "Your account seem to be compromised (%s). " + "Please appeal this ban once you have regained control of your account." +) FilterMatch = Union[re.Match, dict, bool, List[discord.Embed]] @@ -346,6 +350,26 @@ class Filtering(Cog): stats = self._add_stats(filter_name, match, msg.content) await self._send_log(filter_name, _filter, msg, stats, reason) + # If the filter reason contains `[autoban]`, we want to indeed ban + if "[autoban]" in reason.lower(): + # We create a new context from that message and make sure the staffer is the bot + # and the feeback message is sent in #mod-alert + context = await self.bot.get_context(msg) + context.author = self.bot.user + context.channel = self.bot.get_channel(Channels.mod_alerts) + + # We need to convert the user to a member if we are inside a DM channel + if msg.guild is None: + user = self.bot.get_guild(Guild.id).get_member(msg.author.id) + else: + user = msg.author + + await context.invoke( + self.bot.get_command("ban"), + user, + reason=AUTO_BAN_REASON % reason.lower().replace("[autoban]", "").strip() + ) + break # We don't want multiple filters to trigger async def _send_log( @@ -367,6 +391,10 @@ class Filtering(Cog): # Allow specific filters to override ping_everyone ping_everyone = Filter.ping_everyone and _filter.get("ping_everyone", True) + # If we are going to autoban, we don't want to ping + if "[autoban]" in reason: + ping_everyone = False + eval_msg = "using !eval " if is_eval else "" footer = f"Reason: {reason}" if reason else None message = ( -- cgit v1.2.3 From 8e4fe2e73198edb37eb1dcffb7f531a69cec34c2 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 19 Oct 2021 13:39:44 +0200 Subject: Filtering: update auto-ban message Co-authored-by: Boris Muratov <8bee278@gmail.com> --- bot/exts/filters/filtering.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index ad67f3469..82bbd880e 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -43,8 +43,8 @@ ZALGO_RE = regex.compile(rf"[\p{{NONSPACING MARK}}\p{{ENCLOSING MARK}}--[{VARIAT DAYS_BETWEEN_ALERTS = 3 OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) AUTO_BAN_REASON = ( - "Your account seem to be compromised (%s). " - "Please appeal this ban once you have regained control of your account." + "Your account seems to be compromised (%s). " + "You're welcome to appeal this ban once you have regained control of your account." ) FilterMatch = Union[re.Match, dict, bool, List[discord.Embed]] -- cgit v1.2.3 From cf6f11488523db9c25d396a6c70b4a60604e6306 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 19 Oct 2021 11:43:37 +0000 Subject: Filtering: do not try to convert to a member --- bot/exts/filters/filtering.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 82bbd880e..11d0f038b 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -358,9 +358,9 @@ class Filtering(Cog): context.author = self.bot.user context.channel = self.bot.get_channel(Channels.mod_alerts) - # We need to convert the user to a member if we are inside a DM channel + # We try to convert the user to a member if we are inside a DM channel if msg.guild is None: - user = self.bot.get_guild(Guild.id).get_member(msg.author.id) + user = self.bot.get_guild(Guild.id).get_member(msg.author.id) or msg.author else: user = msg.author -- cgit v1.2.3 From 9216e7e341e014528a4fdfaad3bd1a198b8fba02 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 19 Oct 2021 12:20:25 +0000 Subject: Filtering: make autoban temp --- bot/exts/filters/filtering.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 11d0f038b..15c12d27c 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -46,6 +46,7 @@ AUTO_BAN_REASON = ( "Your account seems to be compromised (%s). " "You're welcome to appeal this ban once you have regained control of your account." ) +AUTO_BAN_DURATION = timedelta(days=4) FilterMatch = Union[re.Match, dict, bool, List[discord.Embed]] @@ -358,15 +359,10 @@ class Filtering(Cog): context.author = self.bot.user context.channel = self.bot.get_channel(Channels.mod_alerts) - # We try to convert the user to a member if we are inside a DM channel - if msg.guild is None: - user = self.bot.get_guild(Guild.id).get_member(msg.author.id) or msg.author - else: - user = msg.author - await context.invoke( - self.bot.get_command("ban"), - user, + self.bot.get_command("tempban"), + msg.author, + datetime.now() + AUTO_BAN_DURATION, reason=AUTO_BAN_REASON % reason.lower().replace("[autoban]", "").strip() ) -- cgit v1.2.3 From 03d42584a185d0673df186377c1064b72825bc55 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 19 Oct 2021 17:32:36 +0200 Subject: Mod-log thread: use soft colors Seems like we have been using the wrong colors in mod-log. --- bot/exts/moderation/modlog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index b90480f0d..fb6888755 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -786,11 +786,11 @@ class ModLog(Cog, name="ModLog"): return if not before.archived and after.archived: - colour = Colour.red() + colour = Colour.soft_red() action = "archived" icon = Icons.hash_red elif before.archived and not after.archived: - colour = Colour.green() + colour = Colour.soft_green() action = "un-archived" icon = Icons.hash_green else: @@ -808,7 +808,7 @@ class ModLog(Cog, name="ModLog"): """Log thread deletion.""" await self.send_log_message( Icons.hash_red, - Colour.red(), + Colour.soft_red(), "Thread deleted", f"Thread {thread.mention} (`{thread.id}`) from {thread.parent.mention} (`{thread.parent.id}`) deleted" ) @@ -823,7 +823,7 @@ class ModLog(Cog, name="ModLog"): await self.send_log_message( Icons.hash_green, - Colour.green(), + Colour.soft_green(), "Thread created", f"Thread {thread.mention} (`{thread.id}`) from {thread.parent.mention} (`{thread.parent.id}`) created" ) -- cgit v1.2.3 From acc09738dce8bf3324892f6c1a460ee54978dfd0 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Tue, 19 Oct 2021 23:10:45 +0200 Subject: Modlog: correct color names Solves https://github.com/python-discord/bot/issues/1896 --- bot/exts/moderation/modlog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index fb6888755..9d1ae6853 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -786,11 +786,11 @@ class ModLog(Cog, name="ModLog"): return if not before.archived and after.archived: - colour = Colour.soft_red() + colour = Colours.soft_red action = "archived" icon = Icons.hash_red elif before.archived and not after.archived: - colour = Colour.soft_green() + colour = Colours.soft_green action = "un-archived" icon = Icons.hash_green else: @@ -808,7 +808,7 @@ class ModLog(Cog, name="ModLog"): """Log thread deletion.""" await self.send_log_message( Icons.hash_red, - Colour.soft_red(), + Colours.soft_red, "Thread deleted", f"Thread {thread.mention} (`{thread.id}`) from {thread.parent.mention} (`{thread.parent.id}`) deleted" ) @@ -823,7 +823,7 @@ class ModLog(Cog, name="ModLog"): await self.send_log_message( Icons.hash_green, - Colour.soft_green(), + Colours.soft_green, "Thread created", f"Thread {thread.mention} (`{thread.id}`) from {thread.parent.mention} (`{thread.parent.id}`) created" ) -- cgit v1.2.3 From 945313ea29bf053845665d829e675a1d78e2e545 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Thu, 21 Oct 2021 05:24:57 +0530 Subject: avatar.url -> display_avatar.url --- 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 693c01c81..21aaafe4a 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -623,7 +623,7 @@ class Incidents(Cog): webhook_msg = await webhook.send( embeds=[embed for embed in webhook_embed_list if embed], username=sub_clyde(message.author.name), - avatar_url=message.author.avatar_url, + avatar_url=message.author.display_avatar.url, wait=True, ) except discord.DiscordException: -- cgit v1.2.3 From 14685ed77b6a359533ad2d72f11d26683fcc80e6 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Thu, 21 Oct 2021 05:28:07 +0530 Subject: Fix helpers view perms check to use 'permissions_for' --- bot/exts/moderation/incidents.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 21aaafe4a..82add2579 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -209,11 +209,10 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[d log.exception(f"Failed to make message link embed for '{message_link}', raised exception: {e}") else: channel = message.channel - helpers_role = message.guild.get_role(Roles.helpers) - if not channel.overwrites_for(helpers_role).read_messages: + if not channel.permissions_for(channel.guild.get_role(Roles.helpers)).view_channel: log.info( f"Helpers don't have read permissions in #{channel.name}," - " not sending message link embed for {message_link}" + f" not sending message link embed for {message_link}" ) return -- cgit v1.2.3 From 70948bc6ae3191030e3e173aacc1acd5890b3be7 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Thu, 21 Oct 2021 05:48:39 +0530 Subject: Missed a change for 43bed60 --- 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 82add2579..20e73ccf5 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -587,7 +587,7 @@ class Incidents(Cog): If no links are found for the message, just log a trace statement. """ - message_links = DISCORD_MESSAGE_LINK_RE.findall(str(message.content)) + message_links = DISCORD_MESSAGE_LINK_RE.findall(message.content) if not message_links: log.trace( f"No message links detected on incident message with id {message.id}." -- cgit v1.2.3 From fc47cf4888b3589019ebafe037996f505c4a8b30 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Fri, 22 Oct 2021 00:12:16 +0400 Subject: Bumps Pip Licences Updates the pip-licences version to fix a breaking bug in the currently pinned version. Signed-off-by: Hassan Abouelela --- poetry.lock | 181 ++++++++++++++++++++++++++++++--------------------------- pyproject.toml | 2 +- 2 files changed, 95 insertions(+), 88 deletions(-) diff --git a/poetry.lock b/poetry.lock index 16c599bd1..d91941d45 100644 --- a/poetry.lock +++ b/poetry.lock @@ -722,7 +722,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pycares" -version = "4.0.0" +version = "4.1.2" description = "Python interface for c-ares" category = "main" optional = false @@ -902,7 +902,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [[package]] name = "rapidfuzz" -version = "1.7.1" +version = "1.8.0" description = "rapid fuzzy string matching" category = "main" optional = false @@ -1114,7 +1114,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "e37923739c35ef349d57e324579acfe304cc7e6fc20ddc54205fc89f171ae94f" +content-hash = "da321f13297501e62dd1eb362eccb586ea1a9c21ddb395e11a91b93a2f92e9d4" [metadata.files] aio-pika = [ @@ -1471,6 +1471,8 @@ lxml = [ {file = "lxml-4.6.3-cp27-cp27m-win_amd64.whl", hash = "sha256:8157dadbb09a34a6bd95a50690595e1fa0af1a99445e2744110e3dca7831c4ee"}, {file = "lxml-4.6.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7728e05c35412ba36d3e9795ae8995e3c86958179c9770e65558ec3fdfd3724f"}, {file = "lxml-4.6.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:4bff24dfeea62f2e56f5bab929b4428ae6caba2d1eea0c2d6eb618e30a71e6d4"}, + {file = "lxml-4.6.3-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:64812391546a18896adaa86c77c59a4998f33c24788cadc35789e55b727a37f4"}, + {file = "lxml-4.6.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c1a40c06fd5ba37ad39caa0b3144eb3772e813b5fb5b084198a985431c2f1e8d"}, {file = "lxml-4.6.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51"}, {file = "lxml-4.6.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f90ba11136bfdd25cae3951af8da2e95121c9b9b93727b1b896e3fa105b2f586"}, {file = "lxml-4.6.3-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:4c61b3a0db43a1607d6264166b230438f85bfed02e8cff20c22e564d0faff354"}, @@ -1674,39 +1676,37 @@ py = [ {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, ] pycares = [ - {file = "pycares-4.0.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:db5a533111a3cfd481e7e4fb2bf8bef69f4fa100339803e0504dd5aecafb96a5"}, - {file = "pycares-4.0.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:fdff88393c25016f417770d82678423fc7a56995abb2df3d2a1e55725db6977d"}, - {file = "pycares-4.0.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0aa97f900a7ffb259be77d640006585e2a907b0cd4edeee0e85cf16605995d5a"}, - {file = "pycares-4.0.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:a34b0e3e693dceb60b8a1169668d606c75cb100ceba0a2df53c234a0eb067fbc"}, - {file = "pycares-4.0.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7661d6bbd51a337e7373cb356efa8be9b4655fda484e068f9455e939aec8d54e"}, - {file = "pycares-4.0.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:57315b8eb8fdbc56b3ad4932bc4b17132bb7c7fd2bd590f7fb84b6b522098aa9"}, - {file = "pycares-4.0.0-cp36-cp36m-win32.whl", hash = "sha256:dca9dc58845a9d083f302732a3130c68ded845ad5d463865d464e53c75a3dd45"}, - {file = "pycares-4.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:c95c964d5dd307e104b44b193095c67bb6b10c9eda1ffe7d44ab7a9e84c476d9"}, - {file = "pycares-4.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:26e67e4f81c80a5955dcf6193f3d9bee3c491fc0056299b383b84d792252fba4"}, - {file = "pycares-4.0.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:cd3011ffd5e1ad55880f7256791dbab9c43ebeda260474a968f19cd0319e1aef"}, - {file = "pycares-4.0.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1b959dd5921d207d759d421eece1b60416df33a7f862465739d5f2c363c2f523"}, - {file = "pycares-4.0.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:6f258c1b74c048a9501a25f732f11b401564005e5e3c18f1ca6cad0c3dc0fb19"}, - {file = "pycares-4.0.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:b17ef48729786e62b574c6431f675f4cb02b27691b49e7428a605a50cd59c072"}, - {file = "pycares-4.0.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:82b3259cb590ddd107a6d2dc52da2a2e9a986bf242e893d58c786af2f8191047"}, - {file = "pycares-4.0.0-cp37-cp37m-win32.whl", hash = "sha256:4876fc790ae32832ae270c4a010a1a77e12ddf8d8e6ad70ad0b0a9d506c985f7"}, - {file = "pycares-4.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:f60c04c5561b1ddf85ca4e626943cc09d7fb684e1adb22abb632095415a40fd7"}, - {file = "pycares-4.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:615406013cdcd1b445e5d1a551d276c6200b3abe77e534f8a7f7e1551208d14f"}, - {file = "pycares-4.0.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6580aef5d1b29a88c3d72fe73c691eacfd454f86e74d3fdd18f4bad8e8def98b"}, - {file = "pycares-4.0.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8ebb3ba0485f66cae8eed7ce3e9ed6f2c0bfd5e7319d5d0fbbb511064f17e1d4"}, - {file = "pycares-4.0.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:c5362b7690ca481440f6b98395ac6df06aa50518ccb183c560464d1e5e2ab5d4"}, - {file = "pycares-4.0.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:eb60be66accc9a9ea1018b591a1f5800cba83491d07e9acc8c56bc6e6607ab54"}, - {file = "pycares-4.0.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:44896d6e191a6b5a914dbe3aa7c748481bf6ad19a9df33c1e76f8f2dc33fc8f0"}, - {file = "pycares-4.0.0-cp38-cp38-win32.whl", hash = "sha256:09b28fc7bc2cc05f7f69bf1636ddf46086e0a1837b62961e2092fcb40477320d"}, - {file = "pycares-4.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:d4a5081e232c1d181883dcac4675807f3a6cf33911c4173fbea00c0523687ed4"}, - {file = "pycares-4.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:103353577a6266a53e71bfee4cf83825f1401fefa60f0fb8bdec35f13be6a5f2"}, - {file = "pycares-4.0.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ad6caf580ee69806fc6534be93ddbb6e99bf94296d79ab351c37b2992b17abfd"}, - {file = "pycares-4.0.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:3d5e50c95849f6905d2a9dbf02ed03f82580173e3c5604a39e2ad054185631f1"}, - {file = "pycares-4.0.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:53bc4f181b19576499b02cea4b45391e8dcbe30abd4cd01492f66bfc15615a13"}, - {file = "pycares-4.0.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:d52f9c725d2a826d5ffa37681eb07ffb996bfe21788590ef257664a3898fc0b5"}, - {file = "pycares-4.0.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:3c7fb8d34ee11971c39acfaf98d0fac66725385ccef3bfe1b174c92b210e1aa4"}, - {file = "pycares-4.0.0-cp39-cp39-win32.whl", hash = "sha256:e9773e07684a55f54657df05237267611a77b294ec3bacb5f851c4ffca38a465"}, - {file = "pycares-4.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:38e54037f36c149146ff15f17a4a963fbdd0f9871d4a21cd94ff9f368140f57e"}, - {file = "pycares-4.0.0.tar.gz", hash = "sha256:d0154fc5753b088758fbec9bc137e1b24bb84fc0c6a09725c8bac25a342311cd"}, + {file = "pycares-4.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:71b99b9e041ae3356b859822c511f286f84c8889ec9ed1fbf6ac30fb4da13e4c"}, + {file = "pycares-4.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c000942f5fc64e6e046aa61aa53b629b576ba11607d108909727c3c8f211a157"}, + {file = "pycares-4.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b0e50ddc78252f2e2b6b5f2c73e5b2449dfb6bea7a5a0e21dfd1e2bcc9e17382"}, + {file = "pycares-4.1.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6831e963a910b0a8cbdd2750ffcdf5f2bb0edb3f53ca69ff18484de2cc3807c4"}, + {file = "pycares-4.1.2-cp310-cp310-win32.whl", hash = "sha256:ad7b28e1b6bc68edd3d678373fa3af84e39d287090434f25055d21b4716b2fc6"}, + {file = "pycares-4.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:27a6f09dbfb69bb79609724c0f90dfaa7c215876a7cd9f12d585574d1f922112"}, + {file = "pycares-4.1.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e5a060f5fa90ae245aa99a4a8ad13ec39c2340400de037c7e8d27b081e1a3c64"}, + {file = "pycares-4.1.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:056330275dea42b7199494047a745e1d9785d39fb8c4cd469dca043532240b80"}, + {file = "pycares-4.1.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0aa897543a786daba74ec5e19638bd38b2b432d179a0e248eac1e62de5756207"}, + {file = "pycares-4.1.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cbceaa9b2c416aa931627466d3240aecfc905c292c842252e3d77b8630072505"}, + {file = "pycares-4.1.2-cp36-cp36m-win32.whl", hash = "sha256:112e1385c451069112d6b5ea1f9c378544f3c6b89882ff964e9a64be3336d7e4"}, + {file = "pycares-4.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:c6680f7fdc0f1163e8f6c2a11d11b9a0b524a61000d2a71f9ccd410f154fb171"}, + {file = "pycares-4.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58a41a2baabcd95266db776c510d349d417919407f03510fc87ac7488730d913"}, + {file = "pycares-4.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a810d01c9a426ee8b0f36969c2aef5fb966712be9d7e466920beb328cd9cefa3"}, + {file = "pycares-4.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b266cec81dcea2c3efbbd3dda00af8d7eb0693ae9e47e8706518334b21f27d4a"}, + {file = "pycares-4.1.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8319afe4838e09df267c421ca93da408f770b945ec6217dda72f1f6a493e37e4"}, + {file = "pycares-4.1.2-cp37-cp37m-win32.whl", hash = "sha256:4d5da840aa0d9b15fa51107f09270c563a348cb77b14ae9653d0bbdbe326fcc2"}, + {file = "pycares-4.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:5632f21d92cc0225ba5ff906e4e5dec415ef0b3df322c461d138190681cd5d89"}, + {file = "pycares-4.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8fd1ff17a26bb004f0f6bb902ba7dddd810059096ae0cc3b45e4f5be46315d19"}, + {file = "pycares-4.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:439799be4b7576e907139a7f9b3c8a01b90d3e38af4af9cd1fc6c1ee9a42b9e6"}, + {file = "pycares-4.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:40079ed58efa91747c50aac4edf8ecc7e570132ab57dc0a4030eb0d016a6cab8"}, + {file = "pycares-4.1.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e190471a015f8225fa38069617192e06122771cce2b169ac7a60bfdbd3d4ab2"}, + {file = "pycares-4.1.2-cp38-cp38-win32.whl", hash = "sha256:2b837315ed08c7df009b67725fe1f50489e99de9089f58ec1b243dc612f172aa"}, + {file = "pycares-4.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:c7eba3c8354b730a54d23237d0b6445a2f68570fa68d0848887da23a3f3b71f3"}, + {file = "pycares-4.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2f5f84fe9f83eab9cd68544b165b74ba6e3412d029cc9ab20098d9c332869fc5"}, + {file = "pycares-4.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569eef8597b5e02b1bc4644b9f272160304d8c9985357d7ecfcd054da97c0771"}, + {file = "pycares-4.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e1489aa25d14dbf7176110ead937c01176ed5a0ebefd3b092bbd6b202241814c"}, + {file = "pycares-4.1.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:dc942692fca0e27081b7bb414bb971d34609c80df5e953f6d0c62ecc8019acd9"}, + {file = "pycares-4.1.2-cp39-cp39-win32.whl", hash = "sha256:ed71dc4290d9c3353945965604ef1f6a4de631733e9819a7ebc747220b27e641"}, + {file = "pycares-4.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:ec00f3594ee775665167b1a1630edceefb1b1283af9ac57480dba2fb6fd6c360"}, + {file = "pycares-4.1.2.tar.gz", hash = "sha256:03490be0e7b51a0c8073f877bec347eff31003f64f57d9518d419d9369452837"}, ] pycodestyle = [ {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, @@ -1792,57 +1792,64 @@ pyyaml = [ {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] rapidfuzz = [ - {file = "rapidfuzz-1.7.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1ca9888e867aed2bb8d51571270e5f8393d718bb189fe1a7c0b047b8fd72bad3"}, - {file = "rapidfuzz-1.7.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:f336cd32a2a72eb9d7694618c9065ef3a2af330ab7e54bc0ec69d3b2eb08080e"}, - {file = "rapidfuzz-1.7.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:76124767ac3d3213a1aad989f80b156b225defef8addc825a5b631d3164c3213"}, - {file = "rapidfuzz-1.7.1-cp27-cp27m-win32.whl", hash = "sha256:c1090deb95e5369fff47c223c0ed3472644efc56817e288ebeaaa34822a1235c"}, - {file = "rapidfuzz-1.7.1-cp27-cp27m-win_amd64.whl", hash = "sha256:83f94c89e8f16679e0def3c7afa6c9ba477d837fd01250d6a1e3fea12267ce24"}, - {file = "rapidfuzz-1.7.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:cdd5962bd009b1457e280b5619d312cd6305b5b8afeff6c27869f98fee839c36"}, - {file = "rapidfuzz-1.7.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:2940960e212b66f00fc58f9b4a13e6f80221141dcbaee9c51f97e0a1f30ff1ab"}, - {file = "rapidfuzz-1.7.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:5ed4304a91043d27b92fe9af5eb87d1586548da6d03cbda5bbc98b00fee227cb"}, - {file = "rapidfuzz-1.7.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:be18495bd84bf2bd3e888270a3cd4dea868ff4b9b8ec6e540f0e195cda554140"}, - {file = "rapidfuzz-1.7.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:d5779e6f548b6f3edfbdfbeeda4158286684dcb2bae3515ce68c510ea48e1b4d"}, - {file = "rapidfuzz-1.7.1-cp35-cp35m-win32.whl", hash = "sha256:80d780c4f6da08eb6801489df54fdbdc5ef2b882bd73f9585ef6e0cf09f1690d"}, - {file = "rapidfuzz-1.7.1-cp35-cp35m-win_amd64.whl", hash = "sha256:3b205c63b8606c2b8595ba8403a8c3ebd39de9f7f44631a2f651f3efe106ae9a"}, - {file = "rapidfuzz-1.7.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8f96588a8a7d021debb4c60d82b15a80995daa99159bbeddd8a37f68f75ee06c"}, - {file = "rapidfuzz-1.7.1-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b8139116a937691dde17f27aafe774647808339305f4683b3a6d9bae6518aa2a"}, - {file = "rapidfuzz-1.7.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba574801c8410cc1f2d690ef65f898f6a660bba22ec8213e0f34dd0f0590bc71"}, - {file = "rapidfuzz-1.7.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2d5194e3cb638af0cc7c02daa61cef07e332fd3f790ec113006302131be9afa6"}, - {file = "rapidfuzz-1.7.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dd9d8eaae888b966422cbcba954390a63b4933d8c513ea0056fd6e42d421d08"}, - {file = "rapidfuzz-1.7.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3725c61b9cf57b6b7a765b92046e7d9e5ccce845835b523954b410a70dc32692"}, - {file = "rapidfuzz-1.7.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e417961e5ca450d6c7448accc5a7e4e9ab0dd3c63729f76215d5e672785920fc"}, - {file = "rapidfuzz-1.7.1-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:26d756284c8c6274b5d558e759415bfb4016fcdf168159b34702c346875d8cc0"}, - {file = "rapidfuzz-1.7.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4887766f0dcc5df43fe4315df4b3c642829e06dc60d5bcb5e682fb76657e8ed1"}, - {file = "rapidfuzz-1.7.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec0a29671d59998b97998b757ab1c636dd3b7721eda41746ae897abe709681a9"}, - {file = "rapidfuzz-1.7.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dff55750fecd8c0f07bc199e48427c86873be2d0e6a3a80df98972847287f5d3"}, - {file = "rapidfuzz-1.7.1-cp37-cp37m-win32.whl", hash = "sha256:e113f741bb18b0ddd14d714d80ce9c6d5322724f3023b920708e82491e7aef28"}, - {file = "rapidfuzz-1.7.1-cp37-cp37m-win_amd64.whl", hash = "sha256:ef20654be0aed240ee44c98ce02639c37422adc3e144d28c4b6d3da043d9fd20"}, - {file = "rapidfuzz-1.7.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:9e27eb57745a4d2a390b056f6f490b712c2f54250c5d2c794dd76062065a8aef"}, - {file = "rapidfuzz-1.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:de2b0ebb67ee0b78973141dba91f574a325a3425664dbdbad37fd7aca7b28cab"}, - {file = "rapidfuzz-1.7.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:88c65d91dcd3c0595112d16555536c60ac5bcab1a43e517e155a242a39525057"}, - {file = "rapidfuzz-1.7.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:afd525a9b593cc1099f0210e116bcb4d9fc5585728d7bd929e6a4133dacd2d59"}, - {file = "rapidfuzz-1.7.1-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e6d77f104a8d67c01ae4248ced6f0d4ef05e63931afdf49c20decf962318877f"}, - {file = "rapidfuzz-1.7.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7db9d6ad0ab80e9e0f66f157b8e31b1d04ce5fa767b936ca1c212b98092572b1"}, - {file = "rapidfuzz-1.7.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0195c57f4beea0e7691594f59faf62a4be3c818c1955a8b9b712f37adc479d2d"}, - {file = "rapidfuzz-1.7.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9ffca8c8b74d12cd36c051e9befa7c4eb2d34624ce71f22dbfc659af15bf4a1e"}, - {file = "rapidfuzz-1.7.1-cp38-cp38-win32.whl", hash = "sha256:234cb75aa1e21cabad6a8c0718f84e2bfafdd4756b5232d5739545f97e343e59"}, - {file = "rapidfuzz-1.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:058977e93ab736071fcd8828fc6289ec026e9ca4a19f2a0967f9260e63910da8"}, - {file = "rapidfuzz-1.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9d02bb0724326826b1884cc9b9d9fd97ac352c18213f45e465a39ef069a33115"}, - {file = "rapidfuzz-1.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:212d6fa5b824aaa49a921c81d7cdc1d079b3545a30563ae14dc88e17918e76bf"}, - {file = "rapidfuzz-1.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a0cd8117deba10e2a1d6dccb6ff44a4c737adda3048dc45860c5f53cf64db14f"}, - {file = "rapidfuzz-1.7.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:61faa47b6b5d5a0cbe9fa6369df44d3f9435c4cccdb4d38d9de437f18b69dc4d"}, - {file = "rapidfuzz-1.7.1-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1daa756be52a7ee60d553ba667cda3a188ee811c92a9c21df43a4cdadb1eb8ca"}, - {file = "rapidfuzz-1.7.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c98ac10782dadf507e922963c8b8456a79151b4f10dbb08cfc86c1572db366dc"}, - {file = "rapidfuzz-1.7.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:358d80061ca107df6c3e1f67fa7af0f94a62827cb9c44ac09a16e78b38f7c3d5"}, - {file = "rapidfuzz-1.7.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5f90fc31d54fcd74a97d175892555786a8214a3cff43077463915b8a45a191d"}, - {file = "rapidfuzz-1.7.1-cp39-cp39-win32.whl", hash = "sha256:55dffdcdccea6f077a4f09164039411f01f621633be5883c58ceaf94f007a688"}, - {file = "rapidfuzz-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:d712a7f680d2074b587650f81865ca838c04fcc6b77c9d2d742de0853aaa24ce"}, - {file = "rapidfuzz-1.7.1-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:729d73a8db5a2b444a19d4aa2be009b2e628d207d7c754f6d280e3c6a59b94cb"}, - {file = "rapidfuzz-1.7.1-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:a1cabbc645395b6175cad79164d9ec621866a004b476e44cac534020b9f6bddb"}, - {file = "rapidfuzz-1.7.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ae697294f456f7f76e5bd30db5a65e8b855e7e09f9a65e144efa1e2c5009553c"}, - {file = "rapidfuzz-1.7.1-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e8ae51c1cf1f034f15216fec2e1eef658c8b3a9cbdcc1a053cc7133ede9d616d"}, - {file = "rapidfuzz-1.7.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:dccc072f2a0eeb98d46a79427ef793836ebc5184b1fe544b34607be10705ddc3"}, - {file = "rapidfuzz-1.7.1.tar.gz", hash = "sha256:99495c679174b2a02641f7dc2364a208135cacca77fc4825a86efbfe1e23b0ff"}, + {file = "rapidfuzz-1.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:91f094562c683802e6c972bce27a692dad70d6cd1114e626b29d990c3704c653"}, + {file = "rapidfuzz-1.8.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:4a20682121e245cf5ad2dbdd771360763ea11b77520632a1034c4bb9ad1e854c"}, + {file = "rapidfuzz-1.8.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:8810e75d8f9c4453bbd6209c372bf97514359b0b5efff555caf85b15f8a9d862"}, + {file = "rapidfuzz-1.8.0-cp27-cp27m-win32.whl", hash = "sha256:00cf713d843735b5958d87294f08b05c653a593ced7c4120be34f5d26d7a320a"}, + {file = "rapidfuzz-1.8.0-cp27-cp27m-win_amd64.whl", hash = "sha256:2baca64e23a623e077f57e5470de21af2765af15aa1088676eb2d475e664eed0"}, + {file = "rapidfuzz-1.8.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:9bf7a6c61bacedd84023be356e057e1d209dd6997cfaa3c1cee77aa21d642f88"}, + {file = "rapidfuzz-1.8.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:61b6434e3341ca5158ecb371b1ceb4c1f6110563a72d28bdce4eb2a084493e47"}, + {file = "rapidfuzz-1.8.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e425e690383f6cf308e8c2e8d630fa9596f67d233344efd8fae11e70a9f5635f"}, + {file = "rapidfuzz-1.8.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:93db5e693b76d616b09df27ca5c79e0dda169af7f1b8f5ab3262826d981e37e2"}, + {file = "rapidfuzz-1.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a8c4f76ed1c8a65892d98dc2913027c9acdb219d18f3a441cfa427a32861af9"}, + {file = "rapidfuzz-1.8.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:71e217fd30901214cc96c0c15057278bafb7072aa9b2be4c97459c1fedf3e731"}, + {file = "rapidfuzz-1.8.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d579dd447b8e851462e79054b68f94b66b09df8b3abb2aa5ca07fe00912ef5e8"}, + {file = "rapidfuzz-1.8.0-cp310-cp310-win32.whl", hash = "sha256:5808064555273496dcd594d659bd28ee8d399149dd31575321034424455dc955"}, + {file = "rapidfuzz-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:798fef1671ca66c78b47802228e9583f7ab32b99bdfe3984ebb1f96e93e38b5f"}, + {file = "rapidfuzz-1.8.0-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:c9e0ed210831f5c73533bf11099ea7897db491e76c3443bef281d9c1c67d7f3a"}, + {file = "rapidfuzz-1.8.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:c819bb19eb615a31ddc9cb8248a285bf04f58158b53ce096451178631f99b652"}, + {file = "rapidfuzz-1.8.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:942ee45564f28ef70320d1229f02dc998bd93e3519c1f3a80f33ce144b51039c"}, + {file = "rapidfuzz-1.8.0-cp35-cp35m-win32.whl", hash = "sha256:7e6ae2e5a3bc9acc51e118f25d32b8efcd431c5d8deb408336dd2ed0f21d087c"}, + {file = "rapidfuzz-1.8.0-cp35-cp35m-win_amd64.whl", hash = "sha256:98901fba67c89ad2506f3946642cf6eb8f489592fb7eb307ebdf8bdb0c4e97f9"}, + {file = "rapidfuzz-1.8.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:705e1686f406a0c77ef323cdb7369b7cf9e68f2abfcb83ff5f1e0a5b21f5a534"}, + {file = "rapidfuzz-1.8.0-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:da0c5fe5fdbbd74206c1778af6b8c5ff8dfbe2dd04ae12bbe96642b358acefce"}, + {file = "rapidfuzz-1.8.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:535253bc9224215131ae450aad6c9f7ef1b24f15c685045eab2b52511268bd06"}, + {file = "rapidfuzz-1.8.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acdad83f07d886705fce164b0d1f4e3b56788a205602ed3a7fc8b10ceaf05fbf"}, + {file = "rapidfuzz-1.8.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35097f649831f8375d6c65a237deccac3aceb573aa7fae1e5d3fa942e89de1c8"}, + {file = "rapidfuzz-1.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6f4db142e5b4b44314166a90e11603220db659bd2f9c23dd5db402c13eac8eb7"}, + {file = "rapidfuzz-1.8.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:19a3f55f27411d68360540484874beda0b428b062596d5f0f141663ef0738bfd"}, + {file = "rapidfuzz-1.8.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:22b4c1a7f6fe29bd8dae49f7d5ab085dc42c3964f1a78b6dca22fdf83b5c9bfa"}, + {file = "rapidfuzz-1.8.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8bfb2fbc147904b78d5c510ee75dc8704b606e956df23f33a9e89abc03f45c3"}, + {file = "rapidfuzz-1.8.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6dc5111ebfed2c4f2e4d120a9b280ea13ea4fbb60b6915dd239817b4fc092ed"}, + {file = "rapidfuzz-1.8.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:db5ee2457d97cb967ffe08446a8c595c03fe747fdc2e145266713f9c516d1c4a"}, + {file = "rapidfuzz-1.8.0-cp37-cp37m-win32.whl", hash = "sha256:12c1b78cc15fc26f555a4bf66088d5afb6354b5a5aa149a123f01a15af6c411b"}, + {file = "rapidfuzz-1.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:693e9579048d8db4ff020715dd6f25aa315fd6445bc94e7400d7a94a227dad27"}, + {file = "rapidfuzz-1.8.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b4fe19df3edcf7de359448b872aec08e6592b4ca2d3df4d8ee57b5812d68bebf"}, + {file = "rapidfuzz-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f3670b9df0e1f479637cad1577afca7766a02775dc08c14837cf495c82861d7c"}, + {file = "rapidfuzz-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:61d118f36eb942649b0db344f7b7a19ad7e9b5749d831788187eb03b57ce1bfa"}, + {file = "rapidfuzz-1.8.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:fce3a2c8a1d10da12aff4a0d367624e8ae9e15c1b84a5144843681d39be0c355"}, + {file = "rapidfuzz-1.8.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1577ef26e3647ccc4cc9754c34ffaa731639779f4d7779e91a761c72adac093e"}, + {file = "rapidfuzz-1.8.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fec9b7e60fde51990c3b48fc1aa9dba9ac3acaf78f623dbb645a6fe21a9654e"}, + {file = "rapidfuzz-1.8.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b954469d93858bc8b48129bc63fd644382a4df5f3fb1b4b290f48eac1d00a2da"}, + {file = "rapidfuzz-1.8.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:190ba709069a7e5a6b39b7c8bc413a08cfa7f1f4defec5d974c4128b510e0234"}, + {file = "rapidfuzz-1.8.0-cp38-cp38-win32.whl", hash = "sha256:97b2d13d6323649b43d1b113681e4013ba230bd6e9827cc832dcebee447d7250"}, + {file = "rapidfuzz-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:81c3091209b75f6611efe2af18834180946d4ce28f41ca8d44fce816187840d2"}, + {file = "rapidfuzz-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d610afa33e92aa0481a514ffda3ec51ca5df3c684c1c1c795307589c62025931"}, + {file = "rapidfuzz-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d976f33ca6b5fabbb095c0a662f5b86baf706184fc24c7f125d4ddb54b8bf036"}, + {file = "rapidfuzz-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0f5ca7bca2af598d4ddcf5b93b64b50654a9ff684e6f18d865f6e13fee442b3e"}, + {file = "rapidfuzz-1.8.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2aac5ea6b0306dcd28a6d1a89d35ed2c6ac426f2673ee1b92cf3f1d0fd5cd"}, + {file = "rapidfuzz-1.8.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f145c9831c0454a696a3136a6380ea4e01434e9cc2f2bc10d032864c16d1d0e5"}, + {file = "rapidfuzz-1.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4ce53291575b56c9d45add73ea013f43bafcea55eee9d5139aa759918d7685f"}, + {file = "rapidfuzz-1.8.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de5773a39c00a0f23cfc5da9e0e5fd0fb512b0ebe23dc7289a38e1f9a4b5cefc"}, + {file = "rapidfuzz-1.8.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87a802e55792bfbe192e2d557f38867dbe3671b49b3d5ecd873859c7460746ba"}, + {file = "rapidfuzz-1.8.0-cp39-cp39-win32.whl", hash = "sha256:9391abf1121df831316222f28cea37397a0f72bd7978f3be6e7da29a7821e4e5"}, + {file = "rapidfuzz-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:9eeca1b436042b5523dcf314f5822b1131597898c1d967f140d1917541a8a3d1"}, + {file = "rapidfuzz-1.8.0-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:a01f2495aca479b49d3b3a8863d6ba9bea2043447a1ced74ae5ec5270059cbc1"}, + {file = "rapidfuzz-1.8.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:b7d4b1a5d16817f8cdb34365c7b58ae22d5cf1b3207720bb2fa0b55968bdb034"}, + {file = "rapidfuzz-1.8.0-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c738d0d7f1744646d48d19b4c775926082bcefebd2460f45ca383a0e882f5672"}, + {file = "rapidfuzz-1.8.0-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0fb9c6078c17c12b52e66b7d0a2a1674f6bbbdc6a76e454c8479b95147018123"}, + {file = "rapidfuzz-1.8.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:1482b385d83670eb069577c9667f72b41eec4f005aee32f1a4ff4e71e88afde2"}, + {file = "rapidfuzz-1.8.0.tar.gz", hash = "sha256:83fff37acf0367314879231264169dcbc5e7de969a94f4b82055d06a7fddab9a"}, ] redis = [ {file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"}, diff --git a/pyproject.toml b/pyproject.toml index e227ffaa6..563bf4a27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ flake8-isort = "~=4.0" pep8-naming = "~=0.9" pre-commit = "~=2.1" taskipy = "~=1.7.0" -pip-licenses = "~=3.5.2" +pip-licenses = "~=3.5.3" python-dotenv = "~=0.17.1" pytest = "~=6.2.4" pytest-cov = "~=2.12.1" -- cgit v1.2.3 From b912cc7c7e3c52e502ec7c558975dcf558a1b418 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 22 Oct 2021 13:08:13 +0000 Subject: Modlog: explicitly write thread names It seems like there could be some caching issue with threads causing to appear as deleted channels. Beside, we also want to keep the name of deleted threads around. --- bot/exts/moderation/modlog.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 9d1ae6853..c6752d0f9 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -800,7 +800,10 @@ class ModLog(Cog, name="ModLog"): icon, colour, f"Thread {action}", - f"Thread {after.mention} (`{after.id}`) from {after.parent.mention} (`{after.parent.id}`) was {action}" + ( + f"Thread {after.mention} ({after.name}, `{after.id}`) from {after.parent.mention} " + f"(`{after.parent.id}`) was {action}" + ) ) @Cog.listener() @@ -810,7 +813,10 @@ class ModLog(Cog, name="ModLog"): Icons.hash_red, Colours.soft_red, "Thread deleted", - f"Thread {thread.mention} (`{thread.id}`) from {thread.parent.mention} (`{thread.parent.id}`) deleted" + ( + f"Thread {thread.mention} ({thread.name}, `{thread.id}`) from {thread.parent.mention} " + f"(`{thread.parent.id}`) deleted" + ) ) @Cog.listener() @@ -825,7 +831,10 @@ class ModLog(Cog, name="ModLog"): Icons.hash_green, Colours.soft_green, "Thread created", - f"Thread {thread.mention} (`{thread.id}`) from {thread.parent.mention} (`{thread.parent.id}`) created" + ( + f"Thread {thread.mention} ({thread.name}, `{thread.id}`) from {thread.parent.mention} " + f"(`{thread.parent.id}`) created" + ) ) @Cog.listener() -- cgit v1.2.3 From 913b1d5644b57fd900474b8d9bc271e24ea729de Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Fri, 22 Oct 2021 21:52:09 +0300 Subject: Move to timezone aware datetimes (#1895) * Move to timezone aware datetimes With the shift of the discord.py library to timezone aware datetimes, this commit changes datetimes throughout the bot to be in the UTC timezone accordingly. This has several advantages: - There's no need to discard the TZ every time the datetime of a Discord object is fetched. - Using TZ aware datetimes reduces the likelihood of silently adding bugs into the codebase (can't compare an aware datetime with a naive one). - Our DB already stores datetimes in UTC, but we've been discarding the TZ so far whenever we read from it. Specific places in the codebase continue using naive datetimes, mainly for UI purposes (for examples embed footers use naive datetimes to display local time). * Improve ISODateTime converter documentation Co-authored-by: Kieran Siek --- bot/converters.py | 19 ++++++----- bot/exts/filters/antispam.py | 15 ++++----- bot/exts/filters/filtering.py | 19 ++++++----- bot/exts/fun/off_topic_names.py | 7 ++-- bot/exts/moderation/defcon.py | 8 +++-- bot/exts/moderation/infraction/_scheduler.py | 12 +++---- bot/exts/moderation/infraction/management.py | 2 +- bot/exts/moderation/modlog.py | 8 ++--- bot/exts/moderation/modpings.py | 5 +-- bot/exts/moderation/voice_gate.py | 6 ++-- bot/exts/recruitment/talentpool/_review.py | 7 ++-- bot/exts/utils/internal.py | 6 ++-- bot/exts/utils/ping.py | 5 ++- bot/exts/utils/reminders.py | 10 +++--- bot/monkey_patches.py | 7 ++-- bot/utils/checks.py | 3 +- bot/utils/time.py | 19 ++++++----- tests/bot/test_converters.py | 50 ++++++++++++++-------------- tests/bot/utils/test_time.py | 27 +++++++++------ 19 files changed, 122 insertions(+), 113 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index dd02f6ae6..f50acb9c6 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -2,7 +2,7 @@ from __future__ import annotations import re import typing as t -from datetime import datetime +from datetime import datetime, timezone from ssl import CertificateError import dateutil.parser @@ -11,7 +11,7 @@ import discord from aiohttp import ClientConnectorError from dateutil.relativedelta import relativedelta from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, MemberConverter, UserConverter -from discord.utils import DISCORD_EPOCH, escape_markdown, snowflake_time +from discord.utils import escape_markdown, snowflake_time from bot import exts from bot.api import ResponseCodeError @@ -28,7 +28,7 @@ if t.TYPE_CHECKING: log = get_logger(__name__) -DISCORD_EPOCH_DT = datetime.utcfromtimestamp(DISCORD_EPOCH / 1000) +DISCORD_EPOCH_DT = snowflake_time(0) RE_USER_MENTION = re.compile(r"<@!?([0-9]+)>$") @@ -273,14 +273,14 @@ class Snowflake(IDConverter): snowflake = int(arg) try: - time = snowflake_time(snowflake).replace(tzinfo=None) + time = snowflake_time(snowflake) except (OverflowError, OSError) as e: # Not sure if this can ever even happen, but let's be safe. raise BadArgument(f"{error}: {e}") if time < DISCORD_EPOCH_DT: raise BadArgument(f"{error}: timestamp is before the Discord epoch.") - elif (datetime.utcnow() - time).days < -1: + elif (datetime.now(timezone.utc) - time).days < -1: raise BadArgument(f"{error}: timestamp is too far into the future.") return snowflake @@ -387,7 +387,7 @@ class Duration(DurationDelta): The converter supports the same symbols for each unit of time as its parent class. """ delta = await super().convert(ctx, duration) - now = datetime.utcnow() + now = datetime.now(timezone.utc) try: return now + delta @@ -443,8 +443,8 @@ class ISODateTime(Converter): The converter is flexible in the formats it accepts, as it uses the `isoparse` method of `dateutil.parser`. In general, it accepts datetime strings that start with a date, optionally followed by a time. Specifying a timezone offset in the datetime string is - supported, but the `datetime` object will be converted to UTC and will be returned without - `tzinfo` as a timezone-unaware `datetime` object. + supported, but the `datetime` object will be converted to UTC. If no timezone is specified, the datetime will + be assumed to be in UTC already. In all cases, the returned object will have the UTC timezone. See: https://dateutil.readthedocs.io/en/stable/parser.html#dateutil.parser.isoparse @@ -470,7 +470,8 @@ class ISODateTime(Converter): if dt.tzinfo: dt = dt.astimezone(dateutil.tz.UTC) - dt = dt.replace(tzinfo=None) + else: # Without a timezone, assume it represents UTC. + dt = dt.replace(tzinfo=dateutil.tz.UTC) return dt diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 37ac70508..ddfd11231 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -2,11 +2,12 @@ import asyncio from collections import defaultdict from collections.abc import Mapping from dataclasses import dataclass, field -from datetime import datetime, timedelta +from datetime import timedelta from itertools import takewhile from operator import attrgetter, itemgetter from typing import Dict, Iterable, List, Set +import arrow from discord import Colour, Member, Message, NotFound, Object, TextChannel from discord.ext.commands import Cog @@ -177,21 +178,17 @@ class AntiSpam(Cog): self.cache.append(message) - earliest_relevant_at = datetime.utcnow() - timedelta(seconds=self.max_interval) - relevant_messages = list( - takewhile(lambda msg: msg.created_at.replace(tzinfo=None) > earliest_relevant_at, self.cache) - ) + earliest_relevant_at = arrow.utcnow() - timedelta(seconds=self.max_interval) + relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, self.cache)) for rule_name in AntiSpamConfig.rules: rule_config = AntiSpamConfig.rules[rule_name] rule_function = RULE_FUNCTION_MAPPING[rule_name] # Create a list of messages that were sent in the interval that the rule cares about. - latest_interesting_stamp = datetime.utcnow() - timedelta(seconds=rule_config['interval']) + latest_interesting_stamp = arrow.utcnow() - timedelta(seconds=rule_config['interval']) messages_for_rule = list( - takewhile( - lambda msg: msg.created_at.replace(tzinfo=None) > latest_interesting_stamp, relevant_messages - ) + takewhile(lambda msg: msg.created_at > latest_interesting_stamp, relevant_messages) ) result = await rule_function(message, messages_for_rule, rule_config) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index a151db1f0..6df78f550 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -1,9 +1,10 @@ import asyncio import re -from datetime import datetime, timedelta +from datetime import timedelta from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple, Union -import dateutil +import arrow +import dateutil.parser import discord.errors import regex from async_rediscache import RedisCache @@ -192,8 +193,8 @@ class Filtering(Cog): async def check_send_alert(self, member: Member) -> bool: """When there is less than 3 days after last alert, return `False`, otherwise `True`.""" if last_alert := await self.name_alerts.get(member.id): - last_alert = datetime.utcfromtimestamp(last_alert) - if datetime.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert: + last_alert = arrow.get(last_alert) + if arrow.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert: log.trace(f"Last alert was too recent for {member}'s nickname.") return False @@ -227,7 +228,7 @@ class Filtering(Cog): ) # Update time when alert sent - await self.name_alerts.set(member.id, datetime.utcnow().timestamp()) + await self.name_alerts.set(member.id, arrow.utcnow().timestamp()) async def filter_eval(self, result: str, msg: Message) -> bool: """ @@ -603,7 +604,7 @@ class Filtering(Cog): def schedule_msg_delete(self, msg: dict) -> None: """Delete an offensive message once its deletion date is reached.""" - delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) + delete_at = dateutil.parser.isoparse(msg['delete_date']) self.scheduler.schedule_at(delete_at, msg['id'], self.delete_offensive_msg(msg)) async def reschedule_offensive_msg_deletion(self) -> None: @@ -611,17 +612,17 @@ class Filtering(Cog): await self.bot.wait_until_ready() response = await self.bot.api_client.get('bot/offensive-messages',) - now = datetime.utcnow() + now = arrow.utcnow() for msg in response: - delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) + delete_at = dateutil.parser.isoparse(msg['delete_date']) if delete_at < now: await self.delete_offensive_msg(msg) else: self.schedule_msg_delete(msg) - async def delete_offensive_msg(self, msg: Mapping[str, str]) -> None: + async def delete_offensive_msg(self, msg: Mapping[str, int]) -> None: """Delete an offensive message, and then delete it from the db.""" try: channel = self.bot.get_channel(msg['channel_id']) diff --git a/bot/exts/fun/off_topic_names.py b/bot/exts/fun/off_topic_names.py index 427667c66..7df1d172d 100644 --- a/bot/exts/fun/off_topic_names.py +++ b/bot/exts/fun/off_topic_names.py @@ -1,6 +1,7 @@ import difflib -from datetime import datetime, timedelta +from datetime import timedelta +import arrow from discord import Colour, Embed from discord.ext.commands import Cog, Context, group, has_any_role from discord.utils import sleep_until @@ -22,9 +23,9 @@ async def update_names(bot: Bot) -> None: while True: # Since we truncate the compute timedelta to seconds, we add one second to ensure # 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) + today_at_midnight = arrow.utcnow().replace(microsecond=0, second=0, minute=0, hour=0) next_midnight = today_at_midnight + timedelta(days=1) - await sleep_until(next_midnight) + await sleep_until(next_midnight.datetime) try: channel_0_name, channel_1_name, channel_2_name = await bot.api_client.get( diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 80ba10112..822a87b61 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -4,6 +4,7 @@ from datetime import datetime from enum import Enum from typing import Optional, Union +import arrow from aioredis import RedisError from async_rediscache import RedisCache from dateutil.relativedelta import relativedelta @@ -109,9 +110,9 @@ class Defcon(Cog): async def on_member_join(self, member: Member) -> None: """Check newly joining users to see if they meet the account age threshold.""" if self.threshold: - now = datetime.utcnow() + now = arrow.utcnow() - if now - member.created_at.replace(tzinfo=None) < relativedelta_to_timedelta(self.threshold): + if now - member.created_at < relativedelta_to_timedelta(self.threshold): log.info(f"Rejecting user {member}: Account is too new") message_sent = False @@ -254,7 +255,8 @@ class Defcon(Cog): expiry_message = "" if expiry: - expiry_message = f" for the next {humanize_delta(relativedelta(expiry, datetime.utcnow()), max_units=2)}" + activity_duration = relativedelta(expiry, arrow.utcnow().datetime) + expiry_message = f" for the next {humanize_delta(activity_duration, max_units=2)}" if self.threshold: channel_message = ( diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index d4e96b10b..74a987808 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -1,9 +1,9 @@ import textwrap import typing as t from abc import abstractmethod -from datetime import datetime from gettext import ngettext +import arrow import dateutil.parser import discord from discord.ext.commands import Context @@ -67,7 +67,7 @@ class InfractionScheduler: # We make sure to fire this if to_schedule: next_reschedule_point = max( - dateutil.parser.isoparse(infr["expires_at"]).replace(tzinfo=None) for infr in to_schedule + dateutil.parser.isoparse(infr["expires_at"]) for infr in to_schedule ) log.trace("Will reschedule remaining infractions at %s", next_reschedule_point) @@ -83,8 +83,8 @@ class InfractionScheduler: """Reapply an infraction if it's still active or deactivate it if less than 60 sec left.""" if infraction["expires_at"] is not None: # Calculate the time remaining, in seconds, for the mute. - expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) - delta = (expiry - datetime.utcnow()).total_seconds() + expiry = dateutil.parser.isoparse(infraction["expires_at"]) + delta = (expiry - arrow.utcnow()).total_seconds() else: # If the infraction is permanent, it is not possible to get the time remaining. delta = None @@ -382,7 +382,7 @@ class InfractionScheduler: log.info(f"Marking infraction #{id_} as inactive (expired).") - expiry = dateutil.parser.isoparse(expiry).replace(tzinfo=None) if expiry else None + expiry = dateutil.parser.isoparse(expiry) if expiry else None created = time.format_infraction_with_duration(inserted_at, expiry) log_content = None @@ -503,5 +503,5 @@ class InfractionScheduler: At the time of expiration, the infraction is marked as inactive on the website and the expiration task is cancelled. """ - expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) + expiry = dateutil.parser.isoparse(infraction["expires_at"]) self.scheduler.schedule_at(expiry, infraction["id"], self.deactivate_infraction(infraction)) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index b1c8b64dc..96c818c47 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -315,7 +315,7 @@ class ModManagement(commands.Cog): duration = "*Permanent*" else: date_from = datetime.fromtimestamp(float(time.DISCORD_TIMESTAMP_REGEX.match(created).group(1))) - date_to = dateutil.parser.isoparse(expires_at).replace(tzinfo=None) + date_to = dateutil.parser.isoparse(expires_at) duration = humanize_delta(relativedelta(date_to, date_from)) lines = textwrap.dedent(f""" diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index c6752d0f9..6fcf43d8a 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -2,7 +2,7 @@ import asyncio import difflib import itertools import typing as t -from datetime import datetime +from datetime import datetime, timezone from itertools import zip_longest import discord @@ -58,7 +58,7 @@ class ModLog(Cog, name="ModLog"): 'bot/deleted-messages', json={ 'actor': actor_id, - 'creation': datetime.utcnow().isoformat(), + 'creation': datetime.now(timezone.utc).isoformat(), 'deletedmessage_set': [ { 'id': message.id, @@ -404,8 +404,8 @@ class ModLog(Cog, name="ModLog"): if member.guild.id != GuildConstant.id: return - now = datetime.utcnow() - difference = abs(relativedelta(now, member.created_at.replace(tzinfo=None))) + now = datetime.now(timezone.utc) + difference = abs(relativedelta(now, member.created_at)) message = format_user(member) + "\n\n**Account age:** " + humanize_delta(difference) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index a7ccb8162..f67d8f662 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -1,5 +1,6 @@ import datetime +import arrow from async_rediscache import RedisCache from dateutil.parser import isoparse from discord import Embed, Member @@ -57,7 +58,7 @@ class ModPings(Cog): if mod.id not in pings_off: await self.reapply_role(mod) else: - expiry = isoparse(pings_off[mod.id]).replace(tzinfo=None) + expiry = isoparse(pings_off[mod.id]) self._role_scheduler.schedule_at(expiry, mod.id, self.reapply_role(mod)) async def reapply_role(self, mod: Member) -> None: @@ -92,7 +93,7 @@ class ModPings(Cog): The duration cannot be longer than 30 days. """ - delta = duration - datetime.datetime.utcnow() + delta = duration - arrow.utcnow() if delta > datetime.timedelta(days=30): await ctx.send(":x: Cannot remove the role for longer than 30 days.") return diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 8fdc7c76b..31799ec73 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -1,7 +1,8 @@ import asyncio from contextlib import suppress -from datetime import datetime, timedelta +from datetime import timedelta +import arrow import discord from async_rediscache import RedisCache from discord import Colour, Member, VoiceState @@ -166,8 +167,7 @@ class VoiceGate(Cog): checks = { "joined_at": ( - ctx.author.joined_at.replace(tzinfo=None) > datetime.utcnow() - - timedelta(days=GateConf.minimum_days_member) + ctx.author.joined_at > arrow.utcnow() - timedelta(days=GateConf.minimum_days_member) ), "total_messages": data["total_messages"] < GateConf.minimum_messages, "voice_banned": data["voice_banned"], diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index dcf73c2cb..d880c524c 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -8,6 +8,7 @@ from collections import Counter from datetime import datetime, timedelta from typing import List, Optional, Union +import arrow from dateutil.parser import isoparse from discord import Embed, Emoji, Member, Message, NoMoreItems, PartialMessage, TextChannel from discord.ext.commands import Context @@ -68,11 +69,11 @@ class Reviewer: log.trace(f"Scheduling review of user with ID {user_id}") user_data = self._pool.cache.get(user_id) - inserted_at = isoparse(user_data['inserted_at']).replace(tzinfo=None) + inserted_at = isoparse(user_data['inserted_at']) review_at = inserted_at + timedelta(days=MAX_DAYS_IN_POOL) # If it's over a day overdue, it's probably an old nomination and shouldn't be automatically reviewed. - if datetime.utcnow() - review_at < timedelta(days=1): + if arrow.utcnow() - review_at < timedelta(days=1): self._review_scheduler.schedule_at(review_at, user_id, self.post_review(user_id, update_database=True)) async def post_review(self, user_id: int, update_database: bool) -> None: @@ -347,7 +348,7 @@ class Reviewer: nomination_times = f"{num_entries} times" if num_entries > 1 else "once" rejection_times = f"{len(history)} times" if len(history) > 1 else "once" - end_time = time_since(isoparse(history[0]['ended_at']).replace(tzinfo=None)) + end_time = time_since(isoparse(history[0]['ended_at'])) review = ( f"They were nominated **{nomination_times}** before" diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index 96664929b..165b5917d 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -5,10 +5,10 @@ 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 arrow import discord from discord.ext.commands import Cog, Context, group, has_any_role, is_owner @@ -29,7 +29,7 @@ class Internal(Cog): self.ln = 0 self.stdout = StringIO() - self.socket_since = datetime.utcnow() + self.socket_since = arrow.utcnow() self.socket_event_total = 0 self.socket_events = Counter() @@ -236,7 +236,7 @@ async def func(): # (None,) -> Any @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() + running_s = (arrow.utcnow() - self.socket_since).total_seconds() per_s = self.socket_event_total / running_s diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index 43d371d87..9fb5b7b8f 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -1,5 +1,4 @@ -from datetime import datetime - +import arrow from aiohttp import client_exceptions from discord import Embed from discord.ext import commands @@ -32,7 +31,7 @@ class Latency(commands.Cog): """ # datetime.datetime objects do not have the "milliseconds" attribute. # It must be converted to seconds before converting to milliseconds. - bot_ping = (datetime.utcnow() - ctx.message.created_at.replace(tzinfo=None)).total_seconds() * 1000 + bot_ping = (arrow.utcnow() - ctx.message.created_at).total_seconds() * 1000 if bot_ping <= 0: bot_ping = "Your clock is out of sync, could not calculate ping." else: diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 3cb9307a9..3dbcc4513 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -1,7 +1,7 @@ import random import textwrap import typing as t -from datetime import datetime +from datetime import datetime, timezone from operator import itemgetter import discord @@ -52,14 +52,14 @@ class Reminders(Cog): params={'active': 'true'} ) - now = datetime.utcnow() + now = datetime.now(timezone.utc) for reminder in response: is_valid, *_ = self.ensure_valid_reminder(reminder) if not is_valid: continue - remind_at = isoparse(reminder['expiration']).replace(tzinfo=None) + remind_at = isoparse(reminder['expiration']) # If the reminder is already overdue ... if remind_at < now: @@ -144,7 +144,7 @@ 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_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) + reminder_datetime = isoparse(reminder['expiration']) self.scheduler.schedule_at(reminder_datetime, reminder["id"], self.send_reminder(reminder)) async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict: @@ -333,7 +333,7 @@ class Reminders(Cog): for content, remind_at, id_, mentions in reminders: # Parse and humanize the time, make it pretty :D - remind_datetime = isoparse(remind_at).replace(tzinfo=None) + remind_datetime = isoparse(remind_at) time = discord_timestamp(remind_datetime, TimestampFormats.RELATIVE) mentions = ", ".join([ diff --git a/bot/monkey_patches.py b/bot/monkey_patches.py index e56a19da2..23482f7c3 100644 --- a/bot/monkey_patches.py +++ b/bot/monkey_patches.py @@ -1,5 +1,6 @@ -from datetime import datetime, timedelta +from datetime import timedelta +import arrow from discord import Forbidden, http from discord.ext import commands @@ -38,13 +39,13 @@ def patch_typing() -> None: async def honeybadger_type(self, channel_id: int) -> None: # noqa: ANN001 nonlocal last_403 - if last_403 and (datetime.utcnow() - last_403) < timedelta(minutes=5): + if last_403 and (arrow.utcnow() - last_403) < timedelta(minutes=5): log.warning("Not sending typing event, we got a 403 less than 5 minutes ago.") return try: await original(self, channel_id) except Forbidden: - last_403 = datetime.utcnow() + last_403 = arrow.utcnow() log.warning("Got a 403 from typing event!") pass diff --git a/bot/utils/checks.py b/bot/utils/checks.py index e7f2cfbda..188285684 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -1,4 +1,3 @@ -import datetime from typing import Callable, Container, Iterable, Optional, Union from discord.ext.commands import ( @@ -137,7 +136,7 @@ def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketTy return # cooldown logic, taken from discord.py internals - current = ctx.message.created_at.replace(tzinfo=datetime.timezone.utc).timestamp() + current = ctx.message.created_at.timestamp() bucket = buckets.get_bucket(ctx.message) retry_after = bucket.update_rate_limit(current) if retry_after: diff --git a/bot/utils/time.py b/bot/utils/time.py index 8cf7d623b..eaa9b72e9 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -3,6 +3,7 @@ import re from enum import Enum from typing import Optional, Union +import arrow import dateutil.parser from dateutil.relativedelta import relativedelta @@ -67,9 +68,9 @@ def discord_timestamp(timestamp: ValidTimestamp, format: TimestampFormats = Time # Convert each possible timestamp class to an integer. if isinstance(timestamp, datetime.datetime): - timestamp = (timestamp.replace(tzinfo=None) - datetime.datetime.utcfromtimestamp(0)).total_seconds() + timestamp = (timestamp - arrow.get(0)).total_seconds() elif isinstance(timestamp, datetime.date): - timestamp = (timestamp - datetime.date.fromtimestamp(0)).total_seconds() + timestamp = (timestamp - arrow.get(0)).total_seconds() elif isinstance(timestamp, datetime.timedelta): timestamp = timestamp.total_seconds() elif isinstance(timestamp, relativedelta): @@ -124,7 +125,7 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: def get_time_delta(time_string: str) -> str: """Returns the time in human-readable time delta format.""" - date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None) + date_time = dateutil.parser.isoparse(time_string) time_delta = time_since(date_time) return time_delta @@ -157,7 +158,7 @@ def parse_duration_string(duration: str) -> Optional[relativedelta]: def relativedelta_to_timedelta(delta: relativedelta) -> datetime.timedelta: """Converts a relativedelta object to a timedelta object.""" - utcnow = datetime.datetime.utcnow() + utcnow = arrow.utcnow() return utcnow + delta - utcnow @@ -196,8 +197,8 @@ def format_infraction_with_duration( date_to_formatted = format_infraction(date_to) - date_from = date_from or datetime.datetime.utcnow() - date_to = dateutil.parser.isoparse(date_to).replace(tzinfo=None, microsecond=0) + date_from = date_from or datetime.datetime.now(datetime.timezone.utc) + date_to = dateutil.parser.isoparse(date_to).replace(microsecond=0) delta = relativedelta(date_to, date_from) if absolute: @@ -215,15 +216,15 @@ def until_expiration( """ Get the remaining time until infraction's expiration, in a discord timestamp. - Returns a human-readable version of the remaining duration between datetime.utcnow() and an expiry. + Returns a human-readable version of the remaining duration between arrow.utcnow() and an expiry. Similar to time_since, except that this function doesn't error on a null input and return null if the expiry is in the paste """ if not expiry: return None - now = datetime.datetime.utcnow() - since = dateutil.parser.isoparse(expiry).replace(tzinfo=None, microsecond=0) + now = arrow.utcnow() + since = dateutil.parser.isoparse(expiry).replace(microsecond=0) if since < now: return None diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index ef6c8e19e..988b3857b 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -1,6 +1,6 @@ -import datetime import re import unittest +from datetime import MAXYEAR, datetime, timezone from unittest.mock import MagicMock, patch from dateutil.relativedelta import relativedelta @@ -17,7 +17,7 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase): cls.context = MagicMock cls.context.author = 'bob' - cls.fixed_utc_now = datetime.datetime.fromisoformat('2019-01-01T00:00:00') + cls.fixed_utc_now = datetime.fromisoformat('2019-01-01T00:00:00+00:00') async def test_tag_name_converter_for_invalid(self): """TagNameConverter should raise the correct exception for invalid tag names.""" @@ -111,7 +111,7 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase): expected_datetime = self.fixed_utc_now + relativedelta(**duration_dict) with patch('bot.converters.datetime') as mock_datetime: - mock_datetime.utcnow.return_value = self.fixed_utc_now + mock_datetime.now.return_value = self.fixed_utc_now with self.subTest(duration=duration, duration_dict=duration_dict): converted_datetime = await converter.convert(self.context, duration) @@ -157,52 +157,53 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase): async def test_duration_converter_out_of_range(self, mock_datetime): """Duration converter should raise BadArgument if datetime raises a ValueError.""" mock_datetime.__add__.side_effect = ValueError - mock_datetime.utcnow.return_value = mock_datetime + mock_datetime.now.return_value = mock_datetime - duration = f"{datetime.MAXYEAR}y" + duration = f"{MAXYEAR}y" exception_message = f"`{duration}` results in a datetime outside the supported range." with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): await Duration().convert(self.context, duration) async def test_isodatetime_converter_for_valid(self): """ISODateTime converter returns correct datetime for valid datetime string.""" + utc = timezone.utc test_values = ( # `YYYY-mm-ddTHH:MM:SSZ` | `YYYY-mm-dd HH:MM:SSZ` - ('2019-09-02T02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 02:03:05Z', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T02:03:05Z', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), + ('2019-09-02 02:03:05Z', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), # `YYYY-mm-ddTHH:MM:SS±HH:MM` | `YYYY-mm-dd HH:MM:SS±HH:MM` - ('2019-09-02T03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 03:18:05+01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02T00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 00:48:05-01:15', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T03:18:05+01:15', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), + ('2019-09-02 03:18:05+01:15', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), + ('2019-09-02T00:48:05-01:15', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), + ('2019-09-02 00:48:05-01:15', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), # `YYYY-mm-ddTHH:MM:SS±HHMM` | `YYYY-mm-dd HH:MM:SS±HHMM` - ('2019-09-02T03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 03:18:05+0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02T00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 00:48:05-0115', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T03:18:05+0115', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), + ('2019-09-02 03:18:05+0115', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), + ('2019-09-02T00:48:05-0115', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), + ('2019-09-02 00:48:05-0115', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), # `YYYY-mm-ddTHH:MM:SS±HH` | `YYYY-mm-dd HH:MM:SS±HH` - ('2019-09-02 03:03:05+01', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02T01:03:05-01', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02 03:03:05+01', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), + ('2019-09-02T01:03:05-01', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), # `YYYY-mm-ddTHH:MM:SS` | `YYYY-mm-dd HH:MM:SS` - ('2019-09-02T02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), - ('2019-09-02 02:03:05', datetime.datetime(2019, 9, 2, 2, 3, 5)), + ('2019-09-02T02:03:05', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), + ('2019-09-02 02:03:05', datetime(2019, 9, 2, 2, 3, 5, tzinfo=utc)), # `YYYY-mm-ddTHH:MM` | `YYYY-mm-dd HH:MM` - ('2019-11-12T09:15', datetime.datetime(2019, 11, 12, 9, 15)), - ('2019-11-12 09:15', datetime.datetime(2019, 11, 12, 9, 15)), + ('2019-11-12T09:15', datetime(2019, 11, 12, 9, 15, tzinfo=utc)), + ('2019-11-12 09:15', datetime(2019, 11, 12, 9, 15, tzinfo=utc)), # `YYYY-mm-dd` - ('2019-04-01', datetime.datetime(2019, 4, 1)), + ('2019-04-01', datetime(2019, 4, 1, tzinfo=utc)), # `YYYY-mm` - ('2019-02-01', datetime.datetime(2019, 2, 1)), + ('2019-02-01', datetime(2019, 2, 1, tzinfo=utc)), # `YYYY` - ('2025', datetime.datetime(2025, 1, 1)), + ('2025', datetime(2025, 1, 1, tzinfo=utc)), ) converter = ISODateTime() @@ -210,7 +211,6 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase): for datetime_string, expected_dt in test_values: with self.subTest(datetime_string=datetime_string, expected_dt=expected_dt): converted_dt = await converter.convert(self.context, datetime_string) - self.assertIsNone(converted_dt.tzinfo) self.assertEqual(converted_dt, expected_dt) async def test_isodatetime_converter_for_invalid(self): diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 8edffd1c9..a3dcbfc0a 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -72,9 +72,9 @@ class TimeTests(unittest.TestCase): def test_format_infraction_with_duration_custom_units(self): """format_infraction_with_duration should work for custom max_units.""" test_cases = ( - ('3000-12-12T00:01:00Z', datetime(3000, 12, 11, 12, 5, 5), 6, + ('3000-12-12T00:01:00Z', datetime(3000, 12, 11, 12, 5, 5, tzinfo=timezone.utc), 6, ' (11 hours, 55 minutes and 55 seconds)'), - ('3000-11-23T20:09:00Z', datetime(3000, 4, 25, 20, 15), 20, + ('3000-11-23T20:09:00Z', datetime(3000, 4, 25, 20, 15, tzinfo=timezone.utc), 20, ' (6 months, 28 days, 23 hours and 54 minutes)') ) @@ -84,16 +84,21 @@ class TimeTests(unittest.TestCase): def test_format_infraction_with_duration_normal_usage(self): """format_infraction_with_duration should work for normal usage, across various durations.""" + utc = timezone.utc test_cases = ( - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, ' (12 hours and 55 seconds)'), - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, ' (12 hours)'), - ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, ' (1 minute)'), - ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, ' (7 days and 23 hours)'), - ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, ' (6 months and 28 days)'), - ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, ' (5 minutes)'), - ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, ' (1 minute)'), - ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, ' (2 years and 4 months)'), - ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2, + ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5, tzinfo=utc), 2, + ' (12 hours and 55 seconds)'), + ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5, tzinfo=utc), 1, ' (12 hours)'), + ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59, tzinfo=utc), 2, ' (1 minute)'), + ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15, tzinfo=utc), 2, + ' (7 days and 23 hours)'), + ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15, tzinfo=utc), 2, + ' (6 months and 28 days)'), + ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53, tzinfo=utc), 2, ' (5 minutes)'), + ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0, tzinfo=utc), 2, ' (1 minute)'), + ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0, tzinfo=utc), 2, + ' (2 years and 4 months)'), + ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5, tzinfo=utc), 2, ' (9 minutes and 55 seconds)'), (None, datetime(2019, 11, 23, 23, 49, 5), 2, None), ) -- cgit v1.2.3 From bb0018048ea7ec83abe7a34df263aa0ebb29fecd Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 22 Oct 2021 21:45:52 +0100 Subject: Use Arrow.fromtimestamp to get an aware datetime Fixes #1905 Fixes BOT-1P9 datetime.fromtimestamp returned an naive datetime, so when comparing to the aware datetime from dateutil.parser.isoparse, it would raise an error. --- bot/exts/moderation/infraction/management.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 96c818c47..eaaa1e00b 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -1,9 +1,9 @@ import textwrap import typing as t -from datetime import datetime import dateutil.parser import discord +from arrow import Arrow from dateutil.relativedelta import relativedelta from discord.ext import commands from discord.ext.commands import Context @@ -314,7 +314,7 @@ class ModManagement(commands.Cog): if expires_at is None: duration = "*Permanent*" else: - date_from = datetime.fromtimestamp(float(time.DISCORD_TIMESTAMP_REGEX.match(created).group(1))) + date_from = Arrow.fromtimestamp(float(time.DISCORD_TIMESTAMP_REGEX.match(created).group(1))) date_to = dateutil.parser.isoparse(expires_at) duration = humanize_delta(relativedelta(date_to, date_from)) -- cgit v1.2.3 From 22f155e70cf0219d93e9dc17f6f352884ea4eb57 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 22 Oct 2021 21:57:47 +0100 Subject: Use datetime.fromtimestamp so we pass relativedelta a datetime object, not Arrow Fixes #1907 Fixes BOT-1PA --- bot/exts/moderation/infraction/management.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index eaaa1e00b..1cd259a4b 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -1,9 +1,9 @@ import textwrap import typing as t +from datetime import datetime, timezone import dateutil.parser import discord -from arrow import Arrow from dateutil.relativedelta import relativedelta from discord.ext import commands from discord.ext.commands import Context @@ -314,7 +314,10 @@ class ModManagement(commands.Cog): if expires_at is None: duration = "*Permanent*" else: - date_from = Arrow.fromtimestamp(float(time.DISCORD_TIMESTAMP_REGEX.match(created).group(1))) + date_from = datetime.fromtimestamp( + float(time.DISCORD_TIMESTAMP_REGEX.match(created).group(1)), + timezone.utc + ) date_to = dateutil.parser.isoparse(expires_at) duration = humanize_delta(relativedelta(date_to, date_from)) -- cgit v1.2.3 From c504c16f5b438c7c38d60587c0bf5185b3927062 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sun, 24 Oct 2021 21:41:32 +0400 Subject: Unpin All Messages When Moving Help Channels Occasional hiccups in the Discord API would cause unpinning in help channel to sometimes fails. This gets around that by unpinning all messages when making the channel available. Signed-off-by: Hassan Abouelela --- bot/exts/help_channels/_cog.py | 6 ++++++ bot/exts/help_channels/_message.py | 6 +++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 3c6cf7f26..0905cb23d 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -376,6 +376,12 @@ class HelpChannels(commands.Cog): log.trace(f"Moving #{channel} ({channel.id}) to the Available category.") + # Unpin any previously stuck pins + log.trace(f"Looking for pins stuck in #{channel} ({channel.id}).") + for message in await channel.pins(): + await _message.pin_wrapper(message.id, channel, pin=False) + log.debug(f"Removed a stuck pin from #{channel} ({channel.id}). ID: {message.id}") + await _channel.move_to_bottom( channel=channel, category_id=constants.Categories.help_available, diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index a52c67570..241dd606c 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -174,7 +174,7 @@ async def notify(channel: discord.TextChannel, last_notification: t.Optional[Arr async def pin(message: discord.Message) -> None: """Pin an initial question `message` and store it in a cache.""" - if await _pin_wrapper(message.id, message.channel, pin=True): + if await pin_wrapper(message.id, message.channel, pin=True): await _caches.question_messages.set(message.channel.id, message.id) @@ -205,7 +205,7 @@ async def unpin(channel: discord.TextChannel) -> None: if msg_id is None: log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.") else: - await _pin_wrapper(msg_id, channel, pin=False) + await pin_wrapper(msg_id, channel, pin=False) def _match_bot_embed(message: t.Optional[discord.Message], description: str) -> bool: @@ -220,7 +220,7 @@ def _match_bot_embed(message: t.Optional[discord.Message], description: str) -> return message.author == bot.instance.user and bot_msg_desc.strip() == description.strip() -async def _pin_wrapper(msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: +async def pin_wrapper(msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: """ Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. -- cgit v1.2.3 From 6885c6d79d86c5ce8ee93cf9d43d93521e80c3cd Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 25 Oct 2021 16:54:27 +0200 Subject: Infrac: prioritize mod over bot feedback msg --- bot/exts/filters/filtering.py | 2 +- bot/exts/moderation/infraction/_scheduler.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 15c12d27c..fa4b83438 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -354,7 +354,7 @@ class Filtering(Cog): # If the filter reason contains `[autoban]`, we want to indeed ban if "[autoban]" in reason.lower(): # We create a new context from that message and make sure the staffer is the bot - # and the feeback message is sent in #mod-alert + # and the feedback message is sent in #mod-alert context = await self.bot.get_context(msg) context.author = self.bot.user context.channel = self.bot.get_channel(Channels.mod_alerts) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index d4e96b10b..c07b043be 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -175,13 +175,7 @@ class InfractionScheduler: dm_log_text = "\nDM: Sent" end_msg = "" - if infraction["actor"] == self.bot.user.id: - log.trace( - f"Infraction #{id_} actor is bot; including the reason in the confirmation message." - ) - if reason: - end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" - elif is_mod_channel(ctx.channel): + if is_mod_channel(ctx.channel): log.trace(f"Fetching total infraction count for {user}.") infractions = await self.bot.api_client.get( @@ -190,6 +184,12 @@ class InfractionScheduler: ) total = len(infractions) end_msg = f" (#{id_} ; {total} infraction{ngettext('', 's', total)} total)" + elif infraction["actor"] == self.bot.user.id: + log.trace( + f"Infraction #{id_} actor is bot; including the reason in the confirmation message." + ) + if reason: + end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" purge = infraction.get("purge", "") -- cgit v1.2.3 From 4b44480a86ecde68e9f416c9ea8f2b49b1300273 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 25 Oct 2021 17:16:03 +0200 Subject: Filtering: update auto ban message --- bot/exts/filters/filtering.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index fa4b83438..8d55e7ee3 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -42,9 +42,21 @@ ZALGO_RE = regex.compile(rf"[\p{{NONSPACING MARK}}\p{{ENCLOSING MARK}}--[{VARIAT # Other constants. DAYS_BETWEEN_ALERTS = 3 OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) + +# Autoban +LINK_PASSWORD = "https://support.discord.com/hc/en-us/articles/218410947-I-forgot-my-Password-Where-can-I-set-a-new-one" +LINK_2FA = "https://support.discord.com/hc/en-us/articles/219576828-Setting-up-Two-Factor-Authentication" AUTO_BAN_REASON = ( - "Your account seems to be compromised (%s). " - "You're welcome to appeal this ban once you have regained control of your account." + "Your account has been used to send links to a phishing website. You have been automatically banned. " + "If you are not aware of sending them, that means your account has been compromised.\n\n" + + f"Here is a guide from Discord on [how to change your password]({LINK_PASSWORD}).\n\n" + + f"We also highly recommend that you [enable 2 factor authentication on your account]({LINK_2FA}), " + "for heightened security.\n\n" + + "Once you have changed your password, feel free to follow the instructions at the bottom of " + "this message to appeal your ban.""" ) AUTO_BAN_DURATION = timedelta(days=4) @@ -363,7 +375,7 @@ class Filtering(Cog): self.bot.get_command("tempban"), msg.author, datetime.now() + AUTO_BAN_DURATION, - reason=AUTO_BAN_REASON % reason.lower().replace("[autoban]", "").strip() + reason=AUTO_BAN_REASON.strip() ) break # We don't want multiple filters to trigger -- cgit v1.2.3 From 798150eca45e52350773e88d7845a1af913b462f Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 25 Oct 2021 17:25:19 +0200 Subject: Filter list: send warning when autoban trigger is added --- bot/constants.py | 1 + bot/exts/filters/filter_lists.py | 7 +++++++ bot/exts/filters/filtering.py | 4 ++-- config-default.yml | 1 + 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index f704c9e6a..a75a26a9b 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -444,6 +444,7 @@ class Channels(metaclass=YAMLGetter): incidents: int incidents_archive: int mod_alerts: int + mod_tools: int nominations: int nomination_voting: int organisation: int diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py index 4b5200684..b1e07185a 100644 --- a/bot/exts/filters/filter_lists.py +++ b/bot/exts/filters/filter_lists.py @@ -6,6 +6,7 @@ from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group, from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot +from bot.constants import Channels from bot.converters import ValidDiscordServerInvite, ValidFilterListType from bot.log import get_logger from bot.pagination import LinePaginator @@ -100,6 +101,12 @@ class FilterLists(Cog): ) raise + # If it is an autoban trigger we send a warning in #mod-tools + if comment and "[autoban]" in comment: + await self.bot.get_channel(Channels.mod_tools).send( + f":warning: heads-up! The new filter `{content}` (`{comment}`) will automatically ban users." + ) + # Insert the item into the cache self.bot.insert_item_into_filter_list_cache(item) await ctx.message.add_reaction("✅") diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 8d55e7ee3..b20a9c2c9 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -364,7 +364,7 @@ class Filtering(Cog): await self._send_log(filter_name, _filter, msg, stats, reason) # If the filter reason contains `[autoban]`, we want to indeed ban - if "[autoban]" in reason.lower(): + if reason and "[autoban]" in reason.lower(): # We create a new context from that message and make sure the staffer is the bot # and the feedback message is sent in #mod-alert context = await self.bot.get_context(msg) @@ -400,7 +400,7 @@ class Filtering(Cog): ping_everyone = Filter.ping_everyone and _filter.get("ping_everyone", True) # If we are going to autoban, we don't want to ping - if "[autoban]" in reason: + if reason and "[autoban]" in reason: ping_everyone = False eval_msg = "using !eval " if is_eval else "" diff --git a/config-default.yml b/config-default.yml index b61d9c99c..c0e561cca 100644 --- a/config-default.yml +++ b/config-default.yml @@ -207,6 +207,7 @@ guild: incidents_archive: 720668923636351037 mod_alerts: 473092532147060736 mods: &MODS 305126844661760000 + mod_tools: 775413915391098921 nominations: 822920136150745168 nomination_voting: 822853512709931008 organisation: &ORGANISATION 551789653284356126 -- cgit v1.2.3 From 30612bc2f61361ac62a0615181f24366bab3f957 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 25 Oct 2021 19:20:54 +0200 Subject: Filter list: move warning to #mod-meta --- bot/constants.py | 2 +- bot/exts/filters/filter_lists.py | 4 ++-- config-default.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index a75a26a9b..e3846fb3d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -444,7 +444,7 @@ class Channels(metaclass=YAMLGetter): incidents: int incidents_archive: int mod_alerts: int - mod_tools: int + mod_meta: int nominations: int nomination_voting: int organisation: int diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py index b1e07185a..4af76af76 100644 --- a/bot/exts/filters/filter_lists.py +++ b/bot/exts/filters/filter_lists.py @@ -101,9 +101,9 @@ class FilterLists(Cog): ) raise - # If it is an autoban trigger we send a warning in #mod-tools + # If it is an autoban trigger we send a warning in #mod-meta if comment and "[autoban]" in comment: - await self.bot.get_channel(Channels.mod_tools).send( + await self.bot.get_channel(Channels.mod_meta).send( f":warning: heads-up! The new filter `{content}` (`{comment}`) will automatically ban users." ) diff --git a/config-default.yml b/config-default.yml index c0e561cca..4a85ccc56 100644 --- a/config-default.yml +++ b/config-default.yml @@ -207,7 +207,7 @@ guild: incidents_archive: 720668923636351037 mod_alerts: 473092532147060736 mods: &MODS 305126844661760000 - mod_tools: 775413915391098921 + mod_meta: 775412552795947058 nominations: 822920136150745168 nomination_voting: 822853512709931008 organisation: &ORGANISATION 551789653284356126 -- cgit v1.2.3 From aa666737ba0bf3cfcd58a4c9b782382d342632fe Mon Sep 17 00:00:00 2001 From: mbaruh Date: Mon, 25 Oct 2021 21:55:16 +0300 Subject: Adjust docstring to #1876 --- bot/exts/moderation/clean.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index d5bfdb485..c01430a04 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -13,9 +13,7 @@ from discord.ext.commands.converter import TextChannelConverter from discord.ext.commands.errors import BadArgument from bot.bot import Bot -from bot.constants import ( - Channels, CleanMessages, Colours, Emojis, Event, Icons, MODERATION_ROLES -) +from bot.constants import Channels, CleanMessages, Colours, Emojis, Event, Icons, MODERATION_ROLES from bot.converters import Age, ISODateTime from bot.exts.moderation.modlog import ModLog from bot.utils.channel import is_mod_channel @@ -439,20 +437,21 @@ class Clean(Cog): Commands for cleaning messages in channels. If arguments are provided, will act as a master command from which all subcommands can be derived. - • `users`: A series of user mentions, ID's, or names. - • `traverse`: The number of messages to look at in each channel. If using the cache, will look at the first - `traverse` messages in the cache. - • `first_limit` and `second_limit`: A message, a duration delta, or an ISO datetime. + + \u2003• `users`: A series of user mentions, ID's, or names. + \u2003• `traverse`: The number of messages to look at in each channel. If using the cache, will look at the + first `traverse` messages in the cache. + \u2003• `first_limit` and `second_limit`: A message, a duration delta, or an ISO datetime. If a message is provided, cleaning will happen in that channel, and channels cannot be provided. If a limit is provided, multiple channels cannot be provided. If only one of them is provided, acts as `clean until`. If both are provided, acts as `clean between`. - • `use_cache`: Whether to use the message cache. + \u2003• `use_cache`: Whether to use the message cache. If not provided, will default to False unless an asterisk is used for the channels. - • `bots_only`: Whether to delete only bots. If specified, users cannot be specified. - • `regex`: A regex pattern the message must contain to be deleted. + \u2003• `bots_only`: Whether to delete only bots. If specified, users cannot be specified. + \u2003• `regex`: A regex pattern the message must contain to be deleted. The pattern must be provided enclosed in backticks. If the pattern contains spaces, it still needs to be enclosed in double quotes on top of that. - • `channels`: A series of channels to delete in, or an asterisk to delete from all channels. + \u2003• `channels`: A series of channels to delete in, or an asterisk to delete from all channels. """ if not any([traverse, users, first_limit, second_limit, regex, channels]): await ctx.send_help(ctx.command) @@ -521,7 +520,7 @@ class Clean(Cog): Delete all messages that match a certain regex, stop cleaning after traversing `traverse` messages. The pattern must be provided enclosed in backticks. - If the pattern contains spaces, and still needs to be enclosed in double quotes on top of that. + If the pattern contains spaces, it still needs to be enclosed in double quotes on top of that. For example: `[0-9]` """ await self._clean_messages(ctx, traverse, regex=regex, channels=channels, use_cache=use_cache) -- cgit v1.2.3 From b42f148955600d85260c43c50260333fe62b823e Mon Sep 17 00:00:00 2001 From: mbaruh Date: Mon, 25 Oct 2021 22:00:58 +0300 Subject: Apply requested style changes --- bot/exts/moderation/clean.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index c01430a04..65ffec88b 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -1,3 +1,4 @@ +import contextlib import logging import re import time @@ -46,7 +47,8 @@ class Regex(Converter): async def convert(self, ctx: Context, argument: str) -> re.Pattern: """Strips the backticks from the string and compiles it to a regex pattern.""" - if not (match := re.fullmatch(r"`(.+?)`", argument)): + match = re.fullmatch(r"`(.+?)`", argument) + if not match: raise BadArgument("Regex pattern missing wrapping backticks") try: return re.compile(match.group(1), re.IGNORECASE + re.DOTALL) @@ -252,12 +254,8 @@ class Clean(Cog): # Ensure that deletion was not canceled if not self.cleaning: return deleted - try: + with contextlib.suppress(NotFound): # Message doesn't exist or was already deleted await message.delete() - except NotFound: - # Message doesn't exist or was already deleted - continue - else: deleted.append(message) return deleted -- cgit v1.2.3 From cae048338aa31a6c9c12a75e2f7f1674d817ce7f Mon Sep 17 00:00:00 2001 From: mbaruh Date: Mon, 25 Oct 2021 22:07:22 +0300 Subject: Improve documentation of global variables --- bot/exts/moderation/clean.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 65ffec88b..9001b4fe2 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -21,12 +21,14 @@ from bot.utils.channel import is_mod_channel log = logging.getLogger(__name__) +# Default number of messages to look at in each channel. DEFAULT_TRAVERSE = 10 +# Number of seconds before command invocations and responses are deleted in non-moderation channels. MESSAGE_DELETE_DELAY = 5 -# Type alias for checks +# Type alias for checks for whether a message should be deleted. Predicate = Callable[[Message], bool] - +# Type alias for message lookup ranges. CleanLimit = Union[Message, Age, ISODateTime] @@ -56,7 +58,7 @@ class Regex(Converter): raise BadArgument(f"Regex error: {e.msg}") -if TYPE_CHECKING: +if TYPE_CHECKING: # Used to allow method resolution in IDEs like in converters.py. CleanChannels = Union[Literal["*"], list[TextChannel]] # noqa: F811 Regex = re.Pattern # noqa: F811 -- cgit v1.2.3 From 37b7a3b5f6424039f11d4ee8d6f087568ebded16 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Mon, 25 Oct 2021 22:16:17 +0300 Subject: Update Age converter to use TZ aware datetime --- bot/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index 0cd06bf5e..0984fa0a3 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -405,7 +405,7 @@ class Age(DurationDelta): The converter supports the same symbols for each unit of time as its parent class. """ delta = await super().convert(ctx, duration) - now = datetime.utcnow() + now = datetime.now(timezone.utc) try: return now - delta -- cgit v1.2.3 From d19824d76c0cbe793b387002bcf1c6932579a668 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Mon, 25 Oct 2021 22:35:12 +0300 Subject: Remove channel limitation with time range Discussion in the pull request raised some legitimate use cases for supplying a time range for multiple channels (e.g clean the last couple of minutes instead of specifying number of messages to traverse). --- bot/exts/moderation/clean.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 9001b4fe2..94494b983 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -99,9 +99,6 @@ class Clean(Cog): if traverse > CleanMessages.message_limit: raise BadArgument(f"Cannot traverse more than {CleanMessages.message_limit} messages.") - if first_limit and channels and (channels == "*" or len(channels) > 1): - raise BadArgument("Message or time range specified across multiple channels.") - if (isinstance(first_limit, Message) or isinstance(second_limit, Message)) and channels: raise BadArgument("Both a message limit and channels specified.") -- cgit v1.2.3 From 874978e575ad6ef5c615f4736e47fd0f6b360af8 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 25 Oct 2021 21:49:34 +0200 Subject: Fltering: clean up autoban code --- bot/exts/filters/filter_lists.py | 2 +- bot/exts/filters/filtering.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py index 4af76af76..ee5bd89f3 100644 --- a/bot/exts/filters/filter_lists.py +++ b/bot/exts/filters/filter_lists.py @@ -104,7 +104,7 @@ class FilterLists(Cog): # If it is an autoban trigger we send a warning in #mod-meta if comment and "[autoban]" in comment: await self.bot.get_channel(Channels.mod_meta).send( - f":warning: heads-up! The new filter `{content}` (`{comment}`) will automatically ban users." + f":warning: Heads-up! The new filter `{content}` (`{comment}`) will automatically ban users." ) # Insert the item into the cache diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index bda4e7ac2..5e91e7e3d 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -1,6 +1,6 @@ import asyncio import re -from datetime import datetime, timedelta +from datetime import timedelta from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple, Union import arrow @@ -375,8 +375,8 @@ class Filtering(Cog): await context.invoke( self.bot.get_command("tempban"), msg.author, - datetime.now() + AUTO_BAN_DURATION, - reason=AUTO_BAN_REASON.strip() + arrow.utcnow() + AUTO_BAN_DURATION, + reason=AUTO_BAN_REASON ) break # We don't want multiple filters to trigger -- cgit v1.2.3 From d957e2d9158e2fb0e43de33c8b174b759da4d905 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 25 Oct 2021 21:58:36 +0200 Subject: Filtering: fix ban flow --- bot/exts/filters/filtering.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 5e91e7e3d..30b447620 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -369,11 +369,12 @@ class Filtering(Cog): # We create a new context from that message and make sure the staffer is the bot # and the feedback message is sent in #mod-alert context = await self.bot.get_context(msg) - context.author = self.bot.user + context.author = self.bot.get_guild(Guild.id).get_member(self.bot.user.id) context.channel = self.bot.get_channel(Channels.mod_alerts) + context.command = self.bot.get_command("tempban") await context.invoke( - self.bot.get_command("tempban"), + context.command, msg.author, arrow.utcnow() + AUTO_BAN_DURATION, reason=AUTO_BAN_REASON -- cgit v1.2.3 From 4594b2b85d714a5e9a65c3ae6e2a2264b1c9b38d Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 25 Oct 2021 22:33:35 +0200 Subject: Filtering: update auto-ban comments Co-authored-by: ChrisJL --- bot/exts/filters/filtering.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 30b447620..804f60547 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -364,10 +364,11 @@ class Filtering(Cog): stats = self._add_stats(filter_name, match, msg.content) await self._send_log(filter_name, _filter, msg, stats, reason) - # If the filter reason contains `[autoban]`, we want to indeed ban + # If the filter reason contains `[autoban]`, we want to auto-ban the user if reason and "[autoban]" in reason.lower(): - # We create a new context from that message and make sure the staffer is the bot - # and the feedback message is sent in #mod-alert + # Create a new context, with the author as is the bot, and the channel as #mod-alerts. + # This sends the ban confirmation directly under watchlist trigger embed, to inform + # mods that the user was auto-banned for the message. context = await self.bot.get_context(msg) context.author = self.bot.get_guild(Guild.id).get_member(self.bot.user.id) context.channel = self.bot.get_channel(Channels.mod_alerts) -- cgit v1.2.3 From d55197b405d8fd71bf09ff32dc339215997368fa Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 25 Oct 2021 22:34:00 +0200 Subject: Filtering: remove dangling empty quote Co-authored-by: ChrisJL --- bot/exts/filters/filtering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 804f60547..b7a7e8093 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -57,7 +57,7 @@ AUTO_BAN_REASON = ( "for heightened security.\n\n" "Once you have changed your password, feel free to follow the instructions at the bottom of " - "this message to appeal your ban.""" + "this message to appeal your ban." ) AUTO_BAN_DURATION = timedelta(days=4) -- cgit v1.2.3 From e7959146d7949377d35a433ad83f0841070587d9 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Tue, 26 Oct 2021 13:29:37 +0300 Subject: Handle autoban filtering in DMs (#1914) An autoban trigger being sent in DMs caused the ban to fail, but for it to still be registered in the database. That is becuase the ban command uses the `ctx.guild.ban` method, but in DMs `ctx.guild` is None. This commit solves it by overriding the `context.guild` field. --- bot/exts/filters/filtering.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index b7a7e8093..022b4ab02 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -370,7 +370,8 @@ class Filtering(Cog): # This sends the ban confirmation directly under watchlist trigger embed, to inform # mods that the user was auto-banned for the message. context = await self.bot.get_context(msg) - context.author = self.bot.get_guild(Guild.id).get_member(self.bot.user.id) + context.guild = self.bot.get_guild(Guild.id) + context.author = context.guild.get_member(self.bot.user.id) context.channel = self.bot.get_channel(Channels.mod_alerts) context.command = self.bot.get_command("tempban") -- cgit v1.2.3 From c4837978399ce42b7073e17bae7e30b7a43d088d Mon Sep 17 00:00:00 2001 From: Lainika Date: Sun, 31 Oct 2021 16:12:28 +0100 Subject: GH-1873 Fix BigBrother embeds Move text from footer to description. --- bot/exts/moderation/watchchannels/_watchchannel.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index 8f97130ca..34d445912 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -298,8 +298,7 @@ class WatchChannel(metaclass=CogABCMeta): message_jump = f"in [#{msg.channel.name}]({msg.jump_url})" footer = f"Added {time_delta} by {actor} | Reason: {reason}" - embed = Embed(description=f"{msg.author.mention} {message_jump}") - embed.set_footer(text=textwrap.shorten(footer, width=256, placeholder="...")) + embed = Embed(description=f"{msg.author.mention} {message_jump}\n\n{footer}") await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.display_avatar.url) -- cgit v1.2.3 From a2ec6f93403ee77aef803c0eefb90fa16f60f181 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 25 Oct 2021 13:11:37 +0100 Subject: consider parent channels when checking mod channels --- bot/utils/channel.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bot/utils/channel.py b/bot/utils/channel.py index b9e234857..954a10e56 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -1,3 +1,5 @@ +from typing import Union + import discord import bot @@ -16,8 +18,11 @@ def is_help_channel(channel: discord.TextChannel) -> bool: return any(is_in_category(channel, category) for category in categories) -def is_mod_channel(channel: discord.TextChannel) -> bool: - """True if `channel` is considered a mod channel.""" +def is_mod_channel(channel: Union[discord.TextChannel, discord.Thread]) -> bool: + """True if channel, or channel.parent for threads, is considered a mod channel.""" + if isinstance(channel, discord.Thread): + channel = channel.parent + if channel.id in constants.MODERATION_CHANNELS: log.trace(f"Channel #{channel} is a configured mod channel") return True -- cgit v1.2.3 From 88c407077ba7eba709d4d53455bd46bd9607f8e8 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Mon, 1 Nov 2021 19:22:50 +0530 Subject: Make 'parse' imported function name explicit --- bot/exts/moderation/modpings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 65372c312..47bc5e283 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -2,7 +2,7 @@ import asyncio import datetime from async_rediscache import RedisCache -from dateutil.parser import isoparse, parse +from dateutil.parser import isoparse, parse as dateutil_parse from discord import Embed, Member from discord.ext.commands import Cog, Context, group, has_any_role @@ -200,7 +200,7 @@ class ModPings(Cog): @has_any_role(*MODERATION_ROLES) async def schedule_modpings(self, ctx: Context, start: str, end: str) -> None: """Schedule modpings role to be added at and removed at everyday at UTC time!""" - start, end = parse(start), parse(end) + start, end = dateutil_parse(start), dateutil_parse(end) if end < start: end += datetime.timedelta(days=1) -- cgit v1.2.3 From e08764a59443eebd217f67e77bd4f5403e9a189f Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Mon, 1 Nov 2021 18:53:46 +0000 Subject: Migrate to `og_blurple` (#1901) Migrate to `og_blurple` --- bot/exts/backend/branding/_cog.py | 4 ++-- bot/exts/events/code_jams/_cog.py | 2 +- bot/exts/info/information.py | 6 +++--- bot/exts/info/site.py | 12 ++++++------ bot/exts/moderation/defcon.py | 4 ++-- bot/exts/moderation/infraction/management.py | 2 +- bot/exts/moderation/modlog.py | 20 ++++++++++---------- bot/exts/utils/extensions.py | 2 +- bot/exts/utils/internal.py | 2 +- bot/exts/utils/reminders.py | 6 +++--- bot/exts/utils/utils.py | 2 +- tests/bot/exts/info/test_information.py | 14 +++++++------- 12 files changed, 38 insertions(+), 38 deletions(-) diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py index 9c5bdbb4e..0c5839a7a 100644 --- a/bot/exts/backend/branding/_cog.py +++ b/bot/exts/backend/branding/_cog.py @@ -294,7 +294,7 @@ class Branding(commands.Cog): else: content = "Python Discord is entering a new event!" if is_notification else None - embed = discord.Embed(description=description[:4096], colour=discord.Colour.blurple()) + embed = discord.Embed(description=description[:4096], colour=discord.Colour.og_blurple()) embed.set_footer(text=duration[:4096]) await channel.send(content=content, embed=embed) @@ -573,7 +573,7 @@ class Branding(commands.Cog): await ctx.send(embed=resp) return - embed = discord.Embed(title="Current event calendar", colour=discord.Colour.blurple()) + embed = discord.Embed(title="Current event calendar", colour=discord.Colour.og_blurple()) # Because Discord embeds can only contain up to 25 fields, we only show the first 25. first_25 = list(available_events.items())[:25] diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index b31d628d5..452199f5f 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -160,7 +160,7 @@ class CodeJams(commands.Cog): embed = Embed( title=str(member), - colour=Colour.blurple() + colour=Colour.og_blurple() ) embed.add_field(name="Team", value=self.team_name(channel), inline=True) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 0dcb8de11..7f4811a43 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -112,7 +112,7 @@ class Information(Cog): # Build an embed embed = Embed( title=f"Role information (Total {len(roles)} role{'s' * (len(role_list) > 1)})", - colour=Colour.blurple() + colour=Colour.og_blurple() ) await LinePaginator.paginate(role_list, ctx, embed, empty=False) @@ -170,7 +170,7 @@ class Information(Cog): @command(name="server", aliases=["server_info", "guild", "guild_info"]) async def server_info(self, ctx: Context) -> None: """Returns an embed full of server information.""" - embed = Embed(colour=Colour.blurple(), title="Server Information") + embed = Embed(colour=Colour.og_blurple(), title="Server Information") created = discord_timestamp(ctx.guild.created_at, TimestampFormats.RELATIVE) region = ctx.guild.region @@ -316,7 +316,7 @@ class Information(Cog): embed.add_field(name=field_name, value=field_content, inline=False) embed.set_thumbnail(url=user.display_avatar.url) - embed.colour = user.colour if user.colour != Colour.default() else Colour.blurple() + embed.colour = user.colour if user.colour != Colour.default() else Colour.og_blurple() return embed diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index e1f2f5153..e8e71558b 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -29,7 +29,7 @@ class Site(Cog): embed = Embed(title="Python Discord website") embed.set_footer(text=url) - embed.colour = Colour.blurple() + embed.colour = Colour.og_blurple() embed.description = ( f"[Our official website]({url}) is an open-source community project " "created with Python and Django. It contains information about the server " @@ -46,7 +46,7 @@ class Site(Cog): embed = Embed(title="Resources") embed.set_footer(text=f"{learning_url}") - embed.colour = Colour.blurple() + embed.colour = Colour.og_blurple() embed.description = ( f"The [Resources page]({learning_url}) on our website contains a " "list of hand-selected learning resources that we regularly recommend " @@ -62,7 +62,7 @@ class Site(Cog): embed = Embed(title="Tools") embed.set_footer(text=f"{tools_url}") - embed.colour = Colour.blurple() + embed.colour = Colour.og_blurple() embed.description = ( f"The [Tools page]({tools_url}) on our website contains a " f"couple of the most popular tools for programming in Python." @@ -77,7 +77,7 @@ class Site(Cog): embed = Embed(title="Asking Good Questions") embed.set_footer(text=url) - embed.colour = Colour.blurple() + embed.colour = Colour.og_blurple() embed.description = ( "Asking the right question about something that's new to you can sometimes be tricky. " f"To help with this, we've created a [guide to asking good questions]({url}) on our website. " @@ -93,7 +93,7 @@ class Site(Cog): embed = Embed(title="FAQ") embed.set_footer(text=url) - embed.colour = Colour.blurple() + embed.colour = Colour.og_blurple() embed.description = ( "As the largest Python community on Discord, we get hundreds of questions every day. " "Many of these questions have been asked before. We've compiled a list of the most " @@ -106,7 +106,7 @@ class Site(Cog): @site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule")) async def site_rules(self, ctx: Context, rules: Greedy[int]) -> None: """Provides a link to all rules or, if specified, displays specific rule(s).""" - rules_embed = Embed(title='Rules', color=Colour.blurple(), url=f'{BASE_URL}/pages/rules') + rules_embed = Embed(title='Rules', color=Colour.og_blurple(), url=f'{BASE_URL}/pages/rules') if not rules: # Rules were not submitted. Return the default description. diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 822a87b61..14db37367 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -50,7 +50,7 @@ class Action(Enum): SERVER_OPEN = ActionInfo(Icons.defcon_unshutdown, Emojis.defcon_unshutdown, Colours.soft_green, "") SERVER_SHUTDOWN = ActionInfo(Icons.defcon_shutdown, Emojis.defcon_shutdown, Colours.soft_red, "") DURATION_UPDATE = ActionInfo( - Icons.defcon_update, Emojis.defcon_update, Colour.blurple(), "**Threshold:** {threshold}\n\n" + Icons.defcon_update, Emojis.defcon_update, Colour.og_blurple(), "**Threshold:** {threshold}\n\n" ) @@ -152,7 +152,7 @@ class Defcon(Cog): async def status(self, ctx: Context) -> None: """Check the current status of DEFCON mode.""" embed = Embed( - colour=Colour.blurple(), title="DEFCON Status", + colour=Colour.og_blurple(), title="DEFCON Status", description=f""" **Threshold:** {humanize_delta(self.threshold) if self.threshold else "-"} **Expires:** {discord_timestamp(self.expiry, TimestampFormats.RELATIVE) if self.expiry else "-"} diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 1cd259a4b..0a33ac5e2 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -203,7 +203,7 @@ class ModManagement(commands.Cog): await self.mod_log.send_log_message( icon_url=constants.Icons.pencil, - colour=discord.Colour.blurple(), + colour=discord.Colour.og_blurple(), title="Infraction edited", thumbnail=thumbnail, text=textwrap.dedent(f""" diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 6fcf43d8a..462f8533d 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -251,7 +251,7 @@ class ModLog(Cog, name="ModLog"): message = f"**#{after.name}** (`{after.id}`)\n{message}" await self.send_log_message( - Icons.hash_blurple, Colour.blurple(), + Icons.hash_blurple, Colour.og_blurple(), "Channel updated", message ) @@ -326,7 +326,7 @@ class ModLog(Cog, name="ModLog"): message = f"**{after.name}** (`{after.id}`)\n{message}" await self.send_log_message( - Icons.crown_blurple, Colour.blurple(), + Icons.crown_blurple, Colour.og_blurple(), "Role updated", message ) @@ -376,7 +376,7 @@ class ModLog(Cog, name="ModLog"): message = f"**{after.name}** (`{after.id}`)\n{message}" await self.send_log_message( - Icons.guild_update, Colour.blurple(), + Icons.guild_update, Colour.og_blurple(), "Guild updated", message, thumbnail=after.icon.with_static_format("png") ) @@ -447,7 +447,7 @@ class ModLog(Cog, name="ModLog"): return await self.send_log_message( - Icons.user_unban, Colour.blurple(), + Icons.user_unban, Colour.og_blurple(), "User unbanned", format_user(member), thumbnail=member.display_avatar.url, channel_id=Channels.mod_log @@ -512,7 +512,7 @@ class ModLog(Cog, name="ModLog"): await self.send_log_message( icon_url=Icons.user_update, - colour=Colour.blurple(), + colour=Colour.og_blurple(), title="Member updated", text=message, thumbnail=after.display_avatar.url, @@ -718,7 +718,7 @@ class ModLog(Cog, name="ModLog"): footer = None await self.send_log_message( - Icons.message_edit, Colour.blurple(), "Message edited", response, + Icons.message_edit, Colour.og_blurple(), "Message edited", response, channel_id=Channels.message_log, timestamp_override=timestamp, footer=footer ) @@ -761,12 +761,12 @@ class ModLog(Cog, name="ModLog"): ) await self.send_log_message( - Icons.message_edit, Colour.blurple(), "Message edited (Before)", + Icons.message_edit, Colour.og_blurple(), "Message edited (Before)", before_response, channel_id=Channels.message_log ) await self.send_log_message( - Icons.message_edit, Colour.blurple(), "Message edited (After)", + Icons.message_edit, Colour.og_blurple(), "Message edited (After)", after_response, channel_id=Channels.message_log ) @@ -776,7 +776,7 @@ class ModLog(Cog, name="ModLog"): if before.name != after.name: await self.send_log_message( Icons.hash_blurple, - Colour.blurple(), + Colour.og_blurple(), "Thread name edited", ( f"Thread {after.mention} (`{after.id}`) from {after.parent.mention} (`{after.parent.id}`): " @@ -870,7 +870,7 @@ class ModLog(Cog, name="ModLog"): diff_values = {**diff.get("values_changed", {}), **diff.get("type_changes", {})} icon = Icons.voice_state_blue - colour = Colour.blurple() + colour = Colour.og_blurple() changes = [] for attr, values in diff_values.items(): diff --git a/bot/exts/utils/extensions.py b/bot/exts/utils/extensions.py index fa5d38917..fda1e49e2 100644 --- a/bot/exts/utils/extensions.py +++ b/bot/exts/utils/extensions.py @@ -113,7 +113,7 @@ class Extensions(commands.Cog): Grey indicates that the extension is unloaded. Green indicates that the extension is currently loaded. """ - embed = Embed(colour=Colour.blurple()) + embed = Embed(colour=Colour.og_blurple()) embed.set_author( name="Extensions List", url=URLs.github_bot_repo, diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index 165b5917d..e7113c09c 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -243,7 +243,7 @@ async def func(): # (None,) -> Any stats_embed = discord.Embed( title="WebSocket statistics", description=f"Receiving {per_s:0.2f} events per second.", - color=discord.Color.blurple() + color=discord.Color.og_blurple() ) for event_type, count in self.socket_events.most_common(25): diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 3dbcc4513..86e4505fa 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -183,7 +183,7 @@ class Reminders(Cog): name="Sorry, your reminder should have arrived earlier!" ) else: - embed.colour = discord.Colour.blurple() + embed.colour = discord.Colour.og_blurple() embed.set_author( icon_url=Icons.remind_blurple, name="It has arrived!" @@ -350,7 +350,7 @@ class Reminders(Cog): lines.append(text) embed = discord.Embed() - embed.colour = discord.Colour.blurple() + embed.colour = discord.Colour.og_blurple() embed.title = f"Reminders for {ctx.author}" # Remind the user that they have no reminders :^) @@ -360,7 +360,7 @@ class Reminders(Cog): return # Construct the embed and paginate it. - embed.colour = discord.Colour.blurple() + embed.colour = discord.Colour.og_blurple() await LinePaginator.paginate( lines, diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index f69bab781..821cebd8c 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -96,7 +96,7 @@ class Utils(Cog): If a string is provided, the line which matches best will be produced. """ embed = Embed( - colour=Colour.blurple(), + colour=Colour.og_blurple(), title="The Zen of Python", description=ZEN_OF_PYTHON ) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 4b50c3fd9..632287322 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -42,7 +42,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase): embed = kwargs.pop('embed') self.assertEqual(embed.title, "Role information (Total 1 role)") - self.assertEqual(embed.colour, discord.Colour.blurple()) + self.assertEqual(embed.colour, discord.Colour.og_blurple()) self.assertEqual(embed.description, f"\n`{self.moderator_role.id}` - {self.moderator_role.mention}\n") async def test_role_info_command(self): @@ -50,7 +50,7 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase): dummy_role = helpers.MockRole( name="Dummy", id=112233445566778899, - colour=discord.Colour.blurple(), + colour=discord.Colour.og_blurple(), position=10, members=[self.ctx.author], permissions=discord.Permissions(0) @@ -80,11 +80,11 @@ class InformationCogTests(unittest.IsolatedAsyncioTestCase): admin_embed = admin_kwargs["embed"] self.assertEqual(dummy_embed.title, "Dummy info") - self.assertEqual(dummy_embed.colour, discord.Colour.blurple()) + self.assertEqual(dummy_embed.colour, discord.Colour.og_blurple()) self.assertEqual(dummy_embed.fields[0].value, str(dummy_role.id)) self.assertEqual(dummy_embed.fields[1].value, f"#{dummy_role.colour.value:0>6x}") - self.assertEqual(dummy_embed.fields[2].value, "0.65 0.64 242") + self.assertEqual(dummy_embed.fields[2].value, "0.63 0.48 218") self.assertEqual(dummy_embed.fields[3].value, "1") self.assertEqual(dummy_embed.fields[4].value, "10") self.assertEqual(dummy_embed.fields[5].value, "0") @@ -417,14 +417,14 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) ) - 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.""" + async def test_create_user_embed_uses_og_blurple_colour_when_user_has_no_roles(self): + """The embed should be created with the og blurple colour if the user has no assigned roles.""" ctx = helpers.MockContext() user = helpers.MockMember(id=217, colour=discord.Colour.default()) embed = await self.cog.create_user_embed(ctx, user) - self.assertEqual(embed.colour, discord.Colour.blurple()) + self.assertEqual(embed.colour, discord.Colour.og_blurple()) @unittest.mock.patch( f"{COG_PATH}.basic_user_infraction_counts", -- cgit v1.2.3 From f315f4c18571b3e5a7d8e322db4dbc0f3f13943b Mon Sep 17 00:00:00 2001 From: Izan Date: Sun, 31 Oct 2021 16:04:16 +0000 Subject: Add support for `!infractions by ` --- bot/exts/moderation/infraction/management.py | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index b1c8b64dc..8c1ef057c 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -262,6 +262,44 @@ class ModManagement(commands.Cog): ) await self.send_infraction_list(ctx, embed, infraction_list) + # endregion + # region: Search infractions by given user + @infraction_group.command(name="by", aliases=("b",)) + async def search_by_user( + self, + ctx: Context, + user: t.Union[discord.Member, t.Literal["m", "me"]], + oldest_first: bool = False + ) -> None: + """ + Search for infractions made by `user`. + + Use "m" or "me" as the `user` to get infractions by author. + + Use "1" for `oldest_first` to send oldest infractions first. + """ + if isinstance(user, discord.Member): + moderator_id = user.id + moderator_name_discrim = str(user) + else: + moderator_id = ctx.author.id + moderator_name_discrim = str(ctx.author) + + infraction_list = await self.bot.api_client.get( + 'bot/infractions/expanded', + params={ + 'actor__id': str(moderator_id), + 'ordering': f'{["-", ""][oldest_first]}inserted_at' # `'inserted_at'` makes api return oldest first + } + ) + + embed = discord.Embed( + title=f"Infractions by `{moderator_name_discrim}` (`{moderator_id}`)", + colour=discord.Colour.orange() + ) + + await self.send_infraction_list(ctx, embed, infraction_list) + # endregion # region: Utility functions -- cgit v1.2.3 From 06d0c8fc3674fa73e118b85b0d3b444f8d7a906f Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 2 Nov 2021 08:48:42 +0000 Subject: Address Review - Add `format_infraction_count` and migrate - Improve logic for `actor` being `"m"`/`"me"` - Rename `search_by_user` to `search_by_actor` - Better Ordering Logic (thanks @ChrisLovering) - Make embed title consistent with other search embeds --- bot/exts/moderation/infraction/management.py | 44 ++++++++++++++++++---------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 8c1ef057c..64913831a 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -243,8 +243,9 @@ class ModManagement(commands.Cog): else: user_str = str(user.id) + formatted_infraction_count = self.format_infraction_count(len(infraction_list)) embed = discord.Embed( - title=f"Infractions for {user_str} ({len(infraction_list)} total)", + title=f"Infractions for {user_str} ({formatted_infraction_count} total)", colour=discord.Colour.orange() ) await self.send_infraction_list(ctx, embed, infraction_list) @@ -256,45 +257,44 @@ class ModManagement(commands.Cog): 'bot/infractions/expanded', params={'search': reason} ) + + formatted_infraction_count = self.format_infraction_count(len(infraction_list)) embed = discord.Embed( - title=f"Infractions matching `{reason}` ({len(infraction_list)} total)", + title=f"Infractions matching `{reason}` ({formatted_infraction_count} total)", colour=discord.Colour.orange() ) await self.send_infraction_list(ctx, embed, infraction_list) # endregion - # region: Search infractions by given user + # region: Search for infractions by given actor @infraction_group.command(name="by", aliases=("b",)) - async def search_by_user( + async def search_by_actor( self, ctx: Context, - user: t.Union[discord.Member, t.Literal["m", "me"]], + actor: t.Union[discord.Member, t.Literal["m", "me"]], oldest_first: bool = False ) -> None: """ - Search for infractions made by `user`. + Search for infractions made by `actor`. - Use "m" or "me" as the `user` to get infractions by author. + Use "m" or "me" as the `actor` to get infractions by author. Use "1" for `oldest_first` to send oldest infractions first. """ - if isinstance(user, discord.Member): - moderator_id = user.id - moderator_name_discrim = str(user) - else: - moderator_id = ctx.author.id - moderator_name_discrim = str(ctx.author) + if isinstance(actor, str): + actor = ctx.author infraction_list = await self.bot.api_client.get( 'bot/infractions/expanded', params={ - 'actor__id': str(moderator_id), - 'ordering': f'{["-", ""][oldest_first]}inserted_at' # `'inserted_at'` makes api return oldest first + 'actor__id': str(actor.id), + 'ordering': f'{"-"[oldest_first:]}inserted_at' # `'inserted_at'` makes api return oldest first } ) + formatted_infraction_count = self.format_infraction_count(len(infraction_list)) embed = discord.Embed( - title=f"Infractions by `{moderator_name_discrim}` (`{moderator_id}`)", + title=f"Infractions by `{actor}` ({formatted_infraction_count} total)", colour=discord.Colour.orange() ) @@ -303,6 +303,18 @@ class ModManagement(commands.Cog): # endregion # region: Utility functions + @staticmethod + def format_infraction_count(infraction_count: int) -> str: + """ + Returns a string-formatted infraction count. + + API limits returned infractions to a maximum of 100, so if `infraction_count` + is 100 then we return `"100+"`. Otherwise, return `str(infraction_count)`. + """ + if infraction_count == 100: + return "100+" + return str(infraction_count) + async def send_infraction_list( self, ctx: Context, -- cgit v1.2.3 From c6889dead07c0017864d0fb96028c864c2f752dc Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 3 Nov 2021 23:09:42 +0000 Subject: Improve ordering logic in API request --- bot/exts/moderation/infraction/management.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 64913831a..3299979e8 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -284,11 +284,16 @@ class ModManagement(commands.Cog): if isinstance(actor, str): actor = ctx.author + if oldest_first: + ordering = 'inserted_at' # oldest infractions first + else: + ordering = '-inserted_at' # newest infractions first + infraction_list = await self.bot.api_client.get( 'bot/infractions/expanded', params={ 'actor__id': str(actor.id), - 'ordering': f'{"-"[oldest_first:]}inserted_at' # `'inserted_at'` makes api return oldest first + 'ordering': ordering } ) -- cgit v1.2.3 From 69fdd3649e2f9a646b97e445bc4d5440440e5890 Mon Sep 17 00:00:00 2001 From: Janine vN Date: Thu, 4 Nov 2021 12:30:06 -0400 Subject: Add sql-fstring tag * Add sql-fstring tag * Correct link and wording * Correction to grammar and wording Also adds a semicolon * Add missing " Co-authored-by: Bluenix Co-authored-by: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> --- bot/resources/tags/sql-fstring.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 bot/resources/tags/sql-fstring.md diff --git a/bot/resources/tags/sql-fstring.md b/bot/resources/tags/sql-fstring.md new file mode 100644 index 000000000..94dd870fd --- /dev/null +++ b/bot/resources/tags/sql-fstring.md @@ -0,0 +1,16 @@ +**SQL & f-strings** +Don't use f-strings (`f""`) or other forms of "string interpolation" (`%`, `+`, `.format`) to inject data into a SQL query. It is an endless source of bugs and syntax errors. Additionally, in user-facing applications, it presents a major security risk via SQL injection. + +Your database library should support "query parameters". A query parameter is a placeholder that you put in the SQL query. When the query is executed, you provide data to the database library, and the library inserts the data into the query for you, **safely**. + +For example, the sqlite3 package supports using `?` as a placeholder: +```py +query = "SELECT * FROM stocks WHERE symbol = ?;" +params = ("RHAT",) +db.execute(query, params) +``` +Note: Different database libraries support different placeholder styles, e.g. `%s` and `$1`. Consult your library's documentation for details. + +**See Also** +• [Extended Example with SQLite](https://docs.python.org/3/library/sqlite3.html) (search for "Instead, use the DB-API's parameter substitution") +• [PEP-249](https://www.python.org/dev/peps/pep-0249) - A specification of how database libraries in Python should work -- cgit v1.2.3 From 67390298852513d13e0213870e50fb3cff1424e0 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Fri, 5 Nov 2021 16:31:05 +0400 Subject: Merge pull request from GHSA-j8c3-8x46-8pp6 * Don't Exit Token Filtering Early On URLs The token filtering function would exit early if it detected a URL within the message, but it made no extra checks to ensure there weren't other tokens within that message that would trigger it. This made sense when the filtering logic was written, but it's been modified since to introduce this bug. Regression tests included. Signed-off-by: Hassan Abouelela * Links Advisory In Token Filter Tests Adds a link to the advisory with reasoning for the existence of the test. Signed-off-by: Hassan Abouelela --- bot/exts/filters/filtering.py | 4 ---- tests/bot/exts/filters/test_filtering.py | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 tests/bot/exts/filters/test_filtering.py diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 022b4ab02..f05b1d00b 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -496,10 +496,6 @@ class Filtering(Cog): text = self.clean_input(text) - # Make sure it's not a URL - if URL_RE.search(text): - return False, None - watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) for pattern in watchlist_patterns: match = re.search(pattern, text, flags=re.IGNORECASE) diff --git a/tests/bot/exts/filters/test_filtering.py b/tests/bot/exts/filters/test_filtering.py new file mode 100644 index 000000000..8ae59c1f1 --- /dev/null +++ b/tests/bot/exts/filters/test_filtering.py @@ -0,0 +1,40 @@ +import unittest +from unittest.mock import patch + +from bot.exts.filters import filtering +from tests.helpers import MockBot, autospec + + +class FilteringCogTests(unittest.IsolatedAsyncioTestCase): + """Tests the `Filtering` cog.""" + + def setUp(self): + """Instantiate the bot and cog.""" + self.bot = MockBot() + with patch("bot.utils.scheduling.create_task", new=lambda task, **_: task.close()): + self.cog = filtering.Filtering(self.bot) + + @autospec(filtering.Filtering, "_get_filterlist_items", pass_mocks=False, return_value=["TOKEN"]) + async def test_token_filter(self): + """Ensure that a filter token is correctly detected in a message.""" + messages = { + "": False, + "no matches": False, + "TOKEN": True, + + # See advisory https://github.com/python-discord/bot/security/advisories/GHSA-j8c3-8x46-8pp6 + "https://google.com TOKEN": True, + "https://google.com something else": False, + } + + for message, match in messages.items(): + with self.subTest(input=message, match=match): + result, _ = await self.cog._has_watch_regex_match(message) + + self.assertEqual( + match, + bool(result), + msg=f"Hit was {'expected' if match else 'not expected'} for this input." + ) + if result: + self.assertEqual("TOKEN", result.group()) -- cgit v1.2.3 From 1302632c39469a124e85e6b43c5278526874ce5e Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Sat, 6 Nov 2021 10:56:09 +0000 Subject: Only re-run filters in `on_message_update` if contents/attachments changed (#1937) --- bot/exts/filters/filtering.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index f05b1d00b..79b7abe9f 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -189,8 +189,16 @@ class Filtering(Cog): """ Invoke message filter for message edits. - If there have been multiple edits, calculate the time delta from the previous edit. + Also calculates the time delta from the previous edit or when message was sent if there's no prior edits. """ + # We only care about changes to the message contents/attachments and embed additions, not pin status etc. + if all(( + before.content == after.content, # content hasn't changed + before.attachments == after.attachments, # attachments haven't changed + len(before.embeds) >= len(after.embeds) # embeds haven't been added + )): + return + if not before.edited_at: delta = relativedelta(after.edited_at, before.created_at).microseconds else: @@ -341,7 +349,7 @@ class Filtering(Cog): await self.notify_member(msg.author, _filter["notification_msg"], msg.channel) # If the message is classed as offensive, we store it in the site db and - # it will be deleted it after one week. + # it will be deleted after one week. if _filter["schedule_deletion"] and not is_private: delete_date = (msg.created_at + OFFENSIVE_MSG_DELETE_TIME).isoformat() data = { -- cgit v1.2.3 From 689850fd99737ab742dedf265cebbc19333535e1 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 7 Nov 2021 01:39:04 +0530 Subject: Add no message content handling --- bot/exts/moderation/incidents.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index c531f4902..87a6579f7 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -224,12 +224,14 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[d ), timestamp=message.created_at ) - embed.add_field( - name="Content", - value=shorten_text(message.content) - ) embed.set_footer(text=f"Message ID: {message.id}") + if message.content: + embed.add_field( + name="Content", + value=shorten_text(message.content) + ) + return embed -- cgit v1.2.3 From 19cb6d19134676b1eca797a9782010448d91eace Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 7 Nov 2021 01:40:26 +0530 Subject: Attach attachments if present in linked message --- bot/exts/moderation/incidents.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 87a6579f7..e7c9d0399 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -231,6 +231,8 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[d name="Content", value=shorten_text(message.content) ) + if message.attachments: + embed.set_image(url=message.attachments[0].url) return embed -- cgit v1.2.3 From a3ee621fb53da75ba357a90c5b2d17529aef5c99 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 7 Nov 2021 01:43:11 +0530 Subject: SHow thread parent name if linked message in thread --- bot/exts/moderation/incidents.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index e7c9d0399..18102587a 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -220,7 +220,9 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[d colour=discord.Colour.gold(), description=( f"**Author:** {format_user(message.author)}\n" - f"**Channel:** {channel.mention} ({channel.category}/#{channel.name})\n" + f"**Channel:** {channel.mention} ({channel.category}" + f"{f'/#{channel.parent.name} - ' if isinstance(channel, discord.Thread) else '/#'}" + f"{channel.name})\n" ), timestamp=message.created_at ) -- cgit v1.2.3 From 32381cdfebd33b39bb69019f174b070beb32c44a Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Sun, 7 Nov 2021 01:46:01 +0530 Subject: Explicitly show there is no message content --- bot/exts/moderation/incidents.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 18102587a..bdbce4acb 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -226,13 +226,12 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[d ), timestamp=message.created_at ) + embed.add_field( + name="Content", + value=shorten_text(message.content) if message.content else "[No Message Content]" + ) embed.set_footer(text=f"Message ID: {message.id}") - if message.content: - embed.add_field( - name="Content", - value=shorten_text(message.content) - ) if message.attachments: embed.set_image(url=message.attachments[0].url) -- cgit v1.2.3 From 4b716c204147cfd1871db1b081e036cac172a9ad Mon Sep 17 00:00:00 2001 From: Izan Date: Mon, 8 Nov 2021 23:00:46 +0000 Subject: Add missing newline after region comment --- bot/exts/moderation/infraction/management.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 3299979e8..7314eb61d 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -267,6 +267,7 @@ class ModManagement(commands.Cog): # endregion # region: Search for infractions by given actor + @infraction_group.command(name="by", aliases=("b",)) async def search_by_actor( self, -- cgit v1.2.3 From 19df10cb968081442e9854ffffcdbc7c783dd0af Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Tue, 9 Nov 2021 15:27:14 +0530 Subject: Filter the same messages in both listeners. --- 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 462f8533d..1d8e571fb 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -613,7 +613,7 @@ class ModLog(Cog, name="ModLog"): @Cog.listener() async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None: """Log raw message delete event to message change log.""" - if self.is_channel_ignored(event.channel_id): + if self.is_message_blacklisted(event.channel_id): return await asyncio.sleep(1) # Wait here in case the normal event was fired -- cgit v1.2.3 From 2d2e7a31699a4eedd1f14aecf348368711b53536 Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Tue, 9 Nov 2021 16:14:59 +0530 Subject: Call the appropriate function in the raw listener --- 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 1d8e571fb..462f8533d 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -613,7 +613,7 @@ class ModLog(Cog, name="ModLog"): @Cog.listener() async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None: """Log raw message delete event to message change log.""" - if self.is_message_blacklisted(event.channel_id): + if self.is_channel_ignored(event.channel_id): return await asyncio.sleep(1) # Wait here in case the normal event was fired -- cgit v1.2.3 From 78957a7ae4fbbe974b59f6a70fee5c0970378629 Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Tue, 9 Nov 2021 16:27:01 +0530 Subject: Listen to only on_raw_message_delete --- bot/exts/moderation/modlog.py | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 462f8533d..6416bc3c7 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -41,7 +41,6 @@ class ModLog(Cog, name="ModLog"): self.bot = bot self._ignored = {event: [] for event in Event} - self._cached_deletes = [] self._cached_edits = [] async def upload_log( @@ -552,24 +551,22 @@ class ModLog(Cog, name="ModLog"): return channel.id in GuildConstant.modlog_blacklist - @Cog.listener() - async def on_message_delete(self, message: discord.Message) -> None: - """Log message delete event to message change log.""" + async def log_cached_deleted_message(self, message: discord.Message) -> None: + """ + Log the message's details to message change log. + + This is called when a cached message is deleted. + """ channel = message.channel author = message.author if self.is_message_blacklisted(message): return - self._cached_deletes.append(message.id) - if message.id in self._ignored[Event.message_delete]: self._ignored[Event.message_delete].remove(message.id) return - if author.bot: - return - if channel.category: response = ( f"**Author:** {format_user(author)}\n" @@ -610,17 +607,14 @@ class ModLog(Cog, name="ModLog"): channel_id=Channels.message_log ) - @Cog.listener() - async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None: - """Log raw message delete event to message change log.""" - if self.is_channel_ignored(event.channel_id): - return - - await asyncio.sleep(1) # Wait here in case the normal event was fired + async def log_uncached_deleted_message(self, event: discord.RawMessageDeleteEvent) -> None: + """ + Log the message's details to message change log. - if event.message_id in self._cached_deletes: - # It was in the cache and the normal event was fired, so we can just ignore it - self._cached_deletes.remove(event.message_id) + This is called when a message absent from the cache is deleted. + Hence, the message contents aren't logged. + """ + if self.is_channel_ignored(event.channel_id): return if event.message_id in self._ignored[Event.message_delete]: @@ -651,6 +645,14 @@ class ModLog(Cog, name="ModLog"): channel_id=Channels.message_log ) + @Cog.listener() + async def on_raw_message_delete(self, event: discord.RawMessageDeleteEvent) -> None: + """Log message deletions to message change log.""" + if event.cached_message is not None: + await self.log_cached_deleted_message(event.cached_message) + else: + await self.log_uncached_deleted_message(event) + @Cog.listener() async def on_message_edit(self, msg_before: discord.Message, msg_after: discord.Message) -> None: """Log message edit event to message change log.""" -- cgit v1.2.3 From cd3db5f9e51c79a9fe8f621cdafdc9052ef22033 Mon Sep 17 00:00:00 2001 From: Shivansh-007 Date: Wed, 10 Nov 2021 06:17:23 +0530 Subject: Check if log entry has embeds before indexing them --- bot/exts/moderation/incidents.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index bdbce4acb..77dfad255 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -151,7 +151,7 @@ def shorten_text(text: str) -> str: text = text[:300] # Limit to a maximum of three lines - text = "\n".join(line for line in text.split("\n", maxsplit=3)[:3]) + text = "\n".join(text.split("\n", maxsplit=3)[:3]) # If it is a single word, then truncate it to 50 characters if text.find(" ") == -1: @@ -186,6 +186,9 @@ async def make_message_link_embed(ctx: Context, message_link: str) -> Optional[d last_100_logs: list[discord.Message] = await mod_logs_channel.history(limit=100).flatten() for log_entry in last_100_logs: + if not log_entry.embeds: + continue + log_embed: discord.Embed = log_entry.embeds[0] if ( log_embed.author.name == "Message deleted" -- cgit v1.2.3 From 59c05a8494a5425545d38029b4b26890b6a631c1 Mon Sep 17 00:00:00 2001 From: aru Date: Wed, 10 Nov 2021 01:26:40 -0500 Subject: commands: add pip as an alias to pypi (#1942) Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/exts/info/pypi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py index c3d2e2a3c..dacf7bc12 100644 --- a/bot/exts/info/pypi.py +++ b/bot/exts/info/pypi.py @@ -29,7 +29,7 @@ class PyPi(Cog): def __init__(self, bot: Bot): self.bot = bot - @command(name="pypi", aliases=("package", "pack")) + @command(name="pypi", aliases=("package", "pack", "pip")) async def get_package_info(self, ctx: Context, package: str) -> None: """Provide information about a specific package from PyPI.""" embed = Embed(title=random.choice(NEGATIVE_REPLIES), colour=Colours.soft_red) -- cgit v1.2.3 From 2aad13887f049512ab8330f8aaa790086e3e6bea Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 10 Nov 2021 09:00:45 +0000 Subject: Unify infraction embed title Embed for `!infractions by` no longer has the author in codeblock. --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 7314eb61d..192bb3ba9 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -300,7 +300,7 @@ class ModManagement(commands.Cog): formatted_infraction_count = self.format_infraction_count(len(infraction_list)) embed = discord.Embed( - title=f"Infractions by `{actor}` ({formatted_infraction_count} total)", + title=f"Infractions by {actor} ({formatted_infraction_count} total)", colour=discord.Colour.orange() ) -- cgit v1.2.3 From 9c0d91b0bf5f8bf68a3fbadbf9da66726c355b81 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 10 Nov 2021 21:36:40 +0000 Subject: Merge PR #1947: Fix `!infractions by me` Put the literal converter before the Member converter so that "me"/"m" isn't attempted to be converted to a Member. --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 109b89a95..a833eb227 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -272,7 +272,7 @@ class ModManagement(commands.Cog): async def search_by_actor( self, ctx: Context, - actor: t.Union[discord.Member, t.Literal["m", "me"]], + actor: t.Union[t.Literal["m", "me"], UnambiguousUser], oldest_first: bool = False ) -> None: """ -- cgit v1.2.3 From a345a912a88a53123bdd5d0806530c62d5166a9a Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 12 Nov 2021 00:08:19 +0000 Subject: Change log level from `WARNING` to `DEBUG`. (#1950) --- bot/exts/help_channels/_cog.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 0905cb23d..944a99917 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -128,9 +128,7 @@ class HelpChannels(commands.Cog): # Handle odd edge case of `message.author` not being a `discord.Member` (see bot#1839) if not isinstance(message.author, discord.Member): - log.warning( - f"{message.author} ({message.author.id}) isn't a member. Not giving cooldown role or sending DM." - ) + log.debug(f"{message.author} ({message.author.id}) isn't a member. Not giving cooldown role or sending DM.") else: await self._handle_role_change(message.author, message.author.add_roles) -- cgit v1.2.3 From b1f73f4c1a67f061ee2c1cd74980fcd725ae9f1e Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 14 Nov 2021 03:30:10 +0000 Subject: Add #bot-commands to guild features in !server This prevents spam in dev-contrib and dev-core from people trying to find which Discord feature flags are enabled for Python Discord. It's not ideal that we have to increase output size in #bot-commands but it prevents spam in #dev-contrib. --- bot/exts/info/information.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 7f4811a43..dab2dbb6c 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -178,7 +178,10 @@ class Information(Cog): # Server Features are only useful in certain channels if ctx.channel.id in ( - *constants.MODERATION_CHANNELS, constants.Channels.dev_core, constants.Channels.dev_contrib + *constants.MODERATION_CHANNELS, + constants.Channels.dev_core, + constants.Channels.dev_contrib, + constants.Channels.bot_commands ): features = f"\nFeatures: {', '.join(ctx.guild.features)}" else: -- cgit v1.2.3 From df8a44957aca30f929ed1bdebc97ff33ab5af1ba Mon Sep 17 00:00:00 2001 From: mina <75038675+minalike@users.noreply.github.com> Date: Sun, 14 Nov 2021 16:41:46 -0500 Subject: Update order of off-topic channels (#1956) Reverse order of off-topic channels from ot0, ot1, ot2 to ot2, ot1, ot0 --- bot/resources/tags/off-topic.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/resources/tags/off-topic.md b/bot/resources/tags/off-topic.md index 6a864a1d5..287224d7f 100644 --- a/bot/resources/tags/off-topic.md +++ b/bot/resources/tags/off-topic.md @@ -1,9 +1,9 @@ **Off-topic channels** There are three off-topic channels: -• <#291284109232308226> -• <#463035241142026251> • <#463035268514185226> +• <#463035241142026251> +• <#291284109232308226> Their names change randomly every 24 hours, but you can always find them under the `OFF-TOPIC/GENERAL` category in the channel list. -- cgit v1.2.3 From 94936e499f303deeae785d4b643dbf598ae0a4cc Mon Sep 17 00:00:00 2001 From: Izan Date: Fri, 12 Nov 2021 16:52:19 +0000 Subject: Display whether DM was sent to user when listing infraction(s). --- bot/exts/moderation/infraction/_scheduler.py | 4 +++- bot/exts/moderation/infraction/_utils.py | 19 ++++++++++++++++--- bot/exts/moderation/infraction/management.py | 8 ++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 762eb6afa..52dd79791 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -238,7 +238,9 @@ class InfractionScheduler: dm_log_text = "\nDM: **Failed**" # Accordingly update whether the user was successfully notified via DM. - if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon): + if await _utils.notify_infraction( + ctx.bot, user, infraction["id"], infr_type.replace("_", " ").title(), expiry, user_reason, icon + ): dm_result = ":incoming_envelope: " dm_log_text = "\nDM: Sent" diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index c0ef80e3d..433aa0b05 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -5,6 +5,7 @@ import discord from discord.ext.commands import Context from bot.api import ResponseCodeError +from bot.bot import Bot from bot.constants import Colours, Icons from bot.converters import MemberOrUser from bot.errors import InvalidInfractedUserError @@ -78,7 +79,8 @@ async def post_infraction( reason: str, expires_at: datetime = None, hidden: bool = False, - active: bool = True + active: bool = True, + dm_sent: bool = False, ) -> t.Optional[dict]: """Posts an infraction to the API.""" if isinstance(user, (discord.Member, discord.User)) and user.bot: @@ -93,7 +95,8 @@ async def post_infraction( "reason": reason, "type": infr_type, "user": user.id, - "active": active + "active": active, + "dm_sent": dm_sent } if expires_at: payload['expires_at'] = expires_at.isoformat() @@ -156,7 +159,9 @@ async def send_active_infraction_message(ctx: Context, infraction: Infraction) - async def notify_infraction( + bot: Bot, user: MemberOrUser, + infr_id: id, infr_type: str, expires_at: t.Optional[str] = None, reason: t.Optional[str] = None, @@ -186,7 +191,15 @@ async def notify_infraction( embed.title = INFRACTION_TITLE embed.url = RULES_URL - return await send_private_embed(user, embed) + dm_sent = await send_private_embed(user, embed) + if dm_sent: + await bot.api_client.patch( + f"bot/infractions/{infr_id}", + json={"dm_sent": True} + ) + log.debug(f"Update infraction #{infr_id} dm_sent field to true.") + + return dm_sent async def notify_pardon( diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index a833eb227..b77c20434 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -352,6 +352,7 @@ class ModManagement(commands.Cog): user = infraction["user"] expires_at = infraction["expires_at"] created = time.format_infraction(infraction["inserted_at"]) + dm_sent = infraction["dm_sent"] # Format the user string. if user_obj := self.bot.get_user(user["id"]): @@ -377,11 +378,18 @@ class ModManagement(commands.Cog): date_to = dateutil.parser.isoparse(expires_at) duration = humanize_delta(relativedelta(date_to, date_from)) + # Format `dm_sent` + if dm_sent is None: + dm_sent_text = "N/A" + else: + dm_sent_text = "Yes" if dm_sent else "No" + lines = textwrap.dedent(f""" {"**===============**" if active else "==============="} Status: {"__**Active**__" if active else "Inactive"} User: {user_str} Type: **{infraction["type"]}** + DM Sent: {dm_sent_text} Shadow: {infraction["hidden"]} Created: {created} Expires: {remaining} -- cgit v1.2.3 From 1f327a54640a781026dc223597f8e2a306751460 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 16 Nov 2021 09:40:31 +0000 Subject: Fix tests --- tests/bot/exts/moderation/infraction/test_utils.py | 27 +++++++++++++++------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index 72eebb254..999dbd1c6 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -19,6 +19,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.member = MockMember(id=1234) self.user = MockUser(id=1234) self.ctx = MockContext(bot=self.bot, author=self.member) + self.maxDiff = None async def test_post_user(self): """Should POST a new user and return the response if successful or otherwise send an error message.""" @@ -132,7 +133,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): """ test_cases = [ { - "args": (self.user, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"), + "args": (self.bot, self.user, 0, "ban", "2020-02-26 09:20 (23 hours and 59 minutes)"), "expected_output": Embed( title=utils.INFRACTION_TITLE, description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( @@ -150,7 +151,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "send_result": True }, { - "args": (self.user, "warning", None, "Test reason."), + "args": (self.bot, self.user, 0, "warning", None, "Test reason."), "expected_output": Embed( title=utils.INFRACTION_TITLE, description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( @@ -170,7 +171,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): # Note that this test case asserts that the DM that *would* get sent to the user is formatted # correctly, even though that message is deliberately never sent. { - "args": (self.user, "note", None, None, Icons.defcon_denied), + "args": (self.bot, self.user, 0, "note", None, None, Icons.defcon_denied), "expected_output": Embed( title=utils.INFRACTION_TITLE, description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( @@ -188,7 +189,15 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "send_result": False }, { - "args": (self.user, "mute", "2020-02-26 09:20 (23 hours and 59 minutes)", "Test", Icons.defcon_denied), + "args": ( + self.bot, + self.user, + 0, + "mute", + "2020-02-26 09:20 (23 hours and 59 minutes)", + "Test", + Icons.defcon_denied + ), "expected_output": Embed( title=utils.INFRACTION_TITLE, description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( @@ -206,7 +215,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): "send_result": False }, { - "args": (self.user, "mute", None, "foo bar" * 4000, Icons.defcon_denied), + "args": (self.bot, self.user, 0, "mute", None, "foo bar" * 4000, Icons.defcon_denied), "expected_output": Embed( title=utils.INFRACTION_TITLE, description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( @@ -238,7 +247,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(embed.to_dict(), case["expected_output"].to_dict()) - send_private_embed_mock.assert_awaited_once_with(case["args"][0], embed) + send_private_embed_mock.assert_awaited_once_with(case["args"][1], embed) @patch("bot.exts.moderation.infraction._utils.send_private_embed") async def test_notify_pardon(self, send_private_embed_mock): @@ -313,7 +322,8 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): "type": "ban", "user": self.member.id, "active": False, - "expires_at": now.isoformat() + "expires_at": now.isoformat(), + "dm_sent": False } self.ctx.bot.api_client.post.return_value = "foo" @@ -350,7 +360,8 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): "reason": "Test reason", "type": "mute", "user": self.user.id, - "active": True + "active": True, + "dm_sent": False } self.bot.api_client.post.side_effect = [ResponseCodeError(MagicMock(status=400), {"user": "foo"}), "foo"] -- cgit v1.2.3 From 6cebf897f0f1fa767bb593cfb7208a3d8b3a43c5 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 16 Nov 2021 10:13:06 +0000 Subject: Add ability to reply to message for `!remind` --- bot/exts/utils/reminders.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 86e4505fa..90677b2dd 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -214,7 +214,7 @@ class Reminders(Cog): @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) async def remind_group( - self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: str + self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: t.Optional[str] = None ) -> None: """ Commands for managing your reminders. @@ -234,7 +234,7 @@ class Reminders(Cog): @remind_group.command(name="new", aliases=("add", "create")) async def new_reminder( - self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: str + self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: t.Optional[str] = None ) -> None: """ Set yourself a simple reminder. @@ -283,6 +283,20 @@ class Reminders(Cog): mention_ids = [mention.id for mention in mentions] + # If `content` isn't provided then we try to get message content of a replied message + if not content: + if reference := ctx.message.reference: + if isinstance((resolved_message := reference.resolved), discord.Message): + content = resolved_message.content + # If we weren't able to get the content of a replied message + if content is None: + await send_denial(ctx, "Your reminder must have a content and/or reply to a message.") + return + + # If the replied message has no content (e.g. only attachments/embeds) + if content == "": + content = "See referenced message." + # Now we can attempt to actually set the reminder. reminder = await self.bot.api_client.post( 'bot/reminders', -- cgit v1.2.3 From 9cdfe35abcc2379db8b4d98d9c74f3c60e230984 Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Tue, 16 Nov 2021 14:08:34 +0000 Subject: Don't log threads in admin channels (#1954) This change disables the mod-log for any changes to threads in channels that mods don't have read perms to. Co-authored-by: Kieran Siek --- bot/exts/moderation/modlog.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 6416bc3c7..91709e5e5 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -775,6 +775,10 @@ class ModLog(Cog, name="ModLog"): @Cog.listener() async def on_thread_update(self, before: Thread, after: Thread) -> None: """Log thread archiving, un-archiving and name edits.""" + if self.is_channel_ignored(after.id): + log.trace("Ignoring update of thread %s (%d)", after.mention, after.id) + return + if before.name != after.name: await self.send_log_message( Icons.hash_blurple, @@ -811,6 +815,10 @@ class ModLog(Cog, name="ModLog"): @Cog.listener() async def on_thread_delete(self, thread: Thread) -> None: """Log thread deletion.""" + if self.is_channel_ignored(thread.id): + log.trace("Ignoring deletion of thread %s (%d)", thread.mention, thread.id) + return + await self.send_log_message( Icons.hash_red, Colours.soft_red, @@ -829,6 +837,10 @@ class ModLog(Cog, name="ModLog"): if thread.me: return + if self.is_channel_ignored(thread.id): + log.trace("Ignoring creation of thread %s (%d)", thread.mention, thread.id) + return + await self.send_log_message( Icons.hash_green, Colours.soft_green, -- cgit v1.2.3 From b55a2b94ea666f6891bcbc1c4d0e67857b1900ef Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Fri, 19 Nov 2021 20:00:44 +0000 Subject: Remove unneeded new lines These new lines made the output embed look far to spaced out. --- bot/exts/moderation/infraction/_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index c0ef80e3d..bb3cc5380 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -30,9 +30,9 @@ Infraction = t.Dict[str, t.Union[str, int, bool]] APPEAL_SERVER_INVITE = "https://discord.gg/WXrCJxWBnm" INFRACTION_TITLE = "Please review our rules" -INFRACTION_APPEAL_SERVER_FOOTER = f"\n\nTo appeal this infraction, join our [appeals server]({APPEAL_SERVER_INVITE})." +INFRACTION_APPEAL_SERVER_FOOTER = f"\nTo appeal this infraction, join our [appeals server]({APPEAL_SERVER_INVITE})." INFRACTION_APPEAL_MODMAIL_FOOTER = ( - '\n\nIf you would like to discuss or appeal this infraction, ' + '\nIf you would like to discuss or appeal this infraction, ' 'send a message to the ModMail bot.' ) INFRACTION_AUTHOR_NAME = "Infraction information" -- cgit v1.2.3 From d14a15886301ba660564bfd80480d46d5a435e65 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sat, 20 Nov 2021 23:04:01 +0300 Subject: Disable File Logging By Default Place logging to file behind an environment variable, and remove special considerations made for it. Signed-off-by: Hassan Abouelela --- bot/constants.py | 1 + bot/log.py | 15 ++++++++------- config-default.yml | 3 ++- docker-compose.yml | 1 - 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 2dfdd51e2..36b917734 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -685,6 +685,7 @@ class VideoPermission(metaclass=YAMLGetter): # Debug mode DEBUG_MODE: bool = _CONFIG_YAML["debug"] == "true" +FILE_LOGS: bool = _CONFIG_YAML["file_logs"].lower() == "true" # Paths BOT_DIR = os.path.dirname(__file__) diff --git a/bot/log.py b/bot/log.py index b3cecdcf2..100cd06f6 100644 --- a/bot/log.py +++ b/bot/log.py @@ -48,16 +48,17 @@ def setup() -> None: logging.addLevelName(TRACE_LEVEL, "TRACE") logging.setLoggerClass(CustomLogger) + root_log = get_logger() + format_string = "%(asctime)s | %(name)s | %(levelname)s | %(message)s" log_format = logging.Formatter(format_string) - log_file = Path("logs", "bot.log") - log_file.parent.mkdir(exist_ok=True) - file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8") - file_handler.setFormatter(log_format) - - root_log = get_logger() - root_log.addHandler(file_handler) + if constants.FILE_LOGS: + log_file = Path("logs", "bot.log") + log_file.parent.mkdir(exist_ok=True) + file_handler = handlers.RotatingFileHandler(log_file, maxBytes=5242880, backupCount=7, encoding="utf8") + file_handler.setFormatter(log_format) + root_log.addHandler(file_handler) if "COLOREDLOGS_LEVEL_STYLES" not in os.environ: coloredlogs.DEFAULT_LEVEL_STYLES = { diff --git a/config-default.yml b/config-default.yml index ed3c3a638..7400cf200 100644 --- a/config-default.yml +++ b/config-default.yml @@ -1,4 +1,5 @@ -debug: !ENV ["BOT_DEBUG", "true"] +debug: !ENV ["BOT_DEBUG", "true"] +file_logs: !ENV ["FILE_LOGS", "false"] bot: diff --git a/docker-compose.yml b/docker-compose.yml index b3ca6baa4..869d9acb6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -90,7 +90,6 @@ services: context: . dockerfile: Dockerfile volumes: - - ./logs:/bot/logs - .:/bot:ro tty: true depends_on: -- cgit v1.2.3 From 1872bb12766bf3c56284ef614b5ab22166b488e5 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 22 Nov 2021 18:14:43 +0000 Subject: Deal with activity_blocks not being returned by site We are planning to change metricity endpoints on site so that activcity_blocks are not returned if the user has more than 1000 messages. This is because the query to calculate those blocks can get expensive at a high message count. To deal with this, both places activity_blocks are used has been changed to reflect this planned behaviour. --- bot/exts/info/information.py | 7 ++++++- bot/exts/moderation/voice_gate.py | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index dab2dbb6c..5b48495dc 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -422,7 +422,12 @@ class Information(Cog): activity_output = "No activity" else: activity_output.append(user_activity["total_messages"] or "No messages") - activity_output.append(user_activity["activity_blocks"] or "No activity") + + if (activity_blocks := user_activity.get("activity_blocks")) is not None: + # activity_blocks is not included in the response if the user has a lot of messages + activity_output.append(activity_blocks or "No activity") # Special case when activity_blocks is 0. + else: + activity_output.append("Too many to count!") activity_output = "\n".join( f"{name}: {metric}" for name, metric in zip(["Messages", "Activity blocks"], activity_output) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 31799ec73..ae55a03a0 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -171,8 +171,12 @@ class VoiceGate(Cog): ), "total_messages": data["total_messages"] < GateConf.minimum_messages, "voice_banned": data["voice_banned"], - "activity_blocks": data["activity_blocks"] < GateConf.minimum_activity_blocks } + if activity_blocks := data.get("activity_blocks"): + # activity_blocks is not included in the response if the user has a lot of messages. + # Only check if the user has enough activity blocks if it is included. + checks["activity_blocks"] = activity_blocks < GateConf.minimum_activity_blocks + failed = any(checks.values()) failed_reasons = [MESSAGE_FIELD_MAP[key] for key, value in checks.items() if value is True] [self.bot.stats.incr(f"voice_gate.failed.{key}") for key, value in checks.items() if value is True] -- cgit v1.2.3 From 2024766f182b1491e8d2031e3e26d0830563e578 Mon Sep 17 00:00:00 2001 From: zephyrus <75779179+git-zephyrus@users.noreply.github.com> Date: Wed, 24 Nov 2021 21:57:48 +0530 Subject: Suppress NotFound error when cleaning messages * Added suppress for notfound error * Update clean.py --- bot/exts/moderation/clean.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 94494b983..826265aa3 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -293,7 +293,8 @@ class Clean(Cog): return deleted if len(to_delete) > 0: # Deleting any leftover messages if there are any - await channel.delete_messages(to_delete) + with suppress(NotFound): + await channel.delete_messages(to_delete) deleted.extend(to_delete) if not self.cleaning: -- cgit v1.2.3 From a6be95385edc1caccd84dc83a8d11ece86847c8b Mon Sep 17 00:00:00 2001 From: Izan Date: Thu, 25 Nov 2021 19:55:33 +0000 Subject: Remove debug `maxDiff` assignment. --- tests/bot/exts/moderation/infraction/test_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index 999dbd1c6..350274ecd 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -19,7 +19,6 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): self.member = MockMember(id=1234) self.user = MockUser(id=1234) self.ctx = MockContext(bot=self.bot, author=self.member) - self.maxDiff = None async def test_post_user(self): """Should POST a new user and return the response if successful or otherwise send an error message.""" -- cgit v1.2.3 From 68f27b730c27d58805556291f833030d37446425 Mon Sep 17 00:00:00 2001 From: Ben Soyka Date: Sat, 27 Nov 2021 15:57:49 -0700 Subject: Limit length of the invalid rule indices message --- bot/exts/info/site.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index e8e71558b..3b4b561c7 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -1,3 +1,5 @@ +from textwrap import shorten + from discord import Colour, Embed from discord.ext.commands import Cog, Context, Greedy, group @@ -123,7 +125,7 @@ class Site(Cog): # Remove duplicates and sort the rule indices rules = sorted(set(rules)) - invalid = ', '.join(str(index) for index in rules if index < 1 or index > len(full_rules)) + invalid = shorten(', '.join(str(index) for index in rules if index < 1 or index > len(full_rules)), 50) if invalid: await ctx.send(f":x: Invalid rule indices: {invalid}") -- cgit v1.2.3 From 94944ca0bd3dae1c97483f93563846eea7380dfb Mon Sep 17 00:00:00 2001 From: Ben Soyka Date: Sat, 27 Nov 2021 16:10:40 -0700 Subject: Change placeholder for invalid rules message shortening ... is used everywhere else across the codebase where extwrap.shorten is used, so I'm making it match here. --- bot/exts/info/site.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index 3b4b561c7..bcb04c909 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -125,7 +125,8 @@ class Site(Cog): # Remove duplicates and sort the rule indices rules = sorted(set(rules)) - invalid = shorten(', '.join(str(index) for index in rules if index < 1 or index > len(full_rules)), 50) + invalid = shorten(', '.join(str(index) for index in rules if index + < 1 or index > len(full_rules)), 50, placeholder='...') if invalid: await ctx.send(f":x: Invalid rule indices: {invalid}") -- cgit v1.2.3 From 7e8ecb4f2acc7e1e88d4c053091926c07965293d Mon Sep 17 00:00:00 2001 From: Ben Soyka Date: Sat, 27 Nov 2021 16:18:26 -0700 Subject: Add missing space in text shortening placeholder --- bot/exts/info/site.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index bcb04c909..c622441bd 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -126,7 +126,7 @@ class Site(Cog): # Remove duplicates and sort the rule indices rules = sorted(set(rules)) invalid = shorten(', '.join(str(index) for index in rules if index - < 1 or index > len(full_rules)), 50, placeholder='...') + < 1 or index > len(full_rules)), 50, placeholder=' ...') if invalid: await ctx.send(f":x: Invalid rule indices: {invalid}") -- cgit v1.2.3 From d870f28027c708fef3f0e1cc035196e727485cce Mon Sep 17 00:00:00 2001 From: Ben Soyka Date: Sat, 27 Nov 2021 16:33:42 -0700 Subject: Refactor long line Doing this similar to how the docs command works for shortening --- bot/exts/info/site.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index c622441bd..f6499ecce 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -125,11 +125,11 @@ class Site(Cog): # Remove duplicates and sort the rule indices rules = sorted(set(rules)) - invalid = shorten(', '.join(str(index) for index in rules if index - < 1 or index > len(full_rules)), 50, placeholder=' ...') + + invalid = ', '.join(str(index) for index in rules if index < 1 or index > len(full_rules)) if invalid: - await ctx.send(f":x: Invalid rule indices: {invalid}") + await ctx.send(shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=' ...')) return for rule in rules: -- cgit v1.2.3 From b57af0e076ef3e7eb1f7035429e56664ddf1ed55 Mon Sep 17 00:00:00 2001 From: Ben Soyka Date: Sun, 28 Nov 2021 01:47:05 -0700 Subject: Use bright_green for "Currently Helping" DMs (#1979) Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/exts/help_channels/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 944a99917..0c411df04 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -590,7 +590,7 @@ class HelpChannels(commands.Cog): embed = discord.Embed( title="Currently Helping", description=f"You're currently helping in {message.channel.mention}", - color=constants.Colours.soft_green, + color=constants.Colours.bright_green, timestamp=message.created_at ) embed.add_field(name="Conversation", value=f"[Jump to message]({message.jump_url})") -- cgit v1.2.3 From 8680df24222dc4b4828cd2df78f8f2b44d0b1e27 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 12 Oct 2021 20:30:40 +0100 Subject: Move handle_role_change to a util file --- bot/exts/help_channels/_cog.py | 30 +++++++++--------------------- bot/utils/members.py | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 0c411df04..60209ba6e 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -66,6 +66,9 @@ class HelpChannels(commands.Cog): self.bot = bot self.scheduler = scheduling.Scheduler(self.__class__.__name__) + self.guild: discord.Guild = None + self.cooldown_role: discord.Role = None + # Categories self.available_category: discord.CategoryChannel = None self.in_use_category: discord.CategoryChannel = None @@ -95,24 +98,6 @@ class HelpChannels(commands.Cog): self.scheduler.cancel_all() - async def _handle_role_change(self, member: discord.Member, coro: t.Callable[..., t.Coroutine]) -> None: - """ - Change `member`'s cooldown role via awaiting `coro` and handle errors. - - `coro` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. - """ - try: - await coro(self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.help_cooldown)) - except discord.NotFound: - log.debug(f"Failed to change role for {member} ({member.id}): member not found") - except discord.Forbidden: - log.debug( - f"Forbidden to change role for {member} ({member.id}); " - f"possibly due to role hierarchy" - ) - except discord.HTTPException as e: - log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") - @lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id")) @lock.lock_arg(NAMESPACE, "message", attrgetter("author.id")) @lock.lock_arg(f"{NAMESPACE}.unclaim", "message", attrgetter("author.id"), wait=True) @@ -130,7 +115,7 @@ class HelpChannels(commands.Cog): if not isinstance(message.author, discord.Member): log.debug(f"{message.author} ({message.author.id}) isn't a member. Not giving cooldown role or sending DM.") else: - await self._handle_role_change(message.author, message.author.add_roles) + await members.handle_role_change(message.author, message.author.add_roles, self.cooldown_role) try: await _message.dm_on_open(message) @@ -302,6 +287,9 @@ class HelpChannels(commands.Cog): await self.bot.wait_until_guild_available() log.trace("Initialising the cog.") + self.guild = self.bot.get_guild(constants.Guild.id) + self.cooldown_role = self.guild.get_role(constants.Roles.help_cooldown) + await self.init_categories() self.channel_queue = self.create_channel_queue() @@ -445,11 +433,11 @@ class HelpChannels(commands.Cog): await _caches.claimants.delete(channel.id) await _caches.session_participants.delete(channel.id) - claimant = await members.get_or_fetch_member(self.bot.get_guild(constants.Guild.id), claimant_id) + claimant = await members.get_or_fetch_member(self.guild, claimant_id) if claimant is None: log.info(f"{claimant_id} left the guild during their help session; the cooldown role won't be removed") else: - await self._handle_role_change(claimant, claimant.remove_roles) + await members.handle_role_change(claimant, claimant.remove_roles, self.cooldown_role) await _message.unpin(channel) await _stats.report_complete_session(channel.id, closed_on) diff --git a/bot/utils/members.py b/bot/utils/members.py index 77ddf1696..693286045 100644 --- a/bot/utils/members.py +++ b/bot/utils/members.py @@ -23,3 +23,26 @@ async def get_or_fetch_member(guild: discord.Guild, member_id: int) -> t.Optiona return None log.trace("%s fetched from API.", member) return member + + +async def handle_role_change( + member: discord.Member, + coro: t.Callable[..., t.Coroutine], + role: discord.Role +) -> None: + """ + Change `member`'s cooldown role via awaiting `coro` and handle errors. + + `coro` is intended to be `discord.Member.add_roles` or `discord.Member.remove_roles`. + """ + try: + await coro(role) + except discord.NotFound: + log.debug(f"Failed to change role for {member} ({member.id}): member not found") + except discord.Forbidden: + log.debug( + f"Forbidden to change role for {member} ({member.id}); " + f"possibly due to role hierarchy" + ) + except discord.HTTPException as e: + log.error(f"Failed to change role for {member} ({member.id}): {e.status} {e.code}") -- cgit v1.2.3 From 0465db98be1d739eea69e8a2f7cf4b939c65c96d Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 12 Oct 2021 20:31:25 +0100 Subject: Remove the subscribe command from the verification cog --- bot/exts/moderation/verification.py | 71 +++---------------------------------- 1 file changed, 4 insertions(+), 67 deletions(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index ed5571d2a..37338d19c 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -5,9 +5,7 @@ from discord.ext.commands import Cog, Context, command, has_any_role from bot import constants from bot.bot import Bot -from bot.decorators import in_whitelist from bot.log import get_logger -from bot.utils.checks import InWhitelistCheckFailure log = get_logger(__name__) @@ -29,11 +27,11 @@ You can find a copy of our rules for reference at -from time to time, you can send `!subscribe` to <#{constants.Channels.bot_commands}> at any time \ +from time to time, you can send `{constants.Bot.prefix}subscribe` to <#{constants.Channels.bot_commands}> at any time \ to assign yourself the **Announcements** role. We'll mention this role every time we make an announcement. -If you'd like to unsubscribe from the announcement notifications, simply send `!unsubscribe` to \ -<#{constants.Channels.bot_commands}>. +If you'd like to unsubscribe from the announcement notifications, simply send `{constants.Bot.prefix}subscribe` to \ +<#{constants.Channels.bot_commands}> and click the role again!. To introduce you to our community, we've made the following video: https://youtu.be/ZH26PuX3re0 @@ -61,11 +59,9 @@ async def safe_dm(coro: t.Coroutine) -> None: class Verification(Cog): """ - User verification and role management. + User verification. Statistics are collected in the 'verification.' namespace. - - Additionally, this cog offers the !subscribe and !unsubscribe commands, """ def __init__(self, bot: Bot) -> None: @@ -107,68 +103,9 @@ class Verification(Cog): except discord.HTTPException: log.exception("DM dispatch failed on unexpected error code") - # endregion - # region: subscribe commands - - @command(name='subscribe') - @in_whitelist(channels=(constants.Channels.bot_commands,)) - async def subscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args - """Subscribe to announcement notifications by assigning yourself the role.""" - has_role = False - - for role in ctx.author.roles: - if role.id == constants.Roles.announcements: - has_role = True - break - - if has_role: - await ctx.send(f"{ctx.author.mention} You're already subscribed!") - return - - log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.") - await ctx.author.add_roles(discord.Object(constants.Roles.announcements), reason="Subscribed to announcements") - - log.trace(f"Deleting the message posted by {ctx.author}.") - - await ctx.send( - f"{ctx.author.mention} Subscribed to <#{constants.Channels.announcements}> notifications.", - ) - - @command(name='unsubscribe') - @in_whitelist(channels=(constants.Channels.bot_commands,)) - async def unsubscribe_command(self, ctx: Context, *_) -> None: # We don't actually care about the args - """Unsubscribe from announcement notifications by removing the role from yourself.""" - has_role = False - - for role in ctx.author.roles: - if role.id == constants.Roles.announcements: - has_role = True - break - - if not has_role: - await ctx.send(f"{ctx.author.mention} You're already unsubscribed!") - return - - log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.") - await ctx.author.remove_roles( - discord.Object(constants.Roles.announcements), reason="Unsubscribed from announcements" - ) - - log.trace(f"Deleting the message posted by {ctx.author}.") - - await ctx.send( - f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications." - ) - # endregion # region: miscellaneous - # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: Context, error: Exception) -> None: - """Check for & ignore any InWhitelistCheckFailure.""" - if isinstance(error, InWhitelistCheckFailure): - error.handled = True - @command(name='verify') @has_any_role(*constants.MODERATION_ROLES) async def perform_manual_verification(self, ctx: Context, user: discord.Member) -> None: -- cgit v1.2.3 From 5df26bafa58ed036333eb1d4fa7438cf93c4b7c9 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 12 Oct 2021 20:38:13 +0100 Subject: Add self assignable roles to config --- bot/constants.py | 5 +++++ config-default.yml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 36b917734..36a92da1f 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -484,7 +484,12 @@ class Roles(metaclass=YAMLGetter): section = "guild" subsection = "roles" + # Self-assignable roles, see the Subscribe cog + advent_of_code: int announcements: int + lovefest: int + pyweek_announcements: int + contributors: int help_cooldown: int muted: int diff --git a/config-default.yml b/config-default.yml index 7400cf200..0d3ddc005 100644 --- a/config-default.yml +++ b/config-default.yml @@ -264,7 +264,12 @@ guild: - *BLACK_FORMATTER roles: + # Self-assignable roles, see the Subscribe cog + advent_of_code: 518565788744024082 announcements: 463658397560995840 + lovefest: 542431903886606399 + pyweek_announcements: 897568414044938310 + contributors: 295488872404484098 help_cooldown: 699189276025421825 muted: &MUTED_ROLE 277914926603829249 -- cgit v1.2.3 From 4f7010912ccc75ea1415bc5e1e10fbce17c43b69 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 12 Oct 2021 20:38:54 +0100 Subject: Add an interactive subscribe command This command gives the users a set of buttons to click to add or remove pre-determined announcement roles. Adding or removing a role updates the button state to reflect the change and what would happen if the user clicks the button again. --- bot/exts/info/subscribe.py | 139 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 bot/exts/info/subscribe.py diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py new file mode 100644 index 000000000..edf8e8f9e --- /dev/null +++ b/bot/exts/info/subscribe.py @@ -0,0 +1,139 @@ +import logging + +import arrow +import discord +from discord.ext import commands +from discord.interactions import Interaction + +from bot import constants +from bot.bot import Bot +from bot.decorators import in_whitelist +from bot.utils import checks, members, scheduling + +# Tuple of tuples, where each inner tuple is a role id and a month number. +# The month number signifies what month the role should be assignable, +# use None for the month number if it should always be active. +ASSIGNABLE_ROLES = ( + (constants.Roles.announcements, None), + (constants.Roles.pyweek_announcements, None), + (constants.Roles.lovefest, 2), + (constants.Roles.advent_of_code, 12), +) +ITEMS_PER_ROW = 3 + +log = logging.getLogger(__name__) + + +class RoleButtonView(discord.ui.View): + """A list of SingleRoleButtons to show to the member.""" + + def __init__(self, member: discord.Member): + super().__init__() + self.interaction_owner = member + + async def interaction_check(self, interaction: Interaction) -> bool: + """Ensure that the user clicking the button is the member who invoked the command.""" + if interaction.user != self.interaction_owner: + await interaction.response.send_message( + ":x: This is not your command to react to!", + ephemeral=True + ) + return False + return True + + +class SingleRoleButton(discord.ui.Button): + """A button that adds or removes a role from the member depending on it's current state.""" + + ADD_STYLE = discord.ButtonStyle.success + REMOVE_STYLE = discord.ButtonStyle.secondary + LABEL_FORMAT = "{action} role {role_name}" + CUSTOM_ID_FORMAT = "subscribe-{role_id}" + + def __init__(self, role: discord.Role, assigned: bool, row: int): + super().__init__( + style=self.REMOVE_STYLE if assigned else self.ADD_STYLE, + label=self.LABEL_FORMAT.format(action="Remove" if assigned else "Add", role_name=role.name), + custom_id=self.CUSTOM_ID_FORMAT.format(role_id=role.id), + row=row, + ) + self.role = role + self.assigned = assigned + + async def callback(self, interaction: Interaction) -> None: + """Update the member's role and change button text to reflect current text.""" + await members.handle_role_change( + interaction.user, + interaction.user.remove_roles if self.assigned else interaction.user.add_roles, + self.role, + ) + + self.assigned = not self.assigned + await self.update_view(interaction) + await interaction.response.send_message( + self.LABEL_FORMAT.format(action="Added" if self.assigned else "Removed", role_name=self.role.name), + ephemeral=True, + ) + + async def update_view(self, interaction: Interaction) -> None: + """Updates the original interaction message with a new view object with the updated buttons.""" + self.style = self.REMOVE_STYLE if self.assigned else self.ADD_STYLE + self.label = self.LABEL_FORMAT.format(action="Remove" if self.assigned else "Add", role_name=self.role.name) + try: + await interaction.message.edit(view=self.view) + except discord.NotFound: + log.debug("Subscribe message for %s removed before buttons could be updated", interaction.user) + self.view.stop() + + +class Subscribe(commands.Cog): + """Cog to allow user to self-assign & remove the roles present in ASSIGNABLE_ROLES.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.init_task = scheduling.create_task(self.init_cog(), event_loop=self.bot.loop) + self.assignable_roles: list[discord.Role] = [] + self.guild: discord.Guild = None + + async def init_cog(self) -> None: + """Initialise the cog by resolving the role IDs in ASSIGNABLE_ROLES to role names.""" + await self.bot.wait_until_guild_available() + + current_month = arrow.utcnow().month + self.guild = self.bot.get_guild(constants.Guild.id) + + for role_id, month_available in ASSIGNABLE_ROLES: + if month_available is not None and month_available != current_month: + continue + role = self.guild.get_role(role_id) + if role is None: + log.warning("Could not resolve %d to a role in the guild, skipping.", role_id) + continue + self.assignable_roles.append(role) + + @commands.command(name="subscribe") + @in_whitelist(channels=(constants.Channels.bot_commands,)) + async def subscribe_command(self, ctx: commands.Context, *_) -> None: # We don't actually care about the args + """Display the member's current state for each role, and allow them to add/remove the roles.""" + await self.init_task + + button_view = RoleButtonView(ctx.author) + for index, role in enumerate(self.assignable_roles): + row = index // ITEMS_PER_ROW + button_view.add_item(SingleRoleButton(role, role in ctx.author.roles, row)) + + await ctx.send("Click the buttons below to add or remove your roles!", view=button_view) + + # This cannot be static (must have a __func__ attribute). + async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None: + """Check for & ignore any InWhitelistCheckFailure.""" + if isinstance(error, checks.InWhitelistCheckFailure): + error.handled = True + + +def setup(bot: Bot) -> None: + """Load the Subscribe cog.""" + if len(ASSIGNABLE_ROLES) > ITEMS_PER_ROW*5: # Discord limits views to 5 rows of buttons. + log.error("Too many roles for 5 rows, not loading the Subscribe cog.") + else: + bot.add_cog(Subscribe(bot)) -- cgit v1.2.3 From 4c982870749f3545c971c20eb19a3c5eafe67668 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 13 Oct 2021 09:34:07 +0100 Subject: Ensure the user interacting is still in guild before changing roles --- bot/exts/info/subscribe.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index edf8e8f9e..bf3120a3a 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -62,6 +62,10 @@ class SingleRoleButton(discord.ui.Button): async def callback(self, interaction: Interaction) -> None: """Update the member's role and change button text to reflect current text.""" + if isinstance(interaction.user, discord.User): + log.trace("User %s is not a member", interaction.user) + await interaction.message.delete() + return await members.handle_role_change( interaction.user, interaction.user.remove_roles if self.assigned else interaction.user.add_roles, -- cgit v1.2.3 From 1d7765c5629efaccdd4741b8fd6640f7fd6dab09 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 13 Oct 2021 17:49:51 +0100 Subject: Add 10s member cooldown to subscribe command --- bot/exts/info/subscribe.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index bf3120a3a..121fa3685 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -115,6 +115,7 @@ class Subscribe(commands.Cog): continue self.assignable_roles.append(role) + @commands.cooldown(1, 10, commands.BucketType.member) @commands.command(name="subscribe") @in_whitelist(channels=(constants.Channels.bot_commands,)) async def subscribe_command(self, ctx: commands.Context, *_) -> None: # We don't actually care about the args -- cgit v1.2.3 From 9a3be9ee23df63792d942950ccb378750ddc3ac7 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 13 Oct 2021 17:51:28 +0100 Subject: Stop listening for events when message is deleted --- bot/exts/info/subscribe.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index 121fa3685..5dad013d1 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -65,7 +65,9 @@ class SingleRoleButton(discord.ui.Button): if isinstance(interaction.user, discord.User): log.trace("User %s is not a member", interaction.user) await interaction.message.delete() + self.view.stop() return + await members.handle_role_change( interaction.user, interaction.user.remove_roles if self.assigned else interaction.user.add_roles, -- cgit v1.2.3 From b748d1310b2c731ac46e0bbc864d4d28a5439b37 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 17 Oct 2021 09:34:18 +0100 Subject: Use new get_logger helper util --- bot/exts/info/subscribe.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index 5dad013d1..a2a0de976 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -1,5 +1,3 @@ -import logging - import arrow import discord from discord.ext import commands @@ -8,6 +6,7 @@ from discord.interactions import Interaction from bot import constants from bot.bot import Bot from bot.decorators import in_whitelist +from bot.log import get_logger from bot.utils import checks, members, scheduling # Tuple of tuples, where each inner tuple is a role id and a month number. @@ -21,7 +20,7 @@ ASSIGNABLE_ROLES = ( ) ITEMS_PER_ROW = 3 -log = logging.getLogger(__name__) +log = get_logger(__name__) class RoleButtonView(discord.ui.View): -- cgit v1.2.3 From 8b109837e0cba62574ef4269e512d3fe23f6b37e Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 30 Nov 2021 10:30:31 +0000 Subject: Delete the subscribe message after 5 minutes --- bot/exts/info/subscribe.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index a2a0de976..17bb24dca 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -19,6 +19,7 @@ ASSIGNABLE_ROLES = ( (constants.Roles.advent_of_code, 12), ) ITEMS_PER_ROW = 3 +DELETE_MESSAGE_AFTER = 300 # Seconds log = get_logger(__name__) @@ -128,7 +129,11 @@ class Subscribe(commands.Cog): row = index // ITEMS_PER_ROW button_view.add_item(SingleRoleButton(role, role in ctx.author.roles, row)) - await ctx.send("Click the buttons below to add or remove your roles!", view=button_view) + await ctx.send( + "Click the buttons below to add or remove your roles!", + view=button_view, + delete_after=DELETE_MESSAGE_AFTER, + ) # This cannot be static (must have a __func__ attribute). async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None: -- cgit v1.2.3 From 7f22abfd3ec443cf0925f2c6e609be681c723799 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 30 Nov 2021 11:31:30 +0000 Subject: Allow roles to be assignable over multiple months This includes a refactor to use a dataclass for clearer implementation. Along with that, this changes the roles so that they're always available, but un-assignable roles are in red and give a different error. --- bot/exts/info/subscribe.py | 95 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 72 insertions(+), 23 deletions(-) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index 17bb24dca..9b96e7ab2 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -1,3 +1,7 @@ +import calendar +import typing as t +from dataclasses import dataclass + import arrow import discord from discord.ext import commands @@ -9,15 +13,44 @@ from bot.decorators import in_whitelist from bot.log import get_logger from bot.utils import checks, members, scheduling -# Tuple of tuples, where each inner tuple is a role id and a month number. -# The month number signifies what month the role should be assignable, -# use None for the month number if it should always be active. + +@dataclass(frozen=True) +class AssignableRole: + """ + A role that can be assigned to a user. + + months_available is a tuple that signifies what months the role should be + self-assignable, using None for when it should always be available. + """ + + role_id: int + months_available: t.Optional[tuple[int]] + name: t.Optional[str] = None # This gets populated within Subscribe.init_cog() + + def is_currently_available(self) -> bool: + """Check if the role is available for the current month.""" + if self.months_available is None: + return True + return arrow.utcnow().month in self.months_available + + def get_readable_available_months(self) -> str: + """Get a readable string of the months the role is available.""" + if self.months_available is None: + return f"{self.name} is always available." + + # Join the months together with comma separators, but use "and" for the final seperator. + month_names = [calendar.month_name[month] for month in self.months_available] + available_months_str = ", ".join(month_names[:-1]) + f" and {month_names[-1]}" + return f"{self.name} can only be assigned during {available_months_str}." + + ASSIGNABLE_ROLES = ( - (constants.Roles.announcements, None), - (constants.Roles.pyweek_announcements, None), - (constants.Roles.lovefest, 2), - (constants.Roles.advent_of_code, 12), + AssignableRole(constants.Roles.announcements, None), + AssignableRole(constants.Roles.pyweek_announcements, None), + AssignableRole(constants.Roles.lovefest, (1, 2)), + AssignableRole(constants.Roles.advent_of_code, (11, 12)), ) + ITEMS_PER_ROW = 3 DELETE_MESSAGE_AFTER = 300 # Seconds @@ -47,14 +80,22 @@ class SingleRoleButton(discord.ui.Button): ADD_STYLE = discord.ButtonStyle.success REMOVE_STYLE = discord.ButtonStyle.secondary - LABEL_FORMAT = "{action} role {role_name}" + UNAVAILABLE_STYLE = discord.ButtonStyle.red + LABEL_FORMAT = "{action} role {role_name}." CUSTOM_ID_FORMAT = "subscribe-{role_id}" - def __init__(self, role: discord.Role, assigned: bool, row: int): + def __init__(self, role: AssignableRole, assigned: bool, row: int): + if role.is_currently_available(): + style = self.REMOVE_STYLE if assigned else self.ADD_STYLE + label = self.LABEL_FORMAT.format(action="Remove" if assigned else "Add", role_name=role.name) + else: + style = self.UNAVAILABLE_STYLE + label = role.name + super().__init__( - style=self.REMOVE_STYLE if assigned else self.ADD_STYLE, - label=self.LABEL_FORMAT.format(action="Remove" if assigned else "Add", role_name=role.name), - custom_id=self.CUSTOM_ID_FORMAT.format(role_id=role.id), + style=style, + label=label, + custom_id=self.CUSTOM_ID_FORMAT.format(role_id=role.role_id), row=row, ) self.role = role @@ -68,10 +109,14 @@ class SingleRoleButton(discord.ui.Button): self.view.stop() return + if not self.role.is_currently_available(): + await interaction.response.send_message(self.role.get_readable_available_months(), ephemeral=True) + return + await members.handle_role_change( interaction.user, interaction.user.remove_roles if self.assigned else interaction.user.add_roles, - self.role, + discord.Object(self.role.role_id), ) self.assigned = not self.assigned @@ -98,24 +143,27 @@ class Subscribe(commands.Cog): def __init__(self, bot: Bot): self.bot = bot self.init_task = scheduling.create_task(self.init_cog(), event_loop=self.bot.loop) - self.assignable_roles: list[discord.Role] = [] + self.assignable_roles: list[AssignableRole] = [] self.guild: discord.Guild = None async def init_cog(self) -> None: """Initialise the cog by resolving the role IDs in ASSIGNABLE_ROLES to role names.""" await self.bot.wait_until_guild_available() - current_month = arrow.utcnow().month self.guild = self.bot.get_guild(constants.Guild.id) - for role_id, month_available in ASSIGNABLE_ROLES: - if month_available is not None and month_available != current_month: - continue - role = self.guild.get_role(role_id) - if role is None: - log.warning("Could not resolve %d to a role in the guild, skipping.", role_id) + for role in ASSIGNABLE_ROLES: + discord_role = self.guild.get_role(role.role_id) + if discord_role is None: + log.warning("Could not resolve %d to a role in the guild, skipping.", role.role_id) continue - self.assignable_roles.append(role) + self.assignable_roles.append( + AssignableRole( + role_id=role.role_id, + months_available=role.months_available, + name=discord_role.name, + ) + ) @commands.cooldown(1, 10, commands.BucketType.member) @commands.command(name="subscribe") @@ -125,9 +173,10 @@ class Subscribe(commands.Cog): await self.init_task button_view = RoleButtonView(ctx.author) + author_roles = [role.id for role in ctx.author.roles] for index, role in enumerate(self.assignable_roles): row = index // ITEMS_PER_ROW - button_view.add_item(SingleRoleButton(role, role in ctx.author.roles, row)) + button_view.add_item(SingleRoleButton(role, role.role_id in author_roles, row)) await ctx.send( "Click the buttons below to add or remove your roles!", -- cgit v1.2.3 From 19eef3ed7135572ad52bbf145278efcdd142b0c0 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 30 Nov 2021 11:36:15 +0000 Subject: Sort unavailable self-assignable roles to the end of the list --- bot/exts/info/subscribe.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index 9b96e7ab2..d24e8716e 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -1,4 +1,5 @@ import calendar +import operator import typing as t from dataclasses import dataclass @@ -164,6 +165,8 @@ class Subscribe(commands.Cog): name=discord_role.name, ) ) + # Sort unavailable roles to the end of the list + self.assignable_roles.sort(key=operator.methodcaller("is_currently_available"), reverse=True) @commands.cooldown(1, 10, commands.BucketType.member) @commands.command(name="subscribe") -- cgit v1.2.3 From 005af3bc34310d9374bfd1deeaf37da080c7fee1 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 30 Nov 2021 14:06:28 +0000 Subject: Swap remove and unavailable colours for subscribe command --- bot/exts/info/subscribe.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index d24e8716e..d097e6290 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -80,8 +80,8 @@ class SingleRoleButton(discord.ui.Button): """A button that adds or removes a role from the member depending on it's current state.""" ADD_STYLE = discord.ButtonStyle.success - REMOVE_STYLE = discord.ButtonStyle.secondary - UNAVAILABLE_STYLE = discord.ButtonStyle.red + REMOVE_STYLE = discord.ButtonStyle.red + UNAVAILABLE_STYLE = discord.ButtonStyle.secondary LABEL_FORMAT = "{action} role {role_name}." CUSTOM_ID_FORMAT = "subscribe-{role_id}" -- cgit v1.2.3 From 57c1b8e6bbadf8139597e7105d6681a13781b69a Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 30 Nov 2021 14:06:53 +0000 Subject: Add lock emoji to highlight unavailable self-assignable roles --- bot/exts/info/subscribe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index d097e6290..16379d2b2 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -91,7 +91,7 @@ class SingleRoleButton(discord.ui.Button): label = self.LABEL_FORMAT.format(action="Remove" if assigned else "Add", role_name=role.name) else: style = self.UNAVAILABLE_STYLE - label = role.name + label = f"🔒 {role.name}" super().__init__( style=style, -- cgit v1.2.3 From aecb093afc95d28b85a63714cde9ae33e9068ae8 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 30 Nov 2021 14:07:16 +0000 Subject: Subscribe command replies to invocation to keep context --- bot/exts/info/subscribe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index 16379d2b2..2e6101d27 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -181,7 +181,7 @@ class Subscribe(commands.Cog): row = index // ITEMS_PER_ROW button_view.add_item(SingleRoleButton(role, role.role_id in author_roles, row)) - await ctx.send( + await ctx.reply( "Click the buttons below to add or remove your roles!", view=button_view, delete_after=DELETE_MESSAGE_AFTER, -- cgit v1.2.3 From edb18d5f5be3d1dfcfdcfa72bcbf0915e321b895 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 1 Nov 2021 17:16:05 +0000 Subject: Add thread archive time enum to constants --- bot/constants.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 36b917734..93da6a906 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -683,10 +683,22 @@ class VideoPermission(metaclass=YAMLGetter): default_permission_duration: int +class ThreadArchiveTimes(Enum): + HOUR = 60 + DAY = 1440 + THREE_DAY = 4230 + WEEK = 10080 + + # Debug mode DEBUG_MODE: bool = _CONFIG_YAML["debug"] == "true" FILE_LOGS: bool = _CONFIG_YAML["file_logs"].lower() == "true" +if DEBUG_MODE: + DEFAULT_THREAD_ARCHIVE_TIME = ThreadArchiveTimes.HOUR.value +else: + DEFAULT_THREAD_ARCHIVE_TIME = ThreadArchiveTimes.WEEK.value + # Paths BOT_DIR = os.path.dirname(__file__) PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir)) -- cgit v1.2.3 From 292a500d9ebb51b8efc023baf39b76d98d05cae0 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 1 Nov 2021 17:19:36 +0000 Subject: Refactor make_review to return nominee too --- bot/exts/recruitment/talentpool/_cog.py | 2 +- bot/exts/recruitment/talentpool/_review.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 2fafaec97..699d60f42 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -483,7 +483,7 @@ class TalentPool(Cog, name="Talentpool"): @has_any_role(*MODERATION_ROLES) async def get_review(self, ctx: Context, user_id: int) -> None: """Get the user's review as a markdown file.""" - review = (await self.reviewer.make_review(user_id))[0] + review, _, _ = await self.reviewer.make_review(user_id) if review: file = discord.File(StringIO(review), f"{user_id}_review.md") await ctx.send(file=file) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index d880c524c..6b5fae3b1 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -78,14 +78,14 @@ class Reviewer: async def post_review(self, user_id: int, update_database: bool) -> None: """Format the review of a user and post it to the nomination voting channel.""" - review, reviewed_emoji = await self.make_review(user_id) + review, reviewed_emoji, nominee = await self.make_review(user_id) if not review: return guild = self.bot.get_guild(Guild.id) channel = guild.get_channel(Channels.nomination_voting) - log.trace(f"Posting the review of {user_id}") + log.trace(f"Posting the review of {nominee} ({nominee.id})") messages = await self._bulk_send(channel, review) await pin_no_system_message(messages[0]) @@ -113,14 +113,14 @@ class Reviewer: return "", None guild = self.bot.get_guild(Guild.id) - member = await get_or_fetch_member(guild, user_id) + nominee = await get_or_fetch_member(guild, user_id) - if not member: + if not nominee: return ( f"I tried to review the user with ID `{user_id}`, but they don't appear to be on the server :pensive:" - ), None + ), None, None - opening = f"{member.mention} ({member}) for Helper!" + opening = f"{nominee.mention} ({nominee}) for Helper!" current_nominations = "\n\n".join( f"**<@{entry['actor']}>:** {entry['reason'] or '*no reason given*'}" @@ -128,7 +128,7 @@ class Reviewer: ) current_nominations = f"**Nominated by:**\n{current_nominations}" - review_body = await self._construct_review_body(member) + review_body = await self._construct_review_body(nominee) reviewed_emoji = self._random_ducky(guild) vote_request = ( @@ -138,7 +138,7 @@ class Reviewer: ) review = "\n\n".join((opening, current_nominations, review_body, vote_request)) - return review, reviewed_emoji + return review, reviewed_emoji, nominee async def archive_vote(self, message: PartialMessage, passed: bool) -> None: """Archive this vote to #nomination-archive.""" -- cgit v1.2.3 From c217c3ef658954f2d491529a2a5c2085a285c229 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 1 Nov 2021 17:20:42 +0000 Subject: Manage nomination threads This change creates a thread while posting the nomination, and then archives it once the nomination is concluded. --- bot/exts/recruitment/talentpool/_review.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 6b5fae3b1..bc5cccda1 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -15,7 +15,7 @@ from discord.ext.commands import Context from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Colours, Emojis, Guild +from bot.constants import Channels, Colours, DEFAULT_THREAD_ARCHIVE_TIME, Emojis, Guild, Roles from bot.log import get_logger from bot.utils.members import get_or_fetch_member from bot.utils.messages import count_unique_users_reaction, pin_no_system_message @@ -95,6 +95,12 @@ class Reviewer: for reaction in (reviewed_emoji, "\N{THUMBS UP SIGN}", "\N{THUMBS DOWN SIGN}"): await last_message.add_reaction(reaction) + thread = await last_message.create_thread( + name=f"Nomination - {nominee}", + auto_archive_duration=DEFAULT_THREAD_ARCHIVE_TIME + ) + await thread.send(fr"<@&{Roles.mod_team}> <@&{Roles.admins}>") + if update_database: nomination = self._pool.cache.get(user_id) await self.bot.api_client.patch(f"bot/nominations/{nomination['id']}", json={"reviewed": True}) @@ -210,6 +216,13 @@ class Reviewer: colour=colour )) + # Thread channel IDs are the same as the message ID of the parent message. + nomination_thread = message.guild.get_thread(message.id) + if not nomination_thread: + log.warning(f"Could not find a thread linked to {message.channel.id}-{message.id}") + return + await nomination_thread.edit(archived=True) + for message_ in messages: await message_.delete() -- cgit v1.2.3 From 6bd2a56d43d70476d18c5fd66da20d8cf1518373 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 1 Nov 2021 18:52:03 +0000 Subject: Update nomination message regex --- bot/exts/recruitment/talentpool/_review.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index bc5cccda1..8b61a0eb5 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -36,9 +36,8 @@ MAX_MESSAGE_SIZE = 2000 MAX_EMBED_SIZE = 4000 # Regex for finding the first message of a nomination, and extracting the nominee. -# Historic nominations will have 2 role mentions at the start, new ones won't, optionally match for this. NOMINATION_MESSAGE_REGEX = re.compile( - r"(?:<@&\d+> <@&\d+>\n)*?<@!?(\d+?)> \(.+#\d{4}\) for Helper!\n\n\*\*Nominated by:\*\*", + r"<@!?(\d+)> \(.+#\d{4}\) for Helper!\n\n", re.MULTILINE ) -- cgit v1.2.3 From 0a4ba0b5d6341bc8cef13a30e35af5b4dc24248b Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 4 Nov 2021 15:50:14 +0000 Subject: Supress NotFound when archiving a nomination This supresses both the mesage deleteions and the thread archive, so that if they are removed before the code can get to them, it does not raise an error. --- bot/exts/recruitment/talentpool/_review.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 8b61a0eb5..fab126408 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -10,7 +10,7 @@ from typing import List, Optional, Union import arrow from dateutil.parser import isoparse -from discord import Embed, Emoji, Member, Message, NoMoreItems, PartialMessage, TextChannel +from discord import Embed, Emoji, Member, Message, NoMoreItems, NotFound, PartialMessage, TextChannel from discord.ext.commands import Context from bot.api import ResponseCodeError @@ -220,10 +220,13 @@ class Reviewer: if not nomination_thread: log.warning(f"Could not find a thread linked to {message.channel.id}-{message.id}") return - await nomination_thread.edit(archived=True) for message_ in messages: - await message_.delete() + with contextlib.suppress(NotFound): + await message_.delete() + + with contextlib.suppress(NotFound): + await nomination_thread.edit(archived=True) async def _construct_review_body(self, member: Member) -> str: """Formats the body of the nomination, with details of activity, infractions, and previous nominations.""" -- cgit v1.2.3 From e62ff5b4d0cd811e40d54e94ae5ae6d48f934624 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 1 Dec 2021 00:19:42 +0000 Subject: Ensure a nomination archival emoji isn't from the bot This is most relevant in local dev testing where the Emojis.check_mark could be the same as the Emojis.incident_actioned or Emojis.incident_unactioned, which would cause the bot to attempt to archive the post_review invocation if it was posted in the nomination voting channel. --- bot/exts/recruitment/talentpool/_cog.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 699d60f42..615a95d20 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -516,6 +516,9 @@ class TalentPool(Cog, name="Talentpool"): if payload.channel_id != Channels.nomination_voting: return + if payload.user_id == self.bot.user.id: + return + message: PartialMessage = self.bot.get_channel(payload.channel_id).get_partial_message(payload.message_id) emoji = str(payload.emoji) -- cgit v1.2.3 From 96911a9c9b6e833e68fb2ead081d12da4ca5ffd9 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 1 Dec 2021 00:54:31 +0000 Subject: Fix emoji reaction error in reviewer Using a :eyes: style emoji string in a ctx.add_reaciton call will error. Discord expects either a unicode emoji, or a custom emoji. --- bot/exts/recruitment/talentpool/_review.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index fab126408..eced33738 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -375,10 +375,10 @@ class Reviewer: @staticmethod def _random_ducky(guild: Guild) -> Union[Emoji, str]: - """Picks a random ducky emoji. If no duckies found returns :eyes:.""" + """Picks a random ducky emoji. If no duckies found returns 👀.""" duckies = [emoji for emoji in guild.emojis if emoji.name.startswith("ducky")] if not duckies: - return ":eyes:" + return "\N{EYES}" return random.choice(duckies) @staticmethod -- cgit v1.2.3 From 108bf3276b49de4e6153a2c7f96c731907e3ca37 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 1 Dec 2021 01:04:14 +0000 Subject: Always return a review string for a given nomination --- bot/exts/recruitment/talentpool/_cog.py | 7 ++----- bot/exts/recruitment/talentpool/_review.py | 6 +++--- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 615a95d20..ce0b2862f 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -484,11 +484,8 @@ class TalentPool(Cog, name="Talentpool"): async def get_review(self, ctx: Context, user_id: int) -> None: """Get the user's review as a markdown file.""" review, _, _ = await self.reviewer.make_review(user_id) - if review: - file = discord.File(StringIO(review), f"{user_id}_review.md") - await ctx.send(file=file) - else: - await ctx.send(f"There doesn't appear to be an active nomination for {user_id}") + file = discord.File(StringIO(review), f"{user_id}_review.md") + await ctx.send(file=file) @nomination_group.command(aliases=('review',)) @has_any_role(*MODERATION_ROLES) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index eced33738..a68169351 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -104,8 +104,8 @@ class Reviewer: nomination = self._pool.cache.get(user_id) await self.bot.api_client.patch(f"bot/nominations/{nomination['id']}", json={"reviewed": True}) - async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji]]: - """Format a generic review of a user and return it with the reviewed emoji.""" + async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji], Optional[Member]]: + """Format a generic review of a user and return it with the reviewed emoji and the user themselves.""" log.trace(f"Formatting the review of {user_id}") # Since `cache` is a defaultdict, we should take care @@ -115,7 +115,7 @@ class Reviewer: nomination = self._pool.cache.get(user_id) if not nomination: log.trace(f"There doesn't appear to be an active nomination for {user_id}") - return "", None + return f"There doesn't appear to be an active nomination for {user_id}", None, None guild = self.bot.get_guild(Guild.id) nominee = await get_or_fetch_member(guild, user_id) -- cgit v1.2.3 From 8c89ef922c5445f93e26e69ea4a65e5a2ceaf79e Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 1 Dec 2021 01:05:17 +0000 Subject: Use presence of a nominee as check for pending reviews --- bot/exts/recruitment/talentpool/_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index a68169351..110ac47bc 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -78,7 +78,7 @@ class Reviewer: async def post_review(self, user_id: int, update_database: bool) -> None: """Format the review of a user and post it to the nomination voting channel.""" review, reviewed_emoji, nominee = await self.make_review(user_id) - if not review: + if not nominee: return guild = self.bot.get_guild(Guild.id) -- cgit v1.2.3 From 6af87373ee3b97509d67ab611780c7e7892f4545 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 1 Dec 2021 01:05:36 +0000 Subject: Remove redundant Union in a type hint --- bot/exts/recruitment/talentpool/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index ce0b2862f..8fa0be5b1 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -498,7 +498,7 @@ class TalentPool(Cog, name="Talentpool"): await ctx.message.add_reaction(Emojis.check_mark) @Cog.listener() - async def on_member_ban(self, guild: Guild, user: Union[MemberOrUser]) -> None: + async def on_member_ban(self, guild: Guild, user: MemberOrUser) -> None: """Remove `user` from the talent pool after they are banned.""" await self.end_nomination(user.id, "User was banned.") -- cgit v1.2.3 From 8408fb5686a7af43ee9ee9f8c192574e34a5f931 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Thu, 2 Dec 2021 00:44:25 +0200 Subject: Dynamic views for command help embeds (#1939) Dynamic views for command help embeds Adds views for commands to navigate groups. For subcommands, a button is added to show the parent's help embed. For groups, buttons are added for each subcommand to show their help embeds. The views are not generated when help is invoked in the context of an error. --- bot/exts/backend/error_handler.py | 13 ++- bot/exts/info/help.py | 147 ++++++++++++++++++++++++--- tests/bot/exts/backend/test_error_handler.py | 32 ------ 3 files changed, 141 insertions(+), 51 deletions(-) diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 6ab6634a6..5bef72808 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -1,5 +1,4 @@ import difflib -import typing as t from discord import Embed from discord.ext.commands import ChannelNotFound, Cog, Context, TextChannelConverter, VoiceChannelConverter, errors @@ -97,13 +96,14 @@ class ErrorHandler(Cog): # MaxConcurrencyReached, ExtensionError await self.handle_unexpected_error(ctx, e) - @staticmethod - def get_help_command(ctx: Context) -> t.Coroutine: + async def send_command_help(self, ctx: Context) -> None: """Return a prepared `help` command invocation coroutine.""" if ctx.command: - return ctx.send_help(ctx.command) + self.bot.help_command.context = ctx + await ctx.send_help(ctx.command) + return - return ctx.send_help() + await ctx.send_help() async def try_silence(self, ctx: Context) -> bool: """ @@ -245,7 +245,6 @@ class ErrorHandler(Cog): elif isinstance(e, errors.ArgumentParsingError): embed = self._get_error_embed("Argument parsing error", str(e)) await ctx.send(embed=embed) - self.get_help_command(ctx).close() self.bot.stats.incr("errors.argument_parsing_error") return else: @@ -256,7 +255,7 @@ class ErrorHandler(Cog): self.bot.stats.incr("errors.other_user_input_error") await ctx.send(embed=embed) - await self.get_help_command(ctx) + await self.send_command_help(ctx) @staticmethod async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 743dfdd3f..06799fb71 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -1,10 +1,12 @@ +from __future__ import annotations + import itertools import re from collections import namedtuple from contextlib import suppress -from typing import List, Union +from typing import List, Optional, Union -from discord import Colour, Embed +from discord import ButtonStyle, Colour, Embed, Emoji, Interaction, PartialEmoji, ui from discord.ext.commands import Bot, Cog, Command, CommandError, Context, DisabledCommand, Group, HelpCommand from rapidfuzz import fuzz, process from rapidfuzz.utils import default_process @@ -26,6 +28,119 @@ NOT_ALLOWED_TO_RUN_MESSAGE = "***You cannot run this command.***\n\n" Category = namedtuple("Category", ["name", "description", "cogs"]) +class SubcommandButton(ui.Button): + """ + A button shown in a group's help embed. + + The button represents a subcommand, and pressing it will edit the help embed to that of the subcommand. + """ + + def __init__( + self, + help_command: CustomHelpCommand, + command: Command, + *, + style: ButtonStyle = ButtonStyle.primary, + label: Optional[str] = None, + disabled: bool = False, + custom_id: Optional[str] = None, + url: Optional[str] = None, + emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, + row: Optional[int] = None + ): + super().__init__( + style=style, label=label, disabled=disabled, custom_id=custom_id, url=url, emoji=emoji, row=row + ) + + self.help_command = help_command + self.command = command + + async def callback(self, interaction: Interaction) -> None: + """Edits the help embed to that of the subcommand.""" + message = interaction.message + if not message: + return + + subcommand = self.command + if isinstance(subcommand, Group): + embed, subcommand_view = await self.help_command.format_group_help(subcommand) + else: + embed, subcommand_view = await self.help_command.command_formatting(subcommand) + await message.edit(embed=embed, view=subcommand_view) + + +class GroupButton(ui.Button): + """ + A button shown in a subcommand's help embed. + + The button represents the parent command, and pressing it will edit the help embed to that of the parent. + """ + + def __init__( + self, + help_command: CustomHelpCommand, + command: Command, + *, + style: ButtonStyle = ButtonStyle.secondary, + label: Optional[str] = None, + disabled: bool = False, + custom_id: Optional[str] = None, + url: Optional[str] = None, + emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, + row: Optional[int] = None + ): + super().__init__( + style=style, label=label, disabled=disabled, custom_id=custom_id, url=url, emoji=emoji, row=row + ) + + self.help_command = help_command + self.command = command + + async def callback(self, interaction: Interaction) -> None: + """Edits the help embed to that of the parent.""" + message = interaction.message + if not message: + return + + embed, group_view = await self.help_command.format_group_help(self.command.parent) + await message.edit(embed=embed, view=group_view) + + +class CommandView(ui.View): + """ + The view added to any command's help embed. + + If the command has a parent, a button is added to the view to show that parent's help embed. + """ + + def __init__(self, help_command: CustomHelpCommand, command: Command): + super().__init__() + + if command.parent: + self.children.append(GroupButton(help_command, command, emoji="↩️")) + + +class GroupView(CommandView): + """ + The view added to a group's help embed. + + The view generates a SubcommandButton for every subcommand the group has. + """ + + MAX_BUTTONS_IN_ROW = 5 + MAX_ROWS = 5 + + def __init__(self, help_command: CustomHelpCommand, group: Group, subcommands: list[Command]): + super().__init__(help_command, group) + # Don't add buttons if only a portion of the subcommands can be shown. + if len(subcommands) + len(self.children) > self.MAX_ROWS * self.MAX_BUTTONS_IN_ROW: + log.trace(f"Attempted to add navigation buttons for `{group.qualified_name}`, but there was no space.") + return + + for subcommand in subcommands: + self.add_item(SubcommandButton(help_command, subcommand, label=subcommand.name)) + + class HelpQueryNotFound(ValueError): """ Raised when a HelpSession Query doesn't match a command or cog. @@ -148,7 +263,7 @@ class CustomHelpCommand(HelpCommand): await self.context.send(embed=embed) - async def command_formatting(self, command: Command) -> Embed: + async def command_formatting(self, command: Command) -> tuple[Embed, Optional[CommandView]]: """ Takes a command and turns it into an embed. @@ -186,12 +301,14 @@ class CustomHelpCommand(HelpCommand): command_details += f"*{formatted_doc or 'No details provided.'}*\n" embed.description = command_details - return embed + # If the help is invoked in the context of an error, don't show subcommand navigation. + view = CommandView(self, command) if not self.context.command_failed else None + return embed, view async def send_command_help(self, command: Command) -> None: """Send help for a single command.""" - embed = await self.command_formatting(command) - message = await self.context.send(embed=embed) + embed, view = await self.command_formatting(command) + message = await self.context.send(embed=embed, view=view) await wait_for_deletion(message, (self.context.author.id,)) @staticmethod @@ -212,25 +329,31 @@ class CustomHelpCommand(HelpCommand): else: return "".join(details) - async def send_group_help(self, group: Group) -> None: - """Sends help for a group command.""" + async def format_group_help(self, group: Group) -> tuple[Embed, Optional[CommandView]]: + """Formats help for a group command.""" subcommands = group.commands if len(subcommands) == 0: # no subcommands, just treat it like a regular command - await self.send_command_help(group) - return + return await self.command_formatting(group) # remove commands that the user can't run and are hidden, and sort by name commands_ = await self.filter_commands(subcommands, sort=True) - embed = await self.command_formatting(group) + embed, _ = await self.command_formatting(group) command_details = self.get_commands_brief_details(commands_) if command_details: embed.description += f"\n**Subcommands:**\n{command_details}" - message = await self.context.send(embed=embed) + # If the help is invoked in the context of an error, don't show subcommand navigation. + view = GroupView(self, group, commands_) if not self.context.command_failed else None + return embed, view + + async def send_group_help(self, group: Group) -> None: + """Sends help for a group command.""" + embed, view = await self.format_group_help(group) + message = await self.context.send(embed=embed, view=view) await wait_for_deletion(message, (self.context.author.id,)) async def send_cog_help(self, cog: Cog) -> None: diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index 462f718e6..d12329b1f 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -572,38 +572,6 @@ class IndividualErrorHandlerTests(unittest.IsolatedAsyncioTestCase): push_scope_mock.set_extra.has_calls(set_extra_calls) -class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase): - """Other `ErrorHandler` tests.""" - - def setUp(self): - self.bot = MockBot() - self.ctx = MockContext() - - async def test_get_help_command_command_specified(self): - """Should return coroutine of help command of specified command.""" - self.ctx.command = "foo" - result = ErrorHandler.get_help_command(self.ctx) - expected = self.ctx.send_help("foo") - self.assertEqual(result.__qualname__, expected.__qualname__) - self.assertEqual(result.cr_frame.f_locals, expected.cr_frame.f_locals) - - # Await coroutines to avoid warnings - await result - await expected - - async def test_get_help_command_no_command_specified(self): - """Should return coroutine of help command.""" - self.ctx.command = None - result = ErrorHandler.get_help_command(self.ctx) - expected = self.ctx.send_help() - self.assertEqual(result.__qualname__, expected.__qualname__) - self.assertEqual(result.cr_frame.f_locals, expected.cr_frame.f_locals) - - # Await coroutines to avoid warnings - await result - await expected - - class ErrorHandlerSetupTests(unittest.TestCase): """Tests for `ErrorHandler` `setup` function.""" -- cgit v1.2.3 From 1f1ca41b172eda41a94e4ae556a923eee2d7cc26 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 1 Dec 2021 22:45:47 +0000 Subject: Sort subscribe roles alphabetically --- bot/exts/info/subscribe.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index 2e6101d27..4797f2347 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -165,7 +165,9 @@ class Subscribe(commands.Cog): name=discord_role.name, ) ) - # Sort unavailable roles to the end of the list + + # Sort by role name, then shift unavailable roles to the end of the list + self.assignable_roles.sort(key=operator.attrgetter("name")) self.assignable_roles.sort(key=operator.methodcaller("is_currently_available"), reverse=True) @commands.cooldown(1, 10, commands.BucketType.member) -- cgit v1.2.3 From 8265f206517ef1a35b03120993c8fab4e45bb88d Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 1 Dec 2021 22:47:25 +0000 Subject: Redirect subscribe command output to bot commands Instead of silently failing in channels other than bot commands for non-staff, the bot now instead redirects the command output to bot commands and pings the user. To facilitate this, I had to change the ctx.reply to a ctx.send since the invocation message may be in a different channel. --- bot/exts/info/subscribe.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/bot/exts/info/subscribe.py b/bot/exts/info/subscribe.py index 4797f2347..1299d5d59 100644 --- a/bot/exts/info/subscribe.py +++ b/bot/exts/info/subscribe.py @@ -10,9 +10,9 @@ from discord.interactions import Interaction from bot import constants from bot.bot import Bot -from bot.decorators import in_whitelist +from bot.decorators import redirect_output from bot.log import get_logger -from bot.utils import checks, members, scheduling +from bot.utils import members, scheduling @dataclass(frozen=True) @@ -172,7 +172,10 @@ class Subscribe(commands.Cog): @commands.cooldown(1, 10, commands.BucketType.member) @commands.command(name="subscribe") - @in_whitelist(channels=(constants.Channels.bot_commands,)) + @redirect_output( + destination_channel=constants.Channels.bot_commands, + bypass_roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES, + ) async def subscribe_command(self, ctx: commands.Context, *_) -> None: # We don't actually care about the args """Display the member's current state for each role, and allow them to add/remove the roles.""" await self.init_task @@ -183,18 +186,12 @@ class Subscribe(commands.Cog): row = index // ITEMS_PER_ROW button_view.add_item(SingleRoleButton(role, role.role_id in author_roles, row)) - await ctx.reply( + await ctx.send( "Click the buttons below to add or remove your roles!", view=button_view, delete_after=DELETE_MESSAGE_AFTER, ) - # This cannot be static (must have a __func__ attribute). - async def cog_command_error(self, ctx: commands.Context, error: Exception) -> None: - """Check for & ignore any InWhitelistCheckFailure.""" - if isinstance(error, checks.InWhitelistCheckFailure): - error.handled = True - def setup(bot: Bot) -> None: """Load the Subscribe cog.""" -- cgit v1.2.3 From e311048fb884738613201514991fb06f8403254b Mon Sep 17 00:00:00 2001 From: aru Date: Thu, 2 Dec 2021 13:30:51 -0500 Subject: set three_day to 4320, the number of minutes in 3 days --- bot/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index 3170c2915..52143132a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -691,7 +691,7 @@ class VideoPermission(metaclass=YAMLGetter): class ThreadArchiveTimes(Enum): HOUR = 60 DAY = 1440 - THREE_DAY = 4230 + THREE_DAY = 4320 WEEK = 10080 -- cgit v1.2.3 From 88e65f60437dfa9caf2064487e7294b4f029e2f6 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 2 Dec 2021 21:06:27 +0200 Subject: Remove cleaning based on number of messages All clean commands now use the clean limit (message, time delta, ISO datetime) instead of `traverse`. Consequently, `clean all` has been removed as `clean until` now effectively fulfills that role. --- bot/exts/moderation/clean.py | 93 +++++++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 49 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 826265aa3..8ad7a56d8 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -5,7 +5,6 @@ import time from collections import defaultdict from contextlib import suppress from datetime import datetime -from itertools import islice from typing import Any, Callable, Iterable, Literal, Optional, TYPE_CHECKING, Union from discord import Colour, Message, NotFound, TextChannel, User, errors @@ -21,8 +20,6 @@ from bot.utils.channel import is_mod_channel log = logging.getLogger(__name__) -# Default number of messages to look at in each channel. -DEFAULT_TRAVERSE = 10 # Number of seconds before command invocations and responses are deleted in non-moderation channels. MESSAGE_DELETE_DELAY = 5 @@ -87,7 +84,6 @@ class Clean(Cog): @staticmethod def _validate_input( - traverse: int, channels: Optional[CleanChannels], bots_only: bool, users: Optional[list[User]], @@ -95,9 +91,9 @@ class Clean(Cog): second_limit: Optional[CleanLimit], ) -> None: """Raise errors if an argument value or a combination of values is invalid.""" - # Is this an acceptable amount of messages to traverse? - if traverse > CleanMessages.message_limit: - raise BadArgument(f"Cannot traverse more than {CleanMessages.message_limit} messages.") + if first_limit is None: + # This is an optional argument for the sake of the master command, but it's actually required. + raise BadArgument("Missing cleaning limit.") if (isinstance(first_limit, Message) or isinstance(second_limit, Message)) and channels: raise BadArgument("Both a message limit and channels specified.") @@ -195,11 +191,11 @@ class Clean(Cog): # Invocation message has already been deleted log.info("Tried to delete invocation message, but it was already deleted.") - def _get_messages_from_cache(self, traverse: int, to_delete: Predicate) -> tuple[defaultdict[Any, list], list[int]]: + def _get_messages_from_cache(self, to_delete: Predicate) -> tuple[defaultdict[Any, list], list[int]]: """Helper function for getting messages from the cache.""" message_mappings = defaultdict(list) message_ids = [] - for message in islice(self.bot.cached_messages, traverse): + for message in self.bot.cached_messages: if not self.cleaning: # Cleaning was canceled return message_mappings, message_ids @@ -212,17 +208,16 @@ class Clean(Cog): async def _get_messages_from_channels( self, - traverse: int, channels: Iterable[TextChannel], to_delete: Predicate, - before: Optional[datetime] = None, + before: datetime, after: Optional[datetime] = None ) -> tuple[defaultdict[Any, list], list]: message_mappings = defaultdict(list) message_ids = [] for channel in channels: - async for message in channel.history(limit=traverse, before=before, after=after): + async for message in channel.history(limit=CleanMessages.message_limit, before=before, after=after): if not self.cleaning: # Cleaning was canceled, return empty containers. @@ -343,7 +338,6 @@ class Clean(Cog): async def _clean_messages( self, ctx: Context, - traverse: int, channels: Optional[CleanChannels], bots_only: bool = False, users: Optional[list[User]] = None, @@ -353,7 +347,7 @@ class Clean(Cog): use_cache: Optional[bool] = True ) -> None: """A helper function that does the actual message cleaning.""" - self._validate_input(traverse, channels, bots_only, users, first_limit, second_limit) + self._validate_input(channels, bots_only, users, first_limit, second_limit) # Are we already performing a clean? if self.cleaning: @@ -387,13 +381,12 @@ class Clean(Cog): await self._delete_invocation(ctx) if channels == "*" and use_cache: - message_mappings, message_ids = self._get_messages_from_cache(traverse=traverse, to_delete=predicate) + message_mappings, message_ids = self._get_messages_from_cache(to_delete=predicate) else: deletion_channels = channels if channels == "*": deletion_channels = [channel for channel in ctx.guild.channels if isinstance(channel, TextChannel)] message_mappings, message_ids = await self._get_messages_from_channels( - traverse=traverse, channels=deletion_channels, to_delete=predicate, before=second_limit, @@ -422,7 +415,6 @@ class Clean(Cog): self, ctx: Context, users: Greedy[User] = None, - traverse: Optional[int] = None, first_limit: Optional[CleanLimit] = None, second_limit: Optional[CleanLimit] = None, use_cache: Optional[bool] = None, @@ -437,11 +429,9 @@ class Clean(Cog): If arguments are provided, will act as a master command from which all subcommands can be derived. \u2003• `users`: A series of user mentions, ID's, or names. - \u2003• `traverse`: The number of messages to look at in each channel. If using the cache, will look at the - first `traverse` messages in the cache. \u2003• `first_limit` and `second_limit`: A message, a duration delta, or an ISO datetime. + At least one limit is required. If a message is provided, cleaning will happen in that channel, and channels cannot be provided. - If a limit is provided, multiple channels cannot be provided. If only one of them is provided, acts as `clean until`. If both are provided, acts as `clean between`. \u2003• `use_cache`: Whether to use the message cache. If not provided, will default to False unless an asterisk is used for the channels. @@ -451,20 +441,15 @@ class Clean(Cog): If the pattern contains spaces, it still needs to be enclosed in double quotes on top of that. \u2003• `channels`: A series of channels to delete in, or an asterisk to delete from all channels. """ - if not any([traverse, users, first_limit, second_limit, regex, channels]): + if not any([users, first_limit, second_limit, regex, channels]): await ctx.send_help(ctx.command) return - if not traverse: - if first_limit: - traverse = CleanMessages.message_limit - else: - traverse = DEFAULT_TRAVERSE if use_cache is None: use_cache = channels == "*" await self._clean_messages( - ctx, traverse, channels, bots_only, users, regex, first_limit, second_limit, use_cache + ctx, channels, bots_only, users, regex, first_limit, second_limit, use_cache ) @clean_group.command(name="user", aliases=["users"]) @@ -472,56 +457,68 @@ class Clean(Cog): self, ctx: Context, user: User, - traverse: Optional[int] = DEFAULT_TRAVERSE, + message_or_time: CleanLimit, use_cache: Optional[bool] = True, *, channels: CleanChannels = None ) -> None: - """Delete messages posted by the provided user, stop cleaning after traversing `traverse` messages.""" - await self._clean_messages(ctx, traverse, users=[user], channels=channels, use_cache=use_cache) + """ + Delete messages posted by the provided user, stop cleaning after reaching `message_or_time`. - @clean_group.command(name="all", aliases=["everything"]) - async def clean_all( - self, - ctx: Context, - traverse: Optional[int] = DEFAULT_TRAVERSE, - use_cache: Optional[bool] = True, - *, - channels: CleanChannels = None - ) -> None: - """Delete all messages, regardless of poster, stop cleaning after traversing `traverse` messages.""" - await self._clean_messages(ctx, traverse, channels=channels, use_cache=use_cache) + `message_or_time` can be either a message to stop at (exclusive), a timedelta for max message age, or an ISO + datetime. + + If a message is specified, `channels` cannot be specified. + """ + await self._clean_messages( + ctx, users=[user], channels=channels, first_limit=message_or_time, use_cache=use_cache + ) @clean_group.command(name="bots", aliases=["bot"]) async def clean_bots( self, ctx: Context, - traverse: Optional[int] = DEFAULT_TRAVERSE, + message_or_time: CleanLimit, use_cache: Optional[bool] = True, *, channels: CleanChannels = None ) -> None: - """Delete all messages posted by a bot, stop cleaning after traversing `traverse` messages.""" - await self._clean_messages(ctx, traverse, bots_only=True, channels=channels, use_cache=use_cache) + """ + Delete all messages posted by a bot, stop cleaning after traversing `traverse` messages. + + `message_or_time` can be either a message to stop at (exclusive), a timedelta for max message age, or an ISO + datetime. + + If a message is specified, `channels` cannot be specified. + """ + await self._clean_messages( + ctx, bots_only=True, channels=channels, first_limit=message_or_time, use_cache=use_cache + ) @clean_group.command(name="regex", aliases=["word", "expression", "pattern"]) async def clean_regex( self, ctx: Context, regex: Regex, - traverse: Optional[int] = DEFAULT_TRAVERSE, + message_or_time: CleanLimit, use_cache: Optional[bool] = True, *, channels: CleanChannels = None ) -> None: """ - Delete all messages that match a certain regex, stop cleaning after traversing `traverse` messages. + Delete all messages that match a certain regex, stop cleaning after reaching `message_or_time`. + + `message_or_time` can be either a message to stop at (exclusive), a timedelta for max message age, or an ISO + datetime. + If a message is specified, `channels` cannot be specified. The pattern must be provided enclosed in backticks. If the pattern contains spaces, it still needs to be enclosed in double quotes on top of that. For example: `[0-9]` """ - await self._clean_messages(ctx, traverse, regex=regex, channels=channels, use_cache=use_cache) + await self._clean_messages( + ctx, regex=regex, channels=channels, first_limit=message_or_time, use_cache=use_cache + ) @clean_group.command(name="until") async def clean_until( @@ -538,7 +535,6 @@ class Clean(Cog): """ await self._clean_messages( ctx, - CleanMessages.message_limit, channels=[channel] if channel else None, first_limit=until, ) @@ -562,7 +558,6 @@ class Clean(Cog): """ await self._clean_messages( ctx, - CleanMessages.message_limit, channels=[channel] if channel else None, first_limit=first_limit, second_limit=second_limit, -- cgit v1.2.3 From fbd35131a31669b8aff72dd6bc176ea6ae84d333 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Thu, 2 Dec 2021 14:08:45 -0500 Subject: remove default thread archive time as discord.py supports that already --- bot/constants.py | 5 ----- bot/exts/recruitment/talentpool/_review.py | 3 +-- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 3170c2915..a0978fae2 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -699,11 +699,6 @@ class ThreadArchiveTimes(Enum): DEBUG_MODE: bool = _CONFIG_YAML["debug"] == "true" FILE_LOGS: bool = _CONFIG_YAML["file_logs"].lower() == "true" -if DEBUG_MODE: - DEFAULT_THREAD_ARCHIVE_TIME = ThreadArchiveTimes.HOUR.value -else: - DEFAULT_THREAD_ARCHIVE_TIME = ThreadArchiveTimes.WEEK.value - # Paths BOT_DIR = os.path.dirname(__file__) PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir)) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 110ac47bc..f6b81ae50 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -15,7 +15,7 @@ from discord.ext.commands import Context from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Colours, DEFAULT_THREAD_ARCHIVE_TIME, Emojis, Guild, Roles +from bot.constants import Channels, Colours, Emojis, Guild, Roles from bot.log import get_logger from bot.utils.members import get_or_fetch_member from bot.utils.messages import count_unique_users_reaction, pin_no_system_message @@ -96,7 +96,6 @@ class Reviewer: thread = await last_message.create_thread( name=f"Nomination - {nominee}", - auto_archive_duration=DEFAULT_THREAD_ARCHIVE_TIME ) await thread.send(fr"<@&{Roles.mod_team}> <@&{Roles.admins}>") -- cgit v1.2.3 From 0df94eac77703943221e39b9e9898515e266a9ef Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 2 Dec 2021 22:21:35 +0200 Subject: Simplify cache usage Removes the cache usage argument from the clean commands. Cache usage is now an implementation detail. The cache will be used if the age of the oldest message requested for cleaning is younger than the oldest message in the cache. Additionally fixes the logger to the one used in the rest of the bot (caused by a faulty merge). --- bot/exts/moderation/clean.py | 65 ++++++++++++++++++-------------------------- 1 file changed, 26 insertions(+), 39 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 8ad7a56d8..bb6e44d6f 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -1,11 +1,11 @@ import contextlib -import logging import re import time from collections import defaultdict from contextlib import suppress from datetime import datetime -from typing import Any, Callable, Iterable, Literal, Optional, TYPE_CHECKING, Union +from itertools import takewhile +from typing import Callable, Iterable, Literal, Optional, TYPE_CHECKING, Union from discord import Colour, Message, NotFound, TextChannel, User, errors from discord.ext.commands import Cog, Context, Converter, Greedy, group, has_any_role @@ -16,9 +16,10 @@ from bot.bot import Bot from bot.constants import Channels, CleanMessages, Colours, Emojis, Event, Icons, MODERATION_ROLES from bot.converters import Age, ISODateTime from bot.exts.moderation.modlog import ModLog +from bot.log import get_logger from bot.utils.channel import is_mod_channel -log = logging.getLogger(__name__) +log = get_logger(__name__) # Number of seconds before command invocations and responses are deleted in non-moderation channels. MESSAGE_DELETE_DELAY = 5 @@ -191,11 +192,19 @@ class Clean(Cog): # Invocation message has already been deleted log.info("Tried to delete invocation message, but it was already deleted.") - def _get_messages_from_cache(self, to_delete: Predicate) -> tuple[defaultdict[Any, list], list[int]]: + def _use_cache(self, limit: datetime) -> bool: + """Tell whether all messages to be cleaned can be found in the cache.""" + return self.bot.cached_messages[0].created_at <= limit + + def _get_messages_from_cache( + self, + to_delete: Predicate, + lower_limit: datetime + ) -> tuple[defaultdict[TextChannel, list], list[int]]: """Helper function for getting messages from the cache.""" message_mappings = defaultdict(list) message_ids = [] - for message in self.bot.cached_messages: + for message in takewhile(lambda m: m.created_at > lower_limit, reversed(self.bot.cached_messages)): if not self.cleaning: # Cleaning was canceled return message_mappings, message_ids @@ -212,7 +221,7 @@ class Clean(Cog): to_delete: Predicate, before: datetime, after: Optional[datetime] = None - ) -> tuple[defaultdict[Any, list], list]: + ) -> tuple[defaultdict[TextChannel, list], list]: message_mappings = defaultdict(list) message_ids = [] @@ -344,7 +353,6 @@ class Clean(Cog): regex: Optional[re.Pattern] = None, first_limit: Optional[CleanLimit] = None, second_limit: Optional[CleanLimit] = None, - use_cache: Optional[bool] = True ) -> None: """A helper function that does the actual message cleaning.""" self._validate_input(channels, bots_only, users, first_limit, second_limit) @@ -380,9 +388,11 @@ class Clean(Cog): # Delete the invocation first await self._delete_invocation(ctx) - if channels == "*" and use_cache: - message_mappings, message_ids = self._get_messages_from_cache(to_delete=predicate) + if self._use_cache(first_limit): + log.trace(f"Messages for cleaning by {ctx.author.id} will be searched in the cache.") + message_mappings, message_ids = self._get_messages_from_cache(to_delete=predicate, lower_limit=first_limit) else: + log.trace(f"Messages for cleaning by {ctx.author.id} will be searched in channel histories.") deletion_channels = channels if channels == "*": deletion_channels = [channel for channel in ctx.guild.channels if isinstance(channel, TextChannel)] @@ -417,9 +427,8 @@ class Clean(Cog): users: Greedy[User] = None, first_limit: Optional[CleanLimit] = None, second_limit: Optional[CleanLimit] = None, - use_cache: Optional[bool] = None, - bots_only: Optional[bool] = False, regex: Optional[Regex] = None, + bots_only: Optional[bool] = False, *, channels: CleanChannels = None # "Optional" with discord.py silently ignores incorrect input. ) -> None: @@ -433,24 +442,17 @@ class Clean(Cog): At least one limit is required. If a message is provided, cleaning will happen in that channel, and channels cannot be provided. If only one of them is provided, acts as `clean until`. If both are provided, acts as `clean between`. - \u2003• `use_cache`: Whether to use the message cache. - If not provided, will default to False unless an asterisk is used for the channels. - \u2003• `bots_only`: Whether to delete only bots. If specified, users cannot be specified. \u2003• `regex`: A regex pattern the message must contain to be deleted. The pattern must be provided enclosed in backticks. If the pattern contains spaces, it still needs to be enclosed in double quotes on top of that. + \u2003• `bots_only`: Whether to delete only bots. If specified, users cannot be specified. \u2003• `channels`: A series of channels to delete in, or an asterisk to delete from all channels. """ if not any([users, first_limit, second_limit, regex, channels]): await ctx.send_help(ctx.command) return - if use_cache is None: - use_cache = channels == "*" - - await self._clean_messages( - ctx, channels, bots_only, users, regex, first_limit, second_limit, use_cache - ) + await self._clean_messages(ctx, channels, bots_only, users, regex, first_limit, second_limit) @clean_group.command(name="user", aliases=["users"]) async def clean_user( @@ -458,7 +460,6 @@ class Clean(Cog): ctx: Context, user: User, message_or_time: CleanLimit, - use_cache: Optional[bool] = True, *, channels: CleanChannels = None ) -> None: @@ -470,19 +471,10 @@ class Clean(Cog): If a message is specified, `channels` cannot be specified. """ - await self._clean_messages( - ctx, users=[user], channels=channels, first_limit=message_or_time, use_cache=use_cache - ) + await self._clean_messages(ctx, users=[user], channels=channels, first_limit=message_or_time) @clean_group.command(name="bots", aliases=["bot"]) - async def clean_bots( - self, - ctx: Context, - message_or_time: CleanLimit, - use_cache: Optional[bool] = True, - *, - channels: CleanChannels = None - ) -> None: + async def clean_bots(self, ctx: Context, message_or_time: CleanLimit, *, channels: CleanChannels = None) -> None: """ Delete all messages posted by a bot, stop cleaning after traversing `traverse` messages. @@ -491,9 +483,7 @@ class Clean(Cog): If a message is specified, `channels` cannot be specified. """ - await self._clean_messages( - ctx, bots_only=True, channels=channels, first_limit=message_or_time, use_cache=use_cache - ) + await self._clean_messages(ctx, bots_only=True, channels=channels, first_limit=message_or_time) @clean_group.command(name="regex", aliases=["word", "expression", "pattern"]) async def clean_regex( @@ -501,7 +491,6 @@ class Clean(Cog): ctx: Context, regex: Regex, message_or_time: CleanLimit, - use_cache: Optional[bool] = True, *, channels: CleanChannels = None ) -> None: @@ -516,9 +505,7 @@ class Clean(Cog): If the pattern contains spaces, it still needs to be enclosed in double quotes on top of that. For example: `[0-9]` """ - await self._clean_messages( - ctx, regex=regex, channels=channels, first_limit=message_or_time, use_cache=use_cache - ) + await self._clean_messages(ctx, regex=regex, channels=channels, first_limit=message_or_time) @clean_group.command(name="until") async def clean_until( -- cgit v1.2.3 From 89f991374b1d0e9d9f7312c3c715129a8bba6ac2 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 2 Dec 2021 22:34:06 +0200 Subject: Update _build_predicate to require a limit --- bot/exts/moderation/clean.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index bb6e44d6f..a08788fd6 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -119,11 +119,11 @@ class Clean(Cog): @staticmethod def _build_predicate( + first_limit: datetime, + second_limit: Optional[datetime] = None, bots_only: bool = False, users: Optional[list[User]] = None, regex: Optional[re.Pattern] = None, - first_limit: Optional[datetime] = None, - second_limit: Optional[datetime] = None, ) -> Predicate: """Return the predicate that decides whether to delete a given message.""" def predicate_bots_only(message: Message) -> bool: @@ -164,20 +164,18 @@ class Clean(Cog): predicates = [] # Set up the correct predicate + if second_limit: + predicates.append(predicate_range) # Delete messages in the specified age range + else: + predicates.append(predicate_after) # Delete messages older than the specified age + if bots_only: predicates.append(predicate_bots_only) # Delete messages from bots if users: predicates.append(predicate_specific_users) # Delete messages from specific user if regex: predicates.append(predicate_regex) # Delete messages that match regex - # Add up to one of the following: - if second_limit: - predicates.append(predicate_range) # Delete messages in the specified age range - elif first_limit: - predicates.append(predicate_after) # Delete messages older than specific message - if not predicates: - return lambda m: True if len(predicates) == 1: return predicates[0] return lambda m: all(pred(m) for pred in predicates) @@ -383,7 +381,7 @@ class Clean(Cog): first_limit, second_limit = sorted([first_limit, second_limit]) # Needs to be called after standardizing the input. - predicate = self._build_predicate(bots_only, users, regex, first_limit, second_limit) + predicate = self._build_predicate(first_limit, second_limit, bots_only, users, regex) # Delete the invocation first await self._delete_invocation(ctx) -- cgit v1.2.3 From 0150914b469ae5a8e4b407b4ffc1a15e70bad614 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Fri, 3 Dec 2021 13:35:39 +0300 Subject: Update PEP Repo URL The PEP github repo changed branch from master, to main, breaking our code. Switch the ref from master to main in our code. --- bot/exts/info/pep.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/pep.py b/bot/exts/info/pep.py index 259095b50..8c0db18bc 100644 --- a/bot/exts/info/pep.py +++ b/bot/exts/info/pep.py @@ -16,7 +16,7 @@ log = get_logger(__name__) ICON_URL = "https://www.python.org/static/opengraph-icon-200x200.png" BASE_PEP_URL = "http://www.python.org/dev/peps/pep-" -PEPS_LISTING_API_URL = "https://api.github.com/repos/python/peps/contents?ref=master" +PEPS_LISTING_API_URL = "https://api.github.com/repos/python/peps/contents?ref=main" pep_cache = AsyncCache() -- cgit v1.2.3 From 20eecf06513aaff02a6c8531d90cff0b3b7addce Mon Sep 17 00:00:00 2001 From: mbaruh Date: Fri, 3 Dec 2021 15:32:17 +0200 Subject: Remove now redundant input check. --- bot/exts/moderation/clean.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index a08788fd6..3def2a416 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -107,10 +107,6 @@ class Clean(Cog): if users and bots_only: raise BadArgument("Marked as bots only, but users were specified.") - # This is an implementation error rather than user error. - if second_limit and not first_limit: - raise ValueError("Second limit specified without the first.") - @staticmethod async def _send_expiring_message(ctx: Context, content: str) -> None: """Send `content` to the context channel. Automatically delete if it's not a mod channel.""" -- cgit v1.2.3 From db85e56baf7edbd204fae42572d01923ec398840 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 3 Dec 2021 14:56:09 +0000 Subject: Attepmt to fetch un-cached nomination threads on archive Fixes BOT-1R0 Fixes #1992 The time between a vote passing and the helper being helpered can sometimes be >7 days, meaning the thread may have auto-archived by then. We should deal with this by trying to fetch the threead from the API if it's not cached. --- bot/exts/recruitment/talentpool/_review.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index f6b81ae50..0e7194892 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -217,8 +217,11 @@ class Reviewer: # Thread channel IDs are the same as the message ID of the parent message. nomination_thread = message.guild.get_thread(message.id) if not nomination_thread: - log.warning(f"Could not find a thread linked to {message.channel.id}-{message.id}") - return + try: + nomination_thread = await message.guild.fetch_channel(message.id) + except NotFound: + log.warning(f"Could not find a thread linked to {message.channel.id}-{message.id}") + return for message_ in messages: with contextlib.suppress(NotFound): -- cgit v1.2.3 From 0a3e7ea31e430b9a1474fa321ed771358ad7d952 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 3 Dec 2021 00:03:29 +0000 Subject: Patch d.py's message convertor to infer channelID from the given context Discord.py's Message convertor is supposed to infer channelID based on ctx.channel if only a messageID is given. A 'refactor' (linked below) a few weeks before d.py's archival broke this, so that if only a messageID is given to the convertor, it will only find that message if it's in the bot's cache. Co-authored-by: Hassan Abouelela --- bot/__init__.py | 5 +++++ bot/monkey_patches.py | 23 +++++++++++++++++++++++ bot/utils/regex.py | 1 + 3 files changed, 29 insertions(+) diff --git a/bot/__init__.py b/bot/__init__.py index a1c4466f1..17d99105a 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -18,6 +18,11 @@ if os.name == "nt": monkey_patches.patch_typing() +# This patches any convertors that use PartialMessage, but not the PartialMessageConverter itself +# as library objects are made by this mapping. +# https://github.com/Rapptz/discord.py/blob/1a4e73d59932cdbe7bf2c281f25e32529fc7ae1f/discord/ext/commands/converter.py#L984-L1004 +commands.converter.PartialMessageConverter = monkey_patches.FixedPartialMessageConverter + # Monkey-patch discord.py decorators to use the Command subclass which supports root aliases. # Must be patched before any cogs are added. commands.command = partial(commands.command, cls=monkey_patches.Command) diff --git a/bot/monkey_patches.py b/bot/monkey_patches.py index 23482f7c3..b5c0de8d9 100644 --- a/bot/monkey_patches.py +++ b/bot/monkey_patches.py @@ -5,6 +5,7 @@ from discord import Forbidden, http from discord.ext import commands from bot.log import get_logger +from bot.utils.regex import MESSAGE_ID_RE log = get_logger(__name__) @@ -50,3 +51,25 @@ def patch_typing() -> None: pass http.HTTPClient.send_typing = honeybadger_type + + +class FixedPartialMessageConverter(commands.PartialMessageConverter): + """ + Make the Message converter infer channelID from the given context if only a messageID is given. + + Discord.py's Message converter is supposed to infer channelID based + on ctx.channel if only a messageID is given. A refactor commit, linked below, + a few weeks before d.py's archival broke this defined behaviour of the converter. + Currently, if only a messageID is given to the converter, it will only find that message + if it's in the bot's cache. + + https://github.com/Rapptz/discord.py/commit/1a4e73d59932cdbe7bf2c281f25e32529fc7ae1f + """ + + @staticmethod + def _get_id_matches(ctx: commands.Context, argument: str) -> tuple[int, int, int]: + """Inserts ctx.channel.id before calling super method if argument is just a messageID.""" + match = MESSAGE_ID_RE.match(argument) + if match: + argument = f"{ctx.channel.id}-{match.group('message_id')}" + return commands.PartialMessageConverter._get_id_matches(ctx, argument) diff --git a/bot/utils/regex.py b/bot/utils/regex.py index d77f5950b..9dc1eba9d 100644 --- a/bot/utils/regex.py +++ b/bot/utils/regex.py @@ -12,3 +12,4 @@ INVITE_RE = re.compile( r"(?P[a-zA-Z0-9\-]+)", # the invite code itself flags=re.IGNORECASE ) +MESSAGE_ID_RE = re.compile(r'(?P[0-9]{15,20})$') -- cgit v1.2.3 From ed3f5aaec5ac87a6d92d8068d9e07190adc3a5d2 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 5 Dec 2021 23:49:46 +0200 Subject: Properly check the channel when deleting from cache Previously the cache was only used to delete from all channels. I didn't add a channels check when I changed it. --- bot/exts/moderation/clean.py | 44 ++++++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 3def2a416..0b83fc7e0 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -113,6 +113,28 @@ class Clean(Cog): delete_after = None if is_mod_channel(ctx.channel) else MESSAGE_DELETE_DELAY await ctx.send(content, delete_after=delete_after) + @staticmethod + def _channels_set( + channels: CleanChannels, ctx: Context, first_limit: CleanLimit, second_limit: CleanLimit + ) -> set[TextChannel]: + """Standardize the input `channels` argument to a usable set of text channels.""" + # Default to using the invoking context's channel or the channel of the message limit(s). + if not channels: + # Input was validated - if first_limit is a message, second_limit won't point at a different channel. + if isinstance(first_limit, Message): + channels = {first_limit.channel} + elif isinstance(second_limit, Message): + channels = {second_limit.channel} + else: + channels = {ctx.channel} + else: + if channels == "*": + channels = {channel for channel in ctx.guild.channels if isinstance(channel, TextChannel)} + else: + channels = set(channels) + + return channels + @staticmethod def _build_predicate( first_limit: datetime, @@ -192,6 +214,7 @@ class Clean(Cog): def _get_messages_from_cache( self, + channels: set[TextChannel], to_delete: Predicate, lower_limit: datetime ) -> tuple[defaultdict[TextChannel, list], list[int]]: @@ -203,7 +226,7 @@ class Clean(Cog): # Cleaning was canceled return message_mappings, message_ids - if to_delete(message): + if message.channel in channels and to_delete(message): message_mappings[message.channel].append(message) message_ids.append(message.id) @@ -359,15 +382,7 @@ class Clean(Cog): return self.cleaning = True - # Default to using the invoking context's channel or the channel of the message limit(s). - if not channels: - # Input was validated - if first_limit is a message, second_limit won't point at a different channel. - if isinstance(first_limit, Message): - channels = [first_limit.channel] - elif isinstance(second_limit, Message): - channels = [second_limit.channel] - else: - channels = [ctx.channel] + deletion_channels = self._channels_set(channels, ctx, first_limit, second_limit) if isinstance(first_limit, Message): first_limit = first_limit.created_at @@ -384,12 +399,11 @@ class Clean(Cog): if self._use_cache(first_limit): log.trace(f"Messages for cleaning by {ctx.author.id} will be searched in the cache.") - message_mappings, message_ids = self._get_messages_from_cache(to_delete=predicate, lower_limit=first_limit) + message_mappings, message_ids = self._get_messages_from_cache( + channels=deletion_channels, to_delete=predicate, lower_limit=first_limit + ) else: log.trace(f"Messages for cleaning by {ctx.author.id} will be searched in channel histories.") - deletion_channels = channels - if channels == "*": - deletion_channels = [channel for channel in ctx.guild.channels if isinstance(channel, TextChannel)] message_mappings, message_ids = await self._get_messages_from_channels( channels=deletion_channels, to_delete=predicate, @@ -406,6 +420,8 @@ class Clean(Cog): deleted_messages = await self._delete_found(message_mappings) self.cleaning = False + if not channels: + channels = deletion_channels logged = await self._modlog_cleaned_messages(deleted_messages, channels, ctx) if logged and is_mod_channel(ctx.channel): -- cgit v1.2.3 From a738d05c3f46969a091b1ba2b0eaa14fcd00644a Mon Sep 17 00:00:00 2001 From: mbaruh Date: Mon, 6 Dec 2021 00:11:28 +0200 Subject: Skip private channels when deleting from all When specifying all channels, the command now skips private channels to optimize for speed. --- bot/exts/moderation/clean.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/clean.py b/bot/exts/moderation/clean.py index 0b83fc7e0..e61ef7880 100644 --- a/bot/exts/moderation/clean.py +++ b/bot/exts/moderation/clean.py @@ -31,12 +31,12 @@ CleanLimit = Union[Message, Age, ISODateTime] class CleanChannels(Converter): - """A converter that turns the given string to a list of channels to clean, or the literal `*` for all channels.""" + """A converter to turn the string into a list of channels to clean, or the literal `*` for all public channels.""" _channel_converter = TextChannelConverter() async def convert(self, ctx: Context, argument: str) -> Union[Literal["*"], list[TextChannel]]: - """Converts a string to a list of channels to clean, or the literal `*` for all channels.""" + """Converts a string to a list of channels to clean, or the literal `*` for all public channels.""" if argument == "*": return "*" return [await self._channel_converter.convert(ctx, channel) for channel in argument.split()] @@ -129,7 +129,12 @@ class Clean(Cog): channels = {ctx.channel} else: if channels == "*": - channels = {channel for channel in ctx.guild.channels if isinstance(channel, TextChannel)} + channels = { + channel for channel in ctx.guild.channels + if isinstance(channel, TextChannel) + # Assume that non-public channels are not needed to optimize for speed. + and channel.permissions_for(ctx.guild.default_role).view_channel + } else: channels = set(channels) @@ -339,7 +344,7 @@ class Clean(Cog): # Build the embed and send it if channels == "*": - target_channels = "all channels" + target_channels = "all public channels" else: target_channels = ", ".join(channel.mention for channel in channels) @@ -456,7 +461,7 @@ class Clean(Cog): The pattern must be provided enclosed in backticks. If the pattern contains spaces, it still needs to be enclosed in double quotes on top of that. \u2003• `bots_only`: Whether to delete only bots. If specified, users cannot be specified. - \u2003• `channels`: A series of channels to delete in, or an asterisk to delete from all channels. + \u2003• `channels`: A series of channels to delete in, or an asterisk to delete from all public channels. """ if not any([users, first_limit, second_limit, regex, channels]): await ctx.send_help(ctx.command) -- cgit v1.2.3 From af534ce297f68aedf6aa5a59f82a539c6cbd8686 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Sun, 5 Dec 2021 19:12:10 -0500 Subject: fix: parse whitespace out of pep titles --- bot/exts/info/pep.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/pep.py b/bot/exts/info/pep.py index 8c0db18bc..67866620b 100644 --- a/bot/exts/info/pep.py +++ b/bot/exts/info/pep.py @@ -97,9 +97,12 @@ class PythonEnhancementProposals(Cog): def generate_pep_embed(self, pep_header: Dict, pep_nr: int) -> Embed: """Generate PEP embed based on PEP headers data.""" + # the parsed header can be wrapped to multiple lines, so we need to make sure that is removed + # for an example of a pep with this issue, see pep 500 + title = " ".join(pep_header["Title"].split()) # Assemble the embed pep_embed = Embed( - title=f"**PEP {pep_nr} - {pep_header['Title']}**", + title=f"**PEP {pep_nr} - {title}**", description=f"[Link]({BASE_PEP_URL}{pep_nr:04})", ) -- cgit v1.2.3 From aa08fe2258ce4205272c7f27e1e2380c37275552 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 18 Oct 2021 22:22:47 +0100 Subject: Normalise names before checking for matches --- bot/exts/filters/filtering.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 79b7abe9f..e51d2aad6 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -2,6 +2,7 @@ import asyncio import re from datetime import timedelta from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple, Union +from unicodedata import normalize import arrow import dateutil.parser @@ -207,12 +208,19 @@ class Filtering(Cog): def get_name_matches(self, name: str) -> List[re.Match]: """Check bad words from passed string (name). Return list of matches.""" - name = self.clean_input(name) + normalised_name = normalize("NFKC", name) matches = [] + + # Run filters against normalized and original version, + # in case we have filters for one but not the other. + names_to_check = (name, normalised_name) + watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) for pattern in watchlist_patterns: - if match := re.search(pattern, name, flags=re.IGNORECASE): - matches.append(match) + for name in names_to_check: + if match := re.search(pattern, name, flags=re.IGNORECASE): + matches.append(match) + break # No need to see if other variations of this name match too. return matches async def check_send_alert(self, member: Member) -> bool: -- cgit v1.2.3 From baf8239be8c6a4f6da4bd7ce8f8b2abeaf55e58a Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 18 Oct 2021 22:51:31 +0100 Subject: Check if we recently alerted about a bad name before running all filter tokens again --- bot/exts/filters/filtering.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index e51d2aad6..4b1de9638 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -237,10 +237,14 @@ class Filtering(Cog): """Send a mod alert every 3 days if a username still matches a watchlist pattern.""" # Use lock to avoid race conditions async with self.name_lock: + # Check if we recently alerted about this user first, + # to avoid running all the filter tokens against their name again. + if not await self.check_send_alert(member): + return + # Check whether the users display name contains any words in our blacklist matches = self.get_name_matches(member.display_name) - - if not matches or not await self.check_send_alert(member): + if not matches: return log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).") -- cgit v1.2.3 From 8efbff61aa9a8697ddb140fa5978630a6c609054 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 18 Oct 2021 22:56:22 +0100 Subject: Return early when getting name matches Ss soon as we get a match for a bad name, return it, rather than running it against the rest of the filters. --- bot/exts/filters/filtering.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 4b1de9638..fb1d62e48 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -206,10 +206,9 @@ class Filtering(Cog): delta = relativedelta(after.edited_at, before.edited_at).microseconds await self._filter_message(after, delta) - def get_name_matches(self, name: str) -> List[re.Match]: - """Check bad words from passed string (name). Return list of matches.""" + def get_name_match(self, name: str) -> Optional[re.Match]: + """Check bad words from passed string (name). Return the first match found.""" normalised_name = normalize("NFKC", name) - matches = [] # Run filters against normalized and original version, # in case we have filters for one but not the other. @@ -219,9 +218,8 @@ class Filtering(Cog): for pattern in watchlist_patterns: for name in names_to_check: if match := re.search(pattern, name, flags=re.IGNORECASE): - matches.append(match) - break # No need to see if other variations of this name match too. - return matches + return match + return None async def check_send_alert(self, member: Member) -> bool: """When there is less than 3 days after last alert, return `False`, otherwise `True`.""" @@ -243,8 +241,8 @@ class Filtering(Cog): return # Check whether the users display name contains any words in our blacklist - matches = self.get_name_matches(member.display_name) - if not matches: + match = self.get_name_match(member.display_name) + if not match: return log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).") @@ -252,7 +250,7 @@ class Filtering(Cog): log_string = ( f"**User:** {format_user(member)}\n" f"**Display Name:** {escape_markdown(member.display_name)}\n" - f"**Bad Matches:** {', '.join(match.group() for match in matches)}" + f"**Bad Match:** {match.group()}" ) await self.mod_log.send_log_message( -- cgit v1.2.3 From 5901ac0ba4544f2bd479a74d5d6a345b3d31cb01 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 19 Oct 2021 17:00:30 +0100 Subject: Also run name filters against a cleaned version of the normalised name --- bot/exts/filters/filtering.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index fb1d62e48..21ed090ea 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -1,8 +1,8 @@ import asyncio import re +import unicodedata from datetime import timedelta from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple, Union -from unicodedata import normalize import arrow import dateutil.parser @@ -208,11 +208,12 @@ class Filtering(Cog): def get_name_match(self, name: str) -> Optional[re.Match]: """Check bad words from passed string (name). Return the first match found.""" - normalised_name = normalize("NFKC", name) + normalised_name = unicodedata.normalize("NFKC", name) + cleaned_normalised_name = "".join(c for c in normalised_name if not unicodedata.combining(c)) - # Run filters against normalized and original version, + # Run filters against normalised, cleaned normalised and the original name, # in case we have filters for one but not the other. - names_to_check = (name, normalised_name) + names_to_check = (name, normalised_name, cleaned_normalised_name) watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) for pattern in watchlist_patterns: -- cgit v1.2.3 From d0dc7a0e4e3fc6618ae49d43b24938c84793dcf0 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 6 Dec 2021 22:59:35 +0000 Subject: Build an intermediate list for speed in filtering cog --- bot/exts/filters/filtering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 21ed090ea..8accc61f8 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -209,7 +209,7 @@ class Filtering(Cog): def get_name_match(self, name: str) -> Optional[re.Match]: """Check bad words from passed string (name). Return the first match found.""" normalised_name = unicodedata.normalize("NFKC", name) - cleaned_normalised_name = "".join(c for c in normalised_name if not unicodedata.combining(c)) + cleaned_normalised_name = "".join([c for c in normalised_name if not unicodedata.combining(c)]) # Run filters against normalised, cleaned normalised and the original name, # in case we have filters for one but not the other. -- cgit v1.2.3 From 736c0c8e38ed33e23244e9a509820b519482eec6 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 7 Dec 2021 10:13:00 +0000 Subject: Make snekbox url an env var An issue with snekbox in our cluster has meant that we want to send requests to an external service temporarily while we get this fixed. Making this an env var means we can change this whenever needed in future without leaking the external service's url. --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 0d3ddc005..1e04f5844 100644 --- a/config-default.yml +++ b/config-default.yml @@ -377,7 +377,7 @@ urls: site_logs_view: !JOIN [*STAFF, "/bot/logs"] # Snekbox - snekbox_eval_api: "http://snekbox.default.svc.cluster.local/eval" + snekbox_eval_api: !ENV ["SNEKBOX_EVAL_API", "http://snekbox.default.svc.cluster.local/eval"] # Discord API URLs discord_api: &DISCORD_API "https://discordapp.com/api/v7/" -- cgit v1.2.3 From e8b47826860bcfd42ffd716e671a2e81a712dc63 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Wed, 8 Dec 2021 12:27:41 +0100 Subject: Correct typo in logline --- bot/exts/moderation/modpings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 47bc5e283..c44a16ff6 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -93,7 +93,7 @@ class ModPings(Cog): async def remove_role_schedule(self, mod: Member, work_time: int, schedule_start: datetime.datetime) -> None: """Removes the moderator's role to the given moderator.""" - log.trace(f"Removing moderator role to mod with ID {mod.id}") + log.trace(f"Removing moderator role from mod with ID {mod.id}") await mod.remove_roles(self.moderators_role, reason="Moderator schedule time expired.") # Remove the task before scheduling it again -- cgit v1.2.3 From c72d4944690e374e9ef396cae91094e2464e3f04 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 19 Apr 2021 22:08:29 +0200 Subject: Move the rules command to the Information cog --- bot/exts/info/information.py | 38 +++++++++++++++++++++++++++++++++++++- bot/exts/info/site.py | 37 +------------------------------------ 2 files changed, 38 insertions(+), 37 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 5b48495dc..a5e700678 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -2,16 +2,18 @@ import colorsys import pprint import textwrap from collections import defaultdict +from textwrap import shorten from typing import Any, DefaultDict, Mapping, Optional, Tuple, Union import rapidfuzz from discord import AllowedMentions, Colour, Embed, Guild, Message, Role -from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role +from discord.ext.commands import BucketType, Cog, Context, Greedy, Paginator, command, group, has_any_role from discord.utils import escape_markdown from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot +from bot.constants import URLs from bot.converters import MemberOrUser from bot.decorators import in_whitelist from bot.errors import NonExistentRoleError @@ -523,6 +525,40 @@ class Information(Cog): """Shows information about the raw API response in a copy-pasteable Python format.""" await self.send_raw_content(ctx, message, json=True) + @command(aliases=("rule",)) + async def rules(self, ctx: Context, rules: Greedy[int]) -> None: + """Provides a link to all rules or, if specified, displays specific rule(s).""" + rules_embed = Embed(title="Rules", color=Colour.og_blurple(), url=f"{URLs.site_schema}{URLs.site}/pages/rules") + + if not rules: + # Rules were not submitted. Return the default description. + rules_embed.description = ( + f"The rules and guidelines that apply to this community can be found on" + f" our [rules page]({URLs.site_schema}{URLs.site}/pages/rules). We expect" + f" all members of the community to have read and understood these." + ) + + await ctx.send(embed=rules_embed) + return + + full_rules = await self.bot.api_client.get("rules", params={"link_format": "md"}) + + # Remove duplicates and sort the rule indices + rules = sorted(set(rules)) + + invalid = ", ".join(str(index) for index in rules if index < 1 or index > len(full_rules)) + + if invalid: + await ctx.send(shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=" ...")) + return + + for rule in rules: + self.bot.stats.incr(f"rule_uses.{rule}") + + final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules) + + await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) + def setup(bot: Bot) -> None: """Load the Information cog.""" diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index f6499ecce..665bff3a8 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -1,12 +1,11 @@ from textwrap import shorten from discord import Colour, Embed -from discord.ext.commands import Cog, Context, Greedy, group +from discord.ext.commands import Cog, Context, group from bot.bot import Bot from bot.constants import URLs from bot.log import get_logger -from bot.pagination import LinePaginator log = get_logger(__name__) @@ -105,40 +104,6 @@ class Site(Cog): await ctx.send(embed=embed) - @site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule")) - async def site_rules(self, ctx: Context, rules: Greedy[int]) -> None: - """Provides a link to all rules or, if specified, displays specific rule(s).""" - rules_embed = Embed(title='Rules', color=Colour.og_blurple(), url=f'{BASE_URL}/pages/rules') - - if not rules: - # Rules were not submitted. Return the default description. - rules_embed.description = ( - "The rules and guidelines that apply to this community can be found on" - f" our [rules page]({BASE_URL}/pages/rules). We expect" - " all members of the community to have read and understood these." - ) - - await ctx.send(embed=rules_embed) - return - - full_rules = await self.bot.api_client.get('rules', params={'link_format': 'md'}) - - # Remove duplicates and sort the rule indices - rules = sorted(set(rules)) - - invalid = ', '.join(str(index) for index in rules if index < 1 or index > len(full_rules)) - - if invalid: - await ctx.send(shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=' ...')) - return - - for rule in rules: - self.bot.stats.incr(f"rule_uses.{rule}") - - final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules) - - await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) - def setup(bot: Bot) -> None: """Load the Site cog.""" -- cgit v1.2.3 From aa5485b5d91b1b1568a89c58853343a461e610f9 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 19 Apr 2021 22:08:46 +0200 Subject: Remove the site help command --- bot/exts/info/site.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index 665bff3a8..4b0b7649d 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -71,22 +71,6 @@ class Site(Cog): await ctx.send(embed=embed) - @site_group.command(name="help") - async def site_help(self, ctx: Context) -> None: - """Info about the site's Getting Help page.""" - url = f"{BASE_URL}/pages/guides/pydis-guides/asking-good-questions/" - - embed = Embed(title="Asking Good Questions") - embed.set_footer(text=url) - embed.colour = Colour.og_blurple() - embed.description = ( - "Asking the right question about something that's new to you can sometimes be tricky. " - f"To help with this, we've created a [guide to asking good questions]({url}) on our website. " - "It contains everything you need to get the very best help from our community." - ) - - await ctx.send(embed=embed) - @site_group.command(name="faq", root_aliases=("faq",)) async def site_faq(self, ctx: Context) -> None: """Info about the site's FAQ page.""" -- cgit v1.2.3 From fc84bcfb3ae749248e47aa805a40558ac678647b Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 19 Apr 2021 22:18:41 +0200 Subject: Move static content site commands to tags and remove the site cog --- bot/exts/info/site.py | 94 ----------------------------------------- bot/resources/tags/faq.md | 6 +++ bot/resources/tags/resources.md | 6 +++ bot/resources/tags/site.md | 6 +++ bot/resources/tags/tools.md | 6 +++ 5 files changed, 24 insertions(+), 94 deletions(-) delete mode 100644 bot/exts/info/site.py create mode 100644 bot/resources/tags/faq.md create mode 100644 bot/resources/tags/resources.md create mode 100644 bot/resources/tags/site.md create mode 100644 bot/resources/tags/tools.md diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py deleted file mode 100644 index 4b0b7649d..000000000 --- a/bot/exts/info/site.py +++ /dev/null @@ -1,94 +0,0 @@ -from textwrap import shorten - -from discord import Colour, Embed -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.constants import URLs -from bot.log import get_logger - -log = get_logger(__name__) - -BASE_URL = f"{URLs.site_schema}{URLs.site}" - - -class Site(Cog): - """Commands for linking to different parts of the site.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @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.send_help(ctx.command) - - @site_group.command(name="home", aliases=("about",), root_aliases=("home",)) - async def site_main(self, ctx: Context) -> None: - """Info about the website itself.""" - url = f"{URLs.site_schema}{URLs.site}/" - - embed = Embed(title="Python Discord website") - embed.set_footer(text=url) - embed.colour = Colour.og_blurple() - embed.description = ( - f"[Our official website]({url}) is an open-source community project " - "created with Python and Django. It contains information about the server " - "itself, lets you sign up for upcoming events, has its own wiki, contains " - "a list of valuable learning resources, and much more." - ) - - await ctx.send(embed=embed) - - @site_group.command(name="resources", root_aliases=("resources", "resource")) - async def site_resources(self, ctx: Context) -> None: - """Info about the site's Resources page.""" - learning_url = f"{BASE_URL}/resources" - - embed = Embed(title="Resources") - embed.set_footer(text=f"{learning_url}") - embed.colour = Colour.og_blurple() - embed.description = ( - f"The [Resources page]({learning_url}) on our website contains a " - "list of hand-selected learning resources that we regularly recommend " - f"to both beginners and experts." - ) - - await ctx.send(embed=embed) - - @site_group.command(name="tools", root_aliases=("tools",)) - async def site_tools(self, ctx: Context) -> None: - """Info about the site's Tools page.""" - tools_url = f"{BASE_URL}/resources/tools" - - embed = Embed(title="Tools") - embed.set_footer(text=f"{tools_url}") - embed.colour = Colour.og_blurple() - embed.description = ( - f"The [Tools page]({tools_url}) on our website contains a " - f"couple of the most popular tools for programming in Python." - ) - - await ctx.send(embed=embed) - - @site_group.command(name="faq", root_aliases=("faq",)) - async def site_faq(self, ctx: Context) -> None: - """Info about the site's FAQ page.""" - url = f"{BASE_URL}/pages/frequently-asked-questions" - - embed = Embed(title="FAQ") - embed.set_footer(text=url) - embed.colour = Colour.og_blurple() - embed.description = ( - "As the largest Python community on Discord, we get hundreds of questions every day. " - "Many of these questions have been asked before. We've compiled a list of the most " - "frequently asked questions along with their answers, which can be found on " - f"our [FAQ page]({url})." - ) - - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Load the Site cog.""" - bot.add_cog(Site(bot)) diff --git a/bot/resources/tags/faq.md b/bot/resources/tags/faq.md new file mode 100644 index 000000000..e1c57b3a0 --- /dev/null +++ b/bot/resources/tags/faq.md @@ -0,0 +1,6 @@ +--- +embed: + title: "Frequently asked questions" +--- + +As the largest Python community on Discord, we get hundreds of questions every day. Many of these questions have been asked before. We've compiled a list of the most frequently asked questions along with their answers, which can be found on our [FAQ page](https://www.pythondiscord.com/pages/frequently-asked-questions/). diff --git a/bot/resources/tags/resources.md b/bot/resources/tags/resources.md new file mode 100644 index 000000000..201e0eb1e --- /dev/null +++ b/bot/resources/tags/resources.md @@ -0,0 +1,6 @@ +--- +embed: + title: "Resources" +--- + +The [Resources page](https://www.pythondiscord.com/resources/) on our website contains a list of hand-selected learning resources that we regularly recommend to both beginners and experts. diff --git a/bot/resources/tags/site.md b/bot/resources/tags/site.md new file mode 100644 index 000000000..376f84742 --- /dev/null +++ b/bot/resources/tags/site.md @@ -0,0 +1,6 @@ +--- +embed: + title: "Python Discord Website" +--- + +[Our official website](https://www.pythondiscord.com/) is an open-source community project created with Python and Django. It contains information about the server itself, lets you sign up for upcoming events, has its own wiki, contains a list of valuable learning resources, and much more. diff --git a/bot/resources/tags/tools.md b/bot/resources/tags/tools.md new file mode 100644 index 000000000..3cae75552 --- /dev/null +++ b/bot/resources/tags/tools.md @@ -0,0 +1,6 @@ +--- +embed: + title: "Tools" +--- + +The [Tools page](https://www.pythondiscord.com/resources/tools/) on our website contains a couple of the most popular tools for programming in Python. -- cgit v1.2.3 From 1f2b60364d04ad8e1a21a9ee8b33704a1845f12d Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Wed, 8 Dec 2021 16:14:50 +0100 Subject: Use hardcoded rules url instead of constructing it from consts Discord does validation on the embed url which may fail for valid local urls --- bot/exts/info/information.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index a5e700678..fa22a4fe9 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -13,7 +13,6 @@ from discord.utils import escape_markdown from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import URLs from bot.converters import MemberOrUser from bot.decorators import in_whitelist from bot.errors import NonExistentRoleError @@ -528,14 +527,14 @@ class Information(Cog): @command(aliases=("rule",)) async def rules(self, ctx: Context, rules: Greedy[int]) -> None: """Provides a link to all rules or, if specified, displays specific rule(s).""" - rules_embed = Embed(title="Rules", color=Colour.og_blurple(), url=f"{URLs.site_schema}{URLs.site}/pages/rules") + rules_embed = Embed(title="Rules", color=Colour.og_blurple(), url="https://www.pythondiscord.com/pages/rules") if not rules: # Rules were not submitted. Return the default description. rules_embed.description = ( - f"The rules and guidelines that apply to this community can be found on" - f" our [rules page]({URLs.site_schema}{URLs.site}/pages/rules). We expect" - f" all members of the community to have read and understood these." + "The rules and guidelines that apply to this community can be found on" + " our [rules page](https://www.pythondiscord.com/pages/rules). We expect" + " all members of the community to have read and understood these." ) await ctx.send(embed=rules_embed) -- cgit v1.2.3 From a55ed25126149d0dd092287c6e41415419f4927c Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 8 Dec 2021 17:21:36 +0000 Subject: Reduce threshold before fuzzy matching to 2 Commands such as !ot, !if, !xy are commonly used as shortcuts to their respective tags. We recently upped the threshold before fuzzy matching to 3 characters, which broke these shortcuts. This commit reduces that threshold down to 2, so users who are used to those commands can still use them. --- bot/exts/info/tags.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 7c8d378a9..e5930a433 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -168,11 +168,11 @@ class Tags(Cog): """Get tags with identifiers similar to `tag_identifier`.""" suggestions = [] - if tag_identifier.group is not None and len(tag_identifier.group) >= 3: + if tag_identifier.group is not None and len(tag_identifier.group) >= 2: # Try fuzzy matching with only a name first suggestions += self._get_suggestions(TagIdentifier(None, tag_identifier.group)) - if len(tag_identifier.name) >= 3: + if len(tag_identifier.name) >= 2: suggestions += self._get_suggestions(tag_identifier) return suggestions -- cgit v1.2.3 From 0eca9ee39feb8b6a9038de23a69af1ce9a13785c Mon Sep 17 00:00:00 2001 From: PH-KDX <50588793+PH-KDX@users.noreply.github.com> Date: Thu, 9 Dec 2021 20:51:34 +0100 Subject: Remove deprecated server voice region Discord's current model for voice regions is setting it per server. Hence, the "Voice region" section in the server info tag will always display as "deprecated". This pull request removes it. --- bot/exts/info/information.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index fa22a4fe9..73357211e 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -174,7 +174,6 @@ class Information(Cog): embed = Embed(colour=Colour.og_blurple(), title="Server Information") created = discord_timestamp(ctx.guild.created_at, TimestampFormats.RELATIVE) - region = ctx.guild.region num_roles = len(ctx.guild.roles) - 1 # Exclude @everyone # Server Features are only useful in certain channels @@ -199,7 +198,6 @@ class Information(Cog): embed.description = ( f"Created: {created}" - f"\nVoice region: {region}" f"{features}" f"\nRoles: {num_roles}" f"\nMember status: {member_status}" -- cgit v1.2.3 From 850db8933713f80bc0879d5e398e1ba496b827f9 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 10 Dec 2021 13:39:15 +0100 Subject: Remove myself from the code ownership --- .github/CODEOWNERS | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6dfe7e859..ea69f7677 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,10 +4,10 @@ **/bot/exts/moderation/*silence.py @MarkKoz bot/exts/info/codeblock/** @MarkKoz bot/exts/utils/extensions.py @MarkKoz -bot/exts/utils/snekbox.py @MarkKoz @Akarys42 @jb3 -bot/exts/help_channels/** @MarkKoz @Akarys42 -bot/exts/moderation/** @Akarys42 @mbaruh @Den4200 @ks129 @jb3 -bot/exts/info/** @Akarys42 @Den4200 @jb3 +bot/exts/utils/snekbox.py @MarkKoz @jb3 +bot/exts/help_channels/** @MarkKoz +bot/exts/moderation/** @mbaruh @Den4200 @ks129 @jb3 +bot/exts/info/** @Den4200 @jb3 bot/exts/info/information.py @mbaruh @jb3 bot/exts/filters/** @mbaruh @jb3 bot/exts/fun/** @ks129 @@ -21,22 +21,16 @@ bot/rules/** @mbaruh bot/utils/extensions.py @MarkKoz bot/utils/function.py @MarkKoz bot/utils/lock.py @MarkKoz -bot/utils/regex.py @Akarys42 bot/utils/scheduling.py @MarkKoz # Tests tests/_autospec.py @MarkKoz tests/bot/exts/test_cogs.py @MarkKoz -tests/** @Akarys42 # CI & Docker -.github/workflows/** @MarkKoz @Akarys42 @SebastiaanZ @Den4200 @jb3 -Dockerfile @MarkKoz @Akarys42 @Den4200 @jb3 -docker-compose.yml @MarkKoz @Akarys42 @Den4200 @jb3 - -# Tools -poetry.lock @Akarys42 -pyproject.toml @Akarys42 +.github/workflows/** @MarkKoz @SebastiaanZ @Den4200 @jb3 +Dockerfile @MarkKoz @Den4200 @jb3 +docker-compose.yml @MarkKoz @Den4200 @jb3 # Statistics bot/async_stats.py @jb3 -- cgit v1.2.3 From d36045993bb01ca044acc3dc090da8771bcb1d05 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Dec 2021 21:01:14 +0000 Subject: Bump lxml from 4.6.3 to 4.6.5 Bumps [lxml](https://github.com/lxml/lxml) from 4.6.3 to 4.6.5. - [Release notes](https://github.com/lxml/lxml/releases) - [Changelog](https://github.com/lxml/lxml/blob/master/CHANGES.txt) - [Commits](https://github.com/lxml/lxml/compare/lxml-4.6.3...lxml-4.6.5) --- updated-dependencies: - dependency-name: lxml dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- poetry.lock | 113 ++++++++++++++++++++++++++++++++------------------------- pyproject.toml | 2 +- 2 files changed, 64 insertions(+), 51 deletions(-) diff --git a/poetry.lock b/poetry.lock index d91941d45..4155a57ff 100644 --- a/poetry.lock +++ b/poetry.lock @@ -281,6 +281,7 @@ voice = ["PyNaCl (>=1.3.0,<1.5)"] [package.source] type = "url" url = "https://github.com/Rapptz/discord.py/archive/45d498c1b76deaf3b394d17ccf56112fa691d160.zip" + [[package]] name = "distlib" version = "0.3.3" @@ -533,7 +534,7 @@ plugins = ["setuptools"] [[package]] name = "lxml" -version = "4.6.3" +version = "4.6.5" description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." category = "main" optional = false @@ -1114,7 +1115,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "da321f13297501e62dd1eb362eccb586ea1a9c21ddb395e11a91b93a2f92e9d4" +content-hash = "e6fe15a64ae57232a639149df793d6580a93f613425cae85c9892cf959710430" [metadata.files] aio-pika = [ @@ -1464,54 +1465,66 @@ isort = [ {file = "isort-5.9.3.tar.gz", hash = "sha256:9c2ea1e62d871267b78307fe511c0838ba0da28698c5732d54e2790bf3ba9899"}, ] lxml = [ - {file = "lxml-4.6.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2"}, - {file = "lxml-4.6.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:1b7584d421d254ab86d4f0b13ec662a9014397678a7c4265a02a6d7c2b18a75f"}, - {file = "lxml-4.6.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:079f3ae844f38982d156efce585bc540c16a926d4436712cf4baee0cce487a3d"}, - {file = "lxml-4.6.3-cp27-cp27m-win32.whl", hash = "sha256:bc4313cbeb0e7a416a488d72f9680fffffc645f8a838bd2193809881c67dd106"}, - {file = "lxml-4.6.3-cp27-cp27m-win_amd64.whl", hash = "sha256:8157dadbb09a34a6bd95a50690595e1fa0af1a99445e2744110e3dca7831c4ee"}, - {file = "lxml-4.6.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7728e05c35412ba36d3e9795ae8995e3c86958179c9770e65558ec3fdfd3724f"}, - {file = "lxml-4.6.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:4bff24dfeea62f2e56f5bab929b4428ae6caba2d1eea0c2d6eb618e30a71e6d4"}, - {file = "lxml-4.6.3-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:64812391546a18896adaa86c77c59a4998f33c24788cadc35789e55b727a37f4"}, - {file = "lxml-4.6.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c1a40c06fd5ba37ad39caa0b3144eb3772e813b5fb5b084198a985431c2f1e8d"}, - {file = "lxml-4.6.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51"}, - {file = "lxml-4.6.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f90ba11136bfdd25cae3951af8da2e95121c9b9b93727b1b896e3fa105b2f586"}, - {file = "lxml-4.6.3-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:4c61b3a0db43a1607d6264166b230438f85bfed02e8cff20c22e564d0faff354"}, - {file = "lxml-4.6.3-cp35-cp35m-manylinux2014_x86_64.whl", hash = "sha256:5c8c163396cc0df3fd151b927e74f6e4acd67160d6c33304e805b84293351d16"}, - {file = "lxml-4.6.3-cp35-cp35m-win32.whl", hash = "sha256:f2380a6376dfa090227b663f9678150ef27543483055cc327555fb592c5967e2"}, - {file = "lxml-4.6.3-cp35-cp35m-win_amd64.whl", hash = "sha256:c4f05c5a7c49d2fb70223d0d5bcfbe474cf928310ac9fa6a7c6dddc831d0b1d4"}, - {file = "lxml-4.6.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d2e35d7bf1c1ac8c538f88d26b396e73dd81440d59c1ef8522e1ea77b345ede4"}, - {file = "lxml-4.6.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:289e9ca1a9287f08daaf796d96e06cb2bc2958891d7911ac7cae1c5f9e1e0ee3"}, - {file = "lxml-4.6.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bccbfc27563652de7dc9bdc595cb25e90b59c5f8e23e806ed0fd623755b6565d"}, - {file = "lxml-4.6.3-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d916d31fd85b2f78c76400d625076d9124de3e4bda8b016d25a050cc7d603f24"}, - {file = "lxml-4.6.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:820628b7b3135403540202e60551e741f9b6d3304371712521be939470b454ec"}, - {file = "lxml-4.6.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:c47ff7e0a36d4efac9fd692cfa33fbd0636674c102e9e8d9b26e1b93a94e7617"}, - {file = "lxml-4.6.3-cp36-cp36m-win32.whl", hash = "sha256:5a0a14e264069c03e46f926be0d8919f4105c1623d620e7ec0e612a2e9bf1c04"}, - {file = "lxml-4.6.3-cp36-cp36m-win_amd64.whl", hash = "sha256:92e821e43ad382332eade6812e298dc9701c75fe289f2a2d39c7960b43d1e92a"}, - {file = "lxml-4.6.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:efd7a09678fd8b53117f6bae4fa3825e0a22b03ef0a932e070c0bdbb3a35e654"}, - {file = "lxml-4.6.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:efac139c3f0bf4f0939f9375af4b02c5ad83a622de52d6dfa8e438e8e01d0eb0"}, - {file = "lxml-4.6.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:0fbcf5565ac01dff87cbfc0ff323515c823081c5777a9fc7703ff58388c258c3"}, - {file = "lxml-4.6.3-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:36108c73739985979bf302006527cf8a20515ce444ba916281d1c43938b8bb96"}, - {file = "lxml-4.6.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:122fba10466c7bd4178b07dba427aa516286b846b2cbd6f6169141917283aae2"}, - {file = "lxml-4.6.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:cdaf11d2bd275bf391b5308f86731e5194a21af45fbaaaf1d9e8147b9160ea92"}, - {file = "lxml-4.6.3-cp37-cp37m-win32.whl", hash = "sha256:3439c71103ef0e904ea0a1901611863e51f50b5cd5e8654a151740fde5e1cade"}, - {file = "lxml-4.6.3-cp37-cp37m-win_amd64.whl", hash = "sha256:4289728b5e2000a4ad4ab8da6e1db2e093c63c08bdc0414799ee776a3f78da4b"}, - {file = "lxml-4.6.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b007cbb845b28db4fb8b6a5cdcbf65bacb16a8bd328b53cbc0698688a68e1caa"}, - {file = "lxml-4.6.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:76fa7b1362d19f8fbd3e75fe2fb7c79359b0af8747e6f7141c338f0bee2f871a"}, - {file = "lxml-4.6.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:26e761ab5b07adf5f555ee82fb4bfc35bf93750499c6c7614bd64d12aaa67927"}, - {file = "lxml-4.6.3-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:e1cbd3f19a61e27e011e02f9600837b921ac661f0c40560eefb366e4e4fb275e"}, - {file = "lxml-4.6.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:66e575c62792c3f9ca47cb8b6fab9e35bab91360c783d1606f758761810c9791"}, - {file = "lxml-4.6.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:1b38116b6e628118dea5b2186ee6820ab138dbb1e24a13e478490c7db2f326ae"}, - {file = "lxml-4.6.3-cp38-cp38-win32.whl", hash = "sha256:89b8b22a5ff72d89d48d0e62abb14340d9e99fd637d046c27b8b257a01ffbe28"}, - {file = "lxml-4.6.3-cp38-cp38-win_amd64.whl", hash = "sha256:2a9d50e69aac3ebee695424f7dbd7b8c6d6eb7de2a2eb6b0f6c7db6aa41e02b7"}, - {file = "lxml-4.6.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ce256aaa50f6cc9a649c51be3cd4ff142d67295bfc4f490c9134d0f9f6d58ef0"}, - {file = "lxml-4.6.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:7610b8c31688f0b1be0ef882889817939490a36d0ee880ea562a4e1399c447a1"}, - {file = "lxml-4.6.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f8380c03e45cf09f8557bdaa41e1fa7c81f3ae22828e1db470ab2a6c96d8bc23"}, - {file = "lxml-4.6.3-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:3082c518be8e97324390614dacd041bb1358c882d77108ca1957ba47738d9d59"}, - {file = "lxml-4.6.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:884ab9b29feaca361f7f88d811b1eea9bfca36cf3da27768d28ad45c3ee6f969"}, - {file = "lxml-4.6.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:6f12e1427285008fd32a6025e38e977d44d6382cf28e7201ed10d6c1698d2a9a"}, - {file = "lxml-4.6.3-cp39-cp39-win32.whl", hash = "sha256:33bb934a044cf32157c12bfcfbb6649807da20aa92c062ef51903415c704704f"}, - {file = "lxml-4.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:542d454665a3e277f76954418124d67516c5f88e51a900365ed54a9806122b83"}, - {file = "lxml-4.6.3.tar.gz", hash = "sha256:39b78571b3b30645ac77b95f7c69d1bffc4cf8c3b157c435a34da72e78c82468"}, + {file = "lxml-4.6.5-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:abcf7daa5ebcc89328326254f6dd6d566adb483d4d00178892afd386ab389de2"}, + {file = "lxml-4.6.5-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3884476a90d415be79adfa4e0e393048630d0d5bcd5757c4c07d8b4b00a1096b"}, + {file = "lxml-4.6.5-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:add017c5bd6b9ec3a5f09248396b6ee2ce61c5621f087eb2269c813cd8813808"}, + {file = "lxml-4.6.5-cp27-cp27m-win32.whl", hash = "sha256:a702005e447d712375433ed0499cb6e1503fadd6c96a47f51d707b4d37b76d3c"}, + {file = "lxml-4.6.5-cp27-cp27m-win_amd64.whl", hash = "sha256:da07c7e7fc9a3f40446b78c54dbba8bfd5c9100dfecb21b65bfe3f57844f5e71"}, + {file = "lxml-4.6.5-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a708c291900c40a7ecf23f1d2384ed0bc0604e24094dd13417c7e7f8f7a50d93"}, + {file = "lxml-4.6.5-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:f33d8efb42e4fc2b31b3b4527940b25cdebb3026fb56a80c1c1c11a4271d2352"}, + {file = "lxml-4.6.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:f6befb83bca720b71d6bd6326a3b26e9496ae6649e26585de024890fe50f49b8"}, + {file = "lxml-4.6.5-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:59d77bfa3bea13caee95bc0d3f1c518b15049b97dd61ea8b3d71ce677a67f808"}, + {file = "lxml-4.6.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:68a851176c931e2b3de6214347b767451243eeed3bea34c172127bbb5bf6c210"}, + {file = "lxml-4.6.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a7790a273225b0c46e5f859c1327f0f659896cc72eaa537d23aa3ad9ff2a1cc1"}, + {file = "lxml-4.6.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6548fc551de15f310dd0564751d9dc3d405278d45ea9b2b369ed1eccf142e1f5"}, + {file = "lxml-4.6.5-cp310-cp310-win32.whl", hash = "sha256:dc8a0dbb2a10ae8bb609584f5c504789f0f3d0d81840da4849102ec84289f952"}, + {file = "lxml-4.6.5-cp310-cp310-win_amd64.whl", hash = "sha256:1ccbfe5d17835db906f2bab6f15b34194db1a5b07929cba3cf45a96dbfbfefc0"}, + {file = "lxml-4.6.5-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca9a40497f7e97a2a961c04fa8a6f23d790b0521350a8b455759d786b0bcb203"}, + {file = "lxml-4.6.5-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e5b4b0d9440046ead3bd425eb2b852499241ee0cef1ae151038e4f87ede888c4"}, + {file = "lxml-4.6.5-cp35-cp35m-win32.whl", hash = "sha256:87f8f7df70b90fbe7b49969f07b347e3f978f8bd1046bb8ecae659921869202b"}, + {file = "lxml-4.6.5-cp35-cp35m-win_amd64.whl", hash = "sha256:ce52aad32ec6e46d1a91ff8b8014a91538800dd533914bfc4a82f5018d971408"}, + {file = "lxml-4.6.5-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:8021eeff7fabde21b9858ed058a8250ad230cede91764d598c2466b0ba70db8b"}, + {file = "lxml-4.6.5-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:cab343b265e38d4e00649cbbad9278b734c5715f9bcbb72c85a1f99b1a58e19a"}, + {file = "lxml-4.6.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:3534d7c468c044f6aef3c0aff541db2826986a29ea73f2ca831f5d5284d9b570"}, + {file = "lxml-4.6.5-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdb98f4c9e8a1735efddfaa995b0c96559792da15d56b76428bdfc29f77c4cdb"}, + {file = "lxml-4.6.5-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:5ea121cb66d7e5cb396b4c3ca90471252b94e01809805cfe3e4e44be2db3a99c"}, + {file = "lxml-4.6.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:121fc6f71c692b49af6c963b84ab7084402624ffbe605287da362f8af0668ea3"}, + {file = "lxml-4.6.5-cp36-cp36m-win32.whl", hash = "sha256:1a2a7659b8eb93c6daee350a0d844994d49245a0f6c05c747f619386fb90ba04"}, + {file = "lxml-4.6.5-cp36-cp36m-win_amd64.whl", hash = "sha256:2f77556266a8fe5428b8759fbfc4bd70be1d1d9c9b25d2a414f6a0c0b0f09120"}, + {file = "lxml-4.6.5-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:558485218ee06458643b929765ac1eb04519ca3d1e2dcc288517de864c747c33"}, + {file = "lxml-4.6.5-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:ba0006799f21d83c3717fe20e2707a10bbc296475155aadf4f5850f6659b96b9"}, + {file = "lxml-4.6.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:916d457ad84e05b7db52700bad0a15c56e0c3000dcaf1263b2fb7a56fe148996"}, + {file = "lxml-4.6.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c580c2a61d8297a6e47f4d01f066517dbb019be98032880d19ece7f337a9401d"}, + {file = "lxml-4.6.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a21b78af7e2e13bec6bea12fc33bc05730197674f3e5402ce214d07026ccfebd"}, + {file = "lxml-4.6.5-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:46515773570a33eae13e451c8fcf440222ef24bd3b26f40774dd0bd8b6db15b2"}, + {file = "lxml-4.6.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:124f09614f999551ac65e5b9875981ce4b66ac4b8e2ba9284572f741935df3d9"}, + {file = "lxml-4.6.5-cp37-cp37m-win32.whl", hash = "sha256:b4015baed99d046c760f09a4c59d234d8f398a454380c3cf0b859aba97136090"}, + {file = "lxml-4.6.5-cp37-cp37m-win_amd64.whl", hash = "sha256:12ae2339d32a2b15010972e1e2467345b7bf962e155671239fba74c229564b7f"}, + {file = "lxml-4.6.5-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:76b6c296e4f7a1a8a128aec42d128646897f9ae9a700ef6839cdc9b3900db9b5"}, + {file = "lxml-4.6.5-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:534032a5ceb34bba1da193b7d386ac575127cc39338379f39a164b10d97ade89"}, + {file = "lxml-4.6.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:60aeb14ff9022d2687ef98ce55f6342944c40d00916452bb90899a191802137a"}, + {file = "lxml-4.6.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:9801bcd52ac9c795a7d81ea67471a42cffe532e46cfb750cd5713befc5c019c0"}, + {file = "lxml-4.6.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3b95fb7e6f9c2f53db88f4642231fc2b8907d854e614710996a96f1f32018d5c"}, + {file = "lxml-4.6.5-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:642eb4cabd997c9b949a994f9643cd8ae00cf4ca8c5cd9c273962296fadf1c44"}, + {file = "lxml-4.6.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:af4139172ff0263d269abdcc641e944c9de4b5d660894a3ec7e9f9db63b56ac9"}, + {file = "lxml-4.6.5-cp38-cp38-win32.whl", hash = "sha256:57cf05466917e08f90e323f025b96f493f92c0344694f5702579ab4b7e2eb10d"}, + {file = "lxml-4.6.5-cp38-cp38-win_amd64.whl", hash = "sha256:4f415624cf8b065796649a5e4621773dc5c9ea574a944c76a7f8a6d3d2906b41"}, + {file = "lxml-4.6.5-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:7679bb6e4d9a3978a46ab19a3560e8d2b7265ef3c88152e7fdc130d649789887"}, + {file = "lxml-4.6.5-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c34234a1bc9e466c104372af74d11a9f98338a3f72fae22b80485171a64e0144"}, + {file = "lxml-4.6.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:4b9390bf973e3907d967b75be199cf1978ca8443183cf1e78ad80ad8be9cf242"}, + {file = "lxml-4.6.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fcc849b28f584ed1dbf277291ded5c32bb3476a37032df4a1d523b55faa5f944"}, + {file = "lxml-4.6.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:46f21f2600d001af10e847df9eb3b832e8a439f696c04891bcb8a8cedd859af9"}, + {file = "lxml-4.6.5-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:99cf827f5a783038eb313beee6533dddb8bdb086d7269c5c144c1c952d142ace"}, + {file = "lxml-4.6.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:925174cafb0f1179a7fd38da90302555d7445e34c9ece68019e53c946be7f542"}, + {file = "lxml-4.6.5-cp39-cp39-win32.whl", hash = "sha256:12d8d6fe3ddef629ac1349fa89a638b296a34b6529573f5055d1cb4e5245f73b"}, + {file = "lxml-4.6.5-cp39-cp39-win_amd64.whl", hash = "sha256:a52e8f317336a44836475e9c802f51c2dc38d612eaa76532cb1d17690338b63b"}, + {file = "lxml-4.6.5-pp37-pypy37_pp73-macosx_10_14_x86_64.whl", hash = "sha256:11ae552a78612620afd15625be9f1b82e3cc2e634f90d6b11709b10a100cba59"}, + {file = "lxml-4.6.5-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:473701599665d874919d05bb33b56180447b3a9da8d52d6d9799f381ce23f95c"}, + {file = "lxml-4.6.5-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:7f00cc64b49d2ef19ddae898a3def9dd8fda9c3d27c8a174c2889ee757918e71"}, + {file = "lxml-4.6.5-pp38-pypy38_pp73-macosx_10_14_x86_64.whl", hash = "sha256:73e8614258404b2689a26cb5d002512b8bc4dfa18aca86382f68f959aee9b0c8"}, + {file = "lxml-4.6.5-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:ff44de36772b05c2eb74f2b4b6d1ae29b8f41ed5506310ce1258d44826ee38c1"}, + {file = "lxml-4.6.5-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:5d5254c815c186744c8f922e2ce861a2bdeabc06520b4b30b2f7d9767791ce6e"}, + {file = "lxml-4.6.5.tar.gz", hash = "sha256:6e84edecc3a82f90d44ddee2ee2a2630d4994b8471816e226d2b771cda7ac4ca"}, ] markdownify = [ {file = "markdownify-0.6.1-py3-none-any.whl", hash = "sha256:7489fd5c601536996a376c4afbcd1dd034db7690af807120681461e82fbc0acc"}, diff --git a/pyproject.toml b/pyproject.toml index 563bf4a27..44d09f89e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ deepdiff = "~=4.0" emoji = "~=0.6" feedparser = "~=6.0.2" rapidfuzz = "~=1.4" -lxml = "~=4.4" +lxml = "~=4.6" markdownify = "==0.6.1" more_itertools = "~=8.2" python-dateutil = "~=2.8" -- cgit v1.2.3 From 40631e98e651d6ef8c50274cb0d0b49cf693b348 Mon Sep 17 00:00:00 2001 From: Ben Soyka Date: Wed, 15 Dec 2021 07:11:46 -0700 Subject: Rename channels.discord_py to discord_bots (#1982) Co-authored-by: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/constants.py | 2 +- bot/exts/moderation/slowmode.py | 2 +- bot/exts/utils/utils.py | 2 +- config-default.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 078ab6912..1b713a7e3 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -432,7 +432,7 @@ class Channels(metaclass=YAMLGetter): black_formatter: int bot_commands: int - discord_py: int + discord_bots: int esoteric: int voice_gate: int code_jam_planning: int diff --git a/bot/exts/moderation/slowmode.py b/bot/exts/moderation/slowmode.py index 9583597e0..da04d1e98 100644 --- a/bot/exts/moderation/slowmode.py +++ b/bot/exts/moderation/slowmode.py @@ -16,7 +16,7 @@ SLOWMODE_MAX_DELAY = 21600 # seconds COMMONLY_SLOWMODED_CHANNELS = { Channels.python_general: "python_general", - Channels.discord_py: "discordpy", + Channels.discord_bots: "discord_bots", Channels.off_topic_0: "ot0", } diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 821cebd8c..f76eea516 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -49,7 +49,7 @@ class Utils(Cog): self.bot = bot @command() - @in_whitelist(channels=(Channels.bot_commands, Channels.discord_py), roles=STAFF_PARTNERS_COMMUNITY_ROLES) + @in_whitelist(channels=(Channels.bot_commands, Channels.discord_bots), roles=STAFF_PARTNERS_COMMUNITY_ROLES) async def charinfo(self, ctx: Context, *, characters: str) -> None: """Shows you information on up to 50 unicode characters.""" match = re.match(r"<(a?):(\w+):(\d+)>", characters) diff --git a/config-default.yml b/config-default.yml index 1e04f5844..583733fda 100644 --- a/config-default.yml +++ b/config-default.yml @@ -174,7 +174,7 @@ guild: how_to_get_help: 704250143020417084 # Topical - discord_py: 343944376055103488 + discord_bots: 343944376055103488 # Logs attachment_log: &ATTACH_LOG 649243850006855680 -- cgit v1.2.3 From 2da8e6462c4bf8724a276858c117d5824bb684ef Mon Sep 17 00:00:00 2001 From: mina Date: Fri, 17 Dec 2021 17:39:41 -0500 Subject: Rename and reword off-topic tags Rename `off-topic` tag to `ot` and shorten description to only include mention of the less-occupied #ot2 off-topic channel. --- bot/resources/tags/off-topic.md | 10 ---------- bot/resources/tags/ot.md | 3 +++ 2 files changed, 3 insertions(+), 10 deletions(-) delete mode 100644 bot/resources/tags/off-topic.md create mode 100644 bot/resources/tags/ot.md diff --git a/bot/resources/tags/off-topic.md b/bot/resources/tags/off-topic.md deleted file mode 100644 index 287224d7f..000000000 --- a/bot/resources/tags/off-topic.md +++ /dev/null @@ -1,10 +0,0 @@ -**Off-topic channels** - -There are three off-topic channels: -• <#463035268514185226> -• <#463035241142026251> -• <#291284109232308226> - -Their names change randomly every 24 hours, but you can always find them under the `OFF-TOPIC/GENERAL` category in the channel list. - -Please read our [off-topic etiquette](https://pythondiscord.com/pages/resources/guides/off-topic-etiquette/) before participating in conversations. diff --git a/bot/resources/tags/ot.md b/bot/resources/tags/ot.md new file mode 100644 index 000000000..636e59110 --- /dev/null +++ b/bot/resources/tags/ot.md @@ -0,0 +1,3 @@ +**Off-topic channel:** <#463035268514185226> + +Please read our [off-topic etiquette](https://pythondiscord.com/pages/resources/guides/off-topic-etiquette/) before participating in conversations. -- cgit v1.2.3 From 842bfe63df4c0272b5739b1ae3850a667d3a1b7f Mon Sep 17 00:00:00 2001 From: mina Date: Fri, 17 Dec 2021 17:55:08 -0500 Subject: Create new tag explaining off-topic channels The new `off-topic-names` tag lists all off-topic channels (in their original order: 0, 1, 2), includes an explanation of the nightly channel name change, and links to the off-topic etiquette guide. --- bot/resources/tags/off-topic-names.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 bot/resources/tags/off-topic-names.md diff --git a/bot/resources/tags/off-topic-names.md b/bot/resources/tags/off-topic-names.md new file mode 100644 index 000000000..1570dc8fd --- /dev/null +++ b/bot/resources/tags/off-topic-names.md @@ -0,0 +1,10 @@ +**Off-topic channels** + +There are three off-topic channels: +• <#291284109232308226> +• <#463035241142026251> +• <#463035268514185226> + +The channel names change every night at midnight UTC and are fun meta references to jokes or conversations that happened on the server. + +See our [off-topic etiquette](https://pythondiscord.com/pages/resources/guides/off-topic-etiquette/) page for more guidance on how the channels should be used. -- cgit v1.2.3 From ecea76d5fc7384573c95c03787ec35bf6321da15 Mon Sep 17 00:00:00 2001 From: mina <75038675+minalike@users.noreply.github.com> Date: Fri, 17 Dec 2021 23:49:40 -0500 Subject: Adjust wording to embed content Co-authored-by: dawn <78233879+dawnofmidnight@users.noreply.github.com> --- bot/resources/tags/off-topic-names.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/off-topic-names.md b/bot/resources/tags/off-topic-names.md index 1570dc8fd..5d0614aaa 100644 --- a/bot/resources/tags/off-topic-names.md +++ b/bot/resources/tags/off-topic-names.md @@ -5,6 +5,6 @@ There are three off-topic channels: • <#463035241142026251> • <#463035268514185226> -The channel names change every night at midnight UTC and are fun meta references to jokes or conversations that happened on the server. +The channel names change every night at midnight UTC and are often fun meta references to jokes or conversations that happened on the server. See our [off-topic etiquette](https://pythondiscord.com/pages/resources/guides/off-topic-etiquette/) page for more guidance on how the channels should be used. -- cgit v1.2.3 From 5c8adb16e868a67d896b94e1e05132fbc5cc080a Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sat, 18 Dec 2021 22:28:26 +0100 Subject: Attempt a name only exact match if a tag with a group is searched --- bot/exts/info/tags.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index e5930a433..f66237c8e 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -275,6 +275,11 @@ class Tags(Cog): ] tag = self.tags.get(tag_identifier) + + if tag is None and tag_identifier.group is not None: + # Try exact match with only the name + tag = self.tags.get(TagIdentifier(None, tag_identifier.group)) + if tag is None and len(filtered_tags) == 1: tag_identifier = filtered_tags[0][0] tag = filtered_tags[0][1] -- cgit v1.2.3 From 4c3186401d8e19fa1edf9f1c7853dc816335e070 Mon Sep 17 00:00:00 2001 From: onerandomusername Date: Tue, 21 Dec 2021 14:40:46 -0500 Subject: fix: pass required argument closes GH-2024 --- bot/exts/info/doc/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index ebf5f5932..4dc5276d9 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -464,7 +464,7 @@ class DocCog(commands.Cog): ) -> None: """Clear the persistent redis cache for `package`.""" if await doc_cache.delete(package_name): - await self.item_fetcher.stale_inventory_notifier.symbol_counter.delete() + await self.item_fetcher.stale_inventory_notifier.symbol_counter.delete(package_name) await ctx.send(f"Successfully cleared the cache for `{package_name}`.") else: await ctx.send("No keys matching the package found.") -- cgit v1.2.3 From 15a846cd14feda8f1e114bbef37b282051882dfe Mon Sep 17 00:00:00 2001 From: Izan Date: Sun, 26 Dec 2021 01:12:44 +0000 Subject: Add missing `bot` parameter to call --- bot/exts/moderation/infraction/_scheduler.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 52dd79791..e25acdbba 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -170,7 +170,9 @@ class InfractionScheduler: dm_log_text = "\nDM: **Failed**" # Accordingly update whether the user was successfully notified via DM. - if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon): + if await _utils.notify_infraction( + ctx.bot, user, infr_type.replace("_", " ").title(), expiry, user_reason, icon + ): dm_result = ":incoming_envelope: " dm_log_text = "\nDM: Sent" -- cgit v1.2.3 From 89743e9db148d31891a4aa65bd1c9cb35eb67f4f Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Sun, 26 Dec 2021 01:37:28 +0000 Subject: Add missing infraction id parameter & change ctx.bot to self.bot (#2028) --- bot/exts/moderation/infraction/_scheduler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index e25acdbba..6f379a9a0 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -171,7 +171,7 @@ class InfractionScheduler: # Accordingly update whether the user was successfully notified via DM. if await _utils.notify_infraction( - ctx.bot, user, infr_type.replace("_", " ").title(), expiry, user_reason, icon + self.bot, user, infraction["id"], infr_type.replace("_", " ").title(), expiry, user_reason, icon ): dm_result = ":incoming_envelope: " dm_log_text = "\nDM: Sent" @@ -241,7 +241,7 @@ class InfractionScheduler: # Accordingly update whether the user was successfully notified via DM. if await _utils.notify_infraction( - ctx.bot, user, infraction["id"], infr_type.replace("_", " ").title(), expiry, user_reason, icon + self.bot, user, infraction["id"], infr_type.replace("_", " ").title(), expiry, user_reason, icon ): dm_result = ":incoming_envelope: " dm_log_text = "\nDM: Sent" -- cgit v1.2.3 From 296e5656242edda2eebe8777b631dc7cb1cd75e3 Mon Sep 17 00:00:00 2001 From: Kronifer <44979306+Kronifer@users.noreply.github.com> Date: Sun, 17 Oct 2021 17:39:52 -0500 Subject: feat: added url parsing to filters with support for relative URLs --- bot/exts/filters/filtering.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 8accc61f8..a1362d791 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -2,6 +2,8 @@ import asyncio import re import unicodedata from datetime import timedelta +import urllib.parse +from datetime import timedelta from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple, Union import arrow @@ -534,8 +536,14 @@ class Filtering(Cog): domain_blacklist = self._get_filterlist_items("domain_name", allowed=False) for match in URL_RE.finditer(text): for url in domain_blacklist: - if url.lower() in match.group(1).lower(): - return True, self._get_filterlist_value("domain_name", url, allowed=False)["comment"] + blacklisted_parsed = urllib.parse.urlparse(url) + url_parsed = urllib.parse.urlparse(match.group(1).lower()) + if blacklisted_parsed.netloc != "": + if url_parsed.netloc in (f"www.{blacklisted_parsed.netloc}", blacklisted_parsed.netloc): + return True, self._get_filterlist_value("domain_name", url, allowed=False)["comment"] + else: + if url_parsed.netloc in (f"www.{blacklisted_parsed.path}", blacklisted_parsed.path): + return True, self._get_filterlist_value("domain_name", url, allowed=False)["comment"] return False, None @staticmethod -- cgit v1.2.3 From 546aee92fef6b88ecfb76a6d98e11cedc02656fb Mon Sep 17 00:00:00 2001 From: Kronifer <44979306+Kronifer@users.noreply.github.com> Date: Fri, 3 Dec 2021 02:58:37 +0000 Subject: feat: changed to tldextract --- bot/exts/filters/filtering.py | 14 +- poetry.lock | 665 ++++++++++++++++++++++++------------------ pyproject.toml | 1 + 3 files changed, 394 insertions(+), 286 deletions(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index a1362d791..ad904d147 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -2,14 +2,13 @@ import asyncio import re import unicodedata from datetime import timedelta -import urllib.parse -from datetime import timedelta from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple, Union import arrow import dateutil.parser import discord.errors import regex +import tldextract from async_rediscache import RedisCache from dateutil.relativedelta import relativedelta from discord import Colour, HTTPException, Member, Message, NotFound, TextChannel @@ -536,13 +535,10 @@ class Filtering(Cog): domain_blacklist = self._get_filterlist_items("domain_name", allowed=False) for match in URL_RE.finditer(text): for url in domain_blacklist: - blacklisted_parsed = urllib.parse.urlparse(url) - url_parsed = urllib.parse.urlparse(match.group(1).lower()) - if blacklisted_parsed.netloc != "": - if url_parsed.netloc in (f"www.{blacklisted_parsed.netloc}", blacklisted_parsed.netloc): - return True, self._get_filterlist_value("domain_name", url, allowed=False)["comment"] - else: - if url_parsed.netloc in (f"www.{blacklisted_parsed.path}", blacklisted_parsed.path): + if url.lower() in match.group(1).lower(): + blacklisted_parsed = tldextract.extract(url.lower()) + url_parsed = tldextract.extract(match.group(1).lower()) + if blacklisted_parsed.registered_domain == url_parsed.registered_domain: return True, self._get_filterlist_value("domain_name", url, allowed=False)["comment"] return False, None diff --git a/poetry.lock b/poetry.lock index 4155a57ff..68eebf8de 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,10 @@ [[package]] name = "aio-pika" -version = "6.8.0" +version = "6.8.1" description = "Wrapper for the aiormq for asyncio and humans." category = "main" optional = false -python-versions = ">3.5.*, <4" +python-versions = ">=3.5, <4" [package.dependencies] aiormq = ">=3.2.3,<4" @@ -128,7 +128,7 @@ tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (> [[package]] name = "backports.entry-points-selectable" -version = "1.1.0" +version = "1.1.1" description = "Compatibility shim providing selectable entry points for older implementations" category = "dev" optional = false @@ -136,7 +136,7 @@ python-versions = ">=2.7" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] +testing = ["pytest", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] [[package]] name = "beautifulsoup4" @@ -190,9 +190,9 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "charset-normalizer" -version = "2.0.7" +version = "2.0.9" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "dev" +category = "main" optional = false python-versions = ">=3.5.0" @@ -262,6 +262,20 @@ ordered-set = ">=3.1.1" [package.extras] murmur = ["mmh3"] +[[package]] +name = "deprecated" +version = "1.2.13" +description = "Python @deprecated decorator to deprecate old python classes, functions or methods." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +wrapt = ">=1.10,<2" + +[package.extras] +dev = ["tox", "bump2version (<1)", "sphinx (<2)", "importlib-metadata (<3)", "importlib-resources (<4)", "configparser (<5)", "sphinxcontrib-websupport (<2)", "zipp (<2)", "PyTest (<5)", "PyTest-Cov (<2.6)", "pytest", "pytest-cov"] + [[package]] name = "discord.py" version = "2.0.0a0" @@ -281,10 +295,9 @@ voice = ["PyNaCl (>=1.3.0,<1.5)"] [package.source] type = "url" url = "https://github.com/Rapptz/discord.py/archive/45d498c1b76deaf3b394d17ccf56112fa691d160.zip" - [[package]] name = "distlib" -version = "0.3.3" +version = "0.3.4" description = "Distribution utilities" category = "dev" optional = false @@ -322,7 +335,7 @@ testing = ["pre-commit"] [[package]] name = "fakeredis" -version = "1.6.1" +version = "1.7.0" description = "Fake implementation of redis API for testing purposes." category = "main" optional = false @@ -330,7 +343,7 @@ python-versions = ">=3.5" [package.dependencies] packaging = "*" -redis = "<3.6.0" +redis = "<4.1.0" six = ">=1.12" sortedcontainers = "*" @@ -351,9 +364,9 @@ sgmllib3k = "*" [[package]] name = "filelock" -version = "3.3.1" +version = "3.4.0" description = "A platform independent file lock." -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -493,14 +506,14 @@ pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_ve [[package]] name = "identify" -version = "2.3.0" +version = "2.4.0" description = "File identification library for Python" category = "dev" optional = false python-versions = ">=3.6.1" [package.extras] -license = ["editdistance-s"] +license = ["ukkonen"] [[package]] name = "idna" @@ -520,7 +533,7 @@ python-versions = "*" [[package]] name = "isort" -version = "5.9.3" +version = "5.10.1" description = "A Python utility / library to sort Python imports." category = "dev" optional = false @@ -534,7 +547,7 @@ plugins = ["setuptools"] [[package]] name = "lxml" -version = "4.6.5" +version = "4.7.1" description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." category = "main" optional = false @@ -568,7 +581,7 @@ python-versions = "*" [[package]] name = "more-itertools" -version = "8.10.0" +version = "8.12.0" description = "More routines for operating on iterables, beyond itertools" category = "main" optional = false @@ -608,14 +621,14 @@ python-versions = ">=3.5" [[package]] name = "packaging" -version = "21.0" +version = "21.3" description = "Core utilities for Python packages" category = "main" optional = false python-versions = ">=3.6" [package.dependencies] -pyparsing = ">=2.0.2" +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" [[package]] name = "pamqp" @@ -680,7 +693,7 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "2.15.0" +version = "2.16.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -715,11 +728,11 @@ python-versions = "*" [[package]] name = "py" -version = "1.10.0" +version = "1.11.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "pycares" @@ -745,7 +758,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pycparser" -version = "2.20" +version = "2.21" description = "C parser in Python" category = "main" optional = false @@ -775,11 +788,14 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pyparsing" -version = "2.4.7" +version = "3.0.6" description = "Python parsing module" category = "main" optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = ">=3.6" + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pyreadline3" @@ -828,11 +844,11 @@ testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtuale [[package]] name = "pytest-forked" -version = "1.3.0" +version = "1.4.0" description = "run tests in isolated forked subprocesses" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.6" [package.dependencies] py = "*" @@ -903,7 +919,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [[package]] name = "rapidfuzz" -version = "1.8.0" +version = "1.9.1" description = "rapid fuzzy string matching" category = "main" optional = false @@ -914,14 +930,17 @@ full = ["numpy"] [[package]] name = "redis" -version = "3.5.3" -description = "Python client for Redis key-value store" +version = "4.0.2" +description = "Python client for Redis database and key-value store" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.6" + +[package.dependencies] +deprecated = "*" [package.extras] -hiredis = ["hiredis (>=0.1.3)"] +hiredis = ["hiredis (>=1.0.0)"] [[package]] name = "regex" @@ -935,7 +954,7 @@ python-versions = "*" name = "requests" version = "2.26.0" description = "Python HTTP for Humans." -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" @@ -949,9 +968,21 @@ urllib3 = ">=1.21.1,<1.27" socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] +[[package]] +name = "requests-file" +version = "1.5.1" +description = "File transport adapter for Requests" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +requests = ">=1.0.0" +six = "*" + [[package]] name = "sentry-sdk" -version = "1.4.3" +version = "1.5.1" description = "Python client for Sentry (https://sentry.io)" category = "main" optional = false @@ -996,7 +1027,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "snowballstemmer" -version = "2.1.0" +version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." category = "dev" optional = false @@ -1012,7 +1043,7 @@ python-versions = "*" [[package]] name = "soupsieve" -version = "2.2.1" +version = "2.3.1" description = "A modern CSS selector implementation for Beautiful Soup." category = "main" optional = false @@ -1052,6 +1083,20 @@ build = ["setuptools-git", "wheel", "twine"] docs = ["sphinx", "zope.component", "sybil", "twisted", "mock", "django (<2)", "django"] test = ["pytest (>=3.6)", "pytest-cov", "pytest-django", "zope.component", "sybil", "twisted", "mock", "django (<2)", "django"] +[[package]] +name = "tldextract" +version = "3.1.2" +description = "Accurately separate the TLD from the registered domain and subdomains of a URL, using the Public Suffix List. By default, this includes the public ICANN TLDs and their exceptions. You can optionally support the Public Suffix List's private domains as well." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +filelock = ">=3.0.8" +idna = "*" +requests = ">=2.1.0" +requests-file = ">=1.4" + [[package]] name = "toml" version = "0.10.2" @@ -1062,11 +1107,11 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "typing-extensions" -version = "3.10.0.2" -description = "Backported and Experimental Type Hints for Python 3.5+" +version = "4.0.1" +description = "Backported and Experimental Type Hints for Python 3.6+" category = "main" optional = false -python-versions = "*" +python-versions = ">=3.6" [[package]] name = "urllib3" @@ -1083,7 +1128,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.8.1" +version = "20.10.0" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -1092,17 +1137,25 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] "backports.entry-points-selectable" = ">=1.0.4" distlib = ">=0.3.1,<1" -filelock = ">=3.0.0,<4" +filelock = ">=3.2,<4" platformdirs = ">=2,<3" six = ">=1.9.0,<2" [package.extras] -docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] +docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] +[[package]] +name = "wrapt" +version = "1.13.3" +description = "Module for decorators, wrappers and monkey patching." +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" + [[package]] name = "yarl" -version = "1.7.0" +version = "1.7.2" description = "Yet another URL library" category = "main" optional = false @@ -1115,12 +1168,12 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "e6fe15a64ae57232a639149df793d6580a93f613425cae85c9892cf959710430" +content-hash = "14ad70153b8c2f4a7e8492bf89f60bf7c468a939da36ce62871b677495f75302" [metadata.files] aio-pika = [ - {file = "aio-pika-6.8.0.tar.gz", hash = "sha256:1d4305a5f78af3857310b4fe48348cdcf6c097e0e275ea88c2cd08570531a369"}, - {file = "aio_pika-6.8.0-py3-none-any.whl", hash = "sha256:e69afef8695f47c5d107bbdba21bdb845d5c249acb3be53ef5c2d497b02657c0"}, + {file = "aio-pika-6.8.1.tar.gz", hash = "sha256:c2b2b46949a34252ff0e64c3bc208eef1893e5791b51aeefabf1676788d56b66"}, + {file = "aio_pika-6.8.1-py3-none-any.whl", hash = "sha256:059ab8ecc03d73997f64ed28df7269105984232174d0e6406389c4e8ed30941c"}, ] aiodns = [ {file = "aiodns-2.0.0-py2.py3-none-any.whl", hash = "sha256:aaa5ac584f40fe778013df0aa6544bf157799bd3f608364b451840ed2c8688de"}, @@ -1194,8 +1247,8 @@ attrs = [ {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, ] "backports.entry-points-selectable" = [ - {file = "backports.entry_points_selectable-1.1.0-py2.py3-none-any.whl", hash = "sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc"}, - {file = "backports.entry_points_selectable-1.1.0.tar.gz", hash = "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a"}, + {file = "backports.entry_points_selectable-1.1.1-py2.py3-none-any.whl", hash = "sha256:7fceed9532a7aa2bd888654a7314f864a3c16a4e710b34a58cfc0f08114c663b"}, + {file = "backports.entry_points_selectable-1.1.1.tar.gz", hash = "sha256:914b21a479fde881635f7af5adc7f6e38d6b274be32269070c53b698c60d5386"}, ] beautifulsoup4 = [ {file = "beautifulsoup4-4.10.0-py3-none-any.whl", hash = "sha256:9a315ce70049920ea4572a4055bc4bd700c940521d36fc858205ad4fcde149bf"}, @@ -1266,8 +1319,8 @@ chardet = [ {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, ] charset-normalizer = [ - {file = "charset-normalizer-2.0.7.tar.gz", hash = "sha256:e019de665e2bcf9c2b64e2e5aa025fa991da8720daa3c1138cadd2fd1856aed0"}, - {file = "charset_normalizer-2.0.7-py3-none-any.whl", hash = "sha256:f7af805c321bfa1ce6714c51f254e0d5bb5e5834039bc17db7ebe3a4cec9492b"}, + {file = "charset-normalizer-2.0.9.tar.gz", hash = "sha256:b0b883e8e874edfdece9c28f314e3dd5badf067342e42fb162203335ae61aa2c"}, + {file = "charset_normalizer-2.0.9-py3-none-any.whl", hash = "sha256:1eecaa09422db5be9e29d7fc65664e6c33bd06f9ced7838578ba40d58bdf3721"}, ] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, @@ -1339,10 +1392,14 @@ deepdiff = [ {file = "deepdiff-4.3.2-py3-none-any.whl", hash = "sha256:59fc1e3e7a28dd0147b0f2b00e3e27181f0f0ef4286b251d5f214a5bcd9a9bc4"}, {file = "deepdiff-4.3.2.tar.gz", hash = "sha256:91360be1d9d93b1d9c13ae9c5048fa83d9cff17a88eb30afaa0d7ff2d0fee17d"}, ] +deprecated = [ + {file = "Deprecated-1.2.13-py2.py3-none-any.whl", hash = "sha256:64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d"}, + {file = "Deprecated-1.2.13.tar.gz", hash = "sha256:43ac5335da90c31c24ba028af536a91d41d53f9e6901ddb021bcc572ce44e38d"}, +] "discord.py" = [] distlib = [ - {file = "distlib-0.3.3-py2.py3-none-any.whl", hash = "sha256:c8b54e8454e5bf6237cc84c20e8264c3e991e824ef27e8f1e81049867d861e31"}, - {file = "distlib-0.3.3.zip", hash = "sha256:d982d0751ff6eaaab5e2ec8e691d949ee80eddf01a62eaa96ddb11531fe16b05"}, + {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, + {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, ] docopt = [ {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, @@ -1355,16 +1412,16 @@ execnet = [ {file = "execnet-1.9.0.tar.gz", hash = "sha256:8f694f3ba9cc92cab508b152dcfe322153975c29bda272e2fd7f3f00f36e47c5"}, ] fakeredis = [ - {file = "fakeredis-1.6.1-py3-none-any.whl", hash = "sha256:5eb1516f1fe1813e9da8f6c482178fc067af09f53de587ae03887ef5d9d13024"}, - {file = "fakeredis-1.6.1.tar.gz", hash = "sha256:0d06a9384fb79da9f2164ce96e34eb9d4e2ea46215070805ea6fd3c174590b47"}, + {file = "fakeredis-1.7.0-py3-none-any.whl", hash = "sha256:6f1e04f64557ad3b6835bdc6e5a8d022cbace4bdc24a47ad58f6a72e0fbff760"}, + {file = "fakeredis-1.7.0.tar.gz", hash = "sha256:c9bd12e430336cbd3e189fae0e91eb99997b93e76dbfdd6ed67fa352dc684c71"}, ] feedparser = [ {file = "feedparser-6.0.8-py3-none-any.whl", hash = "sha256:1b7f57841d9cf85074deb316ed2c795091a238adb79846bc46dccdaf80f9c59a"}, {file = "feedparser-6.0.8.tar.gz", hash = "sha256:5ce0410a05ab248c8c7cfca3a0ea2203968ee9ff4486067379af4827a59f9661"}, ] filelock = [ - {file = "filelock-3.3.1-py3-none-any.whl", hash = "sha256:2b5eb3589e7fdda14599e7eb1a50e09b4cc14f34ed98b8ba56d33bfaafcbef2f"}, - {file = "filelock-3.3.1.tar.gz", hash = "sha256:34a9f35f95c441e7b38209775d6e0337f9a3759f3565f6c5798f19618527c76f"}, + {file = "filelock-3.4.0-py3-none-any.whl", hash = "sha256:2e139a228bcf56dd8b2274a65174d005c4a6b68540ee0bdbb92c76f43f29f7e8"}, + {file = "filelock-3.4.0.tar.gz", hash = "sha256:93d512b32a23baf4cac44ffd72ccf70732aeff7b8050fcaf6d3ec406d954baf4"}, ] flake8 = [ {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, @@ -1449,8 +1506,8 @@ humanfriendly = [ {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"}, ] identify = [ - {file = "identify-2.3.0-py2.py3-none-any.whl", hash = "sha256:d1e82c83d063571bb88087676f81261a4eae913c492dafde184067c584bc7c05"}, - {file = "identify-2.3.0.tar.gz", hash = "sha256:fd08c97f23ceee72784081f1ce5125c8f53a02d3f2716dde79a6ab8f1039fea5"}, + {file = "identify-2.4.0-py2.py3-none-any.whl", hash = "sha256:eba31ca80258de6bb51453084bff4a923187cd2193b9c13710f2516ab30732cc"}, + {file = "identify-2.4.0.tar.gz", hash = "sha256:a33ae873287e81651c7800ca309dc1f84679b763c9c8b30680e16fbfa82f0107"}, ] idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, @@ -1461,70 +1518,70 @@ iniconfig = [ {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] isort = [ - {file = "isort-5.9.3-py3-none-any.whl", hash = "sha256:e17d6e2b81095c9db0a03a8025a957f334d6ea30b26f9ec70805411e5c7c81f2"}, - {file = "isort-5.9.3.tar.gz", hash = "sha256:9c2ea1e62d871267b78307fe511c0838ba0da28698c5732d54e2790bf3ba9899"}, + {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, + {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, ] lxml = [ - {file = "lxml-4.6.5-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:abcf7daa5ebcc89328326254f6dd6d566adb483d4d00178892afd386ab389de2"}, - {file = "lxml-4.6.5-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3884476a90d415be79adfa4e0e393048630d0d5bcd5757c4c07d8b4b00a1096b"}, - {file = "lxml-4.6.5-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:add017c5bd6b9ec3a5f09248396b6ee2ce61c5621f087eb2269c813cd8813808"}, - {file = "lxml-4.6.5-cp27-cp27m-win32.whl", hash = "sha256:a702005e447d712375433ed0499cb6e1503fadd6c96a47f51d707b4d37b76d3c"}, - {file = "lxml-4.6.5-cp27-cp27m-win_amd64.whl", hash = "sha256:da07c7e7fc9a3f40446b78c54dbba8bfd5c9100dfecb21b65bfe3f57844f5e71"}, - {file = "lxml-4.6.5-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a708c291900c40a7ecf23f1d2384ed0bc0604e24094dd13417c7e7f8f7a50d93"}, - {file = "lxml-4.6.5-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:f33d8efb42e4fc2b31b3b4527940b25cdebb3026fb56a80c1c1c11a4271d2352"}, - {file = "lxml-4.6.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:f6befb83bca720b71d6bd6326a3b26e9496ae6649e26585de024890fe50f49b8"}, - {file = "lxml-4.6.5-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:59d77bfa3bea13caee95bc0d3f1c518b15049b97dd61ea8b3d71ce677a67f808"}, - {file = "lxml-4.6.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:68a851176c931e2b3de6214347b767451243eeed3bea34c172127bbb5bf6c210"}, - {file = "lxml-4.6.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a7790a273225b0c46e5f859c1327f0f659896cc72eaa537d23aa3ad9ff2a1cc1"}, - {file = "lxml-4.6.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6548fc551de15f310dd0564751d9dc3d405278d45ea9b2b369ed1eccf142e1f5"}, - {file = "lxml-4.6.5-cp310-cp310-win32.whl", hash = "sha256:dc8a0dbb2a10ae8bb609584f5c504789f0f3d0d81840da4849102ec84289f952"}, - {file = "lxml-4.6.5-cp310-cp310-win_amd64.whl", hash = "sha256:1ccbfe5d17835db906f2bab6f15b34194db1a5b07929cba3cf45a96dbfbfefc0"}, - {file = "lxml-4.6.5-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca9a40497f7e97a2a961c04fa8a6f23d790b0521350a8b455759d786b0bcb203"}, - {file = "lxml-4.6.5-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e5b4b0d9440046ead3bd425eb2b852499241ee0cef1ae151038e4f87ede888c4"}, - {file = "lxml-4.6.5-cp35-cp35m-win32.whl", hash = "sha256:87f8f7df70b90fbe7b49969f07b347e3f978f8bd1046bb8ecae659921869202b"}, - {file = "lxml-4.6.5-cp35-cp35m-win_amd64.whl", hash = "sha256:ce52aad32ec6e46d1a91ff8b8014a91538800dd533914bfc4a82f5018d971408"}, - {file = "lxml-4.6.5-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:8021eeff7fabde21b9858ed058a8250ad230cede91764d598c2466b0ba70db8b"}, - {file = "lxml-4.6.5-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:cab343b265e38d4e00649cbbad9278b734c5715f9bcbb72c85a1f99b1a58e19a"}, - {file = "lxml-4.6.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:3534d7c468c044f6aef3c0aff541db2826986a29ea73f2ca831f5d5284d9b570"}, - {file = "lxml-4.6.5-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdb98f4c9e8a1735efddfaa995b0c96559792da15d56b76428bdfc29f77c4cdb"}, - {file = "lxml-4.6.5-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:5ea121cb66d7e5cb396b4c3ca90471252b94e01809805cfe3e4e44be2db3a99c"}, - {file = "lxml-4.6.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:121fc6f71c692b49af6c963b84ab7084402624ffbe605287da362f8af0668ea3"}, - {file = "lxml-4.6.5-cp36-cp36m-win32.whl", hash = "sha256:1a2a7659b8eb93c6daee350a0d844994d49245a0f6c05c747f619386fb90ba04"}, - {file = "lxml-4.6.5-cp36-cp36m-win_amd64.whl", hash = "sha256:2f77556266a8fe5428b8759fbfc4bd70be1d1d9c9b25d2a414f6a0c0b0f09120"}, - {file = "lxml-4.6.5-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:558485218ee06458643b929765ac1eb04519ca3d1e2dcc288517de864c747c33"}, - {file = "lxml-4.6.5-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:ba0006799f21d83c3717fe20e2707a10bbc296475155aadf4f5850f6659b96b9"}, - {file = "lxml-4.6.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:916d457ad84e05b7db52700bad0a15c56e0c3000dcaf1263b2fb7a56fe148996"}, - {file = "lxml-4.6.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:c580c2a61d8297a6e47f4d01f066517dbb019be98032880d19ece7f337a9401d"}, - {file = "lxml-4.6.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a21b78af7e2e13bec6bea12fc33bc05730197674f3e5402ce214d07026ccfebd"}, - {file = "lxml-4.6.5-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:46515773570a33eae13e451c8fcf440222ef24bd3b26f40774dd0bd8b6db15b2"}, - {file = "lxml-4.6.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:124f09614f999551ac65e5b9875981ce4b66ac4b8e2ba9284572f741935df3d9"}, - {file = "lxml-4.6.5-cp37-cp37m-win32.whl", hash = "sha256:b4015baed99d046c760f09a4c59d234d8f398a454380c3cf0b859aba97136090"}, - {file = "lxml-4.6.5-cp37-cp37m-win_amd64.whl", hash = "sha256:12ae2339d32a2b15010972e1e2467345b7bf962e155671239fba74c229564b7f"}, - {file = "lxml-4.6.5-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:76b6c296e4f7a1a8a128aec42d128646897f9ae9a700ef6839cdc9b3900db9b5"}, - {file = "lxml-4.6.5-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:534032a5ceb34bba1da193b7d386ac575127cc39338379f39a164b10d97ade89"}, - {file = "lxml-4.6.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:60aeb14ff9022d2687ef98ce55f6342944c40d00916452bb90899a191802137a"}, - {file = "lxml-4.6.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:9801bcd52ac9c795a7d81ea67471a42cffe532e46cfb750cd5713befc5c019c0"}, - {file = "lxml-4.6.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3b95fb7e6f9c2f53db88f4642231fc2b8907d854e614710996a96f1f32018d5c"}, - {file = "lxml-4.6.5-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:642eb4cabd997c9b949a994f9643cd8ae00cf4ca8c5cd9c273962296fadf1c44"}, - {file = "lxml-4.6.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:af4139172ff0263d269abdcc641e944c9de4b5d660894a3ec7e9f9db63b56ac9"}, - {file = "lxml-4.6.5-cp38-cp38-win32.whl", hash = "sha256:57cf05466917e08f90e323f025b96f493f92c0344694f5702579ab4b7e2eb10d"}, - {file = "lxml-4.6.5-cp38-cp38-win_amd64.whl", hash = "sha256:4f415624cf8b065796649a5e4621773dc5c9ea574a944c76a7f8a6d3d2906b41"}, - {file = "lxml-4.6.5-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:7679bb6e4d9a3978a46ab19a3560e8d2b7265ef3c88152e7fdc130d649789887"}, - {file = "lxml-4.6.5-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c34234a1bc9e466c104372af74d11a9f98338a3f72fae22b80485171a64e0144"}, - {file = "lxml-4.6.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:4b9390bf973e3907d967b75be199cf1978ca8443183cf1e78ad80ad8be9cf242"}, - {file = "lxml-4.6.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fcc849b28f584ed1dbf277291ded5c32bb3476a37032df4a1d523b55faa5f944"}, - {file = "lxml-4.6.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:46f21f2600d001af10e847df9eb3b832e8a439f696c04891bcb8a8cedd859af9"}, - {file = "lxml-4.6.5-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:99cf827f5a783038eb313beee6533dddb8bdb086d7269c5c144c1c952d142ace"}, - {file = "lxml-4.6.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:925174cafb0f1179a7fd38da90302555d7445e34c9ece68019e53c946be7f542"}, - {file = "lxml-4.6.5-cp39-cp39-win32.whl", hash = "sha256:12d8d6fe3ddef629ac1349fa89a638b296a34b6529573f5055d1cb4e5245f73b"}, - {file = "lxml-4.6.5-cp39-cp39-win_amd64.whl", hash = "sha256:a52e8f317336a44836475e9c802f51c2dc38d612eaa76532cb1d17690338b63b"}, - {file = "lxml-4.6.5-pp37-pypy37_pp73-macosx_10_14_x86_64.whl", hash = "sha256:11ae552a78612620afd15625be9f1b82e3cc2e634f90d6b11709b10a100cba59"}, - {file = "lxml-4.6.5-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:473701599665d874919d05bb33b56180447b3a9da8d52d6d9799f381ce23f95c"}, - {file = "lxml-4.6.5-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:7f00cc64b49d2ef19ddae898a3def9dd8fda9c3d27c8a174c2889ee757918e71"}, - {file = "lxml-4.6.5-pp38-pypy38_pp73-macosx_10_14_x86_64.whl", hash = "sha256:73e8614258404b2689a26cb5d002512b8bc4dfa18aca86382f68f959aee9b0c8"}, - {file = "lxml-4.6.5-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:ff44de36772b05c2eb74f2b4b6d1ae29b8f41ed5506310ce1258d44826ee38c1"}, - {file = "lxml-4.6.5-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:5d5254c815c186744c8f922e2ce861a2bdeabc06520b4b30b2f7d9767791ce6e"}, - {file = "lxml-4.6.5.tar.gz", hash = "sha256:6e84edecc3a82f90d44ddee2ee2a2630d4994b8471816e226d2b771cda7ac4ca"}, + {file = "lxml-4.7.1-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:d546431636edb1d6a608b348dd58cc9841b81f4116745857b6cb9f8dadb2725f"}, + {file = "lxml-4.7.1-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6308062534323f0d3edb4e702a0e26a76ca9e0e23ff99be5d82750772df32a9e"}, + {file = "lxml-4.7.1-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:f76dbe44e31abf516114f6347a46fa4e7c2e8bceaa4b6f7ee3a0a03c8eba3c17"}, + {file = "lxml-4.7.1-cp27-cp27m-win32.whl", hash = "sha256:d5618d49de6ba63fe4510bdada62d06a8acfca0b4b5c904956c777d28382b419"}, + {file = "lxml-4.7.1-cp27-cp27m-win_amd64.whl", hash = "sha256:9393a05b126a7e187f3e38758255e0edf948a65b22c377414002d488221fdaa2"}, + {file = "lxml-4.7.1-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:50d3dba341f1e583265c1a808e897b4159208d814ab07530202b6036a4d86da5"}, + {file = "lxml-4.7.1-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:44f552e0da3c8ee3c28e2eb82b0b784200631687fc6a71277ea8ab0828780e7d"}, + {file = "lxml-4.7.1-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:e662c6266e3a275bdcb6bb049edc7cd77d0b0f7e119a53101d367c841afc66dc"}, + {file = "lxml-4.7.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:4c093c571bc3da9ebcd484e001ba18b8452903cd428c0bc926d9b0141bcb710e"}, + {file = "lxml-4.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:3e26ad9bc48d610bf6cc76c506b9e5ad9360ed7a945d9be3b5b2c8535a0145e3"}, + {file = "lxml-4.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a5f623aeaa24f71fce3177d7fee875371345eb9102b355b882243e33e04b7175"}, + {file = "lxml-4.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7b5e2acefd33c259c4a2e157119c4373c8773cf6793e225006a1649672ab47a6"}, + {file = "lxml-4.7.1-cp310-cp310-win32.whl", hash = "sha256:67fa5f028e8a01e1d7944a9fb616d1d0510d5d38b0c41708310bd1bc45ae89f6"}, + {file = "lxml-4.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:b1d381f58fcc3e63fcc0ea4f0a38335163883267f77e4c6e22d7a30877218a0e"}, + {file = "lxml-4.7.1-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:38d9759733aa04fb1697d717bfabbedb21398046bd07734be7cccc3d19ea8675"}, + {file = "lxml-4.7.1-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:dfd0d464f3d86a1460683cd742306d1138b4e99b79094f4e07e1ca85ee267fe7"}, + {file = "lxml-4.7.1-cp35-cp35m-win32.whl", hash = "sha256:534e946bce61fd162af02bad7bfd2daec1521b71d27238869c23a672146c34a5"}, + {file = "lxml-4.7.1-cp35-cp35m-win_amd64.whl", hash = "sha256:6ec829058785d028f467be70cd195cd0aaf1a763e4d09822584ede8c9eaa4b03"}, + {file = "lxml-4.7.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:ade74f5e3a0fd17df5782896ddca7ddb998845a5f7cd4b0be771e1ffc3b9aa5b"}, + {file = "lxml-4.7.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:41358bfd24425c1673f184d7c26c6ae91943fe51dfecc3603b5e08187b4bcc55"}, + {file = "lxml-4.7.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6e56521538f19c4a6690f439fefed551f0b296bd785adc67c1777c348beb943d"}, + {file = "lxml-4.7.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5b0f782f0e03555c55e37d93d7a57454efe7495dab33ba0ccd2dbe25fc50f05d"}, + {file = "lxml-4.7.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:490712b91c65988012e866c411a40cc65b595929ececf75eeb4c79fcc3bc80a6"}, + {file = "lxml-4.7.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:34c22eb8c819d59cec4444d9eebe2e38b95d3dcdafe08965853f8799fd71161d"}, + {file = "lxml-4.7.1-cp36-cp36m-win32.whl", hash = "sha256:2a906c3890da6a63224d551c2967413b8790a6357a80bf6b257c9a7978c2c42d"}, + {file = "lxml-4.7.1-cp36-cp36m-win_amd64.whl", hash = "sha256:36b16fecb10246e599f178dd74f313cbdc9f41c56e77d52100d1361eed24f51a"}, + {file = "lxml-4.7.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:a5edc58d631170de90e50adc2cc0248083541affef82f8cd93bea458e4d96db8"}, + {file = "lxml-4.7.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:87c1b0496e8c87ec9db5383e30042357b4839b46c2d556abd49ec770ce2ad868"}, + {file = "lxml-4.7.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:0a5f0e4747f31cff87d1eb32a6000bde1e603107f632ef4666be0dc065889c7a"}, + {file = "lxml-4.7.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:bf6005708fc2e2c89a083f258b97709559a95f9a7a03e59f805dd23c93bc3986"}, + {file = "lxml-4.7.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc15874816b9320581133ddc2096b644582ab870cf6a6ed63684433e7af4b0d3"}, + {file = "lxml-4.7.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:0b5e96e25e70917b28a5391c2ed3ffc6156513d3db0e1476c5253fcd50f7a944"}, + {file = "lxml-4.7.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ec9027d0beb785a35aa9951d14e06d48cfbf876d8ff67519403a2522b181943b"}, + {file = "lxml-4.7.1-cp37-cp37m-win32.whl", hash = "sha256:9fbc0dee7ff5f15c4428775e6fa3ed20003140560ffa22b88326669d53b3c0f4"}, + {file = "lxml-4.7.1-cp37-cp37m-win_amd64.whl", hash = "sha256:1104a8d47967a414a436007c52f533e933e5d52574cab407b1e49a4e9b5ddbd1"}, + {file = "lxml-4.7.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:fc9fb11b65e7bc49f7f75aaba1b700f7181d95d4e151cf2f24d51bfd14410b77"}, + {file = "lxml-4.7.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:317bd63870b4d875af3c1be1b19202de34c32623609ec803b81c99193a788c1e"}, + {file = "lxml-4.7.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:610807cea990fd545b1559466971649e69302c8a9472cefe1d6d48a1dee97440"}, + {file = "lxml-4.7.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:09b738360af8cb2da275998a8bf79517a71225b0de41ab47339c2beebfff025f"}, + {file = "lxml-4.7.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6a2ab9d089324d77bb81745b01f4aeffe4094306d939e92ba5e71e9a6b99b71e"}, + {file = "lxml-4.7.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:eed394099a7792834f0cb4a8f615319152b9d801444c1c9e1b1a2c36d2239f9e"}, + {file = "lxml-4.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:735e3b4ce9c0616e85f302f109bdc6e425ba1670a73f962c9f6b98a6d51b77c9"}, + {file = "lxml-4.7.1-cp38-cp38-win32.whl", hash = "sha256:772057fba283c095db8c8ecde4634717a35c47061d24f889468dc67190327bcd"}, + {file = "lxml-4.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:13dbb5c7e8f3b6a2cf6e10b0948cacb2f4c9eb05029fe31c60592d08ac63180d"}, + {file = "lxml-4.7.1-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:718d7208b9c2d86aaf0294d9381a6acb0158b5ff0f3515902751404e318e02c9"}, + {file = "lxml-4.7.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:5bee1b0cbfdb87686a7fb0e46f1d8bd34d52d6932c0723a86de1cc532b1aa489"}, + {file = "lxml-4.7.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:e410cf3a2272d0a85526d700782a2fa92c1e304fdcc519ba74ac80b8297adf36"}, + {file = "lxml-4.7.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:585ea241ee4961dc18a95e2f5581dbc26285fcf330e007459688096f76be8c42"}, + {file = "lxml-4.7.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a555e06566c6dc167fbcd0ad507ff05fd9328502aefc963cb0a0547cfe7f00db"}, + {file = "lxml-4.7.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:adaab25be351fff0d8a691c4f09153647804d09a87a4e4ea2c3f9fe9e8651851"}, + {file = "lxml-4.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:82d16a64236970cb93c8d63ad18c5b9f138a704331e4b916b2737ddfad14e0c4"}, + {file = "lxml-4.7.1-cp39-cp39-win32.whl", hash = "sha256:59e7da839a1238807226f7143c68a479dee09244d1b3cf8c134f2fce777d12d0"}, + {file = "lxml-4.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:a1bbc4efa99ed1310b5009ce7f3a1784698082ed2c1ef3895332f5df9b3b92c2"}, + {file = "lxml-4.7.1-pp37-pypy37_pp73-macosx_10_14_x86_64.whl", hash = "sha256:0607ff0988ad7e173e5ddf7bf55ee65534bd18a5461183c33e8e41a59e89edf4"}, + {file = "lxml-4.7.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:6c198bfc169419c09b85ab10cb0f572744e686f40d1e7f4ed09061284fc1303f"}, + {file = "lxml-4.7.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a58d78653ae422df6837dd4ca0036610b8cb4962b5cfdbd337b7b24de9e5f98a"}, + {file = "lxml-4.7.1-pp38-pypy38_pp73-macosx_10_14_x86_64.whl", hash = "sha256:e18281a7d80d76b66a9f9e68a98cf7e1d153182772400d9a9ce855264d7d0ce7"}, + {file = "lxml-4.7.1-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:8e54945dd2eeb50925500957c7c579df3cd07c29db7810b83cf30495d79af267"}, + {file = "lxml-4.7.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:447d5009d6b5447b2f237395d0018901dcc673f7d9f82ba26c1b9f9c3b444b60"}, + {file = "lxml-4.7.1.tar.gz", hash = "sha256:a1613838aa6b89af4ba10a0f3a972836128801ed008078f8c1244e65958f1b24"}, ] markdownify = [ {file = "markdownify-0.6.1-py3-none-any.whl", hash = "sha256:7489fd5c601536996a376c4afbcd1dd034db7690af807120681461e82fbc0acc"}, @@ -1535,8 +1592,8 @@ mccabe = [ {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] more-itertools = [ - {file = "more-itertools-8.10.0.tar.gz", hash = "sha256:1debcabeb1df793814859d64a81ad7cb10504c24349368ccf214c664c474f41f"}, - {file = "more_itertools-8.10.0-py3-none-any.whl", hash = "sha256:56ddac45541718ba332db05f464bebfb0768110111affd27f66e0051f276fa43"}, + {file = "more-itertools-8.12.0.tar.gz", hash = "sha256:7dc6ad46f05f545f900dd59e8dfb4e84a4827b97b3cfecb175ea0c7d247f6064"}, + {file = "more_itertools-8.12.0-py3-none-any.whl", hash = "sha256:43e6dd9942dffd72661a2c4ef383ad7da1e6a3e968a927ad7a6083ab410a688b"}, ] mslex = [ {file = "mslex-0.3.0-py2.py3-none-any.whl", hash = "sha256:380cb14abf8fabf40e56df5c8b21a6d533dc5cbdcfe42406bbf08dda8f42e42a"}, @@ -1624,8 +1681,8 @@ ordered-set = [ {file = "ordered-set-4.0.2.tar.gz", hash = "sha256:ba93b2df055bca202116ec44b9bead3df33ea63a7d5827ff8e16738b97f33a95"}, ] packaging = [ - {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"}, - {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"}, + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] pamqp = [ {file = "pamqp-2.3.0-py2.py3-none-any.whl", hash = "sha256:2f81b5c186f668a67f165193925b6bfd83db4363a6222f599517f29ecee60b02"}, @@ -1648,8 +1705,8 @@ pluggy = [ {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] pre-commit = [ - {file = "pre_commit-2.15.0-py2.py3-none-any.whl", hash = "sha256:a4ed01000afcb484d9eb8d504272e642c4c4099bbad3a6b27e519bd6a3e928a6"}, - {file = "pre_commit-2.15.0.tar.gz", hash = "sha256:3c25add78dbdfb6a28a651780d5c311ac40dd17f160eb3954a0c59da40a505a7"}, + {file = "pre_commit-2.16.0-py2.py3-none-any.whl", hash = "sha256:758d1dc9b62c2ed8881585c254976d66eae0889919ab9b859064fc2fe3c7743e"}, + {file = "pre_commit-2.16.0.tar.gz", hash = "sha256:fe9897cac830aa7164dbd02a4e7b90cae49630451ce88464bca73db486ba9f65"}, ] psutil = [ {file = "psutil-5.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0066a82f7b1b37d334e68697faba68e5ad5e858279fd6351c8ca6024e8d6ba64"}, @@ -1685,8 +1742,8 @@ ptable = [ {file = "PTable-0.9.2.tar.gz", hash = "sha256:aa7fc151cb40f2dabcd2275ba6f7fd0ff8577a86be3365cd3fb297cbe09cc292"}, ] py = [ - {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, - {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, ] pycares = [ {file = "pycares-4.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:71b99b9e041ae3356b859822c511f286f84c8889ec9ed1fbf6ac30fb4da13e4c"}, @@ -1726,8 +1783,8 @@ pycodestyle = [ {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, ] pycparser = [ - {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"}, - {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, ] pydocstyle = [ {file = "pydocstyle-6.1.1-py3-none-any.whl", hash = "sha256:6987826d6775056839940041beef5c08cc7e3d71d63149b48e36727f70144dc4"}, @@ -1738,8 +1795,8 @@ pyflakes = [ {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, ] pyparsing = [ - {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, - {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, + {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"}, + {file = "pyparsing-3.0.6.tar.gz", hash = "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"}, ] pyreadline3 = [ {file = "pyreadline3-3.3-py3-none-any.whl", hash = "sha256:0003fd0079d152ecbd8111202c5a7dfa6a5569ffd65b235e45f3c2ecbee337b4"}, @@ -1754,8 +1811,8 @@ pytest-cov = [ {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, ] pytest-forked = [ - {file = "pytest-forked-1.3.0.tar.gz", hash = "sha256:6aa9ac7e00ad1a539c41bec6d21011332de671e938c7637378ec9710204e37ca"}, - {file = "pytest_forked-1.3.0-py2.py3-none-any.whl", hash = "sha256:dc4147784048e70ef5d437951728825a131b81714b398d5d52f17c7c144d8815"}, + {file = "pytest-forked-1.4.0.tar.gz", hash = "sha256:8b67587c8f98cbbadfdd804539ed5455b6ed03802203485dd2f53c1422d7440e"}, + {file = "pytest_forked-1.4.0-py3-none-any.whl", hash = "sha256:bbbb6717efc886b9d64537b41fb1497cfaf3c9601276be8da2cccfea5a3c8ad8"}, ] pytest-xdist = [ {file = "pytest-xdist-2.3.0.tar.gz", hash = "sha256:e8ecde2f85d88fbcadb7d28cb33da0fa29bca5cf7d5967fa89fc0e97e5299ea5"}, @@ -1805,68 +1862,62 @@ pyyaml = [ {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] rapidfuzz = [ - {file = "rapidfuzz-1.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:91f094562c683802e6c972bce27a692dad70d6cd1114e626b29d990c3704c653"}, - {file = "rapidfuzz-1.8.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:4a20682121e245cf5ad2dbdd771360763ea11b77520632a1034c4bb9ad1e854c"}, - {file = "rapidfuzz-1.8.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:8810e75d8f9c4453bbd6209c372bf97514359b0b5efff555caf85b15f8a9d862"}, - {file = "rapidfuzz-1.8.0-cp27-cp27m-win32.whl", hash = "sha256:00cf713d843735b5958d87294f08b05c653a593ced7c4120be34f5d26d7a320a"}, - {file = "rapidfuzz-1.8.0-cp27-cp27m-win_amd64.whl", hash = "sha256:2baca64e23a623e077f57e5470de21af2765af15aa1088676eb2d475e664eed0"}, - {file = "rapidfuzz-1.8.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:9bf7a6c61bacedd84023be356e057e1d209dd6997cfaa3c1cee77aa21d642f88"}, - {file = "rapidfuzz-1.8.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:61b6434e3341ca5158ecb371b1ceb4c1f6110563a72d28bdce4eb2a084493e47"}, - {file = "rapidfuzz-1.8.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e425e690383f6cf308e8c2e8d630fa9596f67d233344efd8fae11e70a9f5635f"}, - {file = "rapidfuzz-1.8.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:93db5e693b76d616b09df27ca5c79e0dda169af7f1b8f5ab3262826d981e37e2"}, - {file = "rapidfuzz-1.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a8c4f76ed1c8a65892d98dc2913027c9acdb219d18f3a441cfa427a32861af9"}, - {file = "rapidfuzz-1.8.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:71e217fd30901214cc96c0c15057278bafb7072aa9b2be4c97459c1fedf3e731"}, - {file = "rapidfuzz-1.8.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d579dd447b8e851462e79054b68f94b66b09df8b3abb2aa5ca07fe00912ef5e8"}, - {file = "rapidfuzz-1.8.0-cp310-cp310-win32.whl", hash = "sha256:5808064555273496dcd594d659bd28ee8d399149dd31575321034424455dc955"}, - {file = "rapidfuzz-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:798fef1671ca66c78b47802228e9583f7ab32b99bdfe3984ebb1f96e93e38b5f"}, - {file = "rapidfuzz-1.8.0-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:c9e0ed210831f5c73533bf11099ea7897db491e76c3443bef281d9c1c67d7f3a"}, - {file = "rapidfuzz-1.8.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:c819bb19eb615a31ddc9cb8248a285bf04f58158b53ce096451178631f99b652"}, - {file = "rapidfuzz-1.8.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:942ee45564f28ef70320d1229f02dc998bd93e3519c1f3a80f33ce144b51039c"}, - {file = "rapidfuzz-1.8.0-cp35-cp35m-win32.whl", hash = "sha256:7e6ae2e5a3bc9acc51e118f25d32b8efcd431c5d8deb408336dd2ed0f21d087c"}, - {file = "rapidfuzz-1.8.0-cp35-cp35m-win_amd64.whl", hash = "sha256:98901fba67c89ad2506f3946642cf6eb8f489592fb7eb307ebdf8bdb0c4e97f9"}, - {file = "rapidfuzz-1.8.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:705e1686f406a0c77ef323cdb7369b7cf9e68f2abfcb83ff5f1e0a5b21f5a534"}, - {file = "rapidfuzz-1.8.0-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:da0c5fe5fdbbd74206c1778af6b8c5ff8dfbe2dd04ae12bbe96642b358acefce"}, - {file = "rapidfuzz-1.8.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:535253bc9224215131ae450aad6c9f7ef1b24f15c685045eab2b52511268bd06"}, - {file = "rapidfuzz-1.8.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acdad83f07d886705fce164b0d1f4e3b56788a205602ed3a7fc8b10ceaf05fbf"}, - {file = "rapidfuzz-1.8.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35097f649831f8375d6c65a237deccac3aceb573aa7fae1e5d3fa942e89de1c8"}, - {file = "rapidfuzz-1.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6f4db142e5b4b44314166a90e11603220db659bd2f9c23dd5db402c13eac8eb7"}, - {file = "rapidfuzz-1.8.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:19a3f55f27411d68360540484874beda0b428b062596d5f0f141663ef0738bfd"}, - {file = "rapidfuzz-1.8.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:22b4c1a7f6fe29bd8dae49f7d5ab085dc42c3964f1a78b6dca22fdf83b5c9bfa"}, - {file = "rapidfuzz-1.8.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8bfb2fbc147904b78d5c510ee75dc8704b606e956df23f33a9e89abc03f45c3"}, - {file = "rapidfuzz-1.8.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6dc5111ebfed2c4f2e4d120a9b280ea13ea4fbb60b6915dd239817b4fc092ed"}, - {file = "rapidfuzz-1.8.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:db5ee2457d97cb967ffe08446a8c595c03fe747fdc2e145266713f9c516d1c4a"}, - {file = "rapidfuzz-1.8.0-cp37-cp37m-win32.whl", hash = "sha256:12c1b78cc15fc26f555a4bf66088d5afb6354b5a5aa149a123f01a15af6c411b"}, - {file = "rapidfuzz-1.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:693e9579048d8db4ff020715dd6f25aa315fd6445bc94e7400d7a94a227dad27"}, - {file = "rapidfuzz-1.8.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b4fe19df3edcf7de359448b872aec08e6592b4ca2d3df4d8ee57b5812d68bebf"}, - {file = "rapidfuzz-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f3670b9df0e1f479637cad1577afca7766a02775dc08c14837cf495c82861d7c"}, - {file = "rapidfuzz-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:61d118f36eb942649b0db344f7b7a19ad7e9b5749d831788187eb03b57ce1bfa"}, - {file = "rapidfuzz-1.8.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:fce3a2c8a1d10da12aff4a0d367624e8ae9e15c1b84a5144843681d39be0c355"}, - {file = "rapidfuzz-1.8.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1577ef26e3647ccc4cc9754c34ffaa731639779f4d7779e91a761c72adac093e"}, - {file = "rapidfuzz-1.8.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fec9b7e60fde51990c3b48fc1aa9dba9ac3acaf78f623dbb645a6fe21a9654e"}, - {file = "rapidfuzz-1.8.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b954469d93858bc8b48129bc63fd644382a4df5f3fb1b4b290f48eac1d00a2da"}, - {file = "rapidfuzz-1.8.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:190ba709069a7e5a6b39b7c8bc413a08cfa7f1f4defec5d974c4128b510e0234"}, - {file = "rapidfuzz-1.8.0-cp38-cp38-win32.whl", hash = "sha256:97b2d13d6323649b43d1b113681e4013ba230bd6e9827cc832dcebee447d7250"}, - {file = "rapidfuzz-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:81c3091209b75f6611efe2af18834180946d4ce28f41ca8d44fce816187840d2"}, - {file = "rapidfuzz-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d610afa33e92aa0481a514ffda3ec51ca5df3c684c1c1c795307589c62025931"}, - {file = "rapidfuzz-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d976f33ca6b5fabbb095c0a662f5b86baf706184fc24c7f125d4ddb54b8bf036"}, - {file = "rapidfuzz-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0f5ca7bca2af598d4ddcf5b93b64b50654a9ff684e6f18d865f6e13fee442b3e"}, - {file = "rapidfuzz-1.8.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2aac5ea6b0306dcd28a6d1a89d35ed2c6ac426f2673ee1b92cf3f1d0fd5cd"}, - {file = "rapidfuzz-1.8.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f145c9831c0454a696a3136a6380ea4e01434e9cc2f2bc10d032864c16d1d0e5"}, - {file = "rapidfuzz-1.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4ce53291575b56c9d45add73ea013f43bafcea55eee9d5139aa759918d7685f"}, - {file = "rapidfuzz-1.8.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de5773a39c00a0f23cfc5da9e0e5fd0fb512b0ebe23dc7289a38e1f9a4b5cefc"}, - {file = "rapidfuzz-1.8.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87a802e55792bfbe192e2d557f38867dbe3671b49b3d5ecd873859c7460746ba"}, - {file = "rapidfuzz-1.8.0-cp39-cp39-win32.whl", hash = "sha256:9391abf1121df831316222f28cea37397a0f72bd7978f3be6e7da29a7821e4e5"}, - {file = "rapidfuzz-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:9eeca1b436042b5523dcf314f5822b1131597898c1d967f140d1917541a8a3d1"}, - {file = "rapidfuzz-1.8.0-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:a01f2495aca479b49d3b3a8863d6ba9bea2043447a1ced74ae5ec5270059cbc1"}, - {file = "rapidfuzz-1.8.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:b7d4b1a5d16817f8cdb34365c7b58ae22d5cf1b3207720bb2fa0b55968bdb034"}, - {file = "rapidfuzz-1.8.0-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c738d0d7f1744646d48d19b4c775926082bcefebd2460f45ca383a0e882f5672"}, - {file = "rapidfuzz-1.8.0-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0fb9c6078c17c12b52e66b7d0a2a1674f6bbbdc6a76e454c8479b95147018123"}, - {file = "rapidfuzz-1.8.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:1482b385d83670eb069577c9667f72b41eec4f005aee32f1a4ff4e71e88afde2"}, - {file = "rapidfuzz-1.8.0.tar.gz", hash = "sha256:83fff37acf0367314879231264169dcbc5e7de969a94f4b82055d06a7fddab9a"}, + {file = "rapidfuzz-1.9.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:68227a8b25291d6a2140aef049271ea30a77be5ef672a58e582a55a5cc1fce93"}, + {file = "rapidfuzz-1.9.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:c33541995b96ff40025c1456b8c74b7dd2ab9cbf91943fc35a7bb621f48940e2"}, + {file = "rapidfuzz-1.9.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:c2fafbbf97a4632822248f4201601b691e2eac5fdb30e5d7a96d07a6d058a7d4"}, + {file = "rapidfuzz-1.9.1-cp27-cp27m-win32.whl", hash = "sha256:364795f617a99e1dbb55ac3947ab8366588b72531cb2d6152666287d20610706"}, + {file = "rapidfuzz-1.9.1-cp27-cp27m-win_amd64.whl", hash = "sha256:f171d9e66144b0647f9b998ef10bdd919a640e4b1357250c8ef6259deb5ffe0d"}, + {file = "rapidfuzz-1.9.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:c83801a7c5209663aa120b815a4f2c39e95fe8e0b774ec58a1e0affd6a2fcfc6"}, + {file = "rapidfuzz-1.9.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:67e61c2baa6bb1848c4a33752f1781124dcc90bf3f31b18b44db1ae4e4e26634"}, + {file = "rapidfuzz-1.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8ab7eb003a18991347174910f11d38ff40399081185d9e3199ec277535f7828b"}, + {file = "rapidfuzz-1.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5ad450badf06ddf98a246140b5059ba895ee8445e8102a5a289908327f551f81"}, + {file = "rapidfuzz-1.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:402b2174bded62a793c5f7d9aec16bc32c661402360a934819ae72b54cfbce1e"}, + {file = "rapidfuzz-1.9.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:92066ccb054efc2e17afb4049c98b550969653cd58f71dd756cfcc8e6864630a"}, + {file = "rapidfuzz-1.9.1-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8dc0bf1814accee08a9c9bace6672ef06eae6b0446fce88e3e97e23dfaf3ea10"}, + {file = "rapidfuzz-1.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bdbd387efb8478605951344f327dd03bf053c138d757369a43404305b99e55db"}, + {file = "rapidfuzz-1.9.1-cp310-cp310-win32.whl", hash = "sha256:b1c54807e556dbcc6caf4ce0f24446c01b195f3cc46e2a6e74b82d3a21eaa45d"}, + {file = "rapidfuzz-1.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:ac3273364cd1619cab3bf0ba731efea5405833f9eba362da7dcd70bd42073d8e"}, + {file = "rapidfuzz-1.9.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:d9faf62606c08a0a6992dd480c72b6a068733ae02688dc35f2e36ba0d44673f4"}, + {file = "rapidfuzz-1.9.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:f6a56a48be047637b1b0b2459a11cf7cd5aa7bbe16a439bd4f73b4af39e620e4"}, + {file = "rapidfuzz-1.9.1-cp35-cp35m-win32.whl", hash = "sha256:aa91609979e9d2700f0ff100df99b36e7d700b70169ee385d43d5de9e471ae97"}, + {file = "rapidfuzz-1.9.1-cp35-cp35m-win_amd64.whl", hash = "sha256:b4cfdd0915ab4cec86c2ff6bab9f01b03454f3de0963c37f9f219df2ddf42b95"}, + {file = "rapidfuzz-1.9.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c6bfa4ad0158a093cd304f795ceefdc3861ae6942a61432b2a50858be6de88ca"}, + {file = "rapidfuzz-1.9.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:eb0ea02295d9278bd2dcd2df4760b0f2887b6c3f2f374005ec5af320d8d3a37e"}, + {file = "rapidfuzz-1.9.1-cp36-cp36m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d5187cd5cd6273e9fee07de493a42a2153134a4914df74cb1abb0744551c548a"}, + {file = "rapidfuzz-1.9.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6e5b8af63f9c05b64454460759ed84a715d581d598ec4484f4ec512f398e8b1"}, + {file = "rapidfuzz-1.9.1-cp36-cp36m-win32.whl", hash = "sha256:36137f88f2b28115af506118e64e11c816611eab2434293af7fdacd1290ffb9d"}, + {file = "rapidfuzz-1.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:fcc420cad46be7c9887110edf04cdee545f26dbf22650a443d89790fc35f7b88"}, + {file = "rapidfuzz-1.9.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b06de314f426aebff8a44319016bbe2b22f7848c84e44224f80b0690b7b08b18"}, + {file = "rapidfuzz-1.9.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e5de44e719faea79e45322b037f0d4a141d750b80d2204fa68f43a42a24f0fbc"}, + {file = "rapidfuzz-1.9.1-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f9439df09a782afd01b67005a3b110c70bbf9e1cf06d2ac9b293ce2d02d3c549"}, + {file = "rapidfuzz-1.9.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e903d4702647465721e2d0431c95f04fd56a06577f06f41e2960c83fd63c1bad"}, + {file = "rapidfuzz-1.9.1-cp37-cp37m-win32.whl", hash = "sha256:a5298f4ac1975edcbb15583eab659a44b33aebaf3bccf172e185cfea68771c08"}, + {file = "rapidfuzz-1.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:103193a01921b54fcdad6b01cfda3a68e00aeafca236b7ecd5b1b2c2e7e96337"}, + {file = "rapidfuzz-1.9.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:1d98a3187040dca855e02179a35c137f72ef83ce243783d44ea59efa86b94b3a"}, + {file = "rapidfuzz-1.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cb92bf7fc911b787055a88d9295ca3b4fe8576e3b59271f070f1b1b181eb087d"}, + {file = "rapidfuzz-1.9.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3f014a0f5f8159a94c6ee884fedd1c30e07fb866a5d76ff2c18091bc6363b76f"}, + {file = "rapidfuzz-1.9.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:31474074a99f72289ac325fbd77983e7d355d48860bfe7a4f6f6396fdb24410a"}, + {file = "rapidfuzz-1.9.1-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ec67d79af5a2d7b0cf67b570a5579710e461cadda4120478e813b63491f394dd"}, + {file = "rapidfuzz-1.9.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ebc0d3d15ed32f98f0052cf6e3e9c9b8010fb93c04fb74d2022e3c51ec540e2"}, + {file = "rapidfuzz-1.9.1-cp38-cp38-win32.whl", hash = "sha256:477ab1a3044bab89db45caabc562b158f68765ecaa638b73ba17e92f09dfa5ff"}, + {file = "rapidfuzz-1.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:8e872763dc0367d7544aa585d2e8b27af233323b8a7cd2f9b78cafa05bae5018"}, + {file = "rapidfuzz-1.9.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8401c41e219ae36ca7a88762776a6270511650d4cc70d024ae61561e96d67e47"}, + {file = "rapidfuzz-1.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ea10bd8e0436801c3264f7084a5ea194f12ba9fe1ba898aa4a2107d276501292"}, + {file = "rapidfuzz-1.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:433737914b46c1ffa0c678eceae1c260dc6b7fb5b6cad4c725d3e3607c764b32"}, + {file = "rapidfuzz-1.9.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8c3b08e90e45acbc469d1f456681643256e952bf84ec7714f58979baba0c8a1c"}, + {file = "rapidfuzz-1.9.1-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bbcd265b3c86176e5db4cbba7b4364d7333c214ee80e2d259c7085929934ca9d"}, + {file = "rapidfuzz-1.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d69fabcd635783cd842e7d5ee4b77164314c5124b82df5a0c436ab3d698f8a9"}, + {file = "rapidfuzz-1.9.1-cp39-cp39-win32.whl", hash = "sha256:01f16b6f3fa5d1a26c12f5da5de0032f1e12c919d876005b57492a8ec9a5c043"}, + {file = "rapidfuzz-1.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:0bcc5bbfdbe6068cc2cf0029ab6cde08dceac498d232fa3a61dd34fbfa0b3f36"}, + {file = "rapidfuzz-1.9.1-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:de869c8f4e8edb9b2f7b8232a04896645501defcbd9d85bc0202ff3ec6285f6b"}, + {file = "rapidfuzz-1.9.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:db5978e970fb0955974d51021da4b929e2e4890fef17792989ee32658e2b159c"}, + {file = "rapidfuzz-1.9.1-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:33479f75f36ac3a1d8421365d4fa906e013490790730a89caba31d06e6f71738"}, + {file = "rapidfuzz-1.9.1-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:af991cb333ec526d894923163050931b3a870b7694bf7687aaa6154d341a98f5"}, + {file = "rapidfuzz-1.9.1.tar.gz", hash = "sha256:bd7a4fe33ba49db3417f0f57a8af02462554f1296dedcf35b026cd3525efef74"}, ] redis = [ - {file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"}, - {file = "redis-3.5.3.tar.gz", hash = "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2"}, + {file = "redis-4.0.2-py3-none-any.whl", hash = "sha256:c8481cf414474e3497ec7971a1ba9b998c8efad0f0d289a009a5bbef040894f9"}, + {file = "redis-4.0.2.tar.gz", hash = "sha256:ccf692811f2c1fc7a92b466aa2599e4a6d2d73d5f736a2c70be600657c0da34a"}, ] regex = [ {file = "regex-2021.4.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:619d71c59a78b84d7f18891fe914446d07edd48dc8328c8e149cbe0929b4e000"}, @@ -1915,9 +1966,13 @@ requests = [ {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"}, ] +requests-file = [ + {file = "requests-file-1.5.1.tar.gz", hash = "sha256:07d74208d3389d01c38ab89ef403af0cfec63957d53a0081d8eca738d0247d8e"}, + {file = "requests_file-1.5.1-py2.py3-none-any.whl", hash = "sha256:dfe5dae75c12481f68ba353183c53a65e6044c923e64c24b2209f6c7570ca953"}, +] sentry-sdk = [ - {file = "sentry-sdk-1.4.3.tar.gz", hash = "sha256:b9844751e40710e84a457c5bc29b21c383ccb2b63d76eeaad72f7f1c808c8828"}, - {file = "sentry_sdk-1.4.3-py2.py3-none-any.whl", hash = "sha256:c091cc7115ff25fe3a0e410dbecd7a996f81a3f6137d2272daef32d6c3cfa6dc"}, + {file = "sentry-sdk-1.5.1.tar.gz", hash = "sha256:2a1757d6611e4bec7d672c2b7ef45afef79fed201d064f53994753303944f5a8"}, + {file = "sentry_sdk-1.5.1-py2.py3-none-any.whl", hash = "sha256:e4cb107e305b2c1b919414775fa73a9997f996447417d22b98e7610ded1e9eb5"}, ] sgmllib3k = [ {file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"}, @@ -1927,16 +1982,16 @@ six = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] snowballstemmer = [ - {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"}, - {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"}, + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, ] sortedcontainers = [ {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, ] soupsieve = [ - {file = "soupsieve-2.2.1-py3-none-any.whl", hash = "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"}, - {file = "soupsieve-2.2.1.tar.gz", hash = "sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc"}, + {file = "soupsieve-2.3.1-py3-none-any.whl", hash = "sha256:1a3cca2617c6b38c0343ed661b1fa5de5637f257d4fe22bd9f1338010a1efefb"}, + {file = "soupsieve-2.3.1.tar.gz", hash = "sha256:b8d49b1cd4f037c7082a9683dfa1801aa2597fb11c3a1155b7a5b94829b4f1f9"}, ] statsd = [ {file = "statsd-3.3.0-py2.py3-none-any.whl", hash = "sha256:c610fb80347fca0ef62666d241bce64184bd7cc1efe582f9690e045c25535eaa"}, @@ -1950,94 +2005,150 @@ testfixtures = [ {file = "testfixtures-6.18.3-py2.py3-none-any.whl", hash = "sha256:6ddb7f56a123e1a9339f130a200359092bd0a6455e31838d6c477e8729bb7763"}, {file = "testfixtures-6.18.3.tar.gz", hash = "sha256:2600100ae96ffd082334b378e355550fef8b4a529a6fa4c34f47130905c7426d"}, ] +tldextract = [ + {file = "tldextract-3.1.2-py2.py3-none-any.whl", hash = "sha256:f55e05f6bf4cc952a87d13594386d32ad2dd265630a8bdfc3df03bd60425c6b0"}, + {file = "tldextract-3.1.2.tar.gz", hash = "sha256:d2034c3558651f7d8fdadea83fb681050b2d662dc67a00d950326dc902029444"}, +] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] typing-extensions = [ - {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, - {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, - {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, + {file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"}, + {file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"}, ] urllib3 = [ {file = "urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844"}, {file = "urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece"}, ] virtualenv = [ - {file = "virtualenv-20.8.1-py2.py3-none-any.whl", hash = "sha256:10062e34c204b5e4ec5f62e6ef2473f8ba76513a9a617e873f1f8fb4a519d300"}, - {file = "virtualenv-20.8.1.tar.gz", hash = "sha256:bcc17f0b3a29670dd777d6f0755a4c04f28815395bca279cdcb213b97199a6b8"}, + {file = "virtualenv-20.10.0-py2.py3-none-any.whl", hash = "sha256:4b02e52a624336eece99c96e3ab7111f469c24ba226a53ec474e8e787b365814"}, + {file = "virtualenv-20.10.0.tar.gz", hash = "sha256:576d05b46eace16a9c348085f7d0dc8ef28713a2cabaa1cf0aea41e8f12c9218"}, +] +wrapt = [ + {file = "wrapt-1.13.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e05e60ff3b2b0342153be4d1b597bbcfd8330890056b9619f4ad6b8d5c96a81a"}, + {file = "wrapt-1.13.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:85148f4225287b6a0665eef08a178c15097366d46b210574a658c1ff5b377489"}, + {file = "wrapt-1.13.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:2dded5496e8f1592ec27079b28b6ad2a1ef0b9296d270f77b8e4a3a796cf6909"}, + {file = "wrapt-1.13.3-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:e94b7d9deaa4cc7bac9198a58a7240aaf87fe56c6277ee25fa5b3aa1edebd229"}, + {file = "wrapt-1.13.3-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:498e6217523111d07cd67e87a791f5e9ee769f9241fcf8a379696e25806965af"}, + {file = "wrapt-1.13.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ec7e20258ecc5174029a0f391e1b948bf2906cd64c198a9b8b281b811cbc04de"}, + {file = "wrapt-1.13.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:87883690cae293541e08ba2da22cacaae0a092e0ed56bbba8d018cc486fbafbb"}, + {file = "wrapt-1.13.3-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:f99c0489258086308aad4ae57da9e8ecf9e1f3f30fa35d5e170b4d4896554d80"}, + {file = "wrapt-1.13.3-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:6a03d9917aee887690aa3f1747ce634e610f6db6f6b332b35c2dd89412912bca"}, + {file = "wrapt-1.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:936503cb0a6ed28dbfa87e8fcd0a56458822144e9d11a49ccee6d9a8adb2ac44"}, + {file = "wrapt-1.13.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f9c51d9af9abb899bd34ace878fbec8bf357b3194a10c4e8e0a25512826ef056"}, + {file = "wrapt-1.13.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:220a869982ea9023e163ba915077816ca439489de6d2c09089b219f4e11b6785"}, + {file = "wrapt-1.13.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0877fe981fd76b183711d767500e6b3111378ed2043c145e21816ee589d91096"}, + {file = "wrapt-1.13.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:43e69ffe47e3609a6aec0fe723001c60c65305784d964f5007d5b4fb1bc6bf33"}, + {file = "wrapt-1.13.3-cp310-cp310-win32.whl", hash = "sha256:78dea98c81915bbf510eb6a3c9c24915e4660302937b9ae05a0947164248020f"}, + {file = "wrapt-1.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:ea3e746e29d4000cd98d572f3ee2a6050a4f784bb536f4ac1f035987fc1ed83e"}, + {file = "wrapt-1.13.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:8c73c1a2ec7c98d7eaded149f6d225a692caa1bd7b2401a14125446e9e90410d"}, + {file = "wrapt-1.13.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:086218a72ec7d986a3eddb7707c8c4526d677c7b35e355875a0fe2918b059179"}, + {file = "wrapt-1.13.3-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:e92d0d4fa68ea0c02d39f1e2f9cb5bc4b4a71e8c442207433d8db47ee79d7aa3"}, + {file = "wrapt-1.13.3-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:d4a5f6146cfa5c7ba0134249665acd322a70d1ea61732723c7d3e8cc0fa80755"}, + {file = "wrapt-1.13.3-cp35-cp35m-win32.whl", hash = "sha256:8aab36778fa9bba1a8f06a4919556f9f8c7b33102bd71b3ab307bb3fecb21851"}, + {file = "wrapt-1.13.3-cp35-cp35m-win_amd64.whl", hash = "sha256:944b180f61f5e36c0634d3202ba8509b986b5fbaf57db3e94df11abee244ba13"}, + {file = "wrapt-1.13.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:2ebdde19cd3c8cdf8df3fc165bc7827334bc4e353465048b36f7deeae8ee0918"}, + {file = "wrapt-1.13.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:610f5f83dd1e0ad40254c306f4764fcdc846641f120c3cf424ff57a19d5f7ade"}, + {file = "wrapt-1.13.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5601f44a0f38fed36cc07db004f0eedeaadbdcec90e4e90509480e7e6060a5bc"}, + {file = "wrapt-1.13.3-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:e6906d6f48437dfd80464f7d7af1740eadc572b9f7a4301e7dd3d65db285cacf"}, + {file = "wrapt-1.13.3-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:766b32c762e07e26f50d8a3468e3b4228b3736c805018e4b0ec8cc01ecd88125"}, + {file = "wrapt-1.13.3-cp36-cp36m-win32.whl", hash = "sha256:5f223101f21cfd41deec8ce3889dc59f88a59b409db028c469c9b20cfeefbe36"}, + {file = "wrapt-1.13.3-cp36-cp36m-win_amd64.whl", hash = "sha256:f122ccd12fdc69628786d0c947bdd9cb2733be8f800d88b5a37c57f1f1d73c10"}, + {file = "wrapt-1.13.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:46f7f3af321a573fc0c3586612db4decb7eb37172af1bc6173d81f5b66c2e068"}, + {file = "wrapt-1.13.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:778fd096ee96890c10ce96187c76b3e99b2da44e08c9e24d5652f356873f6709"}, + {file = "wrapt-1.13.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0cb23d36ed03bf46b894cfec777eec754146d68429c30431c99ef28482b5c1df"}, + {file = "wrapt-1.13.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:96b81ae75591a795d8c90edc0bfaab44d3d41ffc1aae4d994c5aa21d9b8e19a2"}, + {file = "wrapt-1.13.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7dd215e4e8514004c8d810a73e342c536547038fb130205ec4bba9f5de35d45b"}, + {file = "wrapt-1.13.3-cp37-cp37m-win32.whl", hash = "sha256:47f0a183743e7f71f29e4e21574ad3fa95676136f45b91afcf83f6a050914829"}, + {file = "wrapt-1.13.3-cp37-cp37m-win_amd64.whl", hash = "sha256:fd76c47f20984b43d93de9a82011bb6e5f8325df6c9ed4d8310029a55fa361ea"}, + {file = "wrapt-1.13.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b73d4b78807bd299b38e4598b8e7bd34ed55d480160d2e7fdaabd9931afa65f9"}, + {file = "wrapt-1.13.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ec9465dd69d5657b5d2fa6133b3e1e989ae27d29471a672416fd729b429eb554"}, + {file = "wrapt-1.13.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:dd91006848eb55af2159375134d724032a2d1d13bcc6f81cd8d3ed9f2b8e846c"}, + {file = "wrapt-1.13.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ae9de71eb60940e58207f8e71fe113c639da42adb02fb2bcbcaccc1ccecd092b"}, + {file = "wrapt-1.13.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:51799ca950cfee9396a87f4a1240622ac38973b6df5ef7a41e7f0b98797099ce"}, + {file = "wrapt-1.13.3-cp38-cp38-win32.whl", hash = "sha256:4b9c458732450ec42578b5642ac53e312092acf8c0bfce140ada5ca1ac556f79"}, + {file = "wrapt-1.13.3-cp38-cp38-win_amd64.whl", hash = "sha256:7dde79d007cd6dfa65afe404766057c2409316135cb892be4b1c768e3f3a11cb"}, + {file = "wrapt-1.13.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:981da26722bebb9247a0601e2922cedf8bb7a600e89c852d063313102de6f2cb"}, + {file = "wrapt-1.13.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:705e2af1f7be4707e49ced9153f8d72131090e52be9278b5dbb1498c749a1e32"}, + {file = "wrapt-1.13.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:25b1b1d5df495d82be1c9d2fad408f7ce5ca8a38085e2da41bb63c914baadff7"}, + {file = "wrapt-1.13.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:77416e6b17926d953b5c666a3cb718d5945df63ecf922af0ee576206d7033b5e"}, + {file = "wrapt-1.13.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:865c0b50003616f05858b22174c40ffc27a38e67359fa1495605f96125f76640"}, + {file = "wrapt-1.13.3-cp39-cp39-win32.whl", hash = "sha256:0a017a667d1f7411816e4bf214646d0ad5b1da2c1ea13dec6c162736ff25a374"}, + {file = "wrapt-1.13.3-cp39-cp39-win_amd64.whl", hash = "sha256:81bd7c90d28a4b2e1df135bfbd7c23aee3050078ca6441bead44c42483f9ebfb"}, + {file = "wrapt-1.13.3.tar.gz", hash = "sha256:1fea9cd438686e6682271d36f3481a9f3636195578bab9ca3382e2f5f01fc185"}, ] yarl = [ - {file = "yarl-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e35d8230e4b08d86ea65c32450533b906a8267a87b873f2954adeaecede85169"}, - {file = "yarl-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb4b3f277880c314e47720b4b6bb2c85114ab3c04c5442c9bc7006b3787904d8"}, - {file = "yarl-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7015dcedb91d90a138eebdc7e432aec8966e0147ab2a55f2df27b1904fa7291"}, - {file = "yarl-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb3e478175e15e00d659fb0354a6a8db71a7811a2a5052aed98048bc972e5d2b"}, - {file = "yarl-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b8c409aa3a7966647e7c1c524846b362a6bcbbe120bf8a176431f940d2b9a2e"}, - {file = "yarl-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b22ea41c7e98170474a01e3eded1377d46b2dfaef45888a0005c683eaaa49285"}, - {file = "yarl-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a7dfc46add4cfe5578013dbc4127893edc69fe19132d2836ff2f6e49edc5ecd6"}, - {file = "yarl-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:82ff6f85f67500a4f74885d81659cd270eb24dfe692fe44e622b8a2fd57e7279"}, - {file = "yarl-1.7.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f3cd2158b2ed0fb25c6811adfdcc47224efe075f2d68a750071dacc03a7a66e4"}, - {file = "yarl-1.7.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:59c0f13f9592820c51280d1cf811294d753e4a18baf90f0139d1dc93d4b6fc5f"}, - {file = "yarl-1.7.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:7f7655ad83d1a8afa48435a449bf2f3009293da1604f5dd95b5ddcf5f673bd69"}, - {file = "yarl-1.7.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:aa9f0d9b62d15182341b3e9816582f46182cab91c1a57b2d308b9a3c4e2c4f78"}, - {file = "yarl-1.7.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fdd1b90c225a653b1bd1c0cae8edf1957892b9a09c8bf7ee6321eeb8208eac0f"}, - {file = "yarl-1.7.0-cp310-cp310-win32.whl", hash = "sha256:7c8d0bb76eabc5299db203e952ec55f8f4c53f08e0df4285aac8c92bd9e12675"}, - {file = "yarl-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:622a36fa779efb4ff9eff5fe52730ff17521431379851a31e040958fc251670c"}, - {file = "yarl-1.7.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3d461b7a8e139b9e4b41f62eb417ffa0b98d1c46d4caf14c845e6a3b349c0bb1"}, - {file = "yarl-1.7.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81cfacdd1e40bc931b5519499342efa388d24d262c30a3d31187bfa04f4a7001"}, - {file = "yarl-1.7.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:821b978f2152be7695d4331ef0621d207aedf9bbd591ba23a63412a3efc29a01"}, - {file = "yarl-1.7.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b64bd24c8c9a487f4a12260dc26732bf41028816dbf0c458f17864fbebdb3131"}, - {file = "yarl-1.7.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:98c9ddb92b60a83c21be42c776d3d9d5ec632a762a094c41bda37b7dfbd2cd83"}, - {file = "yarl-1.7.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a532d75ca74431c053a88a802e161fb3d651b8bf5821a3440bc3616e38754583"}, - {file = "yarl-1.7.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:053e09817eafb892e94e172d05406c1b3a22a93bc68f6eff5198363a3d764459"}, - {file = "yarl-1.7.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:98c51f02d542945d306c8e934aa2c1e66ba5e9c1c86b5bf37f3a51c8a747067e"}, - {file = "yarl-1.7.0-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:15ec41a5a5fdb7bace6d7b16701f9440007a82734f69127c0fbf6d87e10f4a1e"}, - {file = "yarl-1.7.0-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:a7f08819dba1e1255d6991ed37448a1bf4b1352c004bcd899b9da0c47958513d"}, - {file = "yarl-1.7.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8e3ffab21db0542ffd1887f3b9575ddd58961f2cf61429cb6458afc00c4581e0"}, - {file = "yarl-1.7.0-cp36-cp36m-win32.whl", hash = "sha256:50127634f519b2956005891507e3aa4ac345f66a7ea7bbc2d7dcba7401f41898"}, - {file = "yarl-1.7.0-cp36-cp36m-win_amd64.whl", hash = "sha256:36ec44f15193f6d5288d42ebb8e751b967ebdfb72d6830983838d45ab18edb4f"}, - {file = "yarl-1.7.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ec1b5a25a25c880c976d0bb3d107def085bb08dbb3db7f4442e0a2b980359d24"}, - {file = "yarl-1.7.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b36f5a63c891f813c6f04ef19675b382efc190fd5ce7e10ab19386d2548bca06"}, - {file = "yarl-1.7.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38173b8c3a29945e7ecade9a3f6ff39581eee8201338ee6a2c8882db5df3e806"}, - {file = "yarl-1.7.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ba402f32184f0b405fb281b93bd0d8ab7e3257735b57b62a6ed2e94cdf4fe50"}, - {file = "yarl-1.7.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:be52bc5208d767cdd8308a9e93059b3b36d1e048fecbea0e0346d0d24a76adc0"}, - {file = "yarl-1.7.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:08c2044a956f4ef30405f2f433ce77f1f57c2c773bf81ae43201917831044d5a"}, - {file = "yarl-1.7.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:484d61c047c45670ef5967653a1d0783e232c54bf9dd786a7737036828fa8d54"}, - {file = "yarl-1.7.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b7de92a4af85cfcaf4081f8aa6165b1d63ee5de150af3ee85f954145f93105a7"}, - {file = "yarl-1.7.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:376e41775aab79c5575534924a386c8e0f1a5d91db69fc6133fd27a489bcaf10"}, - {file = "yarl-1.7.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:8a8b10d0e7bac154f959b709fcea593cda527b234119311eb950096653816a86"}, - {file = "yarl-1.7.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:f46cd4c43e6175030e2a56def8f1d83b64e6706eeb2bb9ab0ef4756f65eab23f"}, - {file = "yarl-1.7.0-cp37-cp37m-win32.whl", hash = "sha256:b28cfb46140efe1a6092b8c5c4994a1fe70dc83c38fbcea4992401e0c6fb9cce"}, - {file = "yarl-1.7.0-cp37-cp37m-win_amd64.whl", hash = "sha256:9624154ec9c02a776802da1086eed7f5034bd1971977f5146233869c2ac80297"}, - {file = "yarl-1.7.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:69945d13e1bbf81784a9bc48824feb9cd66491e6a503d4e83f6cd7c7cc861361"}, - {file = "yarl-1.7.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:46a742ed9e363bd01be64160ce7520e92e11989bd4cb224403cfd31c101cc83d"}, - {file = "yarl-1.7.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cb4ff1ac7cb4500f43581b3f4cbd627d702143aa6be1fdc1fa3ebffaf4dc1be5"}, - {file = "yarl-1.7.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ad51e17cd65ea3debb0e10f0120cf8dd987c741fe423ed2285087368090b33d"}, - {file = "yarl-1.7.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7e37786ea89a5d3ffbbf318ea9790926f8dfda83858544f128553c347ad143c6"}, - {file = "yarl-1.7.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c63c1e208f800daad71715786bfeb1cecdc595d87e2e9b1cd234fd6e597fd71d"}, - {file = "yarl-1.7.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:91cbe24300c11835ef186436363352b3257db7af165e0a767f4f17aa25761388"}, - {file = "yarl-1.7.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e510dbec7c59d32eaa61ffa48173d5e3d7170a67f4a03e8f5e2e9e3971aca622"}, - {file = "yarl-1.7.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3def6e681cc02397e5d8141ee97b41d02932b2bcf0fb34532ad62855eab7c60e"}, - {file = "yarl-1.7.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:263c81b94e6431942b27f6f671fa62f430a0a5c14bb255f2ab69eeb9b2b66ff7"}, - {file = "yarl-1.7.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:e78c91faefe88d601ddd16e3882918dbde20577a2438e2320f8239c8b7507b8f"}, - {file = "yarl-1.7.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:22b2430c49713bfb2f0a0dd4a8d7aab218b28476ba86fd1c78ad8899462cbcf2"}, - {file = "yarl-1.7.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2e7ad9db939082f5d0b9269cfd92c025cb8f2fbbb1f1b9dc5a393c639db5bd92"}, - {file = "yarl-1.7.0-cp38-cp38-win32.whl", hash = "sha256:3a31e4a8dcb1beaf167b7e7af61b88cb961b220db8d3ba1c839723630e57eef7"}, - {file = "yarl-1.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:d579957439933d752358c6a300c93110f84aae67b63dd0c19dde6ecbf4056f6b"}, - {file = "yarl-1.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:87721b549505a546eb003252185103b5ec8147de6d3ad3714d148a5a67b6fe53"}, - {file = "yarl-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1fa866fa24d9f4108f9e58ea8a2135655419885cdb443e36b39a346e1181532"}, - {file = "yarl-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1d3b8449dfedfe94eaff2b77954258b09b24949f6818dfa444b05dbb05ae1b7e"}, - {file = "yarl-1.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db2372e350794ce8b9f810feb094c606b7e0e4aa6807141ac4fadfe5ddd75bb0"}, - {file = "yarl-1.7.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a06d9d0b9a97fa99b84fee71d9dd11e69e21ac8a27229089f07b5e5e50e8d63c"}, - {file = "yarl-1.7.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3455c2456d6307bcfa80bc1157b8603f7d93573291f5bdc7144489ca0df4628"}, - {file = "yarl-1.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d30d67e3486aea61bb2cbf7cf81385364c2e4f7ce7469a76ed72af76a5cdfe6b"}, - {file = "yarl-1.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c18a4b286e8d780c3a40c31d7b79836aa93b720f71d5743f20c08b7e049ca073"}, - {file = "yarl-1.7.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d54c925396e7891666cabc0199366ca55b27d003393465acef63fd29b8b7aa92"}, - {file = "yarl-1.7.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:64773840952de17851a1c7346ad7f71688c77e74248d1f0bc230e96680f84028"}, - {file = "yarl-1.7.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:acbf1756d9dc7cd0ae943d883be72e84e04396f6c2ff93a6ddeca929d562039f"}, - {file = "yarl-1.7.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:2e48f27936aa838939c798f466c851ba4ae79e347e8dfce43b009c64b930df12"}, - {file = "yarl-1.7.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1beef4734ca1ad40a9d8c6b20a76ab46e3a2ed09f38561f01e4aa2ea82cafcef"}, - {file = "yarl-1.7.0-cp39-cp39-win32.whl", hash = "sha256:8ee78c9a5f3c642219d4607680a4693b59239c27a3aa608b64ef79ddc9698039"}, - {file = "yarl-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:d750503682605088a14d29a4701548c15c510da4f13c8b17409c4097d5b04c52"}, - {file = "yarl-1.7.0.tar.gz", hash = "sha256:8e7ebaf62e19c2feb097ffb7c94deb0f0c9fab52590784c8cd679d30ab009162"}, + {file = "yarl-1.7.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f2a8508f7350512434e41065684076f640ecce176d262a7d54f0da41d99c5a95"}, + {file = "yarl-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da6df107b9ccfe52d3a48165e48d72db0eca3e3029b5b8cb4fe6ee3cb870ba8b"}, + {file = "yarl-1.7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a1d0894f238763717bdcfea74558c94e3bc34aeacd3351d769460c1a586a8b05"}, + {file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfe4b95b7e00c6635a72e2d00b478e8a28bfb122dc76349a06e20792eb53a523"}, + {file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c145ab54702334c42237a6c6c4cc08703b6aa9b94e2f227ceb3d477d20c36c63"}, + {file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ca56f002eaf7998b5fcf73b2421790da9d2586331805f38acd9997743114e98"}, + {file = "yarl-1.7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1d3d5ad8ea96bd6d643d80c7b8d5977b4e2fb1bab6c9da7322616fd26203d125"}, + {file = "yarl-1.7.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:167ab7f64e409e9bdd99333fe8c67b5574a1f0495dcfd905bc7454e766729b9e"}, + {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:95a1873b6c0dd1c437fb3bb4a4aaa699a48c218ac7ca1e74b0bee0ab16c7d60d"}, + {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6152224d0a1eb254f97df3997d79dadd8bb2c1a02ef283dbb34b97d4f8492d23"}, + {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:5bb7d54b8f61ba6eee541fba4b83d22b8a046b4ef4d8eb7f15a7e35db2e1e245"}, + {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:9c1f083e7e71b2dd01f7cd7434a5f88c15213194df38bc29b388ccdf1492b739"}, + {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f44477ae29025d8ea87ec308539f95963ffdc31a82f42ca9deecf2d505242e72"}, + {file = "yarl-1.7.2-cp310-cp310-win32.whl", hash = "sha256:cff3ba513db55cc6a35076f32c4cdc27032bd075c9faef31fec749e64b45d26c"}, + {file = "yarl-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:c9c6d927e098c2d360695f2e9d38870b2e92e0919be07dbe339aefa32a090265"}, + {file = "yarl-1.7.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9b4c77d92d56a4c5027572752aa35082e40c561eec776048330d2907aead891d"}, + {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c01a89a44bb672c38f42b49cdb0ad667b116d731b3f4c896f72302ff77d71656"}, + {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c19324a1c5399b602f3b6e7db9478e5b1adf5cf58901996fc973fe4fccd73eed"}, + {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3abddf0b8e41445426d29f955b24aeecc83fa1072be1be4e0d194134a7d9baee"}, + {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6a1a9fe17621af43e9b9fcea8bd088ba682c8192d744b386ee3c47b56eaabb2c"}, + {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8b0915ee85150963a9504c10de4e4729ae700af11df0dc5550e6587ed7891e92"}, + {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:29e0656d5497733dcddc21797da5a2ab990c0cb9719f1f969e58a4abac66234d"}, + {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:bf19725fec28452474d9887a128e98dd67eee7b7d52e932e6949c532d820dc3b"}, + {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:d6f3d62e16c10e88d2168ba2d065aa374e3c538998ed04996cd373ff2036d64c"}, + {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:ac10bbac36cd89eac19f4e51c032ba6b412b3892b685076f4acd2de18ca990aa"}, + {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:aa32aaa97d8b2ed4e54dc65d241a0da1c627454950f7d7b1f95b13985afd6c5d"}, + {file = "yarl-1.7.2-cp36-cp36m-win32.whl", hash = "sha256:87f6e082bce21464857ba58b569370e7b547d239ca22248be68ea5d6b51464a1"}, + {file = "yarl-1.7.2-cp36-cp36m-win_amd64.whl", hash = "sha256:ac35ccde589ab6a1870a484ed136d49a26bcd06b6a1c6397b1967ca13ceb3913"}, + {file = "yarl-1.7.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a467a431a0817a292121c13cbe637348b546e6ef47ca14a790aa2fa8cc93df63"}, + {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ab0c3274d0a846840bf6c27d2c60ba771a12e4d7586bf550eefc2df0b56b3b4"}, + {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d260d4dc495c05d6600264a197d9d6f7fc9347f21d2594926202fd08cf89a8ba"}, + {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fc4dd8b01a8112809e6b636b00f487846956402834a7fd59d46d4f4267181c41"}, + {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c1164a2eac148d85bbdd23e07dfcc930f2e633220f3eb3c3e2a25f6148c2819e"}, + {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:67e94028817defe5e705079b10a8438b8cb56e7115fa01640e9c0bb3edf67332"}, + {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:89ccbf58e6a0ab89d487c92a490cb5660d06c3a47ca08872859672f9c511fc52"}, + {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8cce6f9fa3df25f55521fbb5c7e4a736683148bcc0c75b21863789e5185f9185"}, + {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:211fcd65c58bf250fb994b53bc45a442ddc9f441f6fec53e65de8cba48ded986"}, + {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c10ea1e80a697cf7d80d1ed414b5cb8f1eec07d618f54637067ae3c0334133c4"}, + {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:52690eb521d690ab041c3919666bea13ab9fbff80d615ec16fa81a297131276b"}, + {file = "yarl-1.7.2-cp37-cp37m-win32.whl", hash = "sha256:695ba021a9e04418507fa930d5f0704edbce47076bdcfeeaba1c83683e5649d1"}, + {file = "yarl-1.7.2-cp37-cp37m-win_amd64.whl", hash = "sha256:c17965ff3706beedafd458c452bf15bac693ecd146a60a06a214614dc097a271"}, + {file = "yarl-1.7.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fce78593346c014d0d986b7ebc80d782b7f5e19843ca798ed62f8e3ba8728576"}, + {file = "yarl-1.7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c2a1ac41a6aa980db03d098a5531f13985edcb451bcd9d00670b03129922cd0d"}, + {file = "yarl-1.7.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:39d5493c5ecd75c8093fa7700a2fb5c94fe28c839c8e40144b7ab7ccba6938c8"}, + {file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1eb6480ef366d75b54c68164094a6a560c247370a68c02dddb11f20c4c6d3c9d"}, + {file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ba63585a89c9885f18331a55d25fe81dc2d82b71311ff8bd378fc8004202ff6"}, + {file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e39378894ee6ae9f555ae2de332d513a5763276a9265f8e7cbaeb1b1ee74623a"}, + {file = "yarl-1.7.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c0910c6b6c31359d2f6184828888c983d54d09d581a4a23547a35f1d0b9484b1"}, + {file = "yarl-1.7.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6feca8b6bfb9eef6ee057628e71e1734caf520a907b6ec0d62839e8293e945c0"}, + {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8300401dc88cad23f5b4e4c1226f44a5aa696436a4026e456fe0e5d2f7f486e6"}, + {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:788713c2896f426a4e166b11f4ec538b5736294ebf7d5f654ae445fd44270832"}, + {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:fd547ec596d90c8676e369dd8a581a21227fe9b4ad37d0dc7feb4ccf544c2d59"}, + {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:737e401cd0c493f7e3dd4db72aca11cfe069531c9761b8ea474926936b3c57c8"}, + {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:baf81561f2972fb895e7844882898bda1eef4b07b5b385bcd308d2098f1a767b"}, + {file = "yarl-1.7.2-cp38-cp38-win32.whl", hash = "sha256:ede3b46cdb719c794427dcce9d8beb4abe8b9aa1e97526cc20de9bd6583ad1ef"}, + {file = "yarl-1.7.2-cp38-cp38-win_amd64.whl", hash = "sha256:cc8b7a7254c0fc3187d43d6cb54b5032d2365efd1df0cd1749c0c4df5f0ad45f"}, + {file = "yarl-1.7.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:580c1f15500e137a8c37053e4cbf6058944d4c114701fa59944607505c2fe3a0"}, + {file = "yarl-1.7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ec1d9a0d7780416e657f1e405ba35ec1ba453a4f1511eb8b9fbab81cb8b3ce1"}, + {file = "yarl-1.7.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3bf8cfe8856708ede6a73907bf0501f2dc4e104085e070a41f5d88e7faf237f3"}, + {file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1be4bbb3d27a4e9aa5f3df2ab61e3701ce8fcbd3e9846dbce7c033a7e8136746"}, + {file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:534b047277a9a19d858cde163aba93f3e1677d5acd92f7d10ace419d478540de"}, + {file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6ddcd80d79c96eb19c354d9dca95291589c5954099836b7c8d29278a7ec0bda"}, + {file = "yarl-1.7.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9bfcd43c65fbb339dc7086b5315750efa42a34eefad0256ba114cd8ad3896f4b"}, + {file = "yarl-1.7.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f64394bd7ceef1237cc604b5a89bf748c95982a84bcd3c4bbeb40f685c810794"}, + {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044daf3012e43d4b3538562da94a88fb12a6490652dbc29fb19adfa02cf72eac"}, + {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:368bcf400247318382cc150aaa632582d0780b28ee6053cd80268c7e72796dec"}, + {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:bab827163113177aee910adb1f48ff7af31ee0289f434f7e22d10baf624a6dfe"}, + {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0cba38120db72123db7c58322fa69e3c0efa933040ffb586c3a87c063ec7cae8"}, + {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:59218fef177296451b23214c91ea3aba7858b4ae3306dde120224cfe0f7a6ee8"}, + {file = "yarl-1.7.2-cp39-cp39-win32.whl", hash = "sha256:1edc172dcca3f11b38a9d5c7505c83c1913c0addc99cd28e993efeaafdfaa18d"}, + {file = "yarl-1.7.2-cp39-cp39-win_amd64.whl", hash = "sha256:797c2c412b04403d2da075fb93c123df35239cd7b4cc4e0cd9e5839b73f52c58"}, + {file = "yarl-1.7.2.tar.gz", hash = "sha256:45399b46d60c253327a460e99856752009fcee5f5d3c80b2f7c0cae1c38d56dd"}, ] diff --git a/pyproject.toml b/pyproject.toml index 44d09f89e..928435975 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ pyyaml = "~=5.1" regex = "==2021.4.4" sentry-sdk = "~=1.3" statsd = "~=3.3" +tldextract = "^3.1.2" [tool.poetry.dev-dependencies] coverage = "~=5.0" -- cgit v1.2.3 From 94f5c99c1ff5815341862431d02129e80ceb6850 Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Tue, 28 Dec 2021 18:11:52 +0000 Subject: Include message counts in all channels (#2016) Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/exts/info/information.py | 11 +++------ bot/exts/moderation/voice_gate.py | 5 +--- tests/bot/exts/info/test_information.py | 43 +++++++++++++++++++++++++++++++-- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 73357211e..d0e1eae74 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -298,11 +298,11 @@ class Information(Cog): "Member information", membership ), + await self.user_messages(user), ] # Show more verbose output in moderation channels for infractions and nominations if is_mod_channel(ctx.channel): - fields.append(await self.user_messages(user)) fields.append(await self.expanded_user_infraction_counts(user)) fields.append(await self.user_nomination_counts(user)) else: @@ -420,13 +420,8 @@ class Information(Cog): if e.status == 404: activity_output = "No activity" else: - activity_output.append(user_activity["total_messages"] or "No messages") - - if (activity_blocks := user_activity.get("activity_blocks")) is not None: - # activity_blocks is not included in the response if the user has a lot of messages - activity_output.append(activity_blocks or "No activity") # Special case when activity_blocks is 0. - else: - activity_output.append("Too many to count!") + activity_output.append(f"{user_activity['total_messages']:,}" or "No messages") + activity_output.append(f"{user_activity['activity_blocks']:,}" or "No activity") activity_output = "\n".join( f"{name}: {metric}" for name, metric in zip(["Messages", "Activity blocks"], activity_output) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index ae55a03a0..a382b13d1 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -171,11 +171,8 @@ class VoiceGate(Cog): ), "total_messages": data["total_messages"] < GateConf.minimum_messages, "voice_banned": data["voice_banned"], + "activity_blocks": data["activity_blocks"] < GateConf.minimum_activity_blocks, } - if activity_blocks := data.get("activity_blocks"): - # activity_blocks is not included in the response if the user has a lot of messages. - # Only check if the user has enough activity blocks if it is included. - checks["activity_blocks"] = activity_blocks < GateConf.minimum_activity_blocks failed = any(checks.values()) failed_reasons = [MESSAGE_FIELD_MAP[key] for key, value in checks.items() if value is True] diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 632287322..724456b04 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -276,6 +276,10 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) ) + @unittest.mock.patch( + f"{COG_PATH}.user_messages", + new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) + ) 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)) @@ -293,6 +297,10 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) ) + @unittest.mock.patch( + f"{COG_PATH}.user_messages", + new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) + ) 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)) @@ -310,6 +318,10 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) ) + @unittest.mock.patch( + f"{COG_PATH}.user_messages", + new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) + ) 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)) @@ -325,6 +337,10 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): @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) + @unittest.mock.patch( + f"{COG_PATH}.user_messages", + new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) + ) async def test_create_user_embed_expanded_information_in_moderation_channels( self, nomination_counts, @@ -363,13 +379,19 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): ) @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) - async def test_create_user_embed_basic_information_outside_of_moderation_channels(self, infraction_counts): + @unittest.mock.patch(f"{COG_PATH}.user_messages", new_callable=unittest.mock.AsyncMock) + async def test_create_user_embed_basic_information_outside_of_moderation_channels( + self, + user_messages, + infraction_counts, + ): """The embed should contain only basic infraction data outside of mod channels.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=100)) moderators_role = helpers.MockRole(name='Moderators') infraction_counts.return_value = ("Infractions", "basic infractions info") + user_messages.return_value = ("Messages", "user message counts") user = helpers.MockMember(id=314, roles=[moderators_role], colour=100) embed = await self.cog.create_user_embed(ctx, user) @@ -394,14 +416,23 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): ) self.assertEqual( - "basic infractions info", + "user message counts", embed.fields[2].value ) + self.assertEqual( + "basic infractions info", + embed.fields[3].value + ) + @unittest.mock.patch( f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) ) + @unittest.mock.patch( + f"{COG_PATH}.user_messages", + new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) + ) 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() @@ -417,6 +448,10 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) ) + @unittest.mock.patch( + f"{COG_PATH}.user_messages", + new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) + ) async def test_create_user_embed_uses_og_blurple_colour_when_user_has_no_roles(self): """The embed should be created with the og blurple colour if the user has no assigned roles.""" ctx = helpers.MockContext() @@ -430,6 +465,10 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) ) + @unittest.mock.patch( + f"{COG_PATH}.user_messages", + new=unittest.mock.AsyncMock(return_value=("Messsages", "user message count")) + ) 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() -- cgit v1.2.3 From 548766959abc77ffc9140ec4f5be52cfcdff3a6c Mon Sep 17 00:00:00 2001 From: Shom770 <82843611+Shom770@users.noreply.github.com> Date: Wed, 29 Dec 2021 13:37:56 -0500 Subject: Strip gotcha tag (PR #2000) * adding strip-gotcha tag --- bot/resources/tags/strip-gotcha.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 bot/resources/tags/strip-gotcha.md diff --git a/bot/resources/tags/strip-gotcha.md b/bot/resources/tags/strip-gotcha.md new file mode 100644 index 000000000..9ad495cd2 --- /dev/null +++ b/bot/resources/tags/strip-gotcha.md @@ -0,0 +1,17 @@ +When working with `strip`, `lstrip`, or `rstrip`, you might think that this would be the case: +```py +>>> "Monty Python".rstrip(" Python") +"Monty" +``` +While this seems intuitive, it would actually result in: +```py +"M" +``` +as Python interprets the argument to these functions as a set of characters rather than a substring. + +If you want to remove a prefix/suffix from a string, `str.removeprefix` and `str.removesuffix` are recommended and were added in 3.9. +```py +>>> "Monty Python".removesuffix(" Python") +"Monty" +``` +See the documentation of [str.removeprefix](https://docs.python.org/3.10/library/stdtypes.html#str.removeprefix) and [str.removesuffix](https://docs.python.org/3.10/library/stdtypes.html#str.removesuffix) for more information. -- cgit v1.2.3 From 681771b945ad9c3968323c083c3ed45a32ba37bf Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 29 Dec 2021 20:38:05 +0000 Subject: Add text indicating when user fetched by message (#2013) Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/exts/info/information.py | 10 ++++++---- tests/bot/exts/info/test_information.py | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index d0e1eae74..1f95c460f 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -227,7 +227,7 @@ class Information(Cog): @command(name="user", aliases=["user_info", "member", "member_info", "u"]) async def user_info(self, ctx: Context, user_or_message: Union[MemberOrUser, Message] = None) -> None: """Returns info about a user.""" - if isinstance(user_or_message, Message): + if passed_as_message := isinstance(user_or_message, Message): user = user_or_message.author else: user = user_or_message @@ -242,10 +242,10 @@ class Information(Cog): # Will redirect to #bot-commands if it fails. if in_whitelist_check(ctx, roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES): - embed = await self.create_user_embed(ctx, user) + embed = await self.create_user_embed(ctx, user, passed_as_message) await ctx.send(embed=embed) - async def create_user_embed(self, ctx: Context, user: MemberOrUser) -> Embed: + async def create_user_embed(self, ctx: Context, user: MemberOrUser, passed_as_message: bool) -> Embed: """Creates an embed containing information on the `user`.""" on_server = bool(await get_or_fetch_member(ctx.guild, user.id)) @@ -256,6 +256,9 @@ class Information(Cog): name = f"{user.nick} ({name})" name = escape_markdown(name) + if passed_as_message: + name += " - From Message" + if user.public_flags.verified_bot: name += f" {constants.Emojis.verified_bot}" elif user.bot: @@ -282,7 +285,6 @@ class Information(Cog): membership = textwrap.dedent("\n".join([f"{key}: {value}" for key, value in membership.items()])) else: - roles = None membership = "The user is not a member of the server" fields = [ diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 724456b04..30e5258fb 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -289,7 +289,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") user.colour = 0 - embed = await self.cog.create_user_embed(ctx, user) + embed = await self.cog.create_user_embed(ctx, user, False) self.assertEqual(embed.title, "Mr. Hemlock") @@ -310,7 +310,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): user.__str__ = unittest.mock.Mock(return_value="Mr. Hemlock") user.colour = 0 - embed = await self.cog.create_user_embed(ctx, user) + embed = await self.cog.create_user_embed(ctx, user, False) self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") @@ -330,7 +330,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], colour=100) - embed = await self.cog.create_user_embed(ctx, user) + embed = await self.cog.create_user_embed(ctx, user, False) self.assertIn("&Admins", embed.fields[1].value) self.assertNotIn("&Everyone", embed.fields[1].value) @@ -355,7 +355,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): nomination_counts.return_value = ("Nominations", "nomination info") user = helpers.MockMember(id=314, roles=[moderators_role], colour=100) - embed = await self.cog.create_user_embed(ctx, user) + embed = await self.cog.create_user_embed(ctx, user, False) infraction_counts.assert_called_once_with(user) nomination_counts.assert_called_once_with(user) @@ -394,7 +394,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): user_messages.return_value = ("Messages", "user message counts") user = helpers.MockMember(id=314, roles=[moderators_role], colour=100) - embed = await self.cog.create_user_embed(ctx, user) + embed = await self.cog.create_user_embed(ctx, user, False) infraction_counts.assert_called_once_with(user) @@ -440,7 +440,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): moderators_role = helpers.MockRole(name='Moderators') user = helpers.MockMember(id=314, roles=[moderators_role], colour=100) - embed = await self.cog.create_user_embed(ctx, user) + embed = await self.cog.create_user_embed(ctx, user, False) self.assertEqual(embed.colour, discord.Colour(100)) @@ -457,7 +457,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): ctx = helpers.MockContext() user = helpers.MockMember(id=217, colour=discord.Colour.default()) - embed = await self.cog.create_user_embed(ctx, user) + embed = await self.cog.create_user_embed(ctx, user, False) self.assertEqual(embed.colour, discord.Colour.og_blurple()) @@ -475,7 +475,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): user = helpers.MockMember(id=217, colour=0) user.display_avatar.url = "avatar url" - embed = await self.cog.create_user_embed(ctx, user) + embed = await self.cog.create_user_embed(ctx, user, False) self.assertEqual(embed.thumbnail.url, "avatar url") @@ -528,7 +528,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): await self.cog.user_info(self.cog, ctx) - create_embed.assert_called_once_with(ctx, self.author) + create_embed.assert_called_once_with(ctx, self.author, False) ctx.send.assert_called_once() @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") @@ -539,7 +539,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): await self.cog.user_info(self.cog, ctx, self.author) - create_embed.assert_called_once_with(ctx, self.author) + create_embed.assert_called_once_with(ctx, self.author, False) ctx.send.assert_called_once() @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") @@ -550,7 +550,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): await self.cog.user_info(self.cog, ctx) - create_embed.assert_called_once_with(ctx, self.moderator) + create_embed.assert_called_once_with(ctx, self.moderator, False) ctx.send.assert_called_once() @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") @@ -562,5 +562,5 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): await self.cog.user_info(self.cog, ctx, self.target) - create_embed.assert_called_once_with(ctx, self.target) + create_embed.assert_called_once_with(ctx, self.target, False) ctx.send.assert_called_once() -- cgit v1.2.3 From d30776a17f058fb526d7fb93fbd3d754d35f2bd5 Mon Sep 17 00:00:00 2001 From: Izan Date: Sat, 1 Jan 2022 17:02:59 +0000 Subject: Infraction mod-log improvements - Add infraction id to infraction edit modlog - Add missing colon in "infraction applied" message - Utilise defined infraction id variable instead of indexing dict again --- bot/exts/moderation/infraction/_scheduler.py | 4 ++-- bot/exts/moderation/infraction/management.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 6f379a9a0..57aa2d9b6 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -223,7 +223,7 @@ class InfractionScheduler: failed = True if failed: - log.trace(f"Deleted infraction {infraction['id']} from database because applying infraction failed.") + log.trace(f"Trying to delete infraction {id_} from database because applying infraction failed.") try: await self.bot.api_client.delete(f"bot/infractions/{id_}") except ResponseCodeError as e: @@ -265,7 +265,7 @@ class InfractionScheduler: {additional_info} """), content=log_content, - footer=f"ID {infraction['id']}" + footer=f"ID: {id_}" ) log.info(f"Applied {purge}{infr_type} infraction #{id_} to {user}.") diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index b77c20434..9649ff852 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -176,7 +176,7 @@ class ModManagement(commands.Cog): if 'expires_at' in request_data: # A scheduled task should only exist if the old infraction wasn't permanent if infraction['expires_at']: - self.infractions_cog.scheduler.cancel(new_infraction['id']) + self.infractions_cog.scheduler.cancel(infraction_id) # If the infraction was not marked as permanent, schedule a new expiration task if request_data['expires_at']: @@ -210,7 +210,8 @@ class ModManagement(commands.Cog): Member: {user_text} Actor: <@{new_infraction['actor']}> Edited by: {ctx.message.author.mention}{log_text} - """) + """), + footer=f"ID: {infraction_id}" ) # endregion -- cgit v1.2.3 From 3824ddbddff8c3191632c0479f0f594985a55b32 Mon Sep 17 00:00:00 2001 From: Kronifer <44979306+Kronifer@users.noreply.github.com> Date: Tue, 4 Jan 2022 13:22:50 -0600 Subject: modlog: wait for guild init before using channel cache Not doing so could cause an error where get_channel would return none for the mod logs channel. --- bot/exts/moderation/modlog.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index 91709e5e5..fc9204998 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -96,6 +96,7 @@ class ModLog(Cog, name="ModLog"): footer: t.Optional[str] = None, ) -> Context: """Generate log embed and send to logging channel.""" + await self.bot.wait_until_guild_available() # Truncate string directly here to avoid removing newlines embed = discord.Embed( description=text[:4093] + "..." if len(text) > 4096 else text @@ -614,6 +615,7 @@ class ModLog(Cog, name="ModLog"): This is called when a message absent from the cache is deleted. Hence, the message contents aren't logged. """ + await self.bot.wait_until_guild_available() if self.is_channel_ignored(event.channel_id): return @@ -727,6 +729,7 @@ class ModLog(Cog, name="ModLog"): @Cog.listener() async def on_raw_message_edit(self, event: discord.RawMessageUpdateEvent) -> None: """Log raw message edit event to message change log.""" + await self.bot.wait_until_guild_available() try: channel = self.bot.get_channel(int(event.data["channel_id"])) message = await channel.fetch_message(event.message_id) -- cgit v1.2.3 From 3962e0e72e6bf6de4977092740ee79776159845e Mon Sep 17 00:00:00 2001 From: Ben Soyka Date: Sat, 8 Jan 2022 12:53:23 -0700 Subject: Restrict allowed mentions for !eval results --- bot/exts/utils/snekbox.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index fbfc58d0b..ef24cbd77 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -7,7 +7,7 @@ from functools import partial from signal import Signals from typing import Optional, Tuple -from discord import HTTPException, Message, NotFound, Reaction, User +from discord import AllowedMentions, HTTPException, Message, NotFound, Reaction, User from discord.ext.commands import Cog, Context, command, guild_only from bot.bot import Bot @@ -218,7 +218,8 @@ class Snekbox(Cog): if filter_triggered: response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") else: - response = await ctx.send(msg) + allowed_mentions = AllowedMentions(everyone=False, roles=False, users=[ctx.author]) + response = await ctx.send(msg, allowed_mentions=allowed_mentions) scheduling.create_task(wait_for_deletion(response, (ctx.author.id,)), event_loop=self.bot.loop) log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") -- cgit v1.2.3 From f6b50c17b59f6ec02c9f7e8a7cf6f7ef1a426b7a Mon Sep 17 00:00:00 2001 From: Ben Soyka Date: Sat, 8 Jan 2022 14:39:15 -0700 Subject: Fix snekbox tests with new allowed_mentions --- tests/bot/exts/utils/test_snekbox.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/bot/exts/utils/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py index 321a92445..8bdeedd27 100644 --- a/tests/bot/exts/utils/test_snekbox.py +++ b/tests/bot/exts/utils/test_snekbox.py @@ -2,6 +2,7 @@ import asyncio import unittest from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch +from discord import AllowedMentions from discord.ext import commands from bot import constants @@ -201,7 +202,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): ctx = MockContext() ctx.message = MockMessage() ctx.send = AsyncMock() - ctx.author.mention = '@LemonLemonishBeard#0042' + ctx.author = MockUser(mention='@LemonLemonishBeard#0042') self.cog.post_eval = AsyncMock(return_value={'stdout': '', 'returncode': 0}) self.cog.get_results_message = MagicMock(return_value=('Return code 0', '')) @@ -213,9 +214,16 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.bot.get_cog.return_value = mocked_filter_cog await self.cog.send_eval(ctx, 'MyAwesomeCode') - ctx.send.assert_called_once_with( + + ctx.send.assert_called_once() + self.assertEqual( + ctx.send.call_args.args[0], '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```\n[No output]\n```' ) + allowed_mentions = ctx.send.call_args.kwargs['allowed_mentions'] + expected_allowed_mentions = AllowedMentions(everyone=False, roles=False, users=[ctx.author]) + self.assertEqual(allowed_mentions.to_dict(), expected_allowed_mentions.to_dict()) + self.cog.post_eval.assert_called_once_with('MyAwesomeCode') self.cog.get_status_emoji.assert_called_once_with({'stdout': '', 'returncode': 0}) self.cog.get_results_message.assert_called_once_with({'stdout': '', 'returncode': 0}) @@ -238,10 +246,14 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.bot.get_cog.return_value = mocked_filter_cog await self.cog.send_eval(ctx, 'MyAwesomeCode') - ctx.send.assert_called_once_with( + + ctx.send.assert_called_once() + self.assertEqual( + ctx.send.call_args.args[0], '@LemonLemonishBeard#0042 :yay!: Return code 0.' '\n\n```\nWay too long beard\n```\nFull output: lookatmybeard.com' ) + self.cog.post_eval.assert_called_once_with('MyAwesomeCode') self.cog.get_status_emoji.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0}) self.cog.get_results_message.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0}) @@ -263,9 +275,13 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.bot.get_cog.return_value = mocked_filter_cog await self.cog.send_eval(ctx, 'MyAwesomeCode') - ctx.send.assert_called_once_with( + + ctx.send.assert_called_once() + self.assertEqual( + ctx.send.call_args.args[0], '@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```\nBeard got stuck in the eval\n```' ) + self.cog.post_eval.assert_called_once_with('MyAwesomeCode') self.cog.get_status_emoji.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) self.cog.get_results_message.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) -- cgit v1.2.3 -- cgit v1.2.3