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