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 78b00dc8f43d7cb25ce72bc97d6236ec9944cd1f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 28 Mar 2021 09:10:35 +0300 Subject: Remove WatchChannel parent of TalentPool and migrate first commands --- bot/exts/recruitment/talentpool/_cog.py | 80 +++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 28 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index b809cea17..09f2fecbd 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -1,6 +1,6 @@ import logging import textwrap -from collections import ChainMap +from collections import ChainMap, defaultdict from typing import Union from discord import Color, Embed, Member, User @@ -8,35 +8,48 @@ from discord.ext.commands import Cog, Context, group, has_any_role from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks +from bot.constants import Guild, MODERATION_ROLES, STAFF_ROLES from bot.converters import FetchedMember -from bot.exts.moderation.watchchannels._watchchannel import WatchChannel from bot.exts.recruitment.talentpool._review import Reviewer from bot.pagination import LinePaginator from bot.utils import time +from bot.utils.time import get_time_delta REASON_MAX_CHARS = 1000 log = logging.getLogger(__name__) -class TalentPool(WatchChannel, Cog, name="Talentpool"): +class TalentPool(Cog, name="Talentpool"): """Relays messages of helper candidates to a watch channel to observe them.""" def __init__(self, bot: Bot) -> None: - super().__init__( - bot, - destination=Channels.talent_pool, - webhook_id=Webhooks.talent_pool, - api_endpoint='bot/nominations', - api_default_params={'active': 'true', 'ordering': '-inserted_at'}, - logger=log, - disable_header=True, - ) - + self.bot = bot self.reviewer = Reviewer(self.__class__.__name__, bot, self) self.bot.loop.create_task(self.reviewer.reschedule_reviews()) + # Stores talentpool users in cache + self.cache = defaultdict(dict) + + async def refresh_cache(self) -> bool: + """Updates TalentPool users cache.""" + try: + data = await self.bot.api_client.get( + 'bot/nominations', + params={'active': 'true', 'ordering': '-inserted_at'} + ) + except ResponseCodeError as err: + log.exception("Failed to fetch the watched users from the API", exc_info=err) + return False + + self.cache = defaultdict(dict) + + for entry in data: + user_id = entry.pop('user') + self.cache[user_id] = entry + + return True + @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) @has_any_role(*MODERATION_ROLES) async def nomination_group(self, ctx: Context) -> None: @@ -78,26 +91,37 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): The optional kwarg `update_cache` specifies whether the cache should be refreshed by polling the API. """ - # TODO Once the watch channel is removed, this can be done in a smarter way, without splitting and overriding - # the list_watched_users function. - watched_data = await self.prepare_watched_users_data(ctx, oldest_first, update_cache) + successful_update = False + if update_cache: + if not (successful_update := await self.refresh_cache()): + await ctx.send(":warning: Unable to update cache. Data may be inaccurate.") - if update_cache and not watched_data["updated"]: - await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") + nominations = self.cache.items() + if oldest_first: + nominations = reversed(nominations) lines = [] - for user_id, line in watched_data["info"].items(): - if self.watched_users[user_id]['reviewed']: + + for user_id, user_data in nominations: + member = ctx.guild.get_member(user_id) + line = f"• `{user_id}`" + if member: + line += f" ({member.name}#{member.discriminator})" + inserted_at = user_data['inserted_at'] + line += f", added {get_time_delta(inserted_at)}" + if not member: # Cross off users who left the server. + line = f"~~{line}~~" + if user_data['reviewed']: line += " *(reviewed)*" elif user_id in self.reviewer: - line += " *(scheduled)*" + line += " *(scheduled)" lines.append(line) if not lines: lines = ("There's nothing here yet.",) embed = Embed( - title=watched_data["title"], + title=f"Talent Pool active nominations ({'updated' if update_cache and successful_update else 'cached'})", color=Color.blue() ) await LinePaginator.paginate(lines, ctx, embed, empty=False) @@ -130,8 +154,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await ctx.send(":x: Nominating staff members, eh? Here's a cookie :cookie:") return - if not await self.fetch_user_cache(): - await ctx.send(f":x: Failed to update the user cache; can't add {user}") + if not await self.refresh_cache(): + await ctx.send(f":x: Failed to update the cache; can't add {user}") return if len(reason) > REASON_MAX_CHARS: @@ -140,7 +164,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): # Manual request with `raise_for_status` as False because we want the actual response session = self.bot.api_client.session - url = self.bot.api_client._url_for(self.api_endpoint) + url = self.bot.api_client._url_for('bot/nominations') kwargs = { 'json': { 'actor': ctx.author.id, @@ -162,13 +186,13 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): else: resp.raise_for_status() - self.watched_users[user.id] = response_data + self.cache[user.id] = response_data if user.id not in self.reviewer: self.reviewer.schedule_review(user.id) history = await self.bot.api_client.get( - self.api_endpoint, + 'bot/nominations', params={ "user__id": str(user.id), "active": "false", -- cgit v1.2.3 From dda3490a0a1a55bb3ccb16b82afc0dd131f335b5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 28 Mar 2021 09:19:51 +0300 Subject: Use more accurate command names and docstring for talent pool commands --- bot/exts/recruitment/talentpool/_cog.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 09f2fecbd..40fb90f11 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -56,25 +56,25 @@ class TalentPool(Cog, name="Talentpool"): """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" await ctx.send_help(ctx.command) - @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",)) + @nomination_group.command(name='list', aliases=('all', 'watched'), root_aliases=("nominees",)) @has_any_role(*MODERATION_ROLES) - async def watched_command( + async def list_command( self, ctx: Context, oldest_first: bool = False, update_cache: bool = True ) -> None: """ - Shows the users that are currently being monitored in the talent pool. + Shows the users that are currently in the talent pool. The optional kwarg `oldest_first` can be used to order the list by oldest nomination. The optional kwarg `update_cache` can be used to update the user cache using the API before listing the users. """ - await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + await self.list_users(ctx, oldest_first=oldest_first, update_cache=update_cache) - async def list_watched_users( + async def list_users( self, ctx: Context, oldest_first: bool = False, @@ -130,21 +130,20 @@ class TalentPool(Cog, name="Talentpool"): @has_any_role(*MODERATION_ROLES) async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: """ - Shows talent pool monitored users ordered by oldest nomination. + Shows talent pool users ordered by oldest nomination. The optional kwarg `update_cache` can be used to update the user cache using the API before listing the users. """ - await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) + await ctx.invoke(self.list_command, oldest_first=True, update_cache=update_cache) - @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",)) + @nomination_group.command(name='add', aliases=('w', 'a', 'watch'), root_aliases=("nominate",)) @has_any_role(*STAFF_ROLES) - async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None: + async def add_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None: """ - Relay messages sent by the given `user` to the `#talent-pool` channel. + Adds user nomination (or nomination entry) to Talent Pool. - A `reason` for adding the user to the talent pool is optional. - If given, it will be displayed in the header when relaying messages of this user to the channel. + If user already have nomination, then entry associated with existing nomination will be created. """ if user.bot: await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") @@ -235,9 +234,9 @@ class TalentPool(Cog, name="Talentpool"): max_size=1000 ) - @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",)) + @nomination_group.command(name='end', aliases=('unwatch',), root_aliases=("unnominate",)) @has_any_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + async def end_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """ Ends the active nomination of the specified user with the given reason. -- cgit v1.2.3 From f144bace9243659887cbb125ea915f809736fc97 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 28 Mar 2021 09:22:13 +0300 Subject: Migrate nominations history command to non-watchchannel system --- bot/exts/recruitment/talentpool/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 40fb90f11..ee5dc5dae 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -210,7 +210,7 @@ class TalentPool(Cog, name="Talentpool"): async def history_command(self, ctx: Context, user: FetchedMember) -> None: """Shows the specified user's nomination history.""" result = await self.bot.api_client.get( - self.api_endpoint, + 'bot/nominations', params={ 'user__id': str(user.id), 'ordering': "-active,-inserted_at" -- cgit v1.2.3 From 807a27ec112684a2a2b8cfd2b470ab8378f0da98 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 28 Mar 2021 09:30:46 +0300 Subject: Migrate Talent Pool Reviewer class to non-watchchannel Talent Pool --- bot/exts/recruitment/talentpool/_review.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index fb3461238..bc2878451 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -47,9 +47,9 @@ class Reviewer: log.trace("Rescheduling reviews") await self.bot.wait_until_guild_available() # TODO Once the watch channel is removed, this can be done in a smarter way, e.g create a sync function. - await self._pool.fetch_user_cache() + await self._pool.refresh_cache() - for user_id, user_data in self._pool.watched_users.items(): + for user_id, user_data in self._pool.cache.items(): if not user_data["reviewed"]: self.schedule_review(user_id) @@ -57,7 +57,7 @@ class Reviewer: """Schedules a single user for review.""" log.trace(f"Scheduling review of user with ID {user_id}") - user_data = self._pool.watched_users[user_id] + user_data = self._pool.cache[user_id] inserted_at = isoparse(user_data['inserted_at']).replace(tzinfo=None) review_at = inserted_at + timedelta(days=MAX_DAYS_IN_POOL) @@ -69,7 +69,7 @@ class Reviewer: """Format a generic review of a user and post it to the nomination voting channel.""" log.trace(f"Posting the review of {user_id}") - nomination = self._pool.watched_users[user_id] + nomination = self._pool.cache[user_id] if not nomination: log.trace(f"There doesn't appear to be an active nomination for {user_id}") return @@ -79,7 +79,7 @@ class Reviewer: member = guild.get_member(user_id) if update_database: - await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) + await self.bot.api_client.patch(f"bot/nominations/{nomination['id']}", json={"reviewed": True}) if not member: await channel.send( @@ -228,7 +228,7 @@ class Reviewer: """ log.trace(f"Fetching the nomination history data for {member.id}'s review") history = await self.bot.api_client.get( - self._pool.api_endpoint, + "bot/nominations", params={ "user__id": str(member.id), "active": "false", @@ -286,18 +286,18 @@ class Reviewer: Returns True if the user was successfully marked as reviewed, False otherwise. """ log.trace(f"Updating user {user_id} as reviewed") - await self._pool.fetch_user_cache() - if user_id not in self._pool.watched_users: + await self._pool.refresh_cache() + if user_id not in self._pool.cache: log.trace(f"Can't find a nominated user with id {user_id}") await ctx.send(f"❌ Can't find a currently nominated user with id `{user_id}`") return False - nomination = self._pool.watched_users[user_id] + nomination = self._pool.cache[user_id] if nomination["reviewed"]: await ctx.send("❌ This nomination was already reviewed, but here's a cookie 🍪") return False - await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) + await self.bot.api_client.patch(f"bot/nominations/{nomination['id']}", json={"reviewed": True}) if user_id in self._review_scheduler: self._review_scheduler.cancel(user_id) -- cgit v1.2.3 From 2d4764041da02ccee64a93c02c0dd392c31e80c4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 28 Mar 2021 09:31:24 +0300 Subject: Migrate unnominate command to non-watchchannel --- bot/exts/recruitment/talentpool/_cog.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index ee5dc5dae..1ad40812a 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -30,13 +30,14 @@ class TalentPool(Cog, name="Talentpool"): # Stores talentpool users in cache self.cache = defaultdict(dict) + self.api_default_params = {'active': 'true', 'ordering': '-inserted_at'} async def refresh_cache(self) -> bool: """Updates TalentPool users cache.""" try: data = await self.bot.api_client.get( 'bot/nominations', - params={'active': 'true', 'ordering': '-inserted_at'} + params=self.api_default_params ) except ResponseCodeError as err: log.exception("Failed to fetch the watched users from the API", exc_info=err) @@ -243,11 +244,11 @@ class TalentPool(Cog, name="Talentpool"): Providing a `reason` is required. """ if len(reason) > REASON_MAX_CHARS: - await ctx.send(f":x: Maxiumum allowed characters for the end reason is {REASON_MAX_CHARS}.") + await ctx.send(f":x: Maximum allowed characters for the end reason is {REASON_MAX_CHARS}.") return if await self.unwatch(user.id, reason): - await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") + await ctx.send(f":white_check_mark: Successfully un-nominated {user}") else: await ctx.send(":x: The specified user does not have an active nomination") @@ -349,7 +350,7 @@ class TalentPool(Cog, name="Talentpool"): async def unwatch(self, user_id: int, reason: str) -> bool: """End the active nomination of a user with the given reason and return True on success.""" active_nomination = await self.bot.api_client.get( - self.api_endpoint, + 'bot/nominations', params=ChainMap( {"user__id": str(user_id)}, self.api_default_params, @@ -364,10 +365,9 @@ class TalentPool(Cog, name="Talentpool"): nomination = active_nomination[0] await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination['id']}", + f"bot/nominations/{nomination['id']}", json={'end_reason': reason, 'active': False} ) - self._remove_user(user_id) self.reviewer.cancel(user_id) -- cgit v1.2.3 From ae3c91082f1a456d37c8ce72c65509315e4a2137 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 28 Mar 2021 09:32:58 +0300 Subject: Add missing asterisk to nominees list --- bot/exts/recruitment/talentpool/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 1ad40812a..4ef3af3d1 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -115,7 +115,7 @@ class TalentPool(Cog, name="Talentpool"): if user_data['reviewed']: line += " *(reviewed)*" elif user_id in self.reviewer: - line += " *(scheduled)" + line += " *(scheduled)*" lines.append(line) if not lines: -- cgit v1.2.3 From 4716b8eba3d25f21e277ee20cff2741cece2e0ed Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 28 Mar 2021 09:52:53 +0300 Subject: Migrate nomination reasons editing commands --- bot/exts/recruitment/talentpool/_cog.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 4ef3af3d1..7824d22d7 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -267,10 +267,10 @@ class TalentPool(Cog, name="Talentpool"): return try: - nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") + nomination = await self.bot.api_client.get(f"bot/nominations/{nomination_id}") except ResponseCodeError as e: if e.response.status == 404: - self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}") + log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}") await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") return else: @@ -284,13 +284,13 @@ class TalentPool(Cog, name="Talentpool"): await ctx.send(f":x: {actor} doesn't have an entry in this nomination.") return - self.log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}") + log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}") await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination_id}", + f"bot/nominations/{nomination_id}", json={"actor": actor.id, "reason": reason} ) - await self.fetch_user_cache() # Update cache + await self.refresh_cache() # Update cache await ctx.send(":white_check_mark: Successfully updated nomination reason.") @nomination_edit_group.command(name='end_reason') @@ -302,10 +302,10 @@ class TalentPool(Cog, name="Talentpool"): return try: - nomination = await self.bot.api_client.get(f"{self.api_endpoint}/{nomination_id}") + nomination = await self.bot.api_client.get(f"bot/nominations/{nomination_id}") except ResponseCodeError as e: if e.response.status == 404: - self.log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}") + log.trace(f"Nomination API 404: Can't find a nomination with id {nomination_id}") await ctx.send(f":x: Can't find a nomination with id `{nomination_id}`") return else: @@ -315,13 +315,13 @@ class TalentPool(Cog, name="Talentpool"): await ctx.send(":x: Can't edit the end reason of an active nomination.") return - self.log.trace(f"Changing end reason for nomination with id {nomination_id} to {repr(reason)}") + log.trace(f"Changing end reason for nomination with id {nomination_id} to {repr(reason)}") await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination_id}", + f"bot/nominations/{nomination_id}", json={"end_reason": reason} ) - await self.fetch_user_cache() # Update cache. + await self.refresh_cache() # Update cache. await ctx.send(":white_check_mark: Updated the end reason of the nomination!") @nomination_group.command(aliases=('mr',)) -- cgit v1.2.3 From e9a233f1f2fbc9b0204041dffa0f3ad6a1f7e8cf Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 28 Mar 2021 10:17:47 +0300 Subject: Remove talentpool channel constants --- bot/constants.py | 2 -- config-default.yml | 3 --- 2 files changed, 5 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 467a4a2c4..840f6fbf6 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -457,7 +457,6 @@ class Channels(metaclass=YAMLGetter): voice_chat: int big_brother_logs: int - talent_pool: int class Webhooks(metaclass=YAMLGetter): @@ -470,7 +469,6 @@ class Webhooks(metaclass=YAMLGetter): duck_pond: int incidents_archive: int reddit: int - talent_pool: int class Roles(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index 502f0f861..8fc1f6718 100644 --- a/config-default.yml +++ b/config-default.yml @@ -223,7 +223,6 @@ guild: # Watch big_brother_logs: &BB_LOGS 468507907357409333 - talent_pool: &TALENT_POOL 534321732593647616 moderation_categories: - *MODMAIL @@ -292,7 +291,6 @@ guild: incidents_archive: 720671599790915702 python_news: &PYNEWS_WEBHOOK 704381182279942324 reddit: 635408384794951680 - talent_pool: 569145364800602132 filter: @@ -323,7 +321,6 @@ filter: - *MESSAGE_LOG - *MOD_LOG - *STAFF_LOUNGE - - *TALENT_POOL - *USER_EVENT_A role_whitelist: -- cgit v1.2.3 From 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 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 d1a2d9965fadc5292685d3ca78a75fd16f07af26 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Sat, 12 Jun 2021 14:08:52 -0700 Subject: Switched back to default value getting. --- bot/exts/recruitment/talentpool/_review.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 585640699..4e61ecb3e 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -65,7 +65,7 @@ class Reviewer: """Schedules a single user for review.""" log.trace(f"Scheduling review of user with ID {user_id}") - user_data = self._pool.cache[user_id] + user_data = self._pool.cache.get(user_id) inserted_at = isoparse(user_data['inserted_at']).replace(tzinfo=None) review_at = inserted_at + timedelta(days=MAX_DAYS_IN_POOL) @@ -93,7 +93,7 @@ class Reviewer: await last_message.add_reaction(reaction) if update_database: - nomination = self._pool.cache[user_id] + nomination = self._pool.cache.get(user_id) await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji]]: @@ -104,7 +104,7 @@ class Reviewer: # not to accidentally insert the IDs of users that have no # active nominated by using the `watched_users.get(user_id)` # instead of `watched_users[user_id]`. - nomination = self._pool.cache[user_id] + nomination = self._pool.cache.get(user_id) if not nomination: log.trace(f"There doesn't appear to be an active nomination for {user_id}") return "", None @@ -393,7 +393,7 @@ class Reviewer: await ctx.send(f":x: Can't find a currently nominated user with id `{user_id}`") return False - nomination = self._pool.cache[user_id] + nomination = self._pool.cache.get(user_id) if nomination["reviewed"]: await ctx.send(":x: This nomination was already reviewed, but here's a cookie :cookie:") return False -- cgit v1.2.3 From 6f45d6896adb3f05962733cec8e5db199def20bc Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 2 Jul 2021 10:29:13 +0200 Subject: Bump embed limit to 4096 characters --- bot/exts/backend/branding/_cog.py | 6 +++--- bot/exts/filters/antispam.py | 2 +- bot/exts/info/doc/_parsing.py | 2 +- bot/exts/info/python_news.py | 2 +- bot/exts/moderation/infraction/_utils.py | 4 ++-- bot/exts/moderation/modlog.py | 4 ++-- bot/exts/moderation/watchchannels/_watchchannel.py | 2 +- bot/exts/recruitment/talentpool/_review.py | 4 +++- bot/pagination.py | 18 +++++++++--------- tests/bot/exts/moderation/infraction/test_utils.py | 2 +- tests/bot/exts/moderation/test_modlog.py | 2 +- 11 files changed, 25 insertions(+), 23 deletions(-) diff --git a/bot/exts/backend/branding/_cog.py b/bot/exts/backend/branding/_cog.py index 47c379a34..0ba146635 100644 --- a/bot/exts/backend/branding/_cog.py +++ b/bot/exts/backend/branding/_cog.py @@ -50,7 +50,7 @@ def make_embed(title: str, description: str, *, success: bool) -> discord.Embed: For both `title` and `description`, empty string are valid values ~ fields will be empty. """ colour = Colours.soft_green if success else Colours.soft_red - return discord.Embed(title=title[:256], description=description[:2048], colour=colour) + return discord.Embed(title=title[:256], description=description[:4096], colour=colour) def extract_event_duration(event: Event) -> str: @@ -293,8 +293,8 @@ class Branding(commands.Cog): else: content = "Python Discord is entering a new event!" if is_notification else None - embed = discord.Embed(description=description[:2048], colour=discord.Colour.blurple()) - embed.set_footer(text=duration[:2048]) + embed = discord.Embed(description=description[:4096], colour=discord.Colour.blurple()) + embed.set_footer(text=duration[:4096]) await channel.send(content=content, embed=embed) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 7555e25a2..2f0771396 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -84,7 +84,7 @@ class DeletionContext: mod_alert_message += "Message:\n" [message] = self.messages.values() content = message.clean_content - remaining_chars = 2040 - len(mod_alert_message) + remaining_chars = 4080 - len(mod_alert_message) if len(content) > remaining_chars: content = content[:remaining_chars] + "..." diff --git a/bot/exts/info/doc/_parsing.py b/bot/exts/info/doc/_parsing.py index bf840b96f..1a0d42c47 100644 --- a/bot/exts/info/doc/_parsing.py +++ b/bot/exts/info/doc/_parsing.py @@ -34,7 +34,7 @@ _EMBED_CODE_BLOCK_LINE_LENGTH = 61 # _MAX_SIGNATURE_AMOUNT code block wrapped lines with py syntax highlight _MAX_SIGNATURES_LENGTH = (_EMBED_CODE_BLOCK_LINE_LENGTH + 8) * MAX_SIGNATURE_AMOUNT # Maximum embed description length - signatures on top -_MAX_DESCRIPTION_LENGTH = 2048 - _MAX_SIGNATURES_LENGTH +_MAX_DESCRIPTION_LENGTH = 4096 - _MAX_SIGNATURES_LENGTH _TRUNCATE_STRIP_CHARACTERS = "!?:;." + string.whitespace BracketPair = namedtuple("BracketPair", ["opening_bracket", "closing_bracket"]) diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index 0ab5738a4..a7837c93a 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -173,7 +173,7 @@ class PythonNews(Cog): # Build an embed and send a message to the webhook embed = discord.Embed( title=thread_information["subject"], - description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content, + description=content[:1000] + f"... [continue reading]({link})" if len(content) > 1000 else content, timestamp=new_date, url=link, colour=constants.Colours.soft_green diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index e4eb7f79c..cfb238fa3 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -169,8 +169,8 @@ async def notify_infraction( ) # For case when other fields than reason is too long and this reach limit, then force-shorten string - if len(text) > 2048: - text = f"{text[:2045]}..." + if len(text) > 4096: + text = f"{text[:4093]}..." embed = discord.Embed( description=text, diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index be65ade6e..be2245650 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -99,7 +99,7 @@ class ModLog(Cog, name="ModLog"): """Generate log embed and send to logging channel.""" # Truncate string directly here to avoid removing newlines embed = discord.Embed( - description=text[:2045] + "..." if len(text) > 2048 else text + description=text[:4093] + "..." if len(text) > 4096 else text ) if title and icon_url: @@ -564,7 +564,7 @@ class ModLog(Cog, name="ModLog"): # Shorten the message content if necessary content = message.clean_content - remaining_chars = 2040 - len(response) + remaining_chars = 4090 - len(response) if len(content) > remaining_chars: botlog_url = await self.upload_log(messages=[message], actor_id=message.author.id) diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index 9f26c34f2..146426569 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -295,7 +295,7 @@ class WatchChannel(metaclass=CogABCMeta): footer = f"Added {time_delta} by {actor} | Reason: {reason}" embed = Embed(description=f"{msg.author.mention} {message_jump}") - embed.set_footer(text=textwrap.shorten(footer, width=128, placeholder="...")) + embed.set_footer(text=textwrap.shorten(footer, width=256, placeholder="...")) await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 0cb786e4b..c4c68dbc3 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -31,6 +31,8 @@ MAX_DAYS_IN_POOL = 30 # Maximum amount of characters allowed in a message MAX_MESSAGE_SIZE = 2000 +# Maximum amount of characters allowed in an embed +MAX_EMBED_SIZE = 4000 # Regex finding the user ID of a user mention MENTION_RE = re.compile(r"<@!?(\d+?)>") @@ -199,7 +201,7 @@ class Reviewer: channel = self.bot.get_channel(Channels.nomination_archive) for number, part in enumerate( - textwrap.wrap(embed_content, width=MAX_MESSAGE_SIZE, replace_whitespace=False, placeholder="") + textwrap.wrap(embed_content, width=MAX_EMBED_SIZE, replace_whitespace=False, placeholder="") ): await channel.send(embed=Embed( title=embed_title if number == 0 else None, diff --git a/bot/pagination.py b/bot/pagination.py index 1c5b94b07..865acce41 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -49,8 +49,8 @@ class LinePaginator(Paginator): self, prefix: str = '```', suffix: str = '```', - max_size: int = 2000, - scale_to_size: int = 2000, + max_size: int = 4000, + scale_to_size: int = 4000, max_lines: t.Optional[int] = None, linesep: str = "\n" ) -> None: @@ -59,10 +59,10 @@ class LinePaginator(Paginator): It overrides in order to allow us to configure the maximum number of lines per page. """ - # Embeds that exceed 2048 characters will result in an HTTPException - # (Discord API limit), so we've set a limit of 2000 - if max_size > 2000: - raise ValueError(f"max_size must be <= 2,000 characters. ({max_size} > 2000)") + # Embeds that exceed 4096 characters will result in an HTTPException + # (Discord API limit), so we've set a limit of 4000 + if max_size > 4000: + raise ValueError(f"max_size must be <= 4,000 characters. ({max_size} > 4000)") super().__init__( prefix, @@ -74,8 +74,8 @@ class LinePaginator(Paginator): if scale_to_size < max_size: raise ValueError(f"scale_to_size must be >= max_size. ({scale_to_size} < {max_size})") - if scale_to_size > 2000: - raise ValueError(f"scale_to_size must be <= 2,000 characters. ({scale_to_size} > 2000)") + if scale_to_size > 4000: + raise ValueError(f"scale_to_size must be <= 2,000 characters. ({scale_to_size} > 4000)") self.scale_to_size = scale_to_size - len(suffix) self.max_lines = max_lines @@ -197,7 +197,7 @@ class LinePaginator(Paginator): suffix: str = "", max_lines: t.Optional[int] = None, max_size: int = 500, - scale_to_size: int = 2000, + scale_to_size: int = 4000, empty: bool = True, restrict_to_user: User = None, timeout: int = 300, diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index 50a717bb5..c6ae76984 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -213,7 +213,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): type="Mute", expires="N/A", reason="foo bar" * 4000 - )[:2045] + "...", + )[:4093] + "...", colour=Colours.soft_red, url=utils.RULES_URL ).set_author( diff --git a/tests/bot/exts/moderation/test_modlog.py b/tests/bot/exts/moderation/test_modlog.py index f8f142484..79e04837d 100644 --- a/tests/bot/exts/moderation/test_modlog.py +++ b/tests/bot/exts/moderation/test_modlog.py @@ -25,5 +25,5 @@ class ModLogTests(unittest.IsolatedAsyncioTestCase): ) embed = self.channel.send.call_args[1]["embed"] self.assertEqual( - embed.description, ("foo bar" * 3000)[:2045] + "..." + embed.description, ("foo bar" * 3000)[:4093] + "..." ) -- cgit v1.2.3 From db410e8def7d1a0d2cc7dde625cabb78958f0af7 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 2 Jul 2021 16:48:20 +0200 Subject: Make use of Discord timestamps --- bot/exts/info/information.py | 8 +- bot/exts/moderation/defcon.py | 6 +- bot/exts/moderation/infraction/_utils.py | 2 +- bot/exts/moderation/infraction/management.py | 18 +++-- bot/exts/moderation/stream.py | 13 +--- bot/exts/recruitment/talentpool/_review.py | 9 +-- bot/exts/utils/reminders.py | 27 +++---- bot/exts/utils/utils.py | 2 +- bot/utils/time.py | 85 ++++++++++++++-------- tests/bot/exts/info/test_information.py | 8 +- tests/bot/exts/moderation/infraction/test_utils.py | 4 +- tests/bot/utils/test_time.py | 73 ++++++++----------- 12 files changed, 126 insertions(+), 129 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 1b1243118..2c89d39e8 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -17,7 +17,7 @@ from bot.decorators import in_whitelist from bot.pagination import LinePaginator from bot.utils.channel import is_mod_channel, is_staff_channel from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check -from bot.utils.time import humanize_delta, time_since +from bot.utils.time import TimestampFormats, discord_timestamp, humanize_delta log = logging.getLogger(__name__) @@ -154,7 +154,7 @@ class Information(Cog): """Returns an embed full of server information.""" embed = Embed(colour=Colour.blurple(), title="Server Information") - created = time_since(ctx.guild.created_at, precision="days") + created = discord_timestamp(ctx.guild.created_at, TimestampFormats.RELATIVE) region = ctx.guild.region num_roles = len(ctx.guild.roles) - 1 # Exclude @everyone @@ -224,7 +224,7 @@ class Information(Cog): """Creates an embed containing information on the `user`.""" on_server = bool(ctx.guild.get_member(user.id)) - created = time_since(user.created_at, max_units=3) + created = discord_timestamp(user.created_at, TimestampFormats.RELATIVE) name = str(user) if on_server and user.nick: @@ -242,7 +242,7 @@ class Information(Cog): badges.append(emoji) if on_server: - joined = time_since(user.joined_at, max_units=3) + joined = discord_timestamp(user.joined_at, TimestampFormats.RELATIVE) roles = ", ".join(role.mention for role in user.roles[1:]) membership = {"Joined": joined, "Verified": not user.pending, "Roles": roles or None} if not is_mod_channel(ctx.channel): diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index dfb1afd19..9801d45ad 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -19,7 +19,9 @@ from bot.converters import DurationDelta, Expiry from bot.exts.moderation.modlog import ModLog from bot.utils.messages import format_user from bot.utils.scheduling import Scheduler -from bot.utils.time import humanize_delta, parse_duration_string, relativedelta_to_timedelta +from bot.utils.time import ( + TimestampFormats, discord_timestamp, humanize_delta, parse_duration_string, relativedelta_to_timedelta +) log = logging.getLogger(__name__) @@ -150,7 +152,7 @@ class Defcon(Cog): colour=Colour.blurple(), title="DEFCON Status", description=f""" **Threshold:** {humanize_delta(self.threshold) if self.threshold else "-"} - **Expires in:** {humanize_delta(relativedelta(self.expiry, datetime.utcnow())) if self.expiry else "-"} + **Expires:** {discord_timestamp(self.expiry, TimestampFormats.RELATIVE) if self.expiry else "-"} **Verification level:** {ctx.guild.verification_level.name} """ ) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index cfb238fa3..92e0596df 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -164,7 +164,7 @@ async def notify_infraction( text = INFRACTION_DESCRIPTION_TEMPLATE.format( type=infr_type.title(), - expires=f"{expires_at} UTC" if expires_at else "N/A", + expires=expires_at or "N/A", reason=reason or "No reason provided." ) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index b3783cd60..4b0cb78a5 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -3,7 +3,9 @@ import textwrap import typing as t from datetime import datetime +import dateutil.parser import discord +from dateutil.relativedelta import relativedelta from discord.ext import commands from discord.ext.commands import Context from discord.utils import escape_markdown @@ -16,6 +18,7 @@ from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator from bot.utils import messages, time from bot.utils.channel import is_mod_channel +from bot.utils.time import humanize_delta, until_expiration log = logging.getLogger(__name__) @@ -164,8 +167,8 @@ class ModManagement(commands.Cog): self.infractions_cog.schedule_expiration(new_infraction) log_text += f""" - Previous expiry: {infraction['expires_at'] or "Permanent"} - New expiry: {new_infraction['expires_at'] or "Permanent"} + Previous expiry: {until_expiration(infraction['expires_at']) or "Permanent"} + New expiry: {until_expiration(new_infraction['expires_at']) or "Permanent"} """.rstrip() changes = ' & '.join(confirm_messages) @@ -288,10 +291,11 @@ class ModManagement(commands.Cog): remaining = "Inactive" if expires_at is None: - expires = "*Permanent*" + duration = "*Permanent*" else: - date_from = datetime.strptime(created, time.INFRACTION_FORMAT) - expires = time.format_infraction_with_duration(expires_at, date_from) + date_from = datetime.fromtimestamp(float(time.DISCORD_TIMESTAMP_REGEX.match(created).group(1))) + date_to = dateutil.parser.isoparse(expires_at).replace(tzinfo=None) + duration = humanize_delta(relativedelta(date_to, date_from)) lines = textwrap.dedent(f""" {"**===============**" if active else "==============="} @@ -300,8 +304,8 @@ class ModManagement(commands.Cog): Type: **{infraction["type"]}** Shadow: {infraction["hidden"]} Created: {created} - Expires: {expires} - Remaining: {remaining} + Expires: {remaining} + Duration: {duration} Actor: <@{infraction["actor"]["id"]}> ID: `{infraction["id"]}` Reason: {infraction["reason"] or "*None*"} diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index fd856a7f4..07ee4099e 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -13,7 +13,7 @@ from bot.constants import Colours, Emojis, Guild, MODERATION_ROLES, Roles, STAFF from bot.converters import Expiry from bot.pagination import LinePaginator from bot.utils.scheduling import Scheduler -from bot.utils.time import format_infraction_with_duration +from bot.utils.time import discord_timestamp, format_infraction_with_duration log = logging.getLogger(__name__) @@ -134,16 +134,7 @@ class Stream(commands.Cog): await member.add_roles(discord.Object(Roles.video), reason="Temporary streaming access granted") - # Use embed as embed timestamps do timezone conversions. - embed = discord.Embed( - description=f"{Emojis.check_mark} {member.mention} can now stream.", - colour=Colours.soft_green - ) - embed.set_footer(text=f"Streaming permission has been given to {member} until") - embed.timestamp = duration - - # Mention in content as mentions in embeds don't ping - await ctx.send(content=member.mention, embed=embed) + await ctx.send(f"{Emojis.check_mark} {member.mention} can now stream until {discord_timestamp(duration)}.") # Convert here for nicer logging revoke_time = format_infraction_with_duration(str(duration)) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index c4c68dbc3..aebb401e0 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -10,7 +10,6 @@ from datetime import datetime, timedelta from typing import List, Optional, Union from dateutil.parser import isoparse -from dateutil.relativedelta import relativedelta from discord import Embed, Emoji, Member, Message, NoMoreItems, PartialMessage, TextChannel from discord.ext.commands import Context @@ -19,7 +18,7 @@ from bot.bot import Bot from bot.constants import Channels, Colours, Emojis, Guild, Roles from bot.utils.messages import count_unique_users_reaction, pin_no_system_message from bot.utils.scheduling import Scheduler -from bot.utils.time import get_time_delta, humanize_delta, time_since +from bot.utils.time import get_time_delta, time_since if typing.TYPE_CHECKING: from bot.exts.recruitment.talentpool._cog import TalentPool @@ -255,9 +254,9 @@ class Reviewer: last_channel = user_activity["top_channel_activity"][-1] channels += f", and {last_channel[1]} in {last_channel[0]}" - time_on_server = humanize_delta(relativedelta(datetime.utcnow(), member.joined_at), max_units=2) + joined_at_formatted = time_since(member.join_at) review = ( - f"{member.name} has been on the server for **{time_on_server}**" + f"{member.name} joined the server **{joined_at_formatted}**" f" and has **{messages} messages**{channels}." ) @@ -347,7 +346,7 @@ class Reviewer: nomination_times = f"{num_entries} times" if num_entries > 1 else "once" rejection_times = f"{len(history)} times" if len(history) > 1 else "once" - end_time = time_since(isoparse(history[0]['ended_at']).replace(tzinfo=None), max_units=2) + end_time = time_since(isoparse(history[0]['ended_at']).replace(tzinfo=None)) review = ( f"They were nominated **{nomination_times}** before" diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 6c21920a1..c7ce8b9e9 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -3,12 +3,11 @@ import logging import random import textwrap import typing as t -from datetime import datetime, timedelta +from datetime import datetime from operator import itemgetter import discord from dateutil.parser import isoparse -from dateutil.relativedelta import relativedelta from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot @@ -19,7 +18,7 @@ from bot.utils.checks import has_any_role_check, has_no_roles_check from bot.utils.lock import lock_arg from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler -from bot.utils.time import humanize_delta +from bot.utils.time import TimestampFormats, discord_timestamp, time_since log = logging.getLogger(__name__) @@ -62,8 +61,7 @@ class Reminders(Cog): # If the reminder is already overdue ... if remind_at < now: - late = relativedelta(now, remind_at) - await self.send_reminder(reminder, late) + await self.send_reminder(reminder, remind_at) else: self.schedule_reminder(reminder) @@ -174,7 +172,7 @@ class Reminders(Cog): self.schedule_reminder(reminder) @lock_arg(LOCK_NAMESPACE, "reminder", itemgetter("id"), raise_error=True) - async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: + async def send_reminder(self, reminder: dict, expected_time: datetime = None) -> None: """Send the reminder.""" is_valid, user, channel = self.ensure_valid_reminder(reminder) if not is_valid: @@ -188,16 +186,17 @@ class Reminders(Cog): name="It has arrived!" ) - embed.description = f"Here's your reminder: `{reminder['content']}`." + # Let's not use a codeblock to keep emojis and mentions working. Embeds are safe anyway. + embed.description = f"Here's your reminder: {reminder['content']}." if reminder.get("jump_url"): # keep backward compatibility embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})" - if late: + if expected_time: embed.colour = discord.Colour.red() embed.set_author( icon_url=Icons.remind_red, - name=f"Sorry it arrived {humanize_delta(late, max_units=2)} late!" + name=f"Sorry it should have arrived {time_since(expected_time)} !" ) additional_mentions = ' '.join( @@ -270,9 +269,7 @@ class Reminders(Cog): } ) - now = datetime.utcnow() - timedelta(seconds=1) - humanized_delta = humanize_delta(relativedelta(expiration, now)) - mention_string = f"Your reminder will arrive in {humanized_delta}" + mention_string = f"Your reminder will arrive {discord_timestamp(expiration, TimestampFormats.RELATIVE)}" if mentions: mention_string += f" and will mention {len(mentions)} other(s)" @@ -297,8 +294,6 @@ class Reminders(Cog): params={'author__id': str(ctx.author.id)} ) - now = datetime.utcnow() - # Make a list of tuples so it can be sorted by time. reminders = sorted( ( @@ -313,7 +308,7 @@ class Reminders(Cog): for content, remind_at, id_, mentions in reminders: # Parse and humanize the time, make it pretty :D remind_datetime = isoparse(remind_at).replace(tzinfo=None) - time = humanize_delta(relativedelta(remind_datetime, now)) + time = discord_timestamp(remind_datetime, TimestampFormats.RELATIVE) mentions = ", ".join( # Both Role and User objects have the `name` attribute @@ -322,7 +317,7 @@ class Reminders(Cog): mention_string = f"\n**Mentions:** {mentions}" if mentions else "" text = textwrap.dedent(f""" - **Reminder #{id_}:** *expires in {time}* (ID: {id_}){mention_string} + **Reminder #{id_}:** *expires {time}* (ID: {id_}){mention_string} {content} """).strip() diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 3b8564aee..2831e30cc 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -175,7 +175,7 @@ class Utils(Cog): lines = [] for snowflake in snowflakes: created_at = snowflake_time(snowflake) - lines.append(f"**{snowflake}**\nCreated at {created_at} ({time_since(created_at, max_units=3)}).") + lines.append(f"**{snowflake}**\nCreated at {created_at} ({time_since(created_at)}).") await LinePaginator.paginate( lines, diff --git a/bot/utils/time.py b/bot/utils/time.py index d55a0e532..8cf7d623b 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -1,12 +1,13 @@ import datetime import re -from typing import Optional +from enum import Enum +from typing import Optional, Union import dateutil.parser from dateutil.relativedelta import relativedelta RFC1123_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" -INFRACTION_FORMAT = "%Y-%m-%d %H:%M" +DISCORD_TIMESTAMP_REGEX = re.compile(r"") _DURATION_REGEX = re.compile( r"((?P\d+?) ?(years|year|Y|y) ?)?" @@ -19,6 +20,25 @@ _DURATION_REGEX = re.compile( ) +ValidTimestamp = Union[int, datetime.datetime, datetime.date, datetime.timedelta, relativedelta] + + +class TimestampFormats(Enum): + """ + Represents the different formats possible for Discord timestamps. + + Examples are given in epoch time. + """ + + DATE_TIME = "f" # January 1, 1970 1:00 AM + DAY_TIME = "F" # Thursday, January 1, 1970 1:00 AM + DATE_SHORT = "d" # 01/01/1970 + DATE = "D" # January 1, 1970 + TIME = "t" # 1:00 AM + TIME_SECONDS = "T" # 1:00:00 AM + RELATIVE = "R" # 52 years ago + + def _stringify_time_unit(value: int, unit: str) -> str: """ Returns a string to represent a value and time unit, ensuring that it uses the right plural form of the unit. @@ -40,6 +60,24 @@ def _stringify_time_unit(value: int, unit: str) -> str: return f"{value} {unit}" +def discord_timestamp(timestamp: ValidTimestamp, format: TimestampFormats = TimestampFormats.DATE_TIME) -> str: + """Create and format a Discord flavored markdown timestamp.""" + if format not in TimestampFormats: + raise ValueError(f"Format can only be one of {', '.join(TimestampFormats.args)}, not {format}.") + + # Convert each possible timestamp class to an integer. + if isinstance(timestamp, datetime.datetime): + timestamp = (timestamp.replace(tzinfo=None) - datetime.datetime.utcfromtimestamp(0)).total_seconds() + elif isinstance(timestamp, datetime.date): + timestamp = (timestamp - datetime.date.fromtimestamp(0)).total_seconds() + elif isinstance(timestamp, datetime.timedelta): + timestamp = timestamp.total_seconds() + elif isinstance(timestamp, relativedelta): + timestamp = timestamp.seconds + + return f"" + + def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: int = 6) -> str: """ Returns a human-readable version of the relativedelta. @@ -87,7 +125,7 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units: def get_time_delta(time_string: str) -> str: """Returns the time in human-readable time delta format.""" date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None) - time_delta = time_since(date_time, precision="minutes", max_units=1) + time_delta = time_since(date_time) return time_delta @@ -123,19 +161,9 @@ def relativedelta_to_timedelta(delta: relativedelta) -> datetime.timedelta: return utcnow + delta - utcnow -def time_since(past_datetime: datetime.datetime, precision: str = "seconds", max_units: int = 6) -> str: - """ - Takes a datetime and returns a human-readable string that describes how long ago that datetime was. - - precision specifies the smallest unit of time to include (e.g. "seconds", "minutes"). - max_units specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). - """ - now = datetime.datetime.utcnow() - delta = abs(relativedelta(now, past_datetime)) - - humanized = humanize_delta(delta, precision, max_units) - - return f"{humanized} ago" +def time_since(past_datetime: datetime.datetime) -> str: + """Takes a datetime and returns a discord timestamp that describes how long ago that datetime was.""" + return discord_timestamp(past_datetime, TimestampFormats.RELATIVE) def parse_rfc1123(stamp: str) -> datetime.datetime: @@ -144,8 +172,8 @@ def parse_rfc1123(stamp: str) -> datetime.datetime: def format_infraction(timestamp: str) -> str: - """Format an infraction timestamp to a more readable ISO 8601 format.""" - return dateutil.parser.isoparse(timestamp).strftime(INFRACTION_FORMAT) + """Format an infraction timestamp to a discord timestamp.""" + return discord_timestamp(dateutil.parser.isoparse(timestamp)) def format_infraction_with_duration( @@ -155,11 +183,7 @@ def format_infraction_with_duration( absolute: bool = True ) -> Optional[str]: """ - Return `date_to` formatted as a readable ISO-8601 with the humanized duration since `date_from`. - - `date_from` must be an ISO-8601 formatted timestamp. The duration is calculated as from - `date_from` until `date_to` with a precision of seconds. If `date_from` is unspecified, the - current time is used. + Return `date_to` formatted as a discord timestamp with the timestamp duration since `date_from`. `max_units` specifies the maximum number of units of time to include in the duration. For example, a value of 1 may include days but not hours. @@ -186,25 +210,22 @@ def format_infraction_with_duration( def until_expiration( - expiry: Optional[str], - now: Optional[datetime.datetime] = None, - max_units: int = 2 + expiry: Optional[str] ) -> Optional[str]: """ - Get the remaining time until infraction's expiration, in a human-readable version of the relativedelta. + Get the remaining time until infraction's expiration, in a discord timestamp. Returns a human-readable version of the remaining duration between datetime.utcnow() and an expiry. - Unlike `humanize_delta`, this function will force the `precision` to be `seconds` by not passing it. - `max_units` specifies the maximum number of units of time to include (e.g. 1 may include days but not hours). - By default, max_units is 2. + Similar to time_since, except that this function doesn't error on a null input + and return null if the expiry is in the paste """ if not expiry: return None - now = now or datetime.datetime.utcnow() + now = datetime.datetime.utcnow() since = dateutil.parser.isoparse(expiry).replace(tzinfo=None, microsecond=0) if since < now: return None - return humanize_delta(relativedelta(since, now), max_units=max_units) + return discord_timestamp(since, TimestampFormats.RELATIVE) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 770660fe3..ced3a2449 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -347,7 +347,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( textwrap.dedent(f""" - Created: {"1 year ago"} + Created: {""} Profile: {user.mention} ID: {user.id} """).strip(), @@ -356,7 +356,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( textwrap.dedent(f""" - Joined: {"1 year ago"} + Joined: {""} Verified: {"True"} Roles: &Moderators """).strip(), @@ -379,7 +379,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( textwrap.dedent(f""" - Created: {"1 year ago"} + Created: {""} Profile: {user.mention} ID: {user.id} """).strip(), @@ -388,7 +388,7 @@ class UserEmbedTests(unittest.IsolatedAsyncioTestCase): self.assertEqual( textwrap.dedent(f""" - Joined: {"1 year ago"} + Joined: {""} Roles: &Moderators """).strip(), embed.fields[1].value diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index c6ae76984..5f95ced9f 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -137,7 +137,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): title=utils.INFRACTION_TITLE, description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Ban", - expires="2020-02-26 09:20 (23 hours and 59 minutes) UTC", + expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="No reason provided." ), colour=Colours.soft_red, @@ -193,7 +193,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): title=utils.INFRACTION_TITLE, description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format( type="Mute", - expires="2020-02-26 09:20 (23 hours and 59 minutes) UTC", + expires="2020-02-26 09:20 (23 hours and 59 minutes)", reason="Test" ), colour=Colours.soft_red, diff --git a/tests/bot/utils/test_time.py b/tests/bot/utils/test_time.py index 115ddfb0d..8edffd1c9 100644 --- a/tests/bot/utils/test_time.py +++ b/tests/bot/utils/test_time.py @@ -52,7 +52,7 @@ class TimeTests(unittest.TestCase): def test_format_infraction(self): """Testing format_infraction.""" - self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '2019-12-12 00:01') + self.assertEqual(time.format_infraction('2019-12-12T00:01:00Z'), '') def test_format_infraction_with_duration_none_expiry(self): """format_infraction_with_duration should work for None expiry.""" @@ -72,10 +72,10 @@ class TimeTests(unittest.TestCase): def test_format_infraction_with_duration_custom_units(self): """format_infraction_with_duration should work for custom max_units.""" test_cases = ( - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, - '2019-12-12 00:01 (11 hours, 55 minutes and 55 seconds)'), - ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20, - '2019-11-23 20:09 (6 months, 28 days, 23 hours and 54 minutes)') + ('3000-12-12T00:01:00Z', datetime(3000, 12, 11, 12, 5, 5), 6, + ' (11 hours, 55 minutes and 55 seconds)'), + ('3000-11-23T20:09:00Z', datetime(3000, 4, 25, 20, 15), 20, + ' (6 months, 28 days, 23 hours and 54 minutes)') ) for expiry, date_from, max_units, expected in test_cases: @@ -85,16 +85,16 @@ class TimeTests(unittest.TestCase): def test_format_infraction_with_duration_normal_usage(self): """format_infraction_with_duration should work for normal usage, across various durations.""" test_cases = ( - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '2019-12-12 00:01 (12 hours and 55 seconds)'), - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '2019-12-12 00:01 (12 hours)'), - ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '2019-12-12 00:00 (1 minute)'), - ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '2019-11-23 20:09 (7 days and 23 hours)'), - ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '2019-11-23 20:09 (6 months and 28 days)'), - ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '2019-11-23 20:58 (5 minutes)'), - ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '2019-11-24 00:00 (1 minute)'), - ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '2019-11-23 23:59 (2 years and 4 months)'), + ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, ' (12 hours and 55 seconds)'), + ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, ' (12 hours)'), + ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, ' (1 minute)'), + ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, ' (7 days and 23 hours)'), + ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, ' (6 months and 28 days)'), + ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, ' (5 minutes)'), + ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, ' (1 minute)'), + ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, ' (2 years and 4 months)'), ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2, - '2019-11-23 23:59 (9 minutes and 55 seconds)'), + ' (9 minutes and 55 seconds)'), (None, datetime(2019, 11, 23, 23, 49, 5), 2, None), ) @@ -104,45 +104,30 @@ class TimeTests(unittest.TestCase): def test_until_expiration_with_duration_none_expiry(self): """until_expiration should work for None expiry.""" - test_cases = ( - (None, None, None, None), - - # To make sure that now and max_units are not touched - (None, 'Why hello there!', None, None), - (None, None, float('inf'), None), - (None, 'Why hello there!', float('inf'), None), - ) - - for expiry, now, max_units, expected in test_cases: - with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected): - self.assertEqual(time.until_expiration(expiry, now, max_units), expected) + self.assertEqual(time.until_expiration(None), None) def test_until_expiration_with_duration_custom_units(self): """until_expiration should work for custom max_units.""" test_cases = ( - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 5, 5), 6, '11 hours, 55 minutes and 55 seconds'), - ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 20, '6 months, 28 days, 23 hours and 54 minutes') + ('3000-12-12T00:01:00Z', ''), + ('3000-11-23T20:09:00Z', '') ) - for expiry, now, max_units, expected in test_cases: - with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected): - self.assertEqual(time.until_expiration(expiry, now, max_units), expected) + for expiry, expected in test_cases: + with self.subTest(expiry=expiry, expected=expected): + self.assertEqual(time.until_expiration(expiry,), expected) def test_until_expiration_normal_usage(self): """until_expiration should work for normal usage, across various durations.""" test_cases = ( - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 2, '12 hours and 55 seconds'), - ('2019-12-12T00:01:00Z', datetime(2019, 12, 11, 12, 0, 5), 1, '12 hours'), - ('2019-12-12T00:00:00Z', datetime(2019, 12, 11, 23, 59), 2, '1 minute'), - ('2019-11-23T20:09:00Z', datetime(2019, 11, 15, 20, 15), 2, '7 days and 23 hours'), - ('2019-11-23T20:09:00Z', datetime(2019, 4, 25, 20, 15), 2, '6 months and 28 days'), - ('2019-11-23T20:58:00Z', datetime(2019, 11, 23, 20, 53), 2, '5 minutes'), - ('2019-11-24T00:00:00Z', datetime(2019, 11, 23, 23, 59, 0), 2, '1 minute'), - ('2019-11-23T23:59:00Z', datetime(2017, 7, 21, 23, 0), 2, '2 years and 4 months'), - ('2019-11-23T23:59:00Z', datetime(2019, 11, 23, 23, 49, 5), 2, '9 minutes and 55 seconds'), - (None, datetime(2019, 11, 23, 23, 49, 5), 2, None), + ('3000-12-12T00:01:00Z', ''), + ('3000-12-12T00:01:00Z', ''), + ('3000-12-12T00:00:00Z', ''), + ('3000-11-23T20:09:00Z', ''), + ('3000-11-23T20:09:00Z', ''), + (None, None), ) - for expiry, now, max_units, expected in test_cases: - with self.subTest(expiry=expiry, now=now, max_units=max_units, expected=expected): - self.assertEqual(time.until_expiration(expiry, now, max_units), expected) + for expiry, expected in test_cases: + with self.subTest(expiry=expiry, expected=expected): + self.assertEqual(time.until_expiration(expiry), expected) -- cgit v1.2.3 From 231f6dc1d7e9fe478c1fcd517c2ec94a54e0763a Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 2 Jul 2021 16:58:32 +0200 Subject: Tests: remove stale patch of time_since --- tests/bot/exts/info/test_information.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index ced3a2449..0aa41d889 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -262,7 +262,6 @@ class UserInfractionHelperMethodTests(unittest.IsolatedAsyncioTestCase): await self._method_subtests(self.cog.user_nomination_counts, test_values, header) -@unittest.mock.patch("bot.exts.info.information.time_since", new=unittest.mock.MagicMock(return_value="1 year ago")) @unittest.mock.patch("bot.exts.info.information.constants.MODERATION_CHANNELS", new=[50]) class UserEmbedTests(unittest.IsolatedAsyncioTestCase): """Tests for the creation of the `!user` embed.""" -- cgit v1.2.3 From 7d1ee897b565daef1a8cc073d4dbaf0602185528 Mon Sep 17 00:00:00 2001 From: ToxicKidz Date: Mon, 5 Jul 2021 17:26:11 -0400 Subject: chore: Add the codejam create command This command takes a CSV file or a link to one. This CSV file has three rows: 'Team Name', 'Team Member Discord ID', and 'Team Leader'. The Team Name will be the name of the team's channel, the Member ID tells which user belongs to this team, and leam leader, which is either Y/N, tells if this user is the team leader. It will create text channels for each team and make a team leaders chat channel as well. It will ping the Events Lead role with updates for this command. --- bot/constants.py | 6 +- bot/exts/utils/jams.py | 171 +++++++++++++++++++++++++++++-------------------- config-default.yml | 5 +- 3 files changed, 111 insertions(+), 71 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 885b5c822..f33c14798 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -400,6 +400,8 @@ class Categories(metaclass=YAMLGetter): modmail: int voice: int + # 2021 Summer Code Jam + summer_code_jam: int class Channels(metaclass=YAMLGetter): section = "guild" @@ -437,6 +439,7 @@ class Channels(metaclass=YAMLGetter): discord_py: int esoteric: int voice_gate: int + code_jam_planning: int admins: int admin_spam: int @@ -495,8 +498,10 @@ class Roles(metaclass=YAMLGetter): admins: int core_developers: int + code_jam_event_team: int devops: int domain_leads: int + events_lead: int helpers: int moderators: int mod_team: int @@ -504,7 +509,6 @@ class Roles(metaclass=YAMLGetter): project_leads: int jammers: int - team_leaders: int class Guild(metaclass=YAMLGetter): diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py index 98fbcb303..d45f9b57f 100644 --- a/bot/exts/utils/jams.py +++ b/bot/exts/utils/jams.py @@ -1,17 +1,19 @@ +import csv import logging import typing as t +from collections import defaultdict -from discord import CategoryChannel, Guild, Member, PermissionOverwrite, Role +import discord from discord.ext import commands -from more_itertools import unique_everseen from bot.bot import Bot -from bot.constants import Roles +from bot.constants import Categories, Channels, Emojis, Roles log = logging.getLogger(__name__) MAX_CHANNELS = 50 CATEGORY_NAME = "Code Jam" +TEAM_LEADERS_COLOUR = 0x11806a class CodeJams(commands.Cog): @@ -20,39 +22,57 @@ class CodeJams(commands.Cog): def __init__(self, bot: Bot): self.bot = bot - @commands.command() + @commands.group() @commands.has_any_role(Roles.admins) - async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None: + async def codejam(self, ctx: commands.Context) -> None: + """A Group of commands for managing Code Jams.""" + if ctx.invoked_subcommand is None: + await ctx.send_help(ctx.command) + + @codejam.command() + async def create(self, ctx: commands.Context, csv_file: t.Optional[str]) -> None: """ - Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team. + Create code-jam teams from a CSV file or a link to one, specifying the team names, leaders and members. + + The CSV file must have 3 columns: 'Team Name', 'Team Member Discord ID', and 'Team Leader'. - The first user passed will always be the team leader. + This will create the text channels for the teams, and give the team leaders their roles. """ - # Ignore duplicate members - members = list(unique_everseen(members)) - - # We had a little issue during Code Jam 4 here, the greedy converter did it's job - # and ignored anything which wasn't a valid argument which left us with teams of - # two members or at some times even 1 member. This fixes that by checking that there - # are always 3 members in the members list. - if len(members) < 3: - await ctx.send( - ":no_entry_sign: One of your arguments was invalid\n" - f"There must be a minimum of 3 valid members in your team. Found: {len(members)}" - " members" - ) - return + async with ctx.typing(): + if csv_file: + async with self.bot.http_session.get(csv_file) as response: + if response.status != 200: + await ctx.send(f"Got a bad response from the URL: {response.status}") + return - team_channel = await self.create_channels(ctx.guild, team_name, members) - await self.add_roles(ctx.guild, members) + csv_file = await response.text() - await ctx.send( - f":ok_hand: Team created: {team_channel}\n" - f"**Team Leader:** {members[0].mention}\n" - f"**Team Members:** {' '.join(member.mention for member in members[1:])}" - ) + elif ctx.message.attachments: + csv_file = (await ctx.message.attachments[0].read()).decode("utf8") + else: + raise commands.BadArgument("You must include either a CSV file or a link to one.") + + teams = defaultdict(list) + reader = csv.DictReader(csv_file.splitlines()) + + for row in reader: + member = ctx.guild.get_member(int(row["Team Member Discord ID"])) + + if member is None: + log.trace(f"Got an invalid member ID: {row['Team Member Discord ID']}") + continue + + teams[row["Team Name"]].append((member, row["Team Leader"].upper() == "Y")) + + team_leaders = await ctx.guild.create_role(name="Team Leaders", colour=TEAM_LEADERS_COLOUR) + + for team_name, members in teams.items(): + await self.create_team_channel(ctx.guild, team_name, members, team_leaders) - async def get_category(self, guild: Guild) -> CategoryChannel: + await self.create_team_leader_channel(ctx.guild, team_leaders) + await ctx.send(f"{Emojis.check_mark} Created Code Jam with {len(teams)} teams.") + + async def get_category(self, guild: discord.Guild) -> discord.CategoryChannel: """ Return a code jam category. @@ -60,84 +80,97 @@ class CodeJams(commands.Cog): """ for category in guild.categories: # Need 2 available spaces: one for the text channel and one for voice. - if category.name == CATEGORY_NAME and MAX_CHANNELS - len(category.channels) >= 2: + if category.name == CATEGORY_NAME and len(category.channels) < MAX_CHANNELS: return category return await self.create_category(guild) - @staticmethod - async def create_category(guild: Guild) -> CategoryChannel: + async def create_category(self, guild: discord.Guild) -> discord.CategoryChannel: """Create a new code jam category and return it.""" log.info("Creating a new code jam category.") category_overwrites = { - guild.default_role: PermissionOverwrite(read_messages=False), - guild.me: PermissionOverwrite(read_messages=True) + guild.default_role: discord.PermissionOverwrite(read_messages=False), + guild.me: discord.PermissionOverwrite(read_messages=True) } - return await guild.create_category_channel( + category = await guild.create_category_channel( CATEGORY_NAME, overwrites=category_overwrites, reason="It's code jam time!" ) + await self.send_status_update( + guild, f"Created a new category with the ID {category.id} for this Code Jam's team channels." + ) + + return category + @staticmethod - def get_overwrites(members: t.List[Member], guild: Guild) -> t.Dict[t.Union[Member, Role], PermissionOverwrite]: + def get_overwrites( + members: list[tuple[discord.Member, bool]], + guild: discord.Guild, + ) -> dict[t.Union[discord.Member, discord.Role], discord.PermissionOverwrite]: """Get code jam team channels permission overwrites.""" - # First member is always the team leader team_channel_overwrites = { - members[0]: PermissionOverwrite( - manage_messages=True, - read_messages=True, - manage_webhooks=True, - connect=True - ), - guild.default_role: PermissionOverwrite(read_messages=False, connect=False), + guild.default_role: discord.PermissionOverwrite(read_messages=False), + guild.get_role(Roles.moderators): discord.PermissionOverwrite(read_messages=True), + guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite(read_messages=True) } - # Rest of members should just have read_messages - for member in members[1:]: - team_channel_overwrites[member] = PermissionOverwrite( - read_messages=True, - connect=True + for member, _ in members: + team_channel_overwrites[member] = discord.PermissionOverwrite( + read_messages=True ) return team_channel_overwrites - async def create_channels(self, guild: Guild, team_name: str, members: t.List[Member]) -> str: - """Create team text and voice channels. Return the mention for the text channel.""" + async def create_team_channel( + self, + guild: discord.Guild, + team_name: str, + members: list[tuple[discord.Member, bool]], + team_leaders: discord.Role + ) -> None: + """Create the team's text channel.""" + await self.add_team_leader_roles(members, team_leaders) + # Get permission overwrites and category team_channel_overwrites = self.get_overwrites(members, guild) code_jam_category = await self.get_category(guild) # Create a text channel for the team - team_channel = await guild.create_text_channel( + await code_jam_category.create_text_channel( team_name, overwrites=team_channel_overwrites, - category=code_jam_category ) - # Create a voice channel for the team - team_voice_name = " ".join(team_name.split("-")).title() + async def create_team_leader_channel(self, guild: discord.Guild, team_leaders: discord.Role) -> None: + """Create the Team Leader Chat channel for the Code Jam team leaders.""" + category: discord.CategoryChannel = guild.get_channel(Categories.summer_code_jam) - await guild.create_voice_channel( - team_voice_name, - overwrites=team_channel_overwrites, - category=code_jam_category + team_leaders_chat = await category.create_text_channel( + name="team-leaders-chat", + overwrites={ + guild.default_role: discord.PermissionOverwrite(read_messages=False), + team_leaders: discord.PermissionOverwrite(read_messages=True) + } ) - return team_channel.mention + await self.send_status_update(guild, f"Created {team_leaders_chat.mention} in the {category} category.") + + async def send_status_update(self, guild: discord.Guild, message: str) -> None: + """Inform the events lead with a status update when the command is ran.""" + channel: discord.TextChannel = guild.get_channel(Channels.code_jam_planning) + + await channel.send(f"<@&{Roles.events_lead}>\n\n{message}") @staticmethod - async def add_roles(guild: Guild, members: t.List[Member]) -> None: - """Assign team leader and jammer roles.""" - # Assign team leader role - await members[0].add_roles(guild.get_role(Roles.team_leaders)) - - # Assign rest of roles - jammer_role = guild.get_role(Roles.jammers) - for member in members: - await member.add_roles(jammer_role) + async def add_team_leader_roles(members: list[tuple[discord.Member, bool]], team_leaders: discord.Role) -> None: + """Assign team leader role, the jammer role and their team role.""" + for member, is_leader in members: + if is_leader: + await member.add_roles(team_leaders) def setup(bot: Bot) -> None: diff --git a/config-default.yml b/config-default.yml index 394c51c26..8c30ecf69 100644 --- a/config-default.yml +++ b/config-default.yml @@ -142,6 +142,7 @@ guild: moderators: &MODS_CATEGORY 749736277464842262 modmail: &MODMAIL 714494672835444826 voice: 356013253765234688 + summer_code_jam: 861692638540857384 channels: # Public announcement and news channels @@ -185,6 +186,7 @@ guild: bot_commands: &BOT_CMD 267659945086812160 esoteric: 470884583684964352 voice_gate: 764802555427029012 + code_jam_planning: 490217981872177157 # Staff admins: &ADMINS 365960823622991872 @@ -258,8 +260,10 @@ guild: # Staff admins: &ADMINS_ROLE 267628507062992896 core_developers: 587606783669829632 + code_jam_event_team: 787816728474288181 devops: 409416496733880320 domain_leads: 807415650778742785 + events_lead: 778361735739998228 helpers: &HELPERS_ROLE 267630620367257601 moderators: &MODS_ROLE 831776746206265384 mod_team: &MOD_TEAM_ROLE 267629731250176001 @@ -268,7 +272,6 @@ guild: # Code Jam jammers: 737249140966162473 - team_leaders: 737250302834638889 # Streaming video: 764245844798079016 -- cgit v1.2.3 From 089ef6219d98cbfcf9eed8bced6cb3ca43f0c55b Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Tue, 6 Jul 2021 02:33:57 +0300 Subject: Adds Documentation For Running A Single Test (#1669) Adds a portion to the testing README explaining how and when to run an individual test file when working with tests. Additionally adds a table of contents as the document has become quite long. Signed-off-by: Hassan Abouelela --- tests/README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/README.md b/tests/README.md index 0192f916e..b7fddfaa2 100644 --- a/tests/README.md +++ b/tests/README.md @@ -4,6 +4,14 @@ Our bot is one of the most important tools we have for running our community. As _**Note:** This is a practical guide to getting started with writing tests for our bot, not a general introduction to writing unit tests in Python. If you're looking for a more general introduction, you can take a look at the [Additional resources](#additional-resources) section at the bottom of this page._ +### Table of contents: +- [Tools](#tools) +- [Running tests](#running-tests) +- [Writing tests](#writing-tests) +- [Mocking](#mocking) +- [Some considerations](#some-considerations) +- [Additional resources](#additional-resources) + ## Tools We are using the following modules and packages for our unit tests: @@ -25,6 +33,29 @@ To ensure the results you obtain on your personal machine are comparable to thos If you want a coverage report, make sure to run the tests with `poetry run task test` *first*. +## Running tests +There are multiple ways to run the tests, which one you use will be determined by your goal, and stage in development. + +When actively developing, you'll most likely be working on one portion of the codebase, and as a result, won't need to run the entire test suite. +To run just one file, and save time, you can use the following command: +```shell +poetry run task test-nocov +``` + +For example: +```shell +poetry run task test-nocov tests/bot/exts/test_cogs.py +``` +will run the test suite in the `test_cogs` file. + +If you'd like to collect coverage as well, you can append `--cov` to the command above. + + +If you're done and are preparing to commit and push your code, it's a good idea to run the entire test suite as a sanity check: +```shell +poetry run task test +``` + ## Writing tests Since consistency is an important consideration for collaborative projects, we have written some guidelines on writing tests for the bot. In addition to these guidelines, it's a good idea to look at the existing code base for examples (e.g., [`test_converters.py`](/tests/bot/test_converters.py)). -- cgit v1.2.3 From 2a5a15f69d8ea3079f60e0e5d44387bc59061de5 Mon Sep 17 00:00:00 2001 From: ToxicKidz Date: Mon, 5 Jul 2021 19:48:29 -0400 Subject: chore: Update tests for the new codejam create command --- bot/exts/utils/jams.py | 1 - tests/bot/exts/utils/test_jams.py | 137 +++++++++++++++++++------------------- tests/helpers.py | 22 ++++++ 3 files changed, 92 insertions(+), 68 deletions(-) diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py index d45f9b57f..0fc84c2eb 100644 --- a/bot/exts/utils/jams.py +++ b/bot/exts/utils/jams.py @@ -79,7 +79,6 @@ class CodeJams(commands.Cog): If all categories are full or none exist, create a new category. """ for category in guild.categories: - # Need 2 available spaces: one for the text channel and one for voice. if category.name == CATEGORY_NAME and len(category.channels) < MAX_CHANNELS: return category diff --git a/tests/bot/exts/utils/test_jams.py b/tests/bot/exts/utils/test_jams.py index 85d6a1173..368a15476 100644 --- a/tests/bot/exts/utils/test_jams.py +++ b/tests/bot/exts/utils/test_jams.py @@ -2,10 +2,24 @@ import unittest from unittest.mock import AsyncMock, MagicMock, create_autospec from discord import CategoryChannel +from discord.ext.commands import BadArgument from bot.constants import Roles from bot.exts.utils import jams -from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel +from tests.helpers import ( + MockAttachment, MockBot, MockCategoryChannel, MockContext, + MockGuild, MockMember, MockRole, MockTextChannel +) + +TEST_CSV = b"""\ +Team Name,Team Member Discord ID,Team Leader +Annoyed Alligators,12345,Y +Annoyed Alligators,54321,N +Oscillating Otters,12358,Y +Oscillating Otters,74832,N +Oscillating Otters,19903,N +Annoyed Alligators,11111,N +""" def get_mock_category(channel_count: int, name: str) -> CategoryChannel: @@ -17,8 +31,8 @@ def get_mock_category(channel_count: int, name: str) -> CategoryChannel: return category -class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): - """Tests for `createteam` command.""" +class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): + """Tests for `codejam create` command.""" def setUp(self): self.bot = MockBot() @@ -28,60 +42,64 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) self.cog = jams.CodeJams(self.bot) - async def test_too_small_amount_of_team_members_passed(self): - """Should `ctx.send` and exit early when too small amount of members.""" - for case in (1, 2): - with self.subTest(amount_of_members=case): - self.cog.create_channels = AsyncMock() - self.cog.add_roles = AsyncMock() + async def test_message_without_attachments(self): + """If no link or attachments are provided, commands.BadArgument should be raised.""" + self.ctx.message.attachments = [] - self.ctx.reset_mock() - members = (MockMember() for _ in range(case)) - await self.cog.createteam(self.cog, self.ctx, "foo", members) + with self.assertRaises(BadArgument): + await self.cog.create(self.cog, self.ctx, None) - self.ctx.send.assert_awaited_once() - self.cog.create_channels.assert_not_awaited() - self.cog.add_roles.assert_not_awaited() + async def test_result_sending(self): + """Should call `ctx.send` when everything goes right.""" + self.ctx.message.attachments = [MockAttachment()] + self.ctx.message.attachments[0].read = AsyncMock() + self.ctx.message.attachments[0].read.return_value = TEST_CSV + + team_leaders = MockRole() + + self.guild.get_member.return_value = MockMember() - async def test_duplicate_members_provided(self): - """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member.""" - self.cog.create_channels = AsyncMock() + self.ctx.guild.create_role = AsyncMock() + self.ctx.guild.create_role.return_value = team_leaders + self.cog.create_team_channel = AsyncMock() + self.cog.create_team_leader_channel = AsyncMock() self.cog.add_roles = AsyncMock() - member = MockMember() - await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5))) + await self.cog.create(self.cog, self.ctx, None) + self.cog.create_team_channel.assert_awaited() + self.cog.create_team_leader_channel.assert_awaited_once_with( + self.ctx.guild, team_leaders + ) self.ctx.send.assert_awaited_once() - self.cog.create_channels.assert_not_awaited() - self.cog.add_roles.assert_not_awaited() - - async def test_result_sending(self): - """Should call `ctx.send` when everything goes right.""" - self.cog.create_channels = AsyncMock() - self.cog.add_roles = AsyncMock() - members = [MockMember() for _ in range(5)] - await self.cog.createteam(self.cog, self.ctx, "foo", members) + async def test_link_returning_non_200_status(self): + """When the URL passed returns a non 200 status, it should send a message informing them.""" + self.bot.http_session.get.return_value = mock = MagicMock() + mock.status = 404 + await self.cog.create(self.cog, self.ctx, "https://not-a-real-link.com") - self.cog.create_channels.assert_awaited_once() - self.cog.add_roles.assert_awaited_once() self.ctx.send.assert_awaited_once() async def test_category_doesnt_exist(self): """Should create a new code jam category.""" subtests = ( [], - [get_mock_category(jams.MAX_CHANNELS - 1, jams.CATEGORY_NAME)], + [get_mock_category(jams.MAX_CHANNELS, jams.CATEGORY_NAME)], [get_mock_category(jams.MAX_CHANNELS - 2, "other")], ) + self.cog.send_status_update = AsyncMock() + for categories in subtests: + self.cog.send_status_update.reset_mock() self.guild.reset_mock() self.guild.categories = categories with self.subTest(categories=categories): actual_category = await self.cog.get_category(self.guild) + self.cog.send_status_update.assert_called_once() self.guild.create_category_channel.assert_awaited_once() category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] @@ -103,62 +121,47 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase): async def test_channel_overwrites(self): """Should have correct permission overwrites for users and roles.""" - leader = MockMember() - members = [leader] + [MockMember() for _ in range(4)] + leader = (MockMember(), True) + members = [leader] + [(MockMember(), False) for _ in range(4)] overwrites = self.cog.get_overwrites(members, self.guild) - # Leader permission overwrites - self.assertTrue(overwrites[leader].manage_messages) - self.assertTrue(overwrites[leader].read_messages) - self.assertTrue(overwrites[leader].manage_webhooks) - self.assertTrue(overwrites[leader].connect) - - # Other members permission overwrites - for member in members[1:]: + for member, _ in members: self.assertTrue(overwrites[member].read_messages) - self.assertTrue(overwrites[member].connect) - - # Everyone role overwrite - self.assertFalse(overwrites[self.guild.default_role].read_messages) - self.assertFalse(overwrites[self.guild.default_role].connect) async def test_team_channels_creation(self): - """Should create new voice and text channel for team.""" - members = [MockMember() for _ in range(5)] + """Should create a text channel for a team.""" + team_leaders = MockRole() + members = [(MockMember(), True)] + [(MockMember(), False) for _ in range(5)] + category = MockCategoryChannel() + category.create_text_channel = AsyncMock() self.cog.get_overwrites = MagicMock() self.cog.get_category = AsyncMock() - self.ctx.guild.create_text_channel.return_value = MockTextChannel(mention="foobar-channel") - actual = await self.cog.create_channels(self.guild, "my-team", members) + self.cog.get_category.return_value = category + self.cog.add_team_leader_roles = AsyncMock() - self.assertEqual("foobar-channel", actual) + await self.cog.create_team_channel(self.guild, "my-team", members, team_leaders) + self.cog.add_team_leader_roles.assert_awaited_once_with(members, team_leaders) self.cog.get_overwrites.assert_called_once_with(members, self.guild) self.cog.get_category.assert_awaited_once_with(self.guild) - self.guild.create_text_channel.assert_awaited_once_with( + category.create_text_channel.assert_awaited_once_with( "my-team", - overwrites=self.cog.get_overwrites.return_value, - category=self.cog.get_category.return_value - ) - self.guild.create_voice_channel.assert_awaited_once_with( - "My Team", - overwrites=self.cog.get_overwrites.return_value, - category=self.cog.get_category.return_value + overwrites=self.cog.get_overwrites.return_value ) async def test_jam_roles_adding(self): """Should add team leader role to leader and jam role to every team member.""" leader_role = MockRole(name="Team Leader") - jam_role = MockRole(name="Jammer") - self.guild.get_role.side_effect = [leader_role, jam_role] leader = MockMember() - members = [leader] + [MockMember() for _ in range(4)] - await self.cog.add_roles(self.guild, members) + members = [(leader, True)] + [(MockMember(), False) for _ in range(4)] + await self.cog.add_team_leader_roles(members, leader_role) - leader.add_roles.assert_any_await(leader_role) - for member in members: - member.add_roles.assert_any_await(jam_role) + leader.add_roles.assert_awaited_once_with(leader_role) + for member, is_leader in members: + if not is_leader: + member.add_roles.assert_not_awaited() class CodeJamSetup(unittest.TestCase): diff --git a/tests/helpers.py b/tests/helpers.py index e3dc5fe5b..eedd7a601 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -361,6 +361,27 @@ class MockDMChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): super().__init__(**collections.ChainMap(kwargs, default_kwargs)) +# Create CategoryChannel instance to get a realistic MagicMock of `discord.CategoryChannel` +category_channel_data = { + 'id': 1, + 'type': discord.ChannelType.category, + 'name': 'category', + 'position': 1, +} + +state = unittest.mock.MagicMock() +guild = unittest.mock.MagicMock() +category_channel_instance = discord.CategoryChannel( + state=state, guild=guild, data=category_channel_data +) + + +class MockCategoryChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin): + def __init__(self, **kwargs) -> None: + default_kwargs = {'id': next(self.discord_id)} + super().__init__(**collections.ChainMap(default_kwargs, kwargs)) + + # Create a Message instance to get a realistic MagicMock of `discord.Message` message_data = { 'id': 1, @@ -403,6 +424,7 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock): self.guild = kwargs.get('guild', MockGuild()) self.author = kwargs.get('author', MockMember()) self.channel = kwargs.get('channel', MockTextChannel()) + self.message = kwargs.get('message', MockMessage()) self.invoked_from_error_handler = kwargs.get('invoked_from_error_handler', False) -- cgit v1.2.3 From 37f8637955350c6c5b437d66807f42d8f26adfcd Mon Sep 17 00:00:00 2001 From: ToxicKidz Date: Mon, 5 Jul 2021 19:58:04 -0400 Subject: chore: Remove the moderators role from the team channels' overwrites --- bot/exts/utils/jams.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py index 0fc84c2eb..b2f7dab04 100644 --- a/bot/exts/utils/jams.py +++ b/bot/exts/utils/jams.py @@ -113,7 +113,6 @@ class CodeJams(commands.Cog): """Get code jam team channels permission overwrites.""" team_channel_overwrites = { guild.default_role: discord.PermissionOverwrite(read_messages=False), - guild.get_role(Roles.moderators): discord.PermissionOverwrite(read_messages=True), guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite(read_messages=True) } -- cgit v1.2.3 From 1905f7cf62370f98ca4e484b54cf912353856a35 Mon Sep 17 00:00:00 2001 From: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> Date: Mon, 5 Jul 2021 20:00:59 -0400 Subject: chore: Change the `Code Jam Team Leader` role's name Co-authored-by: Boris Muratov <8bee278@gmail.com> --- bot/exts/utils/jams.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py index b2f7dab04..87ae847f6 100644 --- a/bot/exts/utils/jams.py +++ b/bot/exts/utils/jams.py @@ -64,7 +64,7 @@ class CodeJams(commands.Cog): teams[row["Team Name"]].append((member, row["Team Leader"].upper() == "Y")) - team_leaders = await ctx.guild.create_role(name="Team Leaders", colour=TEAM_LEADERS_COLOUR) + team_leaders = await ctx.guild.create_role(name="Code Jam Team Leaders", colour=TEAM_LEADERS_COLOUR) for team_name, members in teams.items(): await self.create_team_channel(ctx.guild, team_name, members, team_leaders) -- cgit v1.2.3 From 839e5250f1ba15db59e2e0d9b4f289391b7b87a0 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Tue, 6 Jul 2021 11:56:19 +0100 Subject: Disable filter in codejam team channels (#1670) * Disable filter_invites in codejam team channels * Fix incorrect comment Co-authored-by: ChrisJL Co-authored-by: ChrisJL --- bot/exts/filters/filtering.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 661d6c9a2..5d5f59590 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -20,6 +20,7 @@ from bot.constants import ( Guild, Icons, URLs ) from bot.exts.moderation.modlog import ModLog +from bot.exts.utils.jams import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.utils.messages import format_user from bot.utils.regex import INVITE_RE from bot.utils.scheduling import Scheduler @@ -281,6 +282,12 @@ class Filtering(Cog): if delta is not None and delta < 100: continue + if filter_name == "filter_invites": + # Disable invites filter in codejam team channels + category = msg.channel.category + if category and category.name == JAM_CATEGORY_NAME: + continue + # Does the filter only need the message content or the full message? if _filter["content_only"]: payload = msg.content -- cgit v1.2.3 From 3996c2f17f25b59e65461a1195537a52f0a64a7e Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 6 Jul 2021 12:12:45 +0100 Subject: Use getattr with a default, to protect against DM channels --- bot/exts/filters/filtering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 5d5f59590..77fb324a5 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -284,7 +284,7 @@ class Filtering(Cog): if filter_name == "filter_invites": # Disable invites filter in codejam team channels - category = msg.channel.category + category = getattr(msg.channel, "category", None) if category and category.name == JAM_CATEGORY_NAME: continue -- cgit v1.2.3 From 4500628e21e4a98c56d667d56818947adf34c404 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Tue, 6 Jul 2021 17:03:39 +0300 Subject: Disable more filters in jam channels (#1675) * Disable everyone ping filter in jam channels * Disable anti-spam in jam channels * Disable antimalware in jam channels --- bot/exts/filters/antimalware.py | 5 +++++ bot/exts/filters/antispam.py | 2 ++ bot/exts/filters/filtering.py | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index 89e539e7b..4c4836c88 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/antimalware.py @@ -7,6 +7,7 @@ from discord.ext.commands import Cog from bot.bot import Bot from bot.constants import Channels, Filter, URLs +from bot.exts.utils.jams import CATEGORY_NAME as JAM_CATEGORY_NAME log = logging.getLogger(__name__) @@ -61,6 +62,10 @@ class AntiMalware(Cog): if message.webhook_id or message.author.bot: return + # Ignore code jam channels + if hasattr(message.channel, "category") and message.channel.category.name == JAM_CATEGORY_NAME: + return + # Check if user is staff, if is, return # Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance if hasattr(message.author, "roles") and any(role.id in Filter.role_whitelist for role in message.author.roles): diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 7555e25a2..48c3aa5a6 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -18,6 +18,7 @@ from bot.constants import ( ) from bot.converters import Duration from bot.exts.moderation.modlog import ModLog +from bot.exts.utils.jams import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.utils import lock, scheduling from bot.utils.messages import format_user, send_attachments @@ -148,6 +149,7 @@ class AntiSpam(Cog): not message.guild or message.guild.id != GuildConfig.id or message.author.bot + or (hasattr(message.channel, "category") and message.channel.category.name == JAM_CATEGORY_NAME) or (message.channel.id in Filter.channel_whitelist and not DEBUG_MODE) or (any(role.id in Filter.role_whitelist for role in message.author.roles) and not DEBUG_MODE) ): diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 77fb324a5..16aaf11cf 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -282,7 +282,7 @@ class Filtering(Cog): if delta is not None and delta < 100: continue - if filter_name == "filter_invites": + if filter_name in ("filter_invites", "filter_everyone_ping"): # Disable invites filter in codejam team channels category = getattr(msg.channel, "category", None) if category and category.name == JAM_CATEGORY_NAME: -- cgit v1.2.3 From 9f5dba163a9ca57e09e9a391a09745c032b4c6be Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Wed, 7 Jul 2021 17:17:38 +0300 Subject: Replaces Fuzzywuzzy Replaces Fuzzywuzzy with RapidFuzz, as fuzzywuzzy is licensed under a GPL2 license. Signed-off-by: Hassan Abouelela --- bot/exts/info/help.py | 13 +---- bot/exts/info/information.py | 6 +- poetry.lock | 127 ++++++++++++++++++++++++++++++++----------- pyproject.toml | 2 +- 4 files changed, 102 insertions(+), 46 deletions(-) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 3a05b2c8a..bf9ea5986 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -6,8 +6,8 @@ from typing import List, Union from discord import Colour, Embed from discord.ext.commands import Bot, Cog, Command, CommandError, Context, DisabledCommand, Group, HelpCommand -from fuzzywuzzy import fuzz, process -from fuzzywuzzy.utils import full_process +from rapidfuzz import fuzz, process +from rapidfuzz.utils import default_process from bot import constants from bot.constants import Channels, STAFF_ROLES @@ -126,14 +126,7 @@ class CustomHelpCommand(HelpCommand): Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches. """ choices = await self.get_all_help_choices() - - # Run fuzzywuzzy's processor beforehand, and avoid matching if processed string is empty - # This avoids fuzzywuzzy from raising a warning on inputs with only non-alphanumeric characters - if (processed := full_process(string)): - result = process.extractBests(processed, choices, scorer=fuzz.ratio, score_cutoff=60, processor=None) - else: - result = [] - + result = process.extract(default_process(string), choices, scorer=fuzz.ratio, score_cutoff=60, processor=None) return HelpQueryNotFound(f'Query "{string}" not found.', dict(result)) async def subcommand_not_found(self, command: Command, string: str) -> "HelpQueryNotFound": diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 1b1243118..fc3c2c61e 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -5,7 +5,7 @@ import textwrap from collections import defaultdict from typing import Any, DefaultDict, Dict, Mapping, Optional, Tuple, Union -import fuzzywuzzy +import rapidfuzz from discord import AllowedMentions, Colour, Embed, Guild, Message, Role from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role @@ -117,9 +117,9 @@ class Information(Cog): parsed_roles.add(role_name) continue - match = fuzzywuzzy.process.extractOne( + match = rapidfuzz.process.extractOne( role_name, all_roles, score_cutoff=80, - scorer=fuzzywuzzy.fuzz.ratio + scorer=rapidfuzz.fuzz.ratio ) if not match: diff --git a/poetry.lock b/poetry.lock index 2041824e2..9c9379ebb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -454,17 +454,6 @@ python-versions = "*" [package.dependencies] pycodestyle = ">=2.0.0,<3.0.0" -[[package]] -name = "fuzzywuzzy" -version = "0.18.0" -description = "Fuzzy string matching in python" -category = "main" -optional = false -python-versions = "*" - -[package.extras] -speedup = ["python-levenshtein (>=0.12)"] - [[package]] name = "hiredis" version = "2.0.0" @@ -497,11 +486,11 @@ license = ["editdistance-s"] [[package]] name = "idna" -version = "3.2" +version = "2.10" description = "Internationalized Domain Names in Applications (IDNA)" category = "main" optional = false -python-versions = ">=3.5" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "iniconfig" @@ -587,11 +576,11 @@ python-versions = ">=3.5" [[package]] name = "packaging" -version = "20.9" +version = "21.0" description = "Core utilities for Python packages" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" [package.dependencies] pyparsing = ">=2.0.2" @@ -609,13 +598,14 @@ codegen = ["lxml"] [[package]] name = "pep8-naming" -version = "0.11.1" +version = "0.12.0" description = "Check PEP-8 naming conventions, plugin for flake8" category = "dev" optional = false python-versions = "*" [package.dependencies] +flake8 = ">=3.9.1" flake8-polyfill = ">=1.0.2,<2" [[package]] @@ -844,6 +834,14 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +[[package]] +name = "rapidfuzz" +version = "1.4.1" +description = "rapid fuzzy string matching" +category = "main" +optional = false +python-versions = ">=3.5" + [[package]] name = "redis" version = "3.5.3" @@ -865,14 +863,20 @@ python-versions = "*" [[package]] name = "requests" -version = "2.15.1" +version = "2.25.1" description = "Python HTTP for Humans." category = "dev" optional = false -python-versions = "*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.dependencies] +certifi = ">=2017.4.17" +chardet = ">=3.0.2,<5" +idna = ">=2.5,<3" +urllib3 = ">=1.21.1,<1.27" [package.extras] -security = ["cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)"] +security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] [[package]] @@ -1026,7 +1030,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "feec7372374cc4025f407b64b2e5b45c2c9c8d49c4538b91dc372f2bad89a624" +content-hash = "c7ea9fa5c2dc62eebba817dc0c98c58cfed4cf298b12a1b86a157e63d0882ef9" [metadata.files] aio-pika = [ @@ -1303,10 +1307,6 @@ flake8-tidy-imports = [ flake8-todo = [ {file = "flake8-todo-0.7.tar.gz", hash = "sha256:6e4c5491ff838c06fe5a771b0e95ee15fc005ca57196011011280fc834a85915"}, ] -fuzzywuzzy = [ - {file = "fuzzywuzzy-0.18.0-py2.py3-none-any.whl", hash = "sha256:928244b28db720d1e0ee7587acf660ea49d7e4c632569cad4f1cd7e68a5f0993"}, - {file = "fuzzywuzzy-0.18.0.tar.gz", hash = "sha256:45016e92264780e58972dca1b3d939ac864b78437422beecebb3095f8efd00e8"}, -] hiredis = [ {file = "hiredis-2.0.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b4c8b0bc5841e578d5fb32a16e0c305359b987b850a06964bd5a62739d688048"}, {file = "hiredis-2.0.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0adea425b764a08270820531ec2218d0508f8ae15a448568109ffcae050fee26"}, @@ -1359,8 +1359,8 @@ identify = [ {file = "identify-2.2.10.tar.gz", hash = "sha256:5b41f71471bc738e7b586308c3fca172f78940195cb3bf6734c1e66fdac49306"}, ] idna = [ - {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, - {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, + {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, + {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -1477,16 +1477,16 @@ ordered-set = [ {file = "ordered-set-4.0.2.tar.gz", hash = "sha256:ba93b2df055bca202116ec44b9bead3df33ea63a7d5827ff8e16738b97f33a95"}, ] packaging = [ - {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, - {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, + {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"}, + {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"}, ] pamqp = [ {file = "pamqp-2.3.0-py2.py3-none-any.whl", hash = "sha256:2f81b5c186f668a67f165193925b6bfd83db4363a6222f599517f29ecee60b02"}, {file = "pamqp-2.3.0.tar.gz", hash = "sha256:5cd0f5a85e89f20d5f8e19285a1507788031cfca4a9ea6f067e3cf18f5e294e8"}, ] pep8-naming = [ - {file = "pep8-naming-0.11.1.tar.gz", hash = "sha256:a1dd47dd243adfe8a83616e27cf03164960b507530f155db94e10b36a6cd6724"}, - {file = "pep8_naming-0.11.1-py2.py3-none-any.whl", hash = "sha256:f43bfe3eea7e0d73e8b5d07d6407ab47f2476ccaeff6937c84275cd30b016738"}, + {file = "pep8-naming-0.12.0.tar.gz", hash = "sha256:1f9a3ecb2f3fd83240fd40afdd70acc89695c49c333413e49788f93b61827e12"}, + {file = "pep8_naming-0.12.0-py2.py3-none-any.whl", hash = "sha256:2321ac2b7bf55383dd19a6a9c8ae2ebf05679699927a3af33e60dd7d337099d3"}, ] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, @@ -1641,6 +1641,69 @@ pyyaml = [ {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] +rapidfuzz = [ + {file = "rapidfuzz-1.4.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:72878878d6744883605b5453c382361716887e9e552f677922f76d93d622d8cb"}, + {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:56a67a5b3f783e9af73940f6945366408b3a2060fc6ab18466e5a2894fd85617"}, + {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f5d396b64f8ae3a793633911a1fb5d634ac25bf8f13d440139fa729131be42d8"}, + {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:4990698233e7eda7face7c09f5874a09760c7524686045cbb10317e3a7f3225f"}, + {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:a87e212855b18a951e79ec71d71dbd856d98cd2019d0c2bd46ec30688a8aa68a"}, + {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1897d2ef03f5b51bc19bdb2d0398ae968766750fa319843733f0a8f12ddde986"}, + {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2014_ppc64le.whl", hash = "sha256:e1fc4fd219057f5f1fa40bb9bc5e880f8ef45bf19350d4f5f15ca2ce7f61c99b"}, + {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2014_s390x.whl", hash = "sha256:21300c4d048798985c271a8bf1ed1611902ebd4479fcacda1a3eaaebbad2f744"}, + {file = "rapidfuzz-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:d2659967c6ac74211a87a1109e79253e4bc179641057c64800ef4e2dc0534fdb"}, + {file = "rapidfuzz-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:26ac4bfe564c516e053fc055f1543d2b2433338806738c7582e1f75ed0485f7e"}, + {file = "rapidfuzz-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3b485c98ad1ce3c04556f65aaab5d6d6d72121cde656d43505169c71ae956476"}, + {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:59db06356eaf22c83f44b0dded964736cbb137291cdf2cf7b4974c0983b94932"}, + {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fef95249af9a535854b617a68788c38cd96308d97ee14d44bc598cc73e986167"}, + {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:7d8c186e8270e103d339b26ef498581cf3178470ccf238dfd5fd0e47d80e4c7d"}, + {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:9246b9c5c8992a83a08ac7813c8bbff2e674ad0b681f9b3fb1ec7641eff6c21f"}, + {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:f58c17f7a82b1bcc2ce304942cae14287223e6b6eead7071241273da7d9b9770"}, + {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:ed708620b23a09ac52eaaec0761943c1bbc9a62d19ecd2feb4da8c3f79ef9d37"}, + {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:bdec9ae5fd8a8d4d8813b4aac3505c027b922b4033a32a7aab66a9b2f03a7b47"}, + {file = "rapidfuzz-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:fc668fd706ad1162ce14f26ca2957b4690d47770d23609756536c918a855ced0"}, + {file = "rapidfuzz-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f9f35df5dd9b02669ff6b1d4a386607ff56982c86a7e57d95eb08c6afbab4ddd"}, + {file = "rapidfuzz-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8427310ea29ce2968e1c6f6779ae5a458b3a4984f9150fc4d16f92b96456f848"}, + {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1430dc745476e3798742ad835f61f6e6bf5d3e9a22cf9cd0288b28b7440a9872"}, + {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1d20311da611c8f4638a09e2bc5e04b327bae010cb265ef9628d9c13c6d5da7b"}, + {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7881965e428cf6fe248d6e702e6d5857da02278ab9b21313bee717c080e443e"}, + {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:f76c965f15861ec4d39e904bd65b84a39121334439ac17bfb8b900d1e6779a93"}, + {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:61167f989415e701ac379de247e6b0a21ea62afc86c54d8a79f485b4f0173c02"}, + {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:645cfb9456229f0bd5752b3eda69f221d825fbb8cbb8855433516bc185111506"}, + {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:c28be57c9bc47b3d7f484340fab1bec8ed4393dee1090892c2774a4584435eb8"}, + {file = "rapidfuzz-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:3c94b6d3513c693f253ff762112cc4580d3bd377e4abacb96af31a3d606fbe14"}, + {file = "rapidfuzz-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:506d50a066451502ee2f8bf016bc3ba3e3b04eede7a4059d7956248e2dd96179"}, + {file = "rapidfuzz-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:80b375098658bb3db14215a975d354f6573d3943ac2ae0c4627c7760d57ce075"}, + {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ba8f7cbd8fdbd3ae115f4484888f3cb94bc2ac7cbd4eb1ca95a3d4f874261ff8"}, + {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:5fa8570720b0fdfc52f24f5663d66c52ea88ba19cb8b1ff6a39a8bc0b925b33b"}, + {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:f35c8a4c690447fd335bfd77df4da42dfea37cfa06a8ecbf22543d86dc720e12"}, + {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:27f9eef48e212d73e78f0f5ceedc62180b68f6a25fa0752d2ccfaedc3a840bec"}, + {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:31e99216e2a04aec4f281d472b28a683921f1f669a429cf605d11526623eaeed"}, + {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:f22bf7ba6eddd59764457f74c637ab5c3ed976c5fcfaf827e1d320cc0478e12b"}, + {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:c43ddb354abd00e56f024ce80affb3023fa23206239bb81916d5877cba7f2d1e"}, + {file = "rapidfuzz-1.4.1-cp38-cp38-win32.whl", hash = "sha256:62c1f4ac20c8019ce8d481fb27235306ef3912a8d0b9a60b17905699f43ff072"}, + {file = "rapidfuzz-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:2963f356c70b710dc6337b012ec976ce2fc2b81c2a9918a686838fead6eb4e1d"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c07f301fd549b266410654850c6918318d7dcde8201350e9ac0819f0542cf147"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa4c8b6fc7e93e3a3fb9be9566f1fe7ef920735eadcee248a0d70f3ca8941341"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c200bd813bbd3b146ba0fd284a9ad314bbad9d95ed542813273bdb9d0ee4e796"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2cccc84e1f0c6217747c09cafe93164e57d3644e18a334845a2dfbdd2073cd2c"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f2033e3d61d1e498f618123b54dc7436d50510b0d18fd678d867720e8d7b2f23"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:26b7f48b3ddd9d97cf8482a88f0f6cba47ac13ff16e63386ea7ce06178174770"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:bf18614f87fe3bfff783f0a3d0fad0eb59c92391e52555976e55570a651d2330"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:8cb5c2502ff06028a1468bdf61323b53cc3a37f54b5d62d62c5371795b81086a"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:f37f80c1541d6e0a30547261900086b8c0bac519ebc12c9cd6b61a9a43a7e195"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:c13cd1e840aa93639ac1d131fbfa740a609fd20dfc2a462d5cd7bce747a2398d"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-win32.whl", hash = "sha256:0ec346f271e96c485716c091c8b0b78ba52da33f7c6ebb52a349d64094566c2d"}, + {file = "rapidfuzz-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:5208ce1b1989a10e6fc5b5ef5d0bb7d1ffe5408838f3106abde241aff4dab08c"}, + {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4fa195ea9ca35bacfa2a4319c6d4ab03aa6a283ad2089b70d2dfa0f6a7d9c1bc"}, + {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:6e336cfd8103b0b38e107e01502e9d6bf7c7f04e49b970fb11a4bf6c7a932b94"}, + {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:c798c5b87efe8a7e63f408e07ff3bc03ba8b94f4498a89b48eaab3a9f439d52c"}, + {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-win32.whl", hash = "sha256:bb16a10b40f5bd3c645f7748fbd36f49699a03f550c010a2c665905cc8937de8"}, + {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2278001924031d9d75f821bff2c5fef565c8376f252562e04d8eec8857475c36"}, + {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-manylinux1_x86_64.whl", hash = "sha256:a89d11f3b5da35fdf3e839186203b9367d56e2be792e8dccb098f47634ec6eb9"}, + {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:f8c79cd11b4778d387366a59aa747f5268433f9d68be37b00d16f4fb08fdf850"}, + {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-win32.whl", hash = "sha256:4364db793ed4b439f9dd28a335bee14e2a828283d3b93c2d2686cc645eeafdd5"}, + {file = "rapidfuzz-1.4.1.tar.gz", hash = "sha256:de20550178376d21bfe1b34a7dc42ab107bb282ef82069cf6dfe2805a0029e26"}, +] redis = [ {file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"}, {file = "redis-3.5.3.tar.gz", hash = "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2"}, @@ -1689,8 +1752,8 @@ regex = [ {file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"}, ] requests = [ - {file = "requests-2.15.1-py2.py3-none-any.whl", hash = "sha256:ff753b2196cd18b1bbeddc9dcd5c864056599f7a7d9a4fb5677e723efa2b7fb9"}, - {file = "requests-2.15.1.tar.gz", hash = "sha256:e5659b9315a0610505e050bb7190bf6fa2ccee1ac295f2b760ef9d8a03ebbb2e"}, + {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, + {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, ] sentry-sdk = [ {file = "sentry-sdk-0.20.3.tar.gz", hash = "sha256:4ae8d1ced6c67f1c8ea51d82a16721c166c489b76876c9f2c202b8a50334b237"}, diff --git a/pyproject.toml b/pyproject.toml index c76bb47d6..36db20366 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ deepdiff = "~=4.0" "discord.py" = "~=1.7.3" emoji = "~=0.6" feedparser = "~=6.0.2" -fuzzywuzzy = "~=0.17" +rapidfuzz = "~=1.4" lxml = "~=4.4" markdownify = "==0.6.1" more_itertools = "~=8.2" -- cgit v1.2.3 From 3a0ea4e9543e80f39e7db81c1ec3ac949660bb2f Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Wed, 7 Jul 2021 17:37:04 +0300 Subject: Drops AIOPing Dependency Drops aioping as a dependency for the ping command since it's licenced under GPL2. Substitutes the site ping with a health-check and status to compensate. Signed-off-by: Hassan Abouelela --- bot/exts/utils/ping.py | 28 +++++++++++++--------------- poetry.lock | 18 +----------------- pyproject.toml | 1 - 3 files changed, 14 insertions(+), 33 deletions(-) diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index 750ff46d2..58485fc34 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -1,18 +1,16 @@ -import socket -import urllib.parse from datetime import datetime -import aioping +from aiohttp import client_exceptions from discord import Embed from discord.ext import commands from bot.bot import Bot -from bot.constants import Channels, Emojis, STAFF_ROLES, URLs +from bot.constants import Channels, STAFF_ROLES, URLs from bot.decorators import in_whitelist DESCRIPTIONS = ( "Command processing time", - "Python Discord website latency", + "Python Discord website status", "Discord API latency" ) ROUND_LATENCY = 3 @@ -41,23 +39,23 @@ class Latency(commands.Cog): bot_ping = f"{bot_ping:.{ROUND_LATENCY}f} ms" try: - url = urllib.parse.urlparse(URLs.site_schema + URLs.site).hostname - try: - delay = await aioping.ping(url, family=socket.AddressFamily.AF_INET) * 1000 - site_ping = f"{delay:.{ROUND_LATENCY}f} ms" - except OSError: - # Some machines do not have permission to run ping - site_ping = "Permission denied, could not ping." + request = await self.bot.http_session.get(f"{URLs.site_api_schema}{URLs.site_api}/healthcheck") + request.raise_for_status() + site_status = "Healthy" - except TimeoutError: - site_ping = f"{Emojis.cross_mark} Connection timed out." + except client_exceptions.ClientResponseError as e: + """The site returned an unexpected response.""" + site_status = f"The site returned an error in the response: ({e.status}) {e}" + except client_exceptions.ClientConnectionError: + """Something went wrong with the connection.""" + site_status = "Could not establish connection with the site." # Discord Protocol latency return value is in seconds, must be multiplied by 1000 to get milliseconds. discord_ping = f"{self.bot.latency * 1000:.{ROUND_LATENCY}f} ms" embed = Embed(title="Pong!") - for desc, latency in zip(DESCRIPTIONS, [bot_ping, site_ping, discord_ping]): + for desc, latency in zip(DESCRIPTIONS, [bot_ping, site_status, discord_ping]): embed.add_field(name=desc, value=latency, inline=False) await ctx.send(embed=embed) diff --git a/poetry.lock b/poetry.lock index 9c9379ebb..dac277ed8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -43,18 +43,6 @@ yarl = ">=1.0,<2.0" [package.extras] speedups = ["aiodns", "brotlipy", "cchardet"] -[[package]] -name = "aioping" -version = "0.3.1" -description = "Asyncio ping implementation" -category = "main" -optional = false -python-versions = "*" - -[package.dependencies] -aiodns = "*" -async-timeout = "*" - [[package]] name = "aioredis" version = "1.3.1" @@ -1030,7 +1018,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "c7ea9fa5c2dc62eebba817dc0c98c58cfed4cf298b12a1b86a157e63d0882ef9" +content-hash = "85160036e3b07c9d5d24a32302462591e82cc3bf3d5490b87550d9c26bc5648d" [metadata.files] aio-pika = [ @@ -1080,10 +1068,6 @@ aiohttp = [ {file = "aiohttp-3.7.4.post0-cp39-cp39-win_amd64.whl", hash = "sha256:02f46fc0e3c5ac58b80d4d56eb0a7c7d97fcef69ace9326289fb9f1955e65cfe"}, {file = "aiohttp-3.7.4.post0.tar.gz", hash = "sha256:493d3299ebe5f5a7c66b9819eacdcfbbaaf1a8e84911ddffcdc48888497afecf"}, ] -aioping = [ - {file = "aioping-0.3.1-py3-none-any.whl", hash = "sha256:8900ef2f5a589ba0c12aaa9c2d586f5371820d468d21b374ddb47ef5fc8f297c"}, - {file = "aioping-0.3.1.tar.gz", hash = "sha256:f983d86acab3a04c322731ce88d42c55d04d2842565fc8532fe10c838abfd275"}, -] aioredis = [ {file = "aioredis-1.3.1-py3-none-any.whl", hash = "sha256:b61808d7e97b7cd5a92ed574937a079c9387fdadd22bfbfa7ad2fd319ecc26e3"}, {file = "aioredis-1.3.1.tar.gz", hash = "sha256:15f8af30b044c771aee6787e5ec24694c048184c7b9e54c3b60c750a4b93273a"}, diff --git a/pyproject.toml b/pyproject.toml index 36db20366..8eac504c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,6 @@ python = "3.9.*" aio-pika = "~=6.1" aiodns = "~=2.0" aiohttp = "~=3.7" -aioping = "~=0.3.1" aioredis = "~=1.3.1" arrow = "~=1.0.3" async-rediscache = { version = "~=0.1.2", extras = ["fakeredis"] } -- cgit v1.2.3 From 2ff595670b0603fe9e97d16ffcc2c04457fea1c5 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Wed, 7 Jul 2021 18:21:06 +0300 Subject: Fixes N818 Compliance Renames a couple exceptions to include the error suffix, as enforced by N818. Signed-off-by: Hassan Abouelela --- bot/errors.py | 2 +- bot/exts/backend/error_handler.py | 4 ++-- bot/exts/moderation/infraction/_utils.py | 4 ++-- bot/pagination.py | 4 ++-- tests/bot/exts/backend/test_error_handler.py | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/bot/errors.py b/bot/errors.py index 3544c6320..46efb6d4f 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -22,7 +22,7 @@ class LockedResourceError(RuntimeError): ) -class InvalidInfractedUser(Exception): +class InvalidInfractedUserError(Exception): """ Exception raised upon attempt of infracting an invalid user. diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index d8de177f5..7ef55af45 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -10,7 +10,7 @@ from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Colours, Icons, MODERATION_ROLES from bot.converters import TagNameConverter -from bot.errors import InvalidInfractedUser, LockedResourceError +from bot.errors import InvalidInfractedUserError, LockedResourceError from bot.utils.checks import ContextCheckFailure log = logging.getLogger(__name__) @@ -76,7 +76,7 @@ class ErrorHandler(Cog): await self.handle_api_error(ctx, e.original) elif isinstance(e.original, LockedResourceError): await ctx.send(f"{e.original} Please wait for it to finish and try again later.") - elif isinstance(e.original, InvalidInfractedUser): + elif isinstance(e.original, InvalidInfractedUserError): await ctx.send(f"Cannot infract that user. {e.original.reason}") else: await self.handle_unexpected_error(ctx, e.original) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index e4eb7f79c..adbc641fa 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -7,7 +7,7 @@ from discord.ext.commands import Context from bot.api import ResponseCodeError from bot.constants import Colours, Icons -from bot.errors import InvalidInfractedUser +from bot.errors import InvalidInfractedUserError log = logging.getLogger(__name__) @@ -85,7 +85,7 @@ async def post_infraction( """Posts an infraction to the API.""" if isinstance(user, (discord.Member, discord.User)) and user.bot: log.trace(f"Posting of {infr_type} infraction for {user} to the API aborted. User is a bot.") - raise InvalidInfractedUser(user) + raise InvalidInfractedUserError(user) log.trace(f"Posting {infr_type} infraction for {user} to the API.") diff --git a/bot/pagination.py b/bot/pagination.py index 1c5b94b07..fbab74021 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -22,7 +22,7 @@ PAGINATION_EMOJI = (FIRST_EMOJI, LEFT_EMOJI, RIGHT_EMOJI, LAST_EMOJI, DELETE_EMO log = logging.getLogger(__name__) -class EmptyPaginatorEmbed(Exception): +class EmptyPaginatorEmbedError(Exception): """Raised when attempting to paginate with empty contents.""" pass @@ -233,7 +233,7 @@ class LinePaginator(Paginator): if not lines: if exception_on_empty_embed: log.exception("Pagination asked for empty lines iterable") - raise EmptyPaginatorEmbed("No lines to paginate") + raise EmptyPaginatorEmbedError("No lines to paginate") log.debug("No lines to add to paginator, adding '(nothing to display)' message") lines.append("(nothing to display)") diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index bd4fb5942..944cef6ca 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, call, patch from discord.ext.commands import errors from bot.api import ResponseCodeError -from bot.errors import InvalidInfractedUser, LockedResourceError +from bot.errors import InvalidInfractedUserError, LockedResourceError from bot.exts.backend.error_handler import ErrorHandler, setup from bot.exts.info.tags import Tags from bot.exts.moderation.silence import Silence @@ -130,7 +130,7 @@ class ErrorHandlerTests(unittest.IsolatedAsyncioTestCase): "expect_mock_call": "send" }, { - "args": (self.ctx, errors.CommandInvokeError(InvalidInfractedUser(self.ctx.author))), + "args": (self.ctx, errors.CommandInvokeError(InvalidInfractedUserError(self.ctx.author))), "expect_mock_call": "send" } ) -- cgit v1.2.3 From e6fed40da875a3866d8f2156d1a381faabab5ef7 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Wed, 7 Jul 2021 19:58:15 +0300 Subject: Prevents Blocking In Ping Command Makes the network fetch asynchronous to prevent blocking the program. Signed-off-by: Hassan Abouelela --- bot/exts/utils/ping.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index 58485fc34..c6d7bd900 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -39,9 +39,9 @@ class Latency(commands.Cog): bot_ping = f"{bot_ping:.{ROUND_LATENCY}f} ms" try: - request = await self.bot.http_session.get(f"{URLs.site_api_schema}{URLs.site_api}/healthcheck") - request.raise_for_status() - site_status = "Healthy" + async with self.bot.http_session.get(f"{URLs.site_api_schema}{URLs.site_api}/healthcheck") as request: + request.raise_for_status() + site_status = "Healthy" except client_exceptions.ClientResponseError as e: """The site returned an unexpected response.""" -- cgit v1.2.3 From fb9cbe434fc4531d117e6b8bdbd778dc4e9803a5 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Wed, 7 Jul 2021 22:24:21 +0300 Subject: Create events ext, prepare jams cog for file split --- bot/exts/events/__init__.py | 0 bot/exts/events/code_jams/__init__.py | 8 ++ bot/exts/events/code_jams/_cog.py | 176 ++++++++++++++++++++++++++++++++ bot/exts/filters/antimalware.py | 2 +- bot/exts/filters/antispam.py | 2 +- bot/exts/filters/filtering.py | 2 +- bot/exts/utils/jams.py | 176 -------------------------------- tests/bot/exts/events/__init__.py | 0 tests/bot/exts/events/test_code_jams.py | 174 +++++++++++++++++++++++++++++++ tests/bot/exts/utils/test_jams.py | 174 ------------------------------- 10 files changed, 361 insertions(+), 353 deletions(-) create mode 100644 bot/exts/events/__init__.py create mode 100644 bot/exts/events/code_jams/__init__.py create mode 100644 bot/exts/events/code_jams/_cog.py delete mode 100644 bot/exts/utils/jams.py create mode 100644 tests/bot/exts/events/__init__.py create mode 100644 tests/bot/exts/events/test_code_jams.py delete mode 100644 tests/bot/exts/utils/test_jams.py diff --git a/bot/exts/events/__init__.py b/bot/exts/events/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/exts/events/code_jams/__init__.py b/bot/exts/events/code_jams/__init__.py new file mode 100644 index 000000000..16e81e365 --- /dev/null +++ b/bot/exts/events/code_jams/__init__.py @@ -0,0 +1,8 @@ +from bot.bot import Bot + + +def setup(bot: Bot) -> None: + """Load the CodeJams cog.""" + from bot.exts.events.code_jams._cog import CodeJams + + bot.add_cog(CodeJams(bot)) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py new file mode 100644 index 000000000..87ae847f6 --- /dev/null +++ b/bot/exts/events/code_jams/_cog.py @@ -0,0 +1,176 @@ +import csv +import logging +import typing as t +from collections import defaultdict + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Categories, Channels, Emojis, Roles + +log = logging.getLogger(__name__) + +MAX_CHANNELS = 50 +CATEGORY_NAME = "Code Jam" +TEAM_LEADERS_COLOUR = 0x11806a + + +class CodeJams(commands.Cog): + """Manages the code-jam related parts of our server.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @commands.group() + @commands.has_any_role(Roles.admins) + async def codejam(self, ctx: commands.Context) -> None: + """A Group of commands for managing Code Jams.""" + if ctx.invoked_subcommand is None: + await ctx.send_help(ctx.command) + + @codejam.command() + async def create(self, ctx: commands.Context, csv_file: t.Optional[str]) -> None: + """ + Create code-jam teams from a CSV file or a link to one, specifying the team names, leaders and members. + + The CSV file must have 3 columns: 'Team Name', 'Team Member Discord ID', and 'Team Leader'. + + This will create the text channels for the teams, and give the team leaders their roles. + """ + async with ctx.typing(): + if csv_file: + async with self.bot.http_session.get(csv_file) as response: + if response.status != 200: + await ctx.send(f"Got a bad response from the URL: {response.status}") + return + + csv_file = await response.text() + + elif ctx.message.attachments: + csv_file = (await ctx.message.attachments[0].read()).decode("utf8") + else: + raise commands.BadArgument("You must include either a CSV file or a link to one.") + + teams = defaultdict(list) + reader = csv.DictReader(csv_file.splitlines()) + + for row in reader: + member = ctx.guild.get_member(int(row["Team Member Discord ID"])) + + if member is None: + log.trace(f"Got an invalid member ID: {row['Team Member Discord ID']}") + continue + + teams[row["Team Name"]].append((member, row["Team Leader"].upper() == "Y")) + + team_leaders = await ctx.guild.create_role(name="Code Jam Team Leaders", colour=TEAM_LEADERS_COLOUR) + + for team_name, members in teams.items(): + await self.create_team_channel(ctx.guild, team_name, members, team_leaders) + + await self.create_team_leader_channel(ctx.guild, team_leaders) + await ctx.send(f"{Emojis.check_mark} Created Code Jam with {len(teams)} teams.") + + async def get_category(self, guild: discord.Guild) -> discord.CategoryChannel: + """ + Return a code jam category. + + If all categories are full or none exist, create a new category. + """ + for category in guild.categories: + if category.name == CATEGORY_NAME and len(category.channels) < MAX_CHANNELS: + return category + + return await self.create_category(guild) + + async def create_category(self, guild: discord.Guild) -> discord.CategoryChannel: + """Create a new code jam category and return it.""" + log.info("Creating a new code jam category.") + + category_overwrites = { + guild.default_role: discord.PermissionOverwrite(read_messages=False), + guild.me: discord.PermissionOverwrite(read_messages=True) + } + + category = await guild.create_category_channel( + CATEGORY_NAME, + overwrites=category_overwrites, + reason="It's code jam time!" + ) + + await self.send_status_update( + guild, f"Created a new category with the ID {category.id} for this Code Jam's team channels." + ) + + return category + + @staticmethod + def get_overwrites( + members: list[tuple[discord.Member, bool]], + guild: discord.Guild, + ) -> dict[t.Union[discord.Member, discord.Role], discord.PermissionOverwrite]: + """Get code jam team channels permission overwrites.""" + team_channel_overwrites = { + guild.default_role: discord.PermissionOverwrite(read_messages=False), + guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite(read_messages=True) + } + + for member, _ in members: + team_channel_overwrites[member] = discord.PermissionOverwrite( + read_messages=True + ) + + return team_channel_overwrites + + async def create_team_channel( + self, + guild: discord.Guild, + team_name: str, + members: list[tuple[discord.Member, bool]], + team_leaders: discord.Role + ) -> None: + """Create the team's text channel.""" + await self.add_team_leader_roles(members, team_leaders) + + # Get permission overwrites and category + team_channel_overwrites = self.get_overwrites(members, guild) + code_jam_category = await self.get_category(guild) + + # Create a text channel for the team + await code_jam_category.create_text_channel( + team_name, + overwrites=team_channel_overwrites, + ) + + async def create_team_leader_channel(self, guild: discord.Guild, team_leaders: discord.Role) -> None: + """Create the Team Leader Chat channel for the Code Jam team leaders.""" + category: discord.CategoryChannel = guild.get_channel(Categories.summer_code_jam) + + team_leaders_chat = await category.create_text_channel( + name="team-leaders-chat", + overwrites={ + guild.default_role: discord.PermissionOverwrite(read_messages=False), + team_leaders: discord.PermissionOverwrite(read_messages=True) + } + ) + + await self.send_status_update(guild, f"Created {team_leaders_chat.mention} in the {category} category.") + + async def send_status_update(self, guild: discord.Guild, message: str) -> None: + """Inform the events lead with a status update when the command is ran.""" + channel: discord.TextChannel = guild.get_channel(Channels.code_jam_planning) + + await channel.send(f"<@&{Roles.events_lead}>\n\n{message}") + + @staticmethod + async def add_team_leader_roles(members: list[tuple[discord.Member, bool]], team_leaders: discord.Role) -> None: + """Assign team leader role, the jammer role and their team role.""" + for member, is_leader in members: + if is_leader: + await member.add_roles(team_leaders) + + +def setup(bot: Bot) -> None: + """Load the CodeJams cog.""" + bot.add_cog(CodeJams(bot)) diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index 4c4836c88..3f6213db3 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/antimalware.py @@ -7,7 +7,7 @@ from discord.ext.commands import Cog from bot.bot import Bot from bot.constants import Channels, Filter, URLs -from bot.exts.utils.jams import CATEGORY_NAME as JAM_CATEGORY_NAME +from bot.exts.events.code_jams._cog import CATEGORY_NAME as JAM_CATEGORY_NAME log = logging.getLogger(__name__) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 48c3aa5a6..124905cb4 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -17,8 +17,8 @@ from bot.constants import ( Guild as GuildConfig, Icons, ) from bot.converters import Duration +from bot.exts.events.code_jams._cog import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.exts.moderation.modlog import ModLog -from bot.exts.utils.jams import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.utils import lock, scheduling from bot.utils.messages import format_user, send_attachments diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 16aaf11cf..0810425e2 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -19,8 +19,8 @@ from bot.constants import ( Channels, Colours, Filter, Guild, Icons, URLs ) +from bot.exts.events.code_jams._cog import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.exts.moderation.modlog import ModLog -from bot.exts.utils.jams import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.utils.messages import format_user from bot.utils.regex import INVITE_RE from bot.utils.scheduling import Scheduler diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py deleted file mode 100644 index 87ae847f6..000000000 --- a/bot/exts/utils/jams.py +++ /dev/null @@ -1,176 +0,0 @@ -import csv -import logging -import typing as t -from collections import defaultdict - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Categories, Channels, Emojis, Roles - -log = logging.getLogger(__name__) - -MAX_CHANNELS = 50 -CATEGORY_NAME = "Code Jam" -TEAM_LEADERS_COLOUR = 0x11806a - - -class CodeJams(commands.Cog): - """Manages the code-jam related parts of our server.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @commands.group() - @commands.has_any_role(Roles.admins) - async def codejam(self, ctx: commands.Context) -> None: - """A Group of commands for managing Code Jams.""" - if ctx.invoked_subcommand is None: - await ctx.send_help(ctx.command) - - @codejam.command() - async def create(self, ctx: commands.Context, csv_file: t.Optional[str]) -> None: - """ - Create code-jam teams from a CSV file or a link to one, specifying the team names, leaders and members. - - The CSV file must have 3 columns: 'Team Name', 'Team Member Discord ID', and 'Team Leader'. - - This will create the text channels for the teams, and give the team leaders their roles. - """ - async with ctx.typing(): - if csv_file: - async with self.bot.http_session.get(csv_file) as response: - if response.status != 200: - await ctx.send(f"Got a bad response from the URL: {response.status}") - return - - csv_file = await response.text() - - elif ctx.message.attachments: - csv_file = (await ctx.message.attachments[0].read()).decode("utf8") - else: - raise commands.BadArgument("You must include either a CSV file or a link to one.") - - teams = defaultdict(list) - reader = csv.DictReader(csv_file.splitlines()) - - for row in reader: - member = ctx.guild.get_member(int(row["Team Member Discord ID"])) - - if member is None: - log.trace(f"Got an invalid member ID: {row['Team Member Discord ID']}") - continue - - teams[row["Team Name"]].append((member, row["Team Leader"].upper() == "Y")) - - team_leaders = await ctx.guild.create_role(name="Code Jam Team Leaders", colour=TEAM_LEADERS_COLOUR) - - for team_name, members in teams.items(): - await self.create_team_channel(ctx.guild, team_name, members, team_leaders) - - await self.create_team_leader_channel(ctx.guild, team_leaders) - await ctx.send(f"{Emojis.check_mark} Created Code Jam with {len(teams)} teams.") - - async def get_category(self, guild: discord.Guild) -> discord.CategoryChannel: - """ - Return a code jam category. - - If all categories are full or none exist, create a new category. - """ - for category in guild.categories: - if category.name == CATEGORY_NAME and len(category.channels) < MAX_CHANNELS: - return category - - return await self.create_category(guild) - - async def create_category(self, guild: discord.Guild) -> discord.CategoryChannel: - """Create a new code jam category and return it.""" - log.info("Creating a new code jam category.") - - category_overwrites = { - guild.default_role: discord.PermissionOverwrite(read_messages=False), - guild.me: discord.PermissionOverwrite(read_messages=True) - } - - category = await guild.create_category_channel( - CATEGORY_NAME, - overwrites=category_overwrites, - reason="It's code jam time!" - ) - - await self.send_status_update( - guild, f"Created a new category with the ID {category.id} for this Code Jam's team channels." - ) - - return category - - @staticmethod - def get_overwrites( - members: list[tuple[discord.Member, bool]], - guild: discord.Guild, - ) -> dict[t.Union[discord.Member, discord.Role], discord.PermissionOverwrite]: - """Get code jam team channels permission overwrites.""" - team_channel_overwrites = { - guild.default_role: discord.PermissionOverwrite(read_messages=False), - guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite(read_messages=True) - } - - for member, _ in members: - team_channel_overwrites[member] = discord.PermissionOverwrite( - read_messages=True - ) - - return team_channel_overwrites - - async def create_team_channel( - self, - guild: discord.Guild, - team_name: str, - members: list[tuple[discord.Member, bool]], - team_leaders: discord.Role - ) -> None: - """Create the team's text channel.""" - await self.add_team_leader_roles(members, team_leaders) - - # Get permission overwrites and category - team_channel_overwrites = self.get_overwrites(members, guild) - code_jam_category = await self.get_category(guild) - - # Create a text channel for the team - await code_jam_category.create_text_channel( - team_name, - overwrites=team_channel_overwrites, - ) - - async def create_team_leader_channel(self, guild: discord.Guild, team_leaders: discord.Role) -> None: - """Create the Team Leader Chat channel for the Code Jam team leaders.""" - category: discord.CategoryChannel = guild.get_channel(Categories.summer_code_jam) - - team_leaders_chat = await category.create_text_channel( - name="team-leaders-chat", - overwrites={ - guild.default_role: discord.PermissionOverwrite(read_messages=False), - team_leaders: discord.PermissionOverwrite(read_messages=True) - } - ) - - await self.send_status_update(guild, f"Created {team_leaders_chat.mention} in the {category} category.") - - async def send_status_update(self, guild: discord.Guild, message: str) -> None: - """Inform the events lead with a status update when the command is ran.""" - channel: discord.TextChannel = guild.get_channel(Channels.code_jam_planning) - - await channel.send(f"<@&{Roles.events_lead}>\n\n{message}") - - @staticmethod - async def add_team_leader_roles(members: list[tuple[discord.Member, bool]], team_leaders: discord.Role) -> None: - """Assign team leader role, the jammer role and their team role.""" - for member, is_leader in members: - if is_leader: - await member.add_roles(team_leaders) - - -def setup(bot: Bot) -> None: - """Load the CodeJams cog.""" - bot.add_cog(CodeJams(bot)) diff --git a/tests/bot/exts/events/__init__.py b/tests/bot/exts/events/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/bot/exts/events/test_code_jams.py b/tests/bot/exts/events/test_code_jams.py new file mode 100644 index 000000000..d7b8aa4d2 --- /dev/null +++ b/tests/bot/exts/events/test_code_jams.py @@ -0,0 +1,174 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, create_autospec + +from discord import CategoryChannel +from discord.ext.commands import BadArgument + +from bot.constants import Roles +from bot.exts.events.code_jams import _cog +from tests.helpers import ( + MockAttachment, MockBot, MockCategoryChannel, MockContext, + MockGuild, MockMember, MockRole, MockTextChannel +) + +TEST_CSV = b"""\ +Team Name,Team Member Discord ID,Team Leader +Annoyed Alligators,12345,Y +Annoyed Alligators,54321,N +Oscillating Otters,12358,Y +Oscillating Otters,74832,N +Oscillating Otters,19903,N +Annoyed Alligators,11111,N +""" + + +def get_mock_category(channel_count: int, name: str) -> CategoryChannel: + """Return a mocked code jam category.""" + category = create_autospec(CategoryChannel, spec_set=True, instance=True) + category.name = name + category.channels = [MockTextChannel() for _ in range(channel_count)] + + return category + + +class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): + """Tests for `codejam create` command.""" + + def setUp(self): + self.bot = MockBot() + self.admin_role = MockRole(name="Admins", id=Roles.admins) + self.command_user = MockMember([self.admin_role]) + self.guild = MockGuild([self.admin_role]) + self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) + self.cog = _cog.CodeJams(self.bot) + + async def test_message_without_attachments(self): + """If no link or attachments are provided, commands.BadArgument should be raised.""" + self.ctx.message.attachments = [] + + with self.assertRaises(BadArgument): + await self.cog.create(self.cog, self.ctx, None) + + async def test_result_sending(self): + """Should call `ctx.send` when everything goes right.""" + self.ctx.message.attachments = [MockAttachment()] + self.ctx.message.attachments[0].read = AsyncMock() + self.ctx.message.attachments[0].read.return_value = TEST_CSV + + team_leaders = MockRole() + + self.guild.get_member.return_value = MockMember() + + self.ctx.guild.create_role = AsyncMock() + self.ctx.guild.create_role.return_value = team_leaders + self.cog.create_team_channel = AsyncMock() + self.cog.create_team_leader_channel = AsyncMock() + self.cog.add_roles = AsyncMock() + + await self.cog.create(self.cog, self.ctx, None) + + self.cog.create_team_channel.assert_awaited() + self.cog.create_team_leader_channel.assert_awaited_once_with( + self.ctx.guild, team_leaders + ) + self.ctx.send.assert_awaited_once() + + async def test_link_returning_non_200_status(self): + """When the URL passed returns a non 200 status, it should send a message informing them.""" + self.bot.http_session.get.return_value = mock = MagicMock() + mock.status = 404 + await self.cog.create(self.cog, self.ctx, "https://not-a-real-link.com") + + self.ctx.send.assert_awaited_once() + + async def test_category_doesnt_exist(self): + """Should create a new code jam category.""" + subtests = ( + [], + [get_mock_category(_cog.MAX_CHANNELS, _cog.CATEGORY_NAME)], + [get_mock_category(_cog.MAX_CHANNELS - 2, "other")], + ) + + self.cog.send_status_update = AsyncMock() + + for categories in subtests: + self.cog.send_status_update.reset_mock() + self.guild.reset_mock() + self.guild.categories = categories + + with self.subTest(categories=categories): + actual_category = await self.cog.get_category(self.guild) + + self.cog.send_status_update.assert_called_once() + self.guild.create_category_channel.assert_awaited_once() + category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] + + self.assertFalse(category_overwrites[self.guild.default_role].read_messages) + self.assertTrue(category_overwrites[self.guild.me].read_messages) + self.assertEqual(self.guild.create_category_channel.return_value, actual_category) + + async def test_category_channel_exist(self): + """Should not try to create category channel.""" + expected_category = get_mock_category(_cog.MAX_CHANNELS - 2, _cog.CATEGORY_NAME) + self.guild.categories = [ + get_mock_category(_cog.MAX_CHANNELS - 2, "other"), + expected_category, + get_mock_category(0, _cog.CATEGORY_NAME), + ] + + actual_category = await self.cog.get_category(self.guild) + self.assertEqual(expected_category, actual_category) + + async def test_channel_overwrites(self): + """Should have correct permission overwrites for users and roles.""" + leader = (MockMember(), True) + members = [leader] + [(MockMember(), False) for _ in range(4)] + overwrites = self.cog.get_overwrites(members, self.guild) + + for member, _ in members: + self.assertTrue(overwrites[member].read_messages) + + async def test_team_channels_creation(self): + """Should create a text channel for a team.""" + team_leaders = MockRole() + members = [(MockMember(), True)] + [(MockMember(), False) for _ in range(5)] + category = MockCategoryChannel() + category.create_text_channel = AsyncMock() + + self.cog.get_overwrites = MagicMock() + self.cog.get_category = AsyncMock() + self.cog.get_category.return_value = category + self.cog.add_team_leader_roles = AsyncMock() + + await self.cog.create_team_channel(self.guild, "my-team", members, team_leaders) + self.cog.add_team_leader_roles.assert_awaited_once_with(members, team_leaders) + self.cog.get_overwrites.assert_called_once_with(members, self.guild) + self.cog.get_category.assert_awaited_once_with(self.guild) + + category.create_text_channel.assert_awaited_once_with( + "my-team", + overwrites=self.cog.get_overwrites.return_value + ) + + async def test_jam_roles_adding(self): + """Should add team leader role to leader and jam role to every team member.""" + leader_role = MockRole(name="Team Leader") + + leader = MockMember() + members = [(leader, True)] + [(MockMember(), False) for _ in range(4)] + await self.cog.add_team_leader_roles(members, leader_role) + + leader.add_roles.assert_awaited_once_with(leader_role) + for member, is_leader in members: + if not is_leader: + member.add_roles.assert_not_awaited() + + +class CodeJamSetup(unittest.TestCase): + """Test for `setup` function of `CodeJam` cog.""" + + def test_setup(self): + """Should call `bot.add_cog`.""" + bot = MockBot() + _cog.setup(bot) + bot.add_cog.assert_called_once() diff --git a/tests/bot/exts/utils/test_jams.py b/tests/bot/exts/utils/test_jams.py deleted file mode 100644 index 368a15476..000000000 --- a/tests/bot/exts/utils/test_jams.py +++ /dev/null @@ -1,174 +0,0 @@ -import unittest -from unittest.mock import AsyncMock, MagicMock, create_autospec - -from discord import CategoryChannel -from discord.ext.commands import BadArgument - -from bot.constants import Roles -from bot.exts.utils import jams -from tests.helpers import ( - MockAttachment, MockBot, MockCategoryChannel, MockContext, - MockGuild, MockMember, MockRole, MockTextChannel -) - -TEST_CSV = b"""\ -Team Name,Team Member Discord ID,Team Leader -Annoyed Alligators,12345,Y -Annoyed Alligators,54321,N -Oscillating Otters,12358,Y -Oscillating Otters,74832,N -Oscillating Otters,19903,N -Annoyed Alligators,11111,N -""" - - -def get_mock_category(channel_count: int, name: str) -> CategoryChannel: - """Return a mocked code jam category.""" - category = create_autospec(CategoryChannel, spec_set=True, instance=True) - category.name = name - category.channels = [MockTextChannel() for _ in range(channel_count)] - - return category - - -class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): - """Tests for `codejam create` command.""" - - def setUp(self): - self.bot = MockBot() - self.admin_role = MockRole(name="Admins", id=Roles.admins) - self.command_user = MockMember([self.admin_role]) - self.guild = MockGuild([self.admin_role]) - self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild) - self.cog = jams.CodeJams(self.bot) - - async def test_message_without_attachments(self): - """If no link or attachments are provided, commands.BadArgument should be raised.""" - self.ctx.message.attachments = [] - - with self.assertRaises(BadArgument): - await self.cog.create(self.cog, self.ctx, None) - - async def test_result_sending(self): - """Should call `ctx.send` when everything goes right.""" - self.ctx.message.attachments = [MockAttachment()] - self.ctx.message.attachments[0].read = AsyncMock() - self.ctx.message.attachments[0].read.return_value = TEST_CSV - - team_leaders = MockRole() - - self.guild.get_member.return_value = MockMember() - - self.ctx.guild.create_role = AsyncMock() - self.ctx.guild.create_role.return_value = team_leaders - self.cog.create_team_channel = AsyncMock() - self.cog.create_team_leader_channel = AsyncMock() - self.cog.add_roles = AsyncMock() - - await self.cog.create(self.cog, self.ctx, None) - - self.cog.create_team_channel.assert_awaited() - self.cog.create_team_leader_channel.assert_awaited_once_with( - self.ctx.guild, team_leaders - ) - self.ctx.send.assert_awaited_once() - - async def test_link_returning_non_200_status(self): - """When the URL passed returns a non 200 status, it should send a message informing them.""" - self.bot.http_session.get.return_value = mock = MagicMock() - mock.status = 404 - await self.cog.create(self.cog, self.ctx, "https://not-a-real-link.com") - - self.ctx.send.assert_awaited_once() - - async def test_category_doesnt_exist(self): - """Should create a new code jam category.""" - subtests = ( - [], - [get_mock_category(jams.MAX_CHANNELS, jams.CATEGORY_NAME)], - [get_mock_category(jams.MAX_CHANNELS - 2, "other")], - ) - - self.cog.send_status_update = AsyncMock() - - for categories in subtests: - self.cog.send_status_update.reset_mock() - self.guild.reset_mock() - self.guild.categories = categories - - with self.subTest(categories=categories): - actual_category = await self.cog.get_category(self.guild) - - self.cog.send_status_update.assert_called_once() - self.guild.create_category_channel.assert_awaited_once() - category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] - - self.assertFalse(category_overwrites[self.guild.default_role].read_messages) - self.assertTrue(category_overwrites[self.guild.me].read_messages) - self.assertEqual(self.guild.create_category_channel.return_value, actual_category) - - async def test_category_channel_exist(self): - """Should not try to create category channel.""" - expected_category = get_mock_category(jams.MAX_CHANNELS - 2, jams.CATEGORY_NAME) - self.guild.categories = [ - get_mock_category(jams.MAX_CHANNELS - 2, "other"), - expected_category, - get_mock_category(0, jams.CATEGORY_NAME), - ] - - actual_category = await self.cog.get_category(self.guild) - self.assertEqual(expected_category, actual_category) - - async def test_channel_overwrites(self): - """Should have correct permission overwrites for users and roles.""" - leader = (MockMember(), True) - members = [leader] + [(MockMember(), False) for _ in range(4)] - overwrites = self.cog.get_overwrites(members, self.guild) - - for member, _ in members: - self.assertTrue(overwrites[member].read_messages) - - async def test_team_channels_creation(self): - """Should create a text channel for a team.""" - team_leaders = MockRole() - members = [(MockMember(), True)] + [(MockMember(), False) for _ in range(5)] - category = MockCategoryChannel() - category.create_text_channel = AsyncMock() - - self.cog.get_overwrites = MagicMock() - self.cog.get_category = AsyncMock() - self.cog.get_category.return_value = category - self.cog.add_team_leader_roles = AsyncMock() - - await self.cog.create_team_channel(self.guild, "my-team", members, team_leaders) - self.cog.add_team_leader_roles.assert_awaited_once_with(members, team_leaders) - self.cog.get_overwrites.assert_called_once_with(members, self.guild) - self.cog.get_category.assert_awaited_once_with(self.guild) - - category.create_text_channel.assert_awaited_once_with( - "my-team", - overwrites=self.cog.get_overwrites.return_value - ) - - async def test_jam_roles_adding(self): - """Should add team leader role to leader and jam role to every team member.""" - leader_role = MockRole(name="Team Leader") - - leader = MockMember() - members = [(leader, True)] + [(MockMember(), False) for _ in range(4)] - await self.cog.add_team_leader_roles(members, leader_role) - - leader.add_roles.assert_awaited_once_with(leader_role) - for member, is_leader in members: - if not is_leader: - member.add_roles.assert_not_awaited() - - -class CodeJamSetup(unittest.TestCase): - """Test for `setup` function of `CodeJam` cog.""" - - def test_setup(self): - """Should call `bot.add_cog`.""" - bot = MockBot() - jams.setup(bot) - bot.add_cog.assert_called_once() -- cgit v1.2.3 From 698660004b13273371baefa1f41ce2f908a3431f Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 8 Jul 2021 02:16:33 +0300 Subject: Move jam channels creation to separate file The channel creations are static and clutter the cog class. We want to add more commands to the cog, so we move the static functions away to a separate file first. --- bot/exts/events/code_jams/_channels.py | 113 ++++++++++++++++++++++++++++++++ bot/exts/events/code_jams/_cog.py | 113 ++------------------------------ bot/exts/filters/antimalware.py | 2 +- bot/exts/filters/antispam.py | 2 +- bot/exts/filters/filtering.py | 2 +- tests/bot/exts/events/test_code_jams.py | 64 +++++++++--------- 6 files changed, 150 insertions(+), 146 deletions(-) create mode 100644 bot/exts/events/code_jams/_channels.py diff --git a/bot/exts/events/code_jams/_channels.py b/bot/exts/events/code_jams/_channels.py new file mode 100644 index 000000000..8b199a3c2 --- /dev/null +++ b/bot/exts/events/code_jams/_channels.py @@ -0,0 +1,113 @@ +import logging +import typing as t + +import discord + +from bot.constants import Categories, Channels, Roles + +log = logging.getLogger(__name__) + +MAX_CHANNELS = 50 +CATEGORY_NAME = "Code Jam" + + +async def _get_category(guild: discord.Guild) -> discord.CategoryChannel: + """ + Return a code jam category. + + If all categories are full or none exist, create a new category. + """ + for category in guild.categories: + if category.name == CATEGORY_NAME and len(category.channels) < MAX_CHANNELS: + return category + + return await _create_category(guild) + + +async def _create_category(guild: discord.Guild) -> discord.CategoryChannel: + """Create a new code jam category and return it.""" + log.info("Creating a new code jam category.") + + category_overwrites = { + guild.default_role: discord.PermissionOverwrite(read_messages=False), + guild.me: discord.PermissionOverwrite(read_messages=True) + } + + category = await guild.create_category_channel( + CATEGORY_NAME, + overwrites=category_overwrites, + reason="It's code jam time!" + ) + + await _send_status_update( + guild, f"Created a new category with the ID {category.id} for this Code Jam's team channels." + ) + + return category + + +def _get_overwrites( + members: list[tuple[discord.Member, bool]], + guild: discord.Guild, +) -> dict[t.Union[discord.Member, discord.Role], discord.PermissionOverwrite]: + """Get code jam team channels permission overwrites.""" + team_channel_overwrites = { + guild.default_role: discord.PermissionOverwrite(read_messages=False), + guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite(read_messages=True) + } + + for member, _ in members: + team_channel_overwrites[member] = discord.PermissionOverwrite( + read_messages=True + ) + + return team_channel_overwrites + + +async def create_team_channel( + guild: discord.Guild, + team_name: str, + members: list[tuple[discord.Member, bool]], + team_leaders: discord.Role +) -> None: + """Create the team's text channel.""" + await _add_team_leader_roles(members, team_leaders) + + # Get permission overwrites and category + team_channel_overwrites = _get_overwrites(members, guild) + code_jam_category = await _get_category(guild) + + # Create a text channel for the team + await code_jam_category.create_text_channel( + team_name, + overwrites=team_channel_overwrites, + ) + + +async def create_team_leader_channel(guild: discord.Guild, team_leaders: discord.Role) -> None: + """Create the Team Leader Chat channel for the Code Jam team leaders.""" + category: discord.CategoryChannel = guild.get_channel(Categories.summer_code_jam) + + team_leaders_chat = await category.create_text_channel( + name="team-leaders-chat", + overwrites={ + guild.default_role: discord.PermissionOverwrite(read_messages=False), + team_leaders: discord.PermissionOverwrite(read_messages=True) + } + ) + + await _send_status_update(guild, f"Created {team_leaders_chat.mention} in the {category} category.") + + +async def _send_status_update(guild: discord.Guild, message: str) -> None: + """Inform the events lead with a status update when the command is ran.""" + channel: discord.TextChannel = guild.get_channel(Channels.code_jam_planning) + + await channel.send(f"<@&{Roles.events_lead}>\n\n{message}") + + +async def _add_team_leader_roles(members: list[tuple[discord.Member, bool]], team_leaders: discord.Role) -> None: + """Assign team leader role, the jammer role and their team role.""" + for member, is_leader in members: + if is_leader: + await member.add_roles(team_leaders) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index 87ae847f6..2d0873de7 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -3,16 +3,14 @@ import logging import typing as t from collections import defaultdict -import discord from discord.ext import commands from bot.bot import Bot -from bot.constants import Categories, Channels, Emojis, Roles +from bot.constants import Emojis, Roles +from bot.exts.events.code_jams import _channels log = logging.getLogger(__name__) -MAX_CHANNELS = 50 -CATEGORY_NAME = "Code Jam" TEAM_LEADERS_COLOUR = 0x11806a @@ -67,110 +65,7 @@ class CodeJams(commands.Cog): team_leaders = await ctx.guild.create_role(name="Code Jam Team Leaders", colour=TEAM_LEADERS_COLOUR) for team_name, members in teams.items(): - await self.create_team_channel(ctx.guild, team_name, members, team_leaders) + await _channels.create_team_channel(ctx.guild, team_name, members, team_leaders) - await self.create_team_leader_channel(ctx.guild, team_leaders) + await _channels.create_team_leader_channel(ctx.guild, team_leaders) await ctx.send(f"{Emojis.check_mark} Created Code Jam with {len(teams)} teams.") - - async def get_category(self, guild: discord.Guild) -> discord.CategoryChannel: - """ - Return a code jam category. - - If all categories are full or none exist, create a new category. - """ - for category in guild.categories: - if category.name == CATEGORY_NAME and len(category.channels) < MAX_CHANNELS: - return category - - return await self.create_category(guild) - - async def create_category(self, guild: discord.Guild) -> discord.CategoryChannel: - """Create a new code jam category and return it.""" - log.info("Creating a new code jam category.") - - category_overwrites = { - guild.default_role: discord.PermissionOverwrite(read_messages=False), - guild.me: discord.PermissionOverwrite(read_messages=True) - } - - category = await guild.create_category_channel( - CATEGORY_NAME, - overwrites=category_overwrites, - reason="It's code jam time!" - ) - - await self.send_status_update( - guild, f"Created a new category with the ID {category.id} for this Code Jam's team channels." - ) - - return category - - @staticmethod - def get_overwrites( - members: list[tuple[discord.Member, bool]], - guild: discord.Guild, - ) -> dict[t.Union[discord.Member, discord.Role], discord.PermissionOverwrite]: - """Get code jam team channels permission overwrites.""" - team_channel_overwrites = { - guild.default_role: discord.PermissionOverwrite(read_messages=False), - guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite(read_messages=True) - } - - for member, _ in members: - team_channel_overwrites[member] = discord.PermissionOverwrite( - read_messages=True - ) - - return team_channel_overwrites - - async def create_team_channel( - self, - guild: discord.Guild, - team_name: str, - members: list[tuple[discord.Member, bool]], - team_leaders: discord.Role - ) -> None: - """Create the team's text channel.""" - await self.add_team_leader_roles(members, team_leaders) - - # Get permission overwrites and category - team_channel_overwrites = self.get_overwrites(members, guild) - code_jam_category = await self.get_category(guild) - - # Create a text channel for the team - await code_jam_category.create_text_channel( - team_name, - overwrites=team_channel_overwrites, - ) - - async def create_team_leader_channel(self, guild: discord.Guild, team_leaders: discord.Role) -> None: - """Create the Team Leader Chat channel for the Code Jam team leaders.""" - category: discord.CategoryChannel = guild.get_channel(Categories.summer_code_jam) - - team_leaders_chat = await category.create_text_channel( - name="team-leaders-chat", - overwrites={ - guild.default_role: discord.PermissionOverwrite(read_messages=False), - team_leaders: discord.PermissionOverwrite(read_messages=True) - } - ) - - await self.send_status_update(guild, f"Created {team_leaders_chat.mention} in the {category} category.") - - async def send_status_update(self, guild: discord.Guild, message: str) -> None: - """Inform the events lead with a status update when the command is ran.""" - channel: discord.TextChannel = guild.get_channel(Channels.code_jam_planning) - - await channel.send(f"<@&{Roles.events_lead}>\n\n{message}") - - @staticmethod - async def add_team_leader_roles(members: list[tuple[discord.Member, bool]], team_leaders: discord.Role) -> None: - """Assign team leader role, the jammer role and their team role.""" - for member, is_leader in members: - if is_leader: - await member.add_roles(team_leaders) - - -def setup(bot: Bot) -> None: - """Load the CodeJams cog.""" - bot.add_cog(CodeJams(bot)) diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index 3f6213db3..0eedeb0fb 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/antimalware.py @@ -7,7 +7,7 @@ from discord.ext.commands import Cog from bot.bot import Bot from bot.constants import Channels, Filter, URLs -from bot.exts.events.code_jams._cog import CATEGORY_NAME as JAM_CATEGORY_NAME +from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME log = logging.getLogger(__name__) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 124905cb4..1830e23b8 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -17,7 +17,7 @@ from bot.constants import ( Guild as GuildConfig, Icons, ) from bot.converters import Duration -from bot.exts.events.code_jams._cog import CATEGORY_NAME as JAM_CATEGORY_NAME +from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.exts.moderation.modlog import ModLog from bot.utils import lock, scheduling from bot.utils.messages import format_user, send_attachments diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 0810425e2..10cc7885d 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -19,7 +19,7 @@ from bot.constants import ( Channels, Colours, Filter, Guild, Icons, URLs ) -from bot.exts.events.code_jams._cog import CATEGORY_NAME as JAM_CATEGORY_NAME +from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.exts.moderation.modlog import ModLog from bot.utils.messages import format_user from bot.utils.regex import INVITE_RE diff --git a/tests/bot/exts/events/test_code_jams.py b/tests/bot/exts/events/test_code_jams.py index d7b8aa4d2..b9ee1e363 100644 --- a/tests/bot/exts/events/test_code_jams.py +++ b/tests/bot/exts/events/test_code_jams.py @@ -1,14 +1,15 @@ import unittest -from unittest.mock import AsyncMock, MagicMock, create_autospec +from unittest.mock import AsyncMock, MagicMock, create_autospec, patch from discord import CategoryChannel from discord.ext.commands import BadArgument from bot.constants import Roles -from bot.exts.events.code_jams import _cog +from bot.exts.events import code_jams +from bot.exts.events.code_jams import _channels, _cog from tests.helpers import ( MockAttachment, MockBot, MockCategoryChannel, MockContext, - MockGuild, MockMember, MockRole, MockTextChannel + MockGuild, MockMember, MockRole, MockTextChannel, autospec ) TEST_CSV = b"""\ @@ -49,7 +50,9 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): with self.assertRaises(BadArgument): await self.cog.create(self.cog, self.ctx, None) - async def test_result_sending(self): + @patch.object(_channels, "create_team_channel") + @patch.object(_channels, "create_team_leader_channel") + async def test_result_sending(self, create_leader_channel, create_team_channel): """Should call `ctx.send` when everything goes right.""" self.ctx.message.attachments = [MockAttachment()] self.ctx.message.attachments[0].read = AsyncMock() @@ -61,14 +64,12 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): self.ctx.guild.create_role = AsyncMock() self.ctx.guild.create_role.return_value = team_leaders - self.cog.create_team_channel = AsyncMock() - self.cog.create_team_leader_channel = AsyncMock() self.cog.add_roles = AsyncMock() await self.cog.create(self.cog, self.ctx, None) - self.cog.create_team_channel.assert_awaited() - self.cog.create_team_leader_channel.assert_awaited_once_with( + create_team_channel.assert_awaited() + create_leader_channel.assert_awaited_once_with( self.ctx.guild, team_leaders ) self.ctx.send.assert_awaited_once() @@ -81,25 +82,24 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): self.ctx.send.assert_awaited_once() - async def test_category_doesnt_exist(self): + @patch.object(_channels, "_send_status_update") + async def test_category_doesnt_exist(self, update): """Should create a new code jam category.""" subtests = ( [], - [get_mock_category(_cog.MAX_CHANNELS, _cog.CATEGORY_NAME)], - [get_mock_category(_cog.MAX_CHANNELS - 2, "other")], + [get_mock_category(_channels.MAX_CHANNELS, _channels.CATEGORY_NAME)], + [get_mock_category(_channels.MAX_CHANNELS - 2, "other")], ) - self.cog.send_status_update = AsyncMock() - for categories in subtests: - self.cog.send_status_update.reset_mock() + update.reset_mock() self.guild.reset_mock() self.guild.categories = categories with self.subTest(categories=categories): - actual_category = await self.cog.get_category(self.guild) + actual_category = await _channels._get_category(self.guild) - self.cog.send_status_update.assert_called_once() + update.assert_called_once() self.guild.create_category_channel.assert_awaited_once() category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"] @@ -109,45 +109,41 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): async def test_category_channel_exist(self): """Should not try to create category channel.""" - expected_category = get_mock_category(_cog.MAX_CHANNELS - 2, _cog.CATEGORY_NAME) + expected_category = get_mock_category(_channels.MAX_CHANNELS - 2, _channels.CATEGORY_NAME) self.guild.categories = [ - get_mock_category(_cog.MAX_CHANNELS - 2, "other"), + get_mock_category(_channels.MAX_CHANNELS - 2, "other"), expected_category, - get_mock_category(0, _cog.CATEGORY_NAME), + get_mock_category(0, _channels.CATEGORY_NAME), ] - actual_category = await self.cog.get_category(self.guild) + actual_category = await _channels._get_category(self.guild) self.assertEqual(expected_category, actual_category) async def test_channel_overwrites(self): """Should have correct permission overwrites for users and roles.""" leader = (MockMember(), True) members = [leader] + [(MockMember(), False) for _ in range(4)] - overwrites = self.cog.get_overwrites(members, self.guild) + overwrites = _channels._get_overwrites(members, self.guild) for member, _ in members: self.assertTrue(overwrites[member].read_messages) - async def test_team_channels_creation(self): + @patch.object(_channels, "_get_overwrites") + @patch.object(_channels, "_get_category") + @autospec(_channels, "_add_team_leader_roles", pass_mocks=False) + async def test_team_channels_creation(self, get_category, get_overwrites): """Should create a text channel for a team.""" team_leaders = MockRole() members = [(MockMember(), True)] + [(MockMember(), False) for _ in range(5)] category = MockCategoryChannel() category.create_text_channel = AsyncMock() - self.cog.get_overwrites = MagicMock() - self.cog.get_category = AsyncMock() - self.cog.get_category.return_value = category - self.cog.add_team_leader_roles = AsyncMock() - - await self.cog.create_team_channel(self.guild, "my-team", members, team_leaders) - self.cog.add_team_leader_roles.assert_awaited_once_with(members, team_leaders) - self.cog.get_overwrites.assert_called_once_with(members, self.guild) - self.cog.get_category.assert_awaited_once_with(self.guild) + get_category.return_value = category + await _channels.create_team_channel(self.guild, "my-team", members, team_leaders) category.create_text_channel.assert_awaited_once_with( "my-team", - overwrites=self.cog.get_overwrites.return_value + overwrites=get_overwrites.return_value ) async def test_jam_roles_adding(self): @@ -156,7 +152,7 @@ class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase): leader = MockMember() members = [(leader, True)] + [(MockMember(), False) for _ in range(4)] - await self.cog.add_team_leader_roles(members, leader_role) + await _channels._add_team_leader_roles(members, leader_role) leader.add_roles.assert_awaited_once_with(leader_role) for member, is_leader in members: @@ -170,5 +166,5 @@ class CodeJamSetup(unittest.TestCase): def test_setup(self): """Should call `bot.add_cog`.""" bot = MockBot() - _cog.setup(bot) + code_jams.setup(bot) bot.add_cog.assert_called_once() -- cgit v1.2.3 From d4271b0c1df1e7acbbf694c9f585bacc25edaecd Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Thu, 8 Jul 2021 22:09:12 +0300 Subject: More code jam functionality - An info embed with team the member is in. The team is decided by finding in which channel the member has overwrites. - Command to move a member from one team to another by changing the permissions of the appropriate team channels. - A command to end the code jam and delete all the team channels and categories. --- bot/exts/events/code_jams/_cog.py | 105 +++++++++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index 2d0873de7..39577a5c3 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -3,6 +3,8 @@ import logging import typing as t from collections import defaultdict +import discord +from discord import Colour, Embed, Guild, Member from discord.ext import commands from bot.bot import Bot @@ -20,7 +22,9 @@ class CodeJams(commands.Cog): def __init__(self, bot: Bot): self.bot = bot - @commands.group() + self.end_counter = 0 + + @commands.group(aliases=("cj", "jam")) @commands.has_any_role(Roles.admins) async def codejam(self, ctx: commands.Context) -> None: """A Group of commands for managing Code Jams.""" @@ -69,3 +73,102 @@ class CodeJams(commands.Cog): await _channels.create_team_leader_channel(ctx.guild, team_leaders) await ctx.send(f"{Emojis.check_mark} Created Code Jam with {len(teams)} teams.") + + @codejam.command() + @commands.has_any_role(Roles.admins) + async def end(self, ctx: commands.Context) -> None: + """ + Call it three times while spinning around for it all to end. + + Deletes all code jam channels and wipes the cache. + """ + self.end_counter += 1 + if self.end_counter == 1: + await ctx.send("Are you sure about that?") + return + if self.end_counter == 2: + await ctx.send("Are you *really really* sure about that?") + return + + self.end_counter = 0 + + for category in self.jam_categories(ctx.guild): + for channel in category.channels: + await channel.delete(reason="Code jam ended.") + await category.delete(reason="Code jam ended.") + + await ctx.message.add_reaction(Emojis.check_mark) + + @codejam.command() + @commands.has_any_role(Roles.admins, Roles.code_jam_event_team) + async def info(self, ctx: commands.Context, member: Member) -> None: + """ + Send an info embed about the member with the team they're in. + + The team is found by searching the permissions of the team channels. + """ + channel = self.team_channel(ctx.guild, member) + if not channel: + await ctx.send(":x: I can't find the team channel for this member.") + return + + embed = Embed( + title=str(member), + colour=Colour.blurple() + ) + embed.add_field(name="Team", value=self.team_name(channel), inline=True) + + await ctx.send(embed=embed) + + @codejam.command() + @commands.has_any_role(Roles.admins) + async def move(self, ctx: commands.Context, member: Member, new_team_name: str) -> None: + """Move participant from one team to another by changing the user's permissions for the relevant channels.""" + old_team_channel = self.team_channel(ctx.guild, member) + if not old_team_channel: + await ctx.send(":x: I can't find the team channel for this member.") + return + + if old_team_channel.name == new_team_name or self.team_name(old_team_channel) == new_team_name: + await ctx.send(f"`{member}` is already in `{new_team_name}`.") + return + + new_team_channel = self.team_channel(ctx.guild, new_team_name) + if not new_team_channel: + await ctx.send(f":x: I can't find a team channel named `{new_team_name}`.") + return + + await old_team_channel.set_permissions(member, overwrite=None, reason=f"Participant moved to {new_team_name}") + await new_team_channel.set_permissions( + member, + overwrite=discord.PermissionOverwrite(read_messages=True), + reason=f"Participant moved from {old_team_channel.name}" + ) + + await ctx.send( + f"Participant moved from `{self.team_name(old_team_channel)}` to `{self.team_name(new_team_channel)}`." + ) + + @staticmethod + def jam_categories(guild: Guild) -> list[discord.CategoryChannel]: + """Get all the code jam team categories.""" + return [category for category in guild.categories if category.name == _channels.CATEGORY_NAME] + + @staticmethod + def team_channel(guild: Guild, criterion: t.Union[str, Member]) -> t.Optional[discord.TextChannel]: + """Get a team channel through either a participant or the team name.""" + for category in CodeJams.jam_categories(guild): + for channel in category.channels: + if isinstance(channel, discord.TextChannel): + if ( + # If it's a string. + criterion == channel.name or criterion == CodeJams.team_name(channel) + # If it's a member. + or criterion in channel.overwrites + ): + return channel + + @staticmethod + def team_name(channel: discord.TextChannel) -> str: + """Retrieves the team name from the given channel.""" + return channel.name.replace("-", " ").title() -- cgit v1.2.3 From bc390bcb66d060aeba29c835d6c5cceb3d366626 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 8 Jul 2021 23:14:47 +0300 Subject: Added command to remove from team --- bot/exts/events/code_jams/_cog.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index 39577a5c3..862e53a13 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -149,6 +149,18 @@ class CodeJams(commands.Cog): f"Participant moved from `{self.team_name(old_team_channel)}` to `{self.team_name(new_team_channel)}`." ) + @codejam.command() + @commands.has_any_role(Roles.admins) + async def remove(self, ctx: commands.Context, member: Member) -> None: + """Removes the participant from their team. Does not remove the participants or leader roles.""" + channel = self.team_channel(ctx.guild, member) + if not channel: + await ctx.send(":x: I can't find the team channel for this member.") + return + + await channel.set_permissions(member, overwrite=None, reason="Participant removed from the team.") + await ctx.send(f"Removed the participant from `{self.team_name(channel)}`.") + @staticmethod def jam_categories(guild: Guild) -> list[discord.CategoryChannel]: """Get all the code jam team categories.""" -- cgit v1.2.3 From fdc6f2387ee9894c21f84fb41d9a909a52e42971 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Thu, 8 Jul 2021 23:23:34 +0300 Subject: Fix end command docstring --- bot/exts/events/code_jams/_cog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index 862e53a13..83e2e18ce 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -78,9 +78,9 @@ class CodeJams(commands.Cog): @commands.has_any_role(Roles.admins) async def end(self, ctx: commands.Context) -> None: """ - Call it three times while spinning around for it all to end. + Deletes all code jam channels. - Deletes all code jam channels and wipes the cache. + Call it three times while spinning around for it all to end. """ self.end_counter += 1 if self.end_counter == 1: -- cgit v1.2.3 From 7049a0052b375d8fa28288ab8ca63f7b99c069ed Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Thu, 8 Jul 2021 13:52:28 +0200 Subject: Create `join_role_stats` function in helpers Add `join_role_stats` function that joins the relevant information (number of members) of the given roles into one group under a pre-specified `name` --- bot/utils/helpers.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py index 3501a3933..1da5da5a3 100644 --- a/bot/utils/helpers.py +++ b/bot/utils/helpers.py @@ -1,6 +1,8 @@ from abc import ABCMeta -from typing import Optional +from types import List +from typing import Dict, Optional +from discord import Guild from discord.ext.commands import CogMeta @@ -30,3 +32,11 @@ def has_lines(string: str, count: int) -> bool: def pad_base64(data: str) -> str: """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" return data + "=" * (-len(data) % 4) + + +def join_role_stats(role_ids: List[int], name: str, guild: Guild) -> Dict[str, int]: + """Return a dict object with the number of `members` of each role given, and the `name` for this joined group.""" + members = [] + for role_id in role_ids: + members += guild.get_role(role_id).members + return {name: len(set(members))} -- cgit v1.2.3 From a8e30f6c67cd64a5cf5a1ab0b3668a06a9621485 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Thu, 8 Jul 2021 13:53:48 +0200 Subject: Add `Leads` role(s) to the server information embed --- bot/exts/info/information.py | 7 ++++++- bot/utils/helpers.py | 3 +-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 1b1243118..677b5d1f7 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -17,8 +17,10 @@ from bot.decorators import in_whitelist from bot.pagination import LinePaginator from bot.utils.channel import is_mod_channel, is_staff_channel from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check +from bot.utils.helpers import join_role_stats from bot.utils.time import humanize_delta, time_since + log = logging.getLogger(__name__) @@ -50,7 +52,10 @@ class Information(Cog): constants.Roles.owners, constants.Roles.contributors, ) ) - return {role.name.title(): len(role.members) for role in roles} + role_stats = {role.name.title(): len(role.members) for role in roles} + role_stats.update( + **join_role_stats([constants.Roles.project_leads, constants.Roles.domain_leads], "Leads", guild)) + return role_stats def get_extended_server_info(self, ctx: Context) -> str: """Return additional server info only visible in moderation channels.""" diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py index 1da5da5a3..b0d17c3b8 100644 --- a/bot/utils/helpers.py +++ b/bot/utils/helpers.py @@ -1,6 +1,5 @@ from abc import ABCMeta -from types import List -from typing import Dict, Optional +from typing import Dict, List, Optional from discord import Guild from discord.ext.commands import CogMeta -- cgit v1.2.3 From 5bbd29c8957c320ef94beacd191239032eb959ec Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Fri, 9 Jul 2021 03:32:51 +0300 Subject: Properly Handle Fuzzy Matching Help Fixes a bug where calling help with an invalid command would crash out during fuzzy matching. Signed-off-by: Hassan Abouelela --- bot/exts/info/help.py | 4 ++-- tests/bot/exts/info/test_help.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 tests/bot/exts/info/test_help.py diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index bf9ea5986..0235bbaf3 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -125,9 +125,9 @@ class CustomHelpCommand(HelpCommand): Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches. """ - choices = await self.get_all_help_choices() + choices = list(await self.get_all_help_choices()) result = process.extract(default_process(string), choices, scorer=fuzz.ratio, score_cutoff=60, processor=None) - return HelpQueryNotFound(f'Query "{string}" not found.', dict(result)) + return HelpQueryNotFound(f'Query "{string}" not found.', {choice[0]: choice[1] for choice in result}) async def subcommand_not_found(self, command: Command, string: str) -> "HelpQueryNotFound": """ diff --git a/tests/bot/exts/info/test_help.py b/tests/bot/exts/info/test_help.py new file mode 100644 index 000000000..604c69671 --- /dev/null +++ b/tests/bot/exts/info/test_help.py @@ -0,0 +1,23 @@ +import unittest + +import rapidfuzz + +from bot.exts.info import help +from tests.helpers import MockBot, MockContext, autospec + + +class HelpCogTests(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + """Attach an instance of the cog to the class for tests.""" + self.bot = MockBot() + self.cog = help.Help(self.bot) + self.ctx = MockContext(bot=self.bot) + self.bot.help_command.context = self.ctx + + @autospec(help.CustomHelpCommand, "get_all_help_choices", return_value={"help"}, pass_mocks=False) + async def test_help_fuzzy_matching(self): + """Test fuzzy matching of commands when called from help.""" + result = await self.bot.help_command.command_not_found("holp") + + match = {"help": rapidfuzz.fuzz.ratio("help", "holp")} + self.assertEqual(match, result.possible_matches) -- cgit v1.2.3 From 133a1349ee52cc774c3c991a0753fd12f478a010 Mon Sep 17 00:00:00 2001 From: vcokltfre Date: Tue, 13 Jul 2021 09:03:02 +0100 Subject: feat: add for-else tag (#1643) * feat: add for-else tag Co-authored-by: Joe Banks Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/resources/tags/for-else.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 bot/resources/tags/for-else.md diff --git a/bot/resources/tags/for-else.md b/bot/resources/tags/for-else.md new file mode 100644 index 000000000..e102e4e75 --- /dev/null +++ b/bot/resources/tags/for-else.md @@ -0,0 +1,17 @@ +**for-else** + +In Python it's possible to attach an `else` clause to a for loop. The code under the `else` block will be run when the iterable is exhausted (there are no more items to iterate over). Code within the else block will **not** run if the loop is broken out using `break`. + +Here's an example of its usage: +```py +numbers = [1, 3, 5, 7, 9, 11] + +for number in numbers: + if number % 2 == 0: + print(f"Found an even number: {number}") + break + print(f"{number} is odd.") +else: + print("All numbers are odd. How odd.") +``` +Try running this example but with an even number in the list, see how the output changes as you do so. -- cgit v1.2.3 From 66ec8673a16ec6a2afe7d72d045600d18e882fc8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 15 Jul 2021 22:07:25 -0700 Subject: Allow manual mutes to override auto mutes If a moderator mutes a user, allow them to override any active mute that was automatically applied by the bot (e.g. from antispam). Resolve #1665 --- bot/exts/moderation/infraction/_utils.py | 13 +++++++++---- bot/exts/moderation/infraction/infractions.py | 9 +++++++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index adbc641fa..dd427e413 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -143,15 +143,20 @@ async def get_active_infraction( # Checks to see if the moderator should be told there is an active infraction if send_msg: log.trace(f"{user} has active infractions of type {infr_type}.") - await ctx.send( - f":x: According to my records, this user already has a {infr_type} infraction. " - f"See infraction **#{active_infractions[0]['id']}**." - ) + await send_active_infraction_message(ctx, active_infractions[0]) return active_infractions[0] else: log.trace(f"{user} does not have active infractions of type {infr_type}.") +async def send_active_infraction_message(ctx: Context, infraction: Infraction) -> None: + """Send a message stating that the given infraction is active.""" + await ctx.send( + f":x: According to my records, this user already has a {infraction['type']} infraction. " + f"See infraction **#{infraction['id']}**." + ) + + async def notify_infraction( user: UserObject, infr_type: str, diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index f19323c7c..1b1414ec7 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -280,8 +280,13 @@ class Infractions(InfractionScheduler, commands.Cog): async def apply_mute(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: """Apply a mute infraction with kwargs passed to `post_infraction`.""" - if await _utils.get_active_infraction(ctx, user, "mute"): - return + if active := await _utils.get_active_infraction(ctx, user, "mute", send_msg=False): + if active["actor"] != self.bot.user.id: + await _utils.send_active_infraction_message(ctx, active) + return + + # Let the current mute attempt override an automatically triggered mute. + await self.deactivate_infraction(active) infraction = await _utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs) if infraction is None: -- cgit v1.2.3 From 786b690fab9812e0bda5296d5ea64e2c3cb446d3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 15 Jul 2021 22:25:16 -0700 Subject: Display error if overriding mute fails --- bot/exts/moderation/infraction/infractions.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 1b1414ec7..eaf718af4 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -286,7 +286,13 @@ class Infractions(InfractionScheduler, commands.Cog): return # Let the current mute attempt override an automatically triggered mute. - await self.deactivate_infraction(active) + log_text = await self.deactivate_infraction(active) + if "Failure" in log_text: + await ctx.send( + f":x: can't override infraction **mute** for {user.mention}: " + f"failed to deactivate. {log_text['Failure']}" + ) + return infraction = await _utils.post_infraction(ctx, user, "mute", reason, active=True, **kwargs) if infraction is None: -- cgit v1.2.3 From 8ec6f0880be0ca876b9a255a0fd5e0090695d5b8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 15 Jul 2021 22:31:01 -0700 Subject: Fix get_active_infraction test --- tests/bot/exts/moderation/infraction/test_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index 50a717bb5..f3af7bea9 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -94,8 +94,8 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase): test_case = namedtuple("test_case", ["get_return_value", "expected_output", "infraction_nr", "send_msg"]) test_cases = [ test_case([], None, None, True), - test_case([{"id": 123987}], {"id": 123987}, "123987", False), - test_case([{"id": 123987}], {"id": 123987}, "123987", True) + test_case([{"id": 123987, "type": "ban"}], {"id": 123987, "type": "ban"}, "123987", False), + test_case([{"id": 123987, "type": "ban"}], {"id": 123987, "type": "ban"}, "123987", True) ] for case in test_cases: -- cgit v1.2.3 From 24e4a9d2f4c037f7652b7300772ec0c6c6ab8d3c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 15 Jul 2021 22:44:42 -0700 Subject: Remove redundant parameter from pardon_voice_ban --- bot/exts/moderation/infraction/infractions.py | 4 ++-- tests/bot/exts/moderation/infraction/test_infractions.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index eaf718af4..dbf56d6bb 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -455,7 +455,7 @@ class Infractions(InfractionScheduler, commands.Cog): return log_text - async def pardon_voice_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: + async def pardon_voice_ban(self, user_id: int, guild: discord.Guild) -> t.Dict[str, str]: """Add Voice Verified role back to user, DM them a notification, and return a log dict.""" user = guild.get_member(user_id) log_text = {} @@ -491,7 +491,7 @@ class Infractions(InfractionScheduler, commands.Cog): elif infraction["type"] == "ban": return await self.pardon_ban(user_id, guild, reason) elif infraction["type"] == "voice_ban": - return await self.pardon_voice_ban(user_id, guild, reason) + return await self.pardon_voice_ban(user_id, guild) # endregion diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index b9d527770..f844a9181 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -195,7 +195,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): async def test_voice_unban_user_not_found(self): """Should include info to return dict when user was not found from guild.""" self.guild.get_member.return_value = None - result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + result = await self.cog.pardon_voice_ban(self.user.id, self.guild) self.assertEqual(result, {"Info": "User was not found in the guild."}) @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") @@ -206,7 +206,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): notify_pardon_mock.return_value = True format_user_mock.return_value = "my-user" - result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + result = await self.cog.pardon_voice_ban(self.user.id, self.guild) self.assertEqual(result, { "Member": "my-user", "DM": "Sent" @@ -221,7 +221,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): notify_pardon_mock.return_value = False format_user_mock.return_value = "my-user" - result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + result = await self.cog.pardon_voice_ban(self.user.id, self.guild) self.assertEqual(result, { "Member": "my-user", "DM": "**Failed**" -- cgit v1.2.3 From c84ed0fa6d647116b333327a29525719000f4c29 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 15 Jul 2021 23:02:30 -0700 Subject: Avoid sending pardon DM when overriding a mute --- bot/exts/moderation/infraction/_scheduler.py | 25 ++++++++--- bot/exts/moderation/infraction/infractions.py | 64 +++++++++++++++++---------- 2 files changed, 59 insertions(+), 30 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 8286d3635..c2fd959f7 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -265,13 +265,17 @@ class InfractionScheduler: ctx: Context, infr_type: str, user: UserSnowflake, - send_msg: bool = True + *, + send_msg: bool = True, + notify: bool = True ) -> None: """ Prematurely end an infraction for a user and log the action in the mod log. If `send_msg` is True, then a pardoning confirmation message will be sent to - the context channel. Otherwise, no such message will be sent. + the context channel. Otherwise, no such message will be sent. + + If `notify` is True, notify the user of the pardon via DM where applicable. """ log.trace(f"Pardoning {infr_type} infraction for {user}.") @@ -292,7 +296,7 @@ class InfractionScheduler: return # Deactivate the infraction and cancel its scheduled expiration task. - log_text = await self.deactivate_infraction(response[0], send_log=False) + log_text = await self.deactivate_infraction(response[0], send_log=False, notify=notify) log_text["Member"] = messages.format_user(user) log_text["Actor"] = ctx.author.mention @@ -345,7 +349,9 @@ class InfractionScheduler: async def deactivate_infraction( self, infraction: _utils.Infraction, - send_log: bool = True + *, + send_log: bool = True, + notify: bool = True ) -> t.Dict[str, str]: """ Deactivate an active infraction and return a dictionary of lines to send in a mod log. @@ -354,6 +360,8 @@ class InfractionScheduler: expiration task cancelled. If `send_log` is True, a mod log is sent for the deactivation of the infraction. + If `notify` is True, notify the user of the pardon via DM where applicable. + Infractions of unsupported types will raise a ValueError. """ guild = self.bot.get_guild(constants.Guild.id) @@ -380,7 +388,7 @@ class InfractionScheduler: try: log.trace("Awaiting the pardon action coroutine.") - returned_log = await self._pardon_action(infraction) + returned_log = await self._pardon_action(infraction, notify) if returned_log is not None: log_text = {**log_text, **returned_log} # Merge the logs together @@ -468,10 +476,15 @@ class InfractionScheduler: return log_text @abstractmethod - async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: + async def _pardon_action( + self, + infraction: _utils.Infraction, + notify: bool + ) -> t.Optional[t.Dict[str, str]]: """ Execute deactivation steps specific to the infraction's type and return a log dict. + If `notify` is True, notify the user of the pardon via DM where applicable. If an infraction type is unsupported, return None instead. """ raise NotImplementedError diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index dbf56d6bb..9d5b049e1 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -286,7 +286,7 @@ class Infractions(InfractionScheduler, commands.Cog): return # Let the current mute attempt override an automatically triggered mute. - log_text = await self.deactivate_infraction(active) + log_text = await self.deactivate_infraction(active, notify=False) if "Failure" in log_text: await ctx.send( f":x: can't override infraction **mute** for {user.mention}: " @@ -414,8 +414,15 @@ class Infractions(InfractionScheduler, commands.Cog): # endregion # region: Base pardon functions - async def pardon_mute(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: - """Remove a user's muted role, DM them a notification, and return a log dict.""" + async def pardon_mute( + self, + user_id: int, + guild: discord.Guild, + reason: t.Optional[str], + *, + notify: bool = True + ) -> t.Dict[str, str]: + """Remove a user's muted role, optionally DM them a notification, and return a log dict.""" user = guild.get_member(user_id) log_text = {} @@ -424,16 +431,17 @@ class Infractions(InfractionScheduler, commands.Cog): self.mod_log.ignore(Event.member_update, user.id) await user.remove_roles(self._muted_role, reason=reason) - # DM the user about the expiration. - notified = await _utils.notify_pardon( - user=user, - title="You have been unmuted", - content="You may now send messages in the server.", - icon_url=_utils.INFRACTION_ICONS["mute"][1] - ) + if notify: + # DM the user about the expiration. + notified = await _utils.notify_pardon( + user=user, + title="You have been unmuted", + content="You may now send messages in the server.", + icon_url=_utils.INFRACTION_ICONS["mute"][1] + ) + log_text["DM"] = "Sent" if notified else "**Failed**" log_text["Member"] = format_user(user) - log_text["DM"] = "Sent" if notified else "**Failed**" else: log.info(f"Failed to unmute user {user_id}: user not found") log_text["Failure"] = "User was not found in the guild." @@ -455,31 +463,39 @@ class Infractions(InfractionScheduler, commands.Cog): return log_text - async def pardon_voice_ban(self, user_id: int, guild: discord.Guild) -> t.Dict[str, str]: - """Add Voice Verified role back to user, DM them a notification, and return a log dict.""" + async def pardon_voice_ban( + self, + user_id: int, + guild: discord.Guild, + *, + notify: bool = True + ) -> t.Dict[str, str]: + """Optionally DM the user a pardon notification and return a log dict.""" user = guild.get_member(user_id) log_text = {} if user: - # DM user about infraction expiration - notified = await _utils.notify_pardon( - user=user, - title="Voice ban ended", - content="You have been unbanned and can verify yourself again in the server.", - icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] - ) + if notify: + # DM user about infraction expiration + notified = await _utils.notify_pardon( + user=user, + title="Voice ban ended", + content="You have been unbanned and can verify yourself again in the server.", + icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] + ) + log_text["DM"] = "Sent" if notified else "**Failed**" log_text["Member"] = format_user(user) - log_text["DM"] = "Sent" if notified else "**Failed**" else: log_text["Info"] = "User was not found in the guild." return log_text - async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: + async def _pardon_action(self, infraction: _utils.Infraction, notify: bool) -> t.Optional[t.Dict[str, str]]: """ Execute deactivation steps specific to the infraction's type and return a log dict. + If `notify` is True, notify the user of the pardon via DM where applicable. If an infraction type is unsupported, return None instead. """ guild = self.bot.get_guild(constants.Guild.id) @@ -487,11 +503,11 @@ class Infractions(InfractionScheduler, commands.Cog): reason = f"Infraction #{infraction['id']} expired or was pardoned." if infraction["type"] == "mute": - return await self.pardon_mute(user_id, guild, reason) + return await self.pardon_mute(user_id, guild, reason, notify=notify) elif infraction["type"] == "ban": return await self.pardon_ban(user_id, guild, reason) elif infraction["type"] == "voice_ban": - return await self.pardon_voice_ban(user_id, guild) + return await self.pardon_voice_ban(user_id, guild, notify=notify) # endregion -- cgit v1.2.3 From f848dafef496e75e73160d47983a9c78ac3f7cdf Mon Sep 17 00:00:00 2001 From: NIRDERIi <78727420+NIRDERIi@users.noreply.github.com> Date: Tue, 20 Jul 2021 09:44:23 +0300 Subject: Enabled charinfo for discord_py channel. --- bot/exts/utils/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 4c39a7c2a..fce767d84 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -49,7 +49,7 @@ class Utils(Cog): self.bot = bot @command() - @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) + @in_whitelist(channels=(Channels.bot_commands, Channels.discord_py), roles=STAFF_ROLES) async def charinfo(self, ctx: Context, *, characters: str) -> None: """Shows you information on up to 50 unicode characters.""" match = re.match(r"<(a?):(\w+):(\d+)>", characters) -- cgit v1.2.3 From d6c237cc6d2b1efa21d917df1afa5b5c425c0cc6 Mon Sep 17 00:00:00 2001 From: NotFlameDev <78838310+NotFlameDev@users.noreply.github.com> Date: Wed, 21 Jul 2021 10:14:02 +0700 Subject: Added docstring tag --- bot/resources/tags/docstring.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 bot/resources/tags/docstring.md diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md new file mode 100644 index 000000000..88f6b3a1d --- /dev/null +++ b/bot/resources/tags/docstring.md @@ -0,0 +1,16 @@ +A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that often used in file, classes, functions, etc. Docstrings usually has clear explanation, parameter(s) and return type. + +Here's an example of usage of a docstring: +```py +def greet(name, age) -> str: + """ + :param name: The name to greet. + :type name: str + :param age: The age to display. + :type age: int + :return: String of the greeting. + """ + return_string = f"Hello, {name} you are {age} years old!" + return return_string +``` +You can get the docstring by using `.__doc__` attribute. For the last example you can get it through: `print(greet.__doc__)`. -- cgit v1.2.3 From 40bee31b0db3d528aca442e6b6493fd13bca2f75 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 21 Jul 2021 10:34:15 +0200 Subject: Reminder: remove footer Now that we have Discord timestamps, the timestamp in the footer isn't useful anymore since it can be hovered to have a localised timestamp. --- bot/exts/utils/reminders.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index c7ce8b9e9..7420f1489 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -96,11 +96,6 @@ class Reminders(Cog): footer_str = f"ID: {reminder_id}" - if delivery_dt: - # Reminder deletion will have a `None` `delivery_dt` - footer_str += ', Due' - embed.timestamp = delivery_dt - embed.set_footer(text=footer_str) await ctx.send(embed=embed) -- cgit v1.2.3 From a591154ce4246ac06998325bfe282c4dc85b9ed9 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 21 Jul 2021 18:22:16 +0200 Subject: Information: make !server use moderation_team We use the ping role instead of the team role here, leading to inaccuracies. --- bot/exts/info/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index b9fcb6b40..089ce4eb2 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -46,7 +46,7 @@ class Information(Cog): """Return the total number of members for certain roles in `guild`.""" roles = ( guild.get_role(role_id) for role_id in ( - constants.Roles.helpers, constants.Roles.moderators, constants.Roles.admins, + constants.Roles.helpers, constants.Roles.moderation_team, constants.Roles.admins, constants.Roles.owners, constants.Roles.contributors, ) ) -- cgit v1.2.3 From 114ffade88f6fa4daa6bf846546cd7f070efde7f Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 21 Jul 2021 20:51:05 +0100 Subject: fix reference role constant in !server --- bot/exts/info/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 089ce4eb2..bb713eef1 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -46,7 +46,7 @@ class Information(Cog): """Return the total number of members for certain roles in `guild`.""" roles = ( guild.get_role(role_id) for role_id in ( - constants.Roles.helpers, constants.Roles.moderation_team, constants.Roles.admins, + constants.Roles.helpers, constants.Roles.mod_team, constants.Roles.admins, constants.Roles.owners, constants.Roles.contributors, ) ) -- cgit v1.2.3 From c5b76c93b0b4f6690b927f5bb53a4491a9455b62 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 21 Jul 2021 23:28:08 +0200 Subject: Talentpool: join_at -> joined_at --- bot/exts/recruitment/talentpool/_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index aebb401e0..3a1e66970 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -254,7 +254,7 @@ class Reviewer: last_channel = user_activity["top_channel_activity"][-1] channels += f", and {last_channel[1]} in {last_channel[0]}" - joined_at_formatted = time_since(member.join_at) + joined_at_formatted = time_since(member.joined_at) review = ( f"{member.name} joined the server **{joined_at_formatted}**" f" and has **{messages} messages**{channels}." -- cgit v1.2.3 From d462ad0541acbd0f3282190a0648c28232563767 Mon Sep 17 00:00:00 2001 From: NotFlameDev <78838310+NotFlameDev@users.noreply.github.com> Date: Thu, 22 Jul 2021 08:01:35 +0700 Subject: Changed the documentation as intended --- bot/resources/tags/docstring.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index 88f6b3a1d..cf5413f4b 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -1,9 +1,11 @@ -A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that often used in file, classes, functions, etc. Docstrings usually has clear explanation, parameter(s) and return type. +A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that often used in file, classes, functions, etc. A docstring usually has clear explanation (such as what the function do, purposes of the function, and other details of the function), parameter(s) and a return type. -Here's an example of usage of a docstring: +Here's an example of a docstring: ```py def greet(name, age) -> str: """ + Greet someone with their name and age. + :param name: The name to greet. :type name: str :param age: The age to display. @@ -14,3 +16,5 @@ def greet(name, age) -> str: return return_string ``` You can get the docstring by using `.__doc__` attribute. For the last example you can get it through: `print(greet.__doc__)`. + +For more details about what docstring is and it's usage check out [https://realpython.com/documenting-python-code/#docstrings-background](realpython) or [https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring](official PEP docs). -- cgit v1.2.3 From 9a7a6433de5adfcb973aa69a237242b0bc07d173 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 22 Jul 2021 11:10:45 +0200 Subject: Reminder: remove unused delivery_dt parameter --- bot/exts/utils/reminders.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 7420f1489..7b8c5c4b3 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -84,8 +84,7 @@ class Reminders(Cog): async def _send_confirmation( ctx: Context, on_success: str, - reminder_id: t.Union[str, int], - delivery_dt: t.Optional[datetime], + reminder_id: t.Union[str, int] ) -> None: """Send an embed confirming the reminder change was made successfully.""" embed = discord.Embed( @@ -274,8 +273,7 @@ class Reminders(Cog): await self._send_confirmation( ctx, on_success=mention_string, - reminder_id=reminder["id"], - delivery_dt=expiration, + reminder_id=reminder["id"] ) self.schedule_reminder(reminder) @@ -378,15 +376,11 @@ class Reminders(Cog): return reminder = await self._edit_reminder(id_, payload) - # Parse the reminder expiration back into a datetime - expiration = isoparse(reminder["expiration"]).replace(tzinfo=None) - # Send a confirmation message to the channel await self._send_confirmation( ctx, on_success="That reminder has been edited successfully!", reminder_id=id_, - delivery_dt=expiration, ) await self._reschedule_reminder(reminder) @@ -403,8 +397,7 @@ class Reminders(Cog): await self._send_confirmation( ctx, on_success="That reminder has been deleted successfully!", - reminder_id=id_, - delivery_dt=None, + reminder_id=id_ ) async def _can_modify(self, ctx: Context, reminder_id: t.Union[str, int]) -> bool: -- cgit v1.2.3 From 4fff8ff555162c3a8c99fbbb2f6c62a6366704d9 Mon Sep 17 00:00:00 2001 From: NotFlameDev <78838310+NotFlameDev@users.noreply.github.com> Date: Fri, 23 Jul 2021 08:06:06 +0700 Subject: Hyperlink fix Co-authored-by: ChrisJL --- bot/resources/tags/docstring.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index cf5413f4b..31d9b239a 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -17,4 +17,4 @@ def greet(name, age) -> str: ``` You can get the docstring by using `.__doc__` attribute. For the last example you can get it through: `print(greet.__doc__)`. -For more details about what docstring is and it's usage check out [https://realpython.com/documenting-python-code/#docstrings-background](realpython) or [https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring](official PEP docs). +For more details about what docstring is and it's usage check out this guide by [Real Python](https://realpython.com/documenting-python-code/#docstrings-background), or the [PEP-257 docs](https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring). -- cgit v1.2.3 From ed142e5251000fe12a39844feec31a308e391d96 Mon Sep 17 00:00:00 2001 From: NotFlameDev <78838310+NotFlameDev@users.noreply.github.com> Date: Fri, 23 Jul 2021 08:06:26 +0700 Subject: Update docstring explanation. Co-authored-by: ChrisJL --- bot/resources/tags/docstring.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index 31d9b239a..feafecc41 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -4,7 +4,7 @@ Here's an example of a docstring: ```py def greet(name, age) -> str: """ - Greet someone with their name and age. + Return a string that greets the given person, including their name and age. :param name: The name to greet. :type name: str -- cgit v1.2.3 From b6d339e46b2fb445d00a8c78e796ee97296baaa2 Mon Sep 17 00:00:00 2001 From: NotFlameDev <78838310+NotFlameDev@users.noreply.github.com> Date: Fri, 23 Jul 2021 08:13:19 +0700 Subject: Update docstring's explanation --- bot/resources/tags/docstring.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index feafecc41..9457b629c 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -1,6 +1,4 @@ -A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that often used in file, classes, functions, etc. A docstring usually has clear explanation (such as what the function do, purposes of the function, and other details of the function), parameter(s) and a return type. - -Here's an example of a docstring: +A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that often used in file, classes, functions, etc. A docstring should have a clear explanation of exactly what the function does. You can also include descriptions of the function's parameter(s) and its return type, as shown below. ```py def greet(name, age) -> str: """ -- cgit v1.2.3 From a2592d0b205e43c676106258a059df13a99fd191 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 23 Jul 2021 10:40:52 +0100 Subject: update docstring command to use 4 space indents. --- bot/resources/tags/docstring.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index 9457b629c..33452b998 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -1,17 +1,18 @@ A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that often used in file, classes, functions, etc. A docstring should have a clear explanation of exactly what the function does. You can also include descriptions of the function's parameter(s) and its return type, as shown below. ```py def greet(name, age) -> str: - """ - Return a string that greets the given person, including their name and age. + """ + Return a string that greets the given person, including their name and age. - :param name: The name to greet. - :type name: str - :param age: The age to display. - :type age: int - :return: String of the greeting. - """ - return_string = f"Hello, {name} you are {age} years old!" - return return_string + :param name: The name to greet. + :type name: str + :param age: The age to display. + :type age: int + + :return: String of the greeting. + """ + return_string = f"Hello, {name} you are {age} years old!" + return return_string ``` You can get the docstring by using `.__doc__` attribute. For the last example you can get it through: `print(greet.__doc__)`. -- cgit v1.2.3 From 1abbb06bcb330be672ef4c979f5d83d8b3bbafad Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 23 Jul 2021 10:41:30 +0100 Subject: Fix grammar issues in docstring tag --- bot/resources/tags/docstring.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index 33452b998..2918281c3 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -1,4 +1,4 @@ -A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that often used in file, classes, functions, etc. A docstring should have a clear explanation of exactly what the function does. You can also include descriptions of the function's parameter(s) and its return type, as shown below. +A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that's placed at the top of files, classes and functions. A docstring should contain a clear explanation of what it's describing. You can also include descriptions of the subject's parameter(s) and its return type, as shown below: ```py def greet(name, age) -> str: """ @@ -9,11 +9,10 @@ def greet(name, age) -> str: :param age: The age to display. :type age: int - :return: String of the greeting. + :return: String representation of the greeting. """ - return_string = f"Hello, {name} you are {age} years old!" - return return_string + return f"Hello {name}, you are {age} years old!" ``` -You can get the docstring by using `.__doc__` attribute. For the last example you can get it through: `print(greet.__doc__)`. +You can get the docstring by using the `.__doc__` attribute. For the last example, you can print it by doing this: `print(greet.__doc__)`. -For more details about what docstring is and it's usage check out this guide by [Real Python](https://realpython.com/documenting-python-code/#docstrings-background), or the [PEP-257 docs](https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring). +For more details about what a docstring is and its usage, check out this guide by [Real Python](https://realpython.com/documenting-python-code/#docstrings-background), or the [PEP-257 docs](https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring). -- cgit v1.2.3 From 1dec12a1b024283cc3464f0f81bb78375280e82c Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 23 Jul 2021 11:02:57 +0100 Subject: Move type docs to type hints --- bot/resources/tags/docstring.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index 2918281c3..7144d3702 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -1,13 +1,11 @@ A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that's placed at the top of files, classes and functions. A docstring should contain a clear explanation of what it's describing. You can also include descriptions of the subject's parameter(s) and its return type, as shown below: ```py -def greet(name, age) -> str: +def greet(name: str, age: int) -> str: """ Return a string that greets the given person, including their name and age. :param name: The name to greet. - :type name: str :param age: The age to display. - :type age: int :return: String representation of the greeting. """ -- cgit v1.2.3 From fb7ec43edc0581175cb86fed7df48ef7efb6e743 Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Fri, 23 Jul 2021 11:17:43 +0100 Subject: Refer to PEP-257 as the official spec This is for users who may not know what a PEP is. Co-authored-by: Bluenix --- bot/resources/tags/docstring.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index 7144d3702..8d6fd3615 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -13,4 +13,4 @@ def greet(name: str, age: int) -> str: ``` You can get the docstring by using the `.__doc__` attribute. For the last example, you can print it by doing this: `print(greet.__doc__)`. -For more details about what a docstring is and its usage, check out this guide by [Real Python](https://realpython.com/documenting-python-code/#docstrings-background), or the [PEP-257 docs](https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring). +For more details about what a docstring is and its usage, check out this guide by [Real Python](https://realpython.com/documenting-python-code/#docstrings-background), or the [official docstring specification](https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring). -- cgit v1.2.3 From 4b1cf360f0f7b3bcc400184a127ce74b8c98148b Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 23 Jul 2021 11:19:30 +0100 Subject: Further describe the funciton in docstring tag --- bot/resources/tags/docstring.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index 8d6fd3615..95ad13b35 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -1,11 +1,11 @@ -A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that's placed at the top of files, classes and functions. A docstring should contain a clear explanation of what it's describing. You can also include descriptions of the subject's parameter(s) and its return type, as shown below: +A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that's placed at the top of files, classes and functions. A docstring should contain a clear explanation of what it's describing. You can also include descriptions of the subject's parameter(s) and what it returns, as shown below: ```py def greet(name: str, age: int) -> str: """ - Return a string that greets the given person, including their name and age. + Return a string that greets the given person, using their name and age. - :param name: The name to greet. - :param age: The age to display. + :param name: The name of the person to greet. + :param age: The age of the person to greet. :return: String representation of the greeting. """ -- cgit v1.2.3 From e5ebccc410c38ba84eca555ecef72c4a91cf8779 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 23 Jul 2021 11:28:58 +0100 Subject: Update grammer in docstring tag Co-authored-by: Bluenix --- bot/resources/tags/docstring.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index 95ad13b35..0160d5ff3 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -1,4 +1,4 @@ -A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string with triple quotes that's placed at the top of files, classes and functions. A docstring should contain a clear explanation of what it's describing. You can also include descriptions of the subject's parameter(s) and what it returns, as shown below: +A [`docstring`](https://docs.python.org/3/glossary.html#term-docstring) is a string - always using triple quotes - that's placed at the top of files, classes and functions. A docstring should contain a clear explanation of what it's describing. You can also include descriptions of the subject's parameter(s) and what it returns, as shown below: ```py def greet(name: str, age: int) -> str: """ @@ -7,7 +7,7 @@ def greet(name: str, age: int) -> str: :param name: The name of the person to greet. :param age: The age of the person to greet. - :return: String representation of the greeting. + :return: The greeting. """ return f"Hello {name}, you are {age} years old!" ``` -- cgit v1.2.3 From 39500b40ac2892cb469366103022bd854f7734b2 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 23 Jul 2021 12:14:28 +0100 Subject: Suggest inspect.getdoc in docstring tag Co-authored-by: Numerlor <25886452+Numerlor@users.noreply.github.com> --- bot/resources/tags/docstring.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/resources/tags/docstring.md b/bot/resources/tags/docstring.md index 0160d5ff3..20043131e 100644 --- a/bot/resources/tags/docstring.md +++ b/bot/resources/tags/docstring.md @@ -11,6 +11,8 @@ def greet(name: str, age: int) -> str: """ return f"Hello {name}, you are {age} years old!" ``` -You can get the docstring by using the `.__doc__` attribute. For the last example, you can print it by doing this: `print(greet.__doc__)`. +You can get the docstring by using the [`inspect.getdoc`](https://docs.python.org/3/library/inspect.html#inspect.getdoc) function, from the built-in [`inspect`](https://docs.python.org/3/library/inspect.html) module, or by accessing the `.__doc__` attribute. `inspect.getdoc` is often preferred, as it clears indents from the docstring. + +For the last example, you can print it by doing this: `print(inspect.getdoc(greet))`. For more details about what a docstring is and its usage, check out this guide by [Real Python](https://realpython.com/documenting-python-code/#docstrings-background), or the [official docstring specification](https://www.python.org/dev/peps/pep-0257/#what-is-a-docstring). -- cgit v1.2.3 From 10041a4651676cd02c6282b0cec09b1dcea973f1 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 13:23:08 +0100 Subject: Prevent ghost-pings in docs get command Won't delete the invoking message when the giving symbol is invalid if the message contains user/role mentions. If it has mentions, allows deletions of error message through reactions. --- bot/exts/info/doc/_cog.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index c54a3ee1c..b83c3c47e 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -10,6 +10,7 @@ from types import SimpleNamespace from typing import Dict, NamedTuple, Optional, Tuple, Union import aiohttp +import asyncio import discord from discord.ext import commands @@ -34,6 +35,7 @@ FORCE_PREFIX_GROUPS = ( "pdbcommand", "2to3fixer", ) +DELETE_ERROR_MESSAGE_REACTION = '\u274c' # :x: NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay # Delay to wait before trying to reach a rescheduled inventory again, in minutes FETCH_RESCHEDULE_DELAY = SimpleNamespace(first=2, repeated=5) @@ -340,11 +342,29 @@ class DocCog(commands.Cog): if doc_embed is None: error_message = await send_denial(ctx, "No documentation found for the requested symbol.") - await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) - with suppress(discord.NotFound): - await ctx.message.delete() - with suppress(discord.NotFound): - await error_message.delete() + + if ctx.message.mentions or ctx.message.role_mentions: + await error_message.add_reaction(DELETE_ERROR_MESSAGE_REACTION) + + try: + await self.bot.wait_for( + 'reaction_add', + check=lambda reaction, user: reaction.message == error_message and user == ctx.author and str(reaction) == DELETE_ERROR_MESSAGE_REACTION, + timeout=NOT_FOUND_DELETE_DELAY + ) + + with suppress(discord.HTTPException): + await error_message.delete() + + except asyncio.TimeoutError: + await error_message.clear_reaction(DELETE_ERROR_MESSAGE_REACTION) + + else: + await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) + with suppress(discord.NotFound): + await ctx.message.delete() + with suppress(discord.NotFound): + await error_message.delete() else: msg = await ctx.send(embed=doc_embed) await wait_for_deletion(msg, (ctx.author.id,)) -- cgit v1.2.3 From 4b7962a1d128f29af4caccbf9ae610087a59a142 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 13:37:39 +0100 Subject: Remove duplicate asyncio import --- bot/exts/info/doc/_cog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index b83c3c47e..e7ca634b2 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -10,7 +10,6 @@ from types import SimpleNamespace from typing import Dict, NamedTuple, Optional, Tuple, Union import aiohttp -import asyncio import discord from discord.ext import commands -- cgit v1.2.3 From 155422cdaaaaf7e53892c0f9cd3dde4bb94797a4 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 13:58:24 +0100 Subject: Revamped imports --- bot/exts/info/doc/_cog.py | 53 +++++++++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index e7ca634b2..92a92d201 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -11,7 +11,9 @@ from typing import Dict, NamedTuple, Optional, Tuple, Union import aiohttp import discord -from discord.ext import commands +from discord import Colour, Embed, Message, NotFound, Reaction, User + +from discord.ext.commands import BadArgument, Cog, Context, group, has_any_role from bot.bot import Bot from bot.constants import MODERATION_ROLES, RedirectOutput @@ -57,7 +59,7 @@ class DocItem(NamedTuple): return self.base_url + self.relative_url_path -class DocCog(commands.Cog): +class DocCog(Cog): """A set of commands for querying & displaying documentation.""" def __init__(self, bot: Bot): @@ -265,7 +267,7 @@ class DocCog(commands.Cog): return "Unable to parse the requested symbol." return markdown - async def create_symbol_embed(self, symbol_name: str) -> Optional[discord.Embed]: + async def create_symbol_embed(self, symbol_name: str) -> Optional[Embed]: """ Attempt to scrape and fetch the data for the given `symbol_name`, and build an embed from its contents. @@ -294,7 +296,7 @@ class DocCog(commands.Cog): else: footer_text = "" - embed = discord.Embed( + embed = Embed( title=discord.utils.escape_markdown(symbol_name), url=f"{doc_item.url}#{doc_item.symbol_id}", description=await self.get_symbol_markdown(doc_item) @@ -302,13 +304,13 @@ class DocCog(commands.Cog): embed.set_footer(text=footer_text) return embed - @commands.group(name="docs", aliases=("doc", "d"), invoke_without_command=True) - async def docs_group(self, ctx: commands.Context, *, symbol_name: Optional[str]) -> None: + @group(name="docs", aliases=("doc", "d"), invoke_without_command=True) + async def docs_group(self, ctx: Context, *, symbol_name: Optional[str]) -> None: """Look up documentation for Python symbols.""" await self.get_command(ctx, symbol_name=symbol_name) @docs_group.command(name="getdoc", aliases=("g",)) - async def get_command(self, ctx: commands.Context, *, symbol_name: Optional[str]) -> None: + async def get_command(self, ctx: Context, *, symbol_name: Optional[str]) -> None: """ Return a documentation embed for a given symbol. @@ -321,9 +323,9 @@ class DocCog(commands.Cog): !docs getdoc aiohttp.ClientSession """ if not symbol_name: - inventory_embed = discord.Embed( + inventory_embed = Embed( title=f"All inventories (`{len(self.base_urls)}` total)", - colour=discord.Colour.blue() + colour=Colour.blue() ) lines = sorted(f"• [`{name}`]({url})" for name, url in self.base_urls.items()) @@ -345,14 +347,15 @@ class DocCog(commands.Cog): if ctx.message.mentions or ctx.message.role_mentions: await error_message.add_reaction(DELETE_ERROR_MESSAGE_REACTION) + _predicate_emoji_reaction = partial(predicate_emoji_reaction, ctx, error_message) try: await self.bot.wait_for( 'reaction_add', - check=lambda reaction, user: reaction.message == error_message and user == ctx.author and str(reaction) == DELETE_ERROR_MESSAGE_REACTION, + check=_predicate_emoji_reaction timeout=NOT_FOUND_DELETE_DELAY ) - with suppress(discord.HTTPException): + with suppress(NotFound): await error_message.delete() except asyncio.TimeoutError: @@ -360,20 +363,20 @@ class DocCog(commands.Cog): else: await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) - with suppress(discord.NotFound): + with suppress(NotFound): await ctx.message.delete() - with suppress(discord.NotFound): + with suppress(NotFound): await error_message.delete() else: msg = await ctx.send(embed=doc_embed) await wait_for_deletion(msg, (ctx.author.id,)) @docs_group.command(name="setdoc", aliases=("s",)) - @commands.has_any_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) async def set_command( self, - ctx: commands.Context, + ctx: Context, package_name: PackageName, base_url: ValidURL, inventory: Inventory, @@ -390,7 +393,7 @@ class DocCog(commands.Cog): https://docs.python.org/3/objects.inv """ if not base_url.endswith("/"): - raise commands.BadArgument("The base url must end with a slash.") + raise BadArgument("The base url must end with a slash.") inventory_url, inventory_dict = inventory body = { "package": package_name, @@ -408,9 +411,9 @@ class DocCog(commands.Cog): await ctx.send(f"Added the package `{package_name}` to the database and updated the inventories.") @docs_group.command(name="deletedoc", aliases=("removedoc", "rm", "d")) - @commands.has_any_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) - async def delete_command(self, ctx: commands.Context, package_name: PackageName) -> None: + async def delete_command(self, ctx: Context, package_name: PackageName) -> None: """ Removes the specified package from the database. @@ -425,9 +428,9 @@ class DocCog(commands.Cog): await ctx.send(f"Successfully deleted `{package_name}` and refreshed the inventories.") @docs_group.command(name="refreshdoc", aliases=("rfsh", "r")) - @commands.has_any_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) - async def refresh_command(self, ctx: commands.Context) -> None: + async def refresh_command(self, ctx: Context) -> None: """Refresh inventories and show the difference.""" old_inventories = set(self.base_urls) with ctx.typing(): @@ -440,17 +443,17 @@ class DocCog(commands.Cog): if removed := ", ".join(old_inventories - new_inventories): removed = "- " + removed - embed = discord.Embed( + embed = Embed( title="Inventories refreshed", description=f"```diff\n{added}\n{removed}```" if added or removed else "" ) await ctx.send(embed=embed) @docs_group.command(name="cleardoccache", aliases=("deletedoccache",)) - @commands.has_any_role(*MODERATION_ROLES) + @has_any_role(*MODERATION_ROLES) async def clear_cache_command( self, - ctx: commands.Context, + ctx: Context, package_name: Union[PackageName, allowed_strings("*")] # noqa: F722 ) -> None: """Clear the persistent redis cache for `package`.""" @@ -464,3 +467,7 @@ class DocCog(commands.Cog): self.inventory_scheduler.cancel_all() self.init_refresh_task.cancel() asyncio.create_task(self.item_fetcher.clear(), name="DocCog.item_fetcher unload clear") + + +def predicate_emoji_reaction(ctx: Context, error_message: Message, reaction: Reaction, user: User): + return reaction.message == error_message and user == ctx.author and str(reaction) == DELETE_ERROR_MESSAGE_REACTION -- cgit v1.2.3 From cb4098d816e069e622202c11f2a6fbaea7475c0f Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 14:02:26 +0100 Subject: Add missing functools.partial import --- bot/exts/info/doc/_cog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 92a92d201..01358c453 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -6,6 +6,7 @@ import sys import textwrap from collections import defaultdict from contextlib import suppress +from functools import partial from types import SimpleNamespace from typing import Dict, NamedTuple, Optional, Tuple, Union -- cgit v1.2.3 From 8f66672728e8f1d9adcc07882bcfe037bd1b9d2b Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 14:06:07 +0100 Subject: Add missing comma --- bot/exts/info/doc/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 01358c453..54960ce11 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -352,7 +352,7 @@ class DocCog(Cog): try: await self.bot.wait_for( 'reaction_add', - check=_predicate_emoji_reaction + check=_predicate_emoji_reaction, timeout=NOT_FOUND_DELETE_DELAY ) -- cgit v1.2.3 From b9203197f891932d2aa8d0066cfb495393e6b5ce Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 14:51:29 +0100 Subject: Remove trailing whitespace --- bot/exts/info/doc/_cog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 54960ce11..8faff926c 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -468,7 +468,7 @@ class DocCog(Cog): self.inventory_scheduler.cancel_all() self.init_refresh_task.cancel() asyncio.create_task(self.item_fetcher.clear(), name="DocCog.item_fetcher unload clear") - - + + def predicate_emoji_reaction(ctx: Context, error_message: Message, reaction: Reaction, user: User): return reaction.message == error_message and user == ctx.author and str(reaction) == DELETE_ERROR_MESSAGE_REACTION -- cgit v1.2.3 From 17234954f2ca61c543225d110edbc22edcb55f9b Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 14:57:08 +0100 Subject: Add return type-hint and docstring --- bot/exts/info/doc/_cog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 8faff926c..a768d6af7 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -470,5 +470,6 @@ class DocCog(Cog): asyncio.create_task(self.item_fetcher.clear(), name="DocCog.item_fetcher unload clear") -def predicate_emoji_reaction(ctx: Context, error_message: Message, reaction: Reaction, user: User): +def predicate_emoji_reaction(ctx: Context, error_message: Message, reaction: Reaction, user: User) -> bool: + """Return whether command author added the `:x:` emote to the `error_message`.""" return reaction.message == error_message and user == ctx.author and str(reaction) == DELETE_ERROR_MESSAGE_REACTION -- cgit v1.2.3 From c37cbe534d124f35cb9a654d078dca5d7fdb7b42 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 14:59:31 +0100 Subject: Remove blankline that flake8 didn't like --- bot/exts/info/doc/_cog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index a768d6af7..cd9b69818 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -13,7 +13,6 @@ from typing import Dict, NamedTuple, Optional, Tuple, Union import aiohttp import discord from discord import Colour, Embed, Message, NotFound, Reaction, User - from discord.ext.commands import BadArgument, Cog, Context, group, has_any_role from bot.bot import Bot -- cgit v1.2.3 From 24aa14f5856994ebb2df85284c3cd7140c52aa96 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 15:04:03 +0100 Subject: Fix shadowed name --- bot/exts/info/doc/_cog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index cd9b69818..0b30526a4 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -101,11 +101,11 @@ class DocCog(Cog): """ self.base_urls[package_name] = base_url - for group, items in inventory.items(): + for _group, items in inventory.items(): for symbol_name, relative_doc_url in items: # e.g. get 'class' from 'py:class' - group_name = group.split(":")[1] + group_name = _group.split(":")[1] symbol_name = self.ensure_unique_symbol_name( package_name, group_name, -- cgit v1.2.3 From a4fcca34bdf5ddd67e91e2cbc66506fffac3fe77 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 15:20:00 +0100 Subject: Undo change in import style --- bot/exts/info/doc/_cog.py | 61 +++++++++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 0b30526a4..f6fd92302 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -12,8 +12,8 @@ from typing import Dict, NamedTuple, Optional, Tuple, Union import aiohttp import discord -from discord import Colour, Embed, Message, NotFound, Reaction, User -from discord.ext.commands import BadArgument, Cog, Context, group, has_any_role + +from discord.ext import commands from bot.bot import Bot from bot.constants import MODERATION_ROLES, RedirectOutput @@ -59,7 +59,7 @@ class DocItem(NamedTuple): return self.base_url + self.relative_url_path -class DocCog(Cog): +class DocCog(commands.Cog): """A set of commands for querying & displaying documentation.""" def __init__(self, bot: Bot): @@ -101,11 +101,11 @@ class DocCog(Cog): """ self.base_urls[package_name] = base_url - for _group, items in inventory.items(): + for group, items in inventory.items(): for symbol_name, relative_doc_url in items: # e.g. get 'class' from 'py:class' - group_name = _group.split(":")[1] + group_name = group.split(":")[1] symbol_name = self.ensure_unique_symbol_name( package_name, group_name, @@ -267,7 +267,7 @@ class DocCog(Cog): return "Unable to parse the requested symbol." return markdown - async def create_symbol_embed(self, symbol_name: str) -> Optional[Embed]: + async def create_symbol_embed(self, symbol_name: str) -> Optional[discord.Embed]: """ Attempt to scrape and fetch the data for the given `symbol_name`, and build an embed from its contents. @@ -296,7 +296,7 @@ class DocCog(Cog): else: footer_text = "" - embed = Embed( + embed = discord.Embed( title=discord.utils.escape_markdown(symbol_name), url=f"{doc_item.url}#{doc_item.symbol_id}", description=await self.get_symbol_markdown(doc_item) @@ -304,13 +304,13 @@ class DocCog(Cog): embed.set_footer(text=footer_text) return embed - @group(name="docs", aliases=("doc", "d"), invoke_without_command=True) - async def docs_group(self, ctx: Context, *, symbol_name: Optional[str]) -> None: + @commands.group(name="docs", aliases=("doc", "d"), invoke_without_command=True) + async def docs_group(self, ctx: commands.Context, *, symbol_name: Optional[str]) -> None: """Look up documentation for Python symbols.""" await self.get_command(ctx, symbol_name=symbol_name) @docs_group.command(name="getdoc", aliases=("g",)) - async def get_command(self, ctx: Context, *, symbol_name: Optional[str]) -> None: + async def get_command(self, ctx: commands.Context, *, symbol_name: Optional[str]) -> None: """ Return a documentation embed for a given symbol. @@ -323,9 +323,9 @@ class DocCog(Cog): !docs getdoc aiohttp.ClientSession """ if not symbol_name: - inventory_embed = Embed( + inventory_embed = discord.Embed( title=f"All inventories (`{len(self.base_urls)}` total)", - colour=Colour.blue() + colour=discord.Colour.blue() ) lines = sorted(f"• [`{name}`]({url})" for name, url in self.base_urls.items()) @@ -355,7 +355,7 @@ class DocCog(Cog): timeout=NOT_FOUND_DELETE_DELAY ) - with suppress(NotFound): + with suppress(discord.NotFound): await error_message.delete() except asyncio.TimeoutError: @@ -363,20 +363,20 @@ class DocCog(Cog): else: await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) - with suppress(NotFound): + with suppress(discord.NotFound): await ctx.message.delete() - with suppress(NotFound): + with suppress(discord.NotFound): await error_message.delete() else: msg = await ctx.send(embed=doc_embed) await wait_for_deletion(msg, (ctx.author.id,)) @docs_group.command(name="setdoc", aliases=("s",)) - @has_any_role(*MODERATION_ROLES) + @commands.has_any_role(*MODERATION_ROLES) @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) async def set_command( self, - ctx: Context, + ctx: commands.Context, package_name: PackageName, base_url: ValidURL, inventory: Inventory, @@ -393,7 +393,7 @@ class DocCog(Cog): https://docs.python.org/3/objects.inv """ if not base_url.endswith("/"): - raise BadArgument("The base url must end with a slash.") + raise commands.BadArgument("The base url must end with a slash.") inventory_url, inventory_dict = inventory body = { "package": package_name, @@ -411,9 +411,9 @@ class DocCog(Cog): await ctx.send(f"Added the package `{package_name}` to the database and updated the inventories.") @docs_group.command(name="deletedoc", aliases=("removedoc", "rm", "d")) - @has_any_role(*MODERATION_ROLES) + @commands.has_any_role(*MODERATION_ROLES) @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) - async def delete_command(self, ctx: Context, package_name: PackageName) -> None: + async def delete_command(self, ctx: commands.Context, package_name: PackageName) -> None: """ Removes the specified package from the database. @@ -428,9 +428,9 @@ class DocCog(Cog): await ctx.send(f"Successfully deleted `{package_name}` and refreshed the inventories.") @docs_group.command(name="refreshdoc", aliases=("rfsh", "r")) - @has_any_role(*MODERATION_ROLES) + @commands.has_any_role(*MODERATION_ROLES) @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) - async def refresh_command(self, ctx: Context) -> None: + async def refresh_command(self, ctx: commands.Context) -> None: """Refresh inventories and show the difference.""" old_inventories = set(self.base_urls) with ctx.typing(): @@ -443,17 +443,17 @@ class DocCog(Cog): if removed := ", ".join(old_inventories - new_inventories): removed = "- " + removed - embed = Embed( + embed = discord.Embed( title="Inventories refreshed", description=f"```diff\n{added}\n{removed}```" if added or removed else "" ) await ctx.send(embed=embed) @docs_group.command(name="cleardoccache", aliases=("deletedoccache",)) - @has_any_role(*MODERATION_ROLES) + @commands.has_any_role(*MODERATION_ROLES) async def clear_cache_command( self, - ctx: Context, + ctx: commands.Context, package_name: Union[PackageName, allowed_strings("*")] # noqa: F722 ) -> None: """Clear the persistent redis cache for `package`.""" @@ -469,6 +469,11 @@ class DocCog(Cog): asyncio.create_task(self.item_fetcher.clear(), name="DocCog.item_fetcher unload clear") -def predicate_emoji_reaction(ctx: Context, error_message: Message, reaction: Reaction, user: User) -> bool: - """Return whether command author added the `:x:` emote to the `error_message`.""" - return reaction.message == error_message and user == ctx.author and str(reaction) == DELETE_ERROR_MESSAGE_REACTION +def predicate_emoji_reaction( + ctx: commands.Context, + message: discord.Message, + reaction: discord.Reaction, + user: discord.User +) -> bool: + """Return whether command author added the `:x:` emote to the `message`.""" + return reaction.message == message and user == ctx.author and str(reaction) == DELETE_ERROR_MESSAGE_REACTION -- cgit v1.2.3 From 66f7492a028256f66a20b9255ebd695af67f507a Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 15:21:05 +0100 Subject: Remove blankline that flake8 doesn't like --- bot/exts/info/doc/_cog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index f6fd92302..c9daa3680 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -12,7 +12,6 @@ from typing import Dict, NamedTuple, Optional, Tuple, Union import aiohttp import discord - from discord.ext import commands from bot.bot import Bot -- cgit v1.2.3 From e76057e63452d91b48dbbba70c287cdf3d18423a Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 16:27:27 +0100 Subject: Update code to use `utils.messages.wait_for_deletion` --- bot/exts/info/doc/_cog.py | 25 +++---------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index c9daa3680..e00c64150 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -6,7 +6,6 @@ import sys import textwrap from collections import defaultdict from contextlib import suppress -from functools import partial from types import SimpleNamespace from typing import Dict, NamedTuple, Optional, Tuple, Union @@ -35,7 +34,6 @@ FORCE_PREFIX_GROUPS = ( "pdbcommand", "2to3fixer", ) -DELETE_ERROR_MESSAGE_REACTION = '\u274c' # :x: NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay # Delay to wait before trying to reach a rescheduled inventory again, in minutes FETCH_RESCHEDULE_DELAY = SimpleNamespace(first=2, repeated=5) @@ -343,29 +341,12 @@ class DocCog(commands.Cog): if doc_embed is None: error_message = await send_denial(ctx, "No documentation found for the requested symbol.") - if ctx.message.mentions or ctx.message.role_mentions: - await error_message.add_reaction(DELETE_ERROR_MESSAGE_REACTION) + await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) - _predicate_emoji_reaction = partial(predicate_emoji_reaction, ctx, error_message) - try: - await self.bot.wait_for( - 'reaction_add', - check=_predicate_emoji_reaction, - timeout=NOT_FOUND_DELETE_DELAY - ) - - with suppress(discord.NotFound): - await error_message.delete() - - except asyncio.TimeoutError: - await error_message.clear_reaction(DELETE_ERROR_MESSAGE_REACTION) - - else: - await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) + if not (ctx.message.mentions or ctx.message.role_mentions): with suppress(discord.NotFound): await ctx.message.delete() - with suppress(discord.NotFound): - await error_message.delete() + else: msg = await ctx.send(embed=doc_embed) await wait_for_deletion(msg, (ctx.author.id,)) -- cgit v1.2.3 From b2061e4f5afab8d8d714ca7a1f969d84c6f1e213 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 16:29:56 +0100 Subject: Remove deprecated function --- bot/exts/info/doc/_cog.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index e00c64150..ddf8e65e3 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -447,13 +447,3 @@ class DocCog(commands.Cog): self.inventory_scheduler.cancel_all() self.init_refresh_task.cancel() asyncio.create_task(self.item_fetcher.clear(), name="DocCog.item_fetcher unload clear") - - -def predicate_emoji_reaction( - ctx: commands.Context, - message: discord.Message, - reaction: discord.Reaction, - user: discord.User -) -> bool: - """Return whether command author added the `:x:` emote to the `message`.""" - return reaction.message == message and user == ctx.author and str(reaction) == DELETE_ERROR_MESSAGE_REACTION -- cgit v1.2.3 From c5ab6af7770745d69e0d1d04fc07d7b8c601f98a Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 16:33:52 +0100 Subject: Remove extra lines --- bot/exts/info/doc/_cog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index ddf8e65e3..2cac7c10e 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -340,7 +340,6 @@ class DocCog(commands.Cog): if doc_embed is None: error_message = await send_denial(ctx, "No documentation found for the requested symbol.") - await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) if not (ctx.message.mentions or ctx.message.role_mentions): -- cgit v1.2.3 From d5b6f3d934215eceefdb9905e1b39f7c5c2a8a61 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 16:59:39 +0100 Subject: Delete reaction if error_message not deleted. --- bot/exts/info/doc/_cog.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 2cac7c10e..c0cb4db29 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -14,7 +14,7 @@ import discord from discord.ext import commands from bot.bot import Bot -from bot.constants import MODERATION_ROLES, RedirectOutput +from bot.constants import Emojis, MODERATION_ROLES, RedirectOutput from bot.converters import Inventory, PackageName, ValidURL, allowed_strings from bot.pagination import LinePaginator from bot.utils.lock import SharedEvent, lock @@ -342,6 +342,9 @@ class DocCog(commands.Cog): error_message = await send_denial(ctx, "No documentation found for the requested symbol.") await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) + with suppress(discord.NotFound): + await message.clear_reaction(Emojis.trashcan) + if not (ctx.message.mentions or ctx.message.role_mentions): with suppress(discord.NotFound): await ctx.message.delete() -- cgit v1.2.3 From 75a77e1e85ee9e4a7ea337564dcc25e327d94b8a Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 17:01:27 +0100 Subject: Fix typo causing NameError --- bot/exts/info/doc/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index c0cb4db29..25d69cbed 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -343,7 +343,7 @@ class DocCog(commands.Cog): await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) with suppress(discord.NotFound): - await message.clear_reaction(Emojis.trashcan) + await error_message.clear_reaction(Emojis.trashcan) if not (ctx.message.mentions or ctx.message.role_mentions): with suppress(discord.NotFound): -- cgit v1.2.3 From 47abb8d17c531e75d977ab395752690115f05cab Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 17:17:11 +0100 Subject: Remove extra line Co-authored-by: Bluenix --- bot/exts/info/doc/_cog.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 25d69cbed..eebd39451 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -341,7 +341,6 @@ class DocCog(commands.Cog): if doc_embed is None: error_message = await send_denial(ctx, "No documentation found for the requested symbol.") await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) - with suppress(discord.NotFound): await error_message.clear_reaction(Emojis.trashcan) -- cgit v1.2.3 From 9078afd4465838406fc5db8bba527f1b18f6d175 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Fri, 23 Jul 2021 18:13:16 +0100 Subject: Add comment Co-authored-by: Bluenix --- bot/exts/info/doc/_cog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index eebd39451..704884fd1 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -344,6 +344,7 @@ class DocCog(commands.Cog): with suppress(discord.NotFound): await error_message.clear_reaction(Emojis.trashcan) + # Make sure that we won't cause a ghost-ping by deleting the message if not (ctx.message.mentions or ctx.message.role_mentions): with suppress(discord.NotFound): await ctx.message.delete() -- cgit v1.2.3 From a0c013c95b664eef9df7dd10c987ed51865c378a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 23 Jul 2021 16:42:52 -0700 Subject: Add missing notify param to _pardon_action for superstarify --- bot/exts/moderation/infraction/superstarify.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 07e79b9fe..05a2bbe10 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -192,8 +192,8 @@ class Superstarify(InfractionScheduler, Cog): """Remove the superstarify infraction and allow the user to change their nickname.""" await self.pardon_infraction(ctx, "superstar", member) - async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: - """Pardon a superstar infraction and return a log dict.""" + async def _pardon_action(self, infraction: _utils.Infraction, notify: bool) -> t.Optional[t.Dict[str, str]]: + """Pardon a superstar infraction, optionally notify the user via DM, and return a log dict.""" if infraction["type"] != "superstar": return @@ -208,18 +208,19 @@ class Superstarify(InfractionScheduler, Cog): ) return {} + log_text = {"Member": format_user(user)} + # DM the user about the expiration. - notified = await _utils.notify_pardon( - user=user, - title="You are no longer superstarified", - content="You may now change your nickname on the server.", - icon_url=_utils.INFRACTION_ICONS["superstar"][1] - ) + if notify: + notified = await _utils.notify_pardon( + user=user, + title="You are no longer superstarified", + content="You may now change your nickname on the server.", + icon_url=_utils.INFRACTION_ICONS["superstar"][1] + ) + log_text["DM"] = "Sent" if notified else "**Failed**" - return { - "Member": format_user(user), - "DM": "Sent" if notified else "**Failed**" - } + return log_text @staticmethod def get_nick(infraction_id: int, member_id: int) -> str: -- cgit v1.2.3 From 400f821d203222e4bc2de91bf70207b9401c017f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 23 Jul 2021 16:45:30 -0700 Subject: Fix pardon_infraction call for permanent bans --- bot/exts/moderation/infraction/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 9d5b049e1..ce19bcfdc 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -356,7 +356,7 @@ class Infractions(InfractionScheduler, commands.Cog): return log.trace("Old tempban is being replaced by new permaban.") - await self.pardon_infraction(ctx, "ban", user, is_temporary) + await self.pardon_infraction(ctx, "ban", user, send_msg=is_temporary) infraction = await _utils.post_infraction(ctx, user, "ban", reason, active=True, **kwargs) if infraction is None: -- cgit v1.2.3 From 693573412dcf726426271c66f832e626fa77a5d4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 23 Jul 2021 19:26:03 -0700 Subject: Clarify a comment --- bot/exts/moderation/infraction/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index ce19bcfdc..0df5fb60b 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -285,7 +285,7 @@ class Infractions(InfractionScheduler, commands.Cog): await _utils.send_active_infraction_message(ctx, active) return - # Let the current mute attempt override an automatically triggered mute. + # Allow the current mute attempt to override an automatically triggered mute. log_text = await self.deactivate_infraction(active, notify=False) if "Failure" in log_text: await ctx.send( -- cgit v1.2.3 From 19cd66fa1615ed8f220cef8dbff5f826c7d4670c Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 25 Jul 2021 00:39:22 +0300 Subject: Improved codejam end confirmation The command now sends the details of all channels about to be deleted to the pasting service, and confirmation is done through a reaction by the invoker of the command within a limited time (10 seconds). --- bot/exts/events/code_jams/_cog.py | 74 +++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 15 deletions(-) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index 83e2e18ce..d0c206b5e 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -1,3 +1,4 @@ +import asyncio import csv import logging import typing as t @@ -10,10 +11,12 @@ from discord.ext import commands from bot.bot import Bot from bot.constants import Emojis, Roles from bot.exts.events.code_jams import _channels +from bot.utils.services import send_to_paste_service log = logging.getLogger(__name__) TEAM_LEADERS_COLOUR = 0x11806a +DELETION_REACTION = "\U0001f4a5" class CodeJams(commands.Cog): @@ -22,8 +25,6 @@ class CodeJams(commands.Cog): def __init__(self, bot: Bot): self.bot = bot - self.end_counter = 0 - @commands.group(aliases=("cj", "jam")) @commands.has_any_role(Roles.admins) async def codejam(self, ctx: commands.Context) -> None: @@ -80,24 +81,67 @@ class CodeJams(commands.Cog): """ Deletes all code jam channels. - Call it three times while spinning around for it all to end. + Displays a confirmation message with the categories and channels to be deleted. Pressing the added reaction + deletes those channels. """ - self.end_counter += 1 - if self.end_counter == 1: - await ctx.send("Are you sure about that?") - return - if self.end_counter == 2: - await ctx.send("Are you *really really* sure about that?") + def predicate_deletion_emoji_reaction(reaction: discord.Reaction, user: discord.User) -> bool: + """Return True if the reaction :boom: was added by the context message author on this message.""" + return ( + reaction.message.id == message.id + and user.id == ctx.author.id + and str(reaction) == DELETION_REACTION + ) + + # A copy of the list of channels is stored. This is to make sure that we delete precisely the channels displayed + # in the confirmation message. + categories = self.jam_categories(ctx.guild) + category_channels = {category: category.channels.copy() for category in categories} + + confirmation_message = await self._build_confirmation_message(category_channels) + message = await ctx.send(confirmation_message) + await message.add_reaction(DELETION_REACTION) + try: + await self.bot.wait_for( + 'reaction_add', + check=predicate_deletion_emoji_reaction, + timeout=10 + ) + + except asyncio.TimeoutError: + await message.clear_reaction(DELETION_REACTION) return - self.end_counter = 0 + else: + await message.clear_reaction(DELETION_REACTION) + for category, channels in category_channels.items(): + for channel in channels: + await channel.delete(reason="Code jam ended.") + await category.delete(reason="Code jam ended.") - for category in self.jam_categories(ctx.guild): - for channel in category.channels: - await channel.delete(reason="Code jam ended.") - await category.delete(reason="Code jam ended.") + await message.add_reaction(Emojis.check_mark) + + @staticmethod + async def _build_confirmation_message( + categories: dict[discord.CategoryChannel, list[discord.abc.GuildChannel]] + ) -> str: + """Sends details of the channels to be deleted to the pasting service, and formats the confirmation message.""" + def channel_repr(channel: discord.abc.GuildChannel) -> str: + """Formats the channel name and ID and a readable format.""" + return f"{channel.name} ({channel.id})" + + def format_category_info(category: discord.CategoryChannel, channels: list[discord.abc.GuildChannel]) -> str: + """Displays the category and the channels within it in a readable format.""" + return f"{channel_repr(category)}:" + "".join(f"\n - {channel_repr(channel)}" for channel in channels) + + deletion_details = "\n\n".join( + format_category_info(category, channels) for category, channels in categories.items() + ) + + url = await send_to_paste_service(deletion_details) + if url is None: + url = "**Unable to send deletion details to the pasting service.**" - await ctx.message.add_reaction(Emojis.check_mark) + return f"Are you sure you want to delete all code jam channels?\n\nThe channels to be deleted: {url}" @codejam.command() @commands.has_any_role(Roles.admins, Roles.code_jam_event_team) -- cgit v1.2.3 From 02cd0ebec94286237832e4ea8a57bb17c1e2adfb Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Sun, 25 Jul 2021 10:10:43 +0100 Subject: Prevent ghost-pings in pypi command (#1696) * Update `utils.messages.wait_for_deletion` Will now clear reactions after the timeout ends to indicate it's no longer possible to delete the message through reactions. * Update pypi command to not ghost-ping users Will no longer ghost-ping users when an invalid packaged is search containing a ping and reaction is pressed to delete message. * Update local file * Remove redundant code No longer try to clear reactions after calling `utils.messages.wait_for_deletion()` since the util now does it. * Remove trailing whitespace * Remove redundant import * Fix NameErrors * Remove redundant import * Reword comment * Update `contextlib.suppress` import to be consistent * Update docstring to reflect earlier changes * Update docstring to be more informative * Update to delete error message if invocation doesn't ping * Update to delete error message if invocation doesn't ping --- bot/exts/info/doc/_cog.py | 5 ++--- bot/exts/info/pypi.py | 15 ++++++++++++--- bot/utils/messages.py | 10 +++++++--- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index 704884fd1..fb9b2584a 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -14,7 +14,7 @@ import discord from discord.ext import commands from bot.bot import Bot -from bot.constants import Emojis, MODERATION_ROLES, RedirectOutput +from bot.constants import MODERATION_ROLES, RedirectOutput from bot.converters import Inventory, PackageName, ValidURL, allowed_strings from bot.pagination import LinePaginator from bot.utils.lock import SharedEvent, lock @@ -341,13 +341,12 @@ class DocCog(commands.Cog): if doc_embed is None: error_message = await send_denial(ctx, "No documentation found for the requested symbol.") await wait_for_deletion(error_message, (ctx.author.id,), timeout=NOT_FOUND_DELETE_DELAY) - with suppress(discord.NotFound): - await error_message.clear_reaction(Emojis.trashcan) # Make sure that we won't cause a ghost-ping by deleting the message if not (ctx.message.mentions or ctx.message.role_mentions): with suppress(discord.NotFound): await ctx.message.delete() + await error_message.delete() else: msg = await ctx.send(embed=doc_embed) diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py index 2e42e7d6b..62498ce0b 100644 --- a/bot/exts/info/pypi.py +++ b/bot/exts/info/pypi.py @@ -2,13 +2,15 @@ import itertools import logging import random import re +from contextlib import suppress -from discord import Embed +from discord import Embed, NotFound from discord.ext.commands import Cog, Context, command from discord.utils import escape_markdown from bot.bot import Bot from bot.constants import Colours, NEGATIVE_REPLIES, RedirectOutput +from bot.utils.messages import wait_for_deletion URL = "https://pypi.org/pypi/{package}/json" PYPI_ICON = "https://cdn.discordapp.com/emojis/766274397257334814.png" @@ -67,8 +69,15 @@ class PyPi(Cog): log.trace(f"Error when fetching PyPi package: {response.status}.") if error: - await ctx.send(embed=embed, delete_after=INVALID_INPUT_DELETE_DELAY) - await ctx.message.delete(delay=INVALID_INPUT_DELETE_DELAY) + error_message = await ctx.send(embed=embed) + await wait_for_deletion(error_message, (ctx.author.id,), timeout=INVALID_INPUT_DELETE_DELAY) + + # Make sure that we won't cause a ghost-ping by deleting the message + if not (ctx.message.mentions or ctx.message.role_mentions): + with suppress(NotFound): + await ctx.message.delete() + await error_message.delete() + else: await ctx.send(embed=embed) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index d4a921161..90672fba2 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -1,5 +1,4 @@ import asyncio -import contextlib import logging import random import re @@ -69,7 +68,9 @@ async def wait_for_deletion( allow_mods: bool = True ) -> None: """ - Wait for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message. + Wait for any of `user_ids` to react with one of the `deletion_emojis` within `timeout` seconds to delete `message`. + + If `timeout` expires then reactions are cleared to indicate the option to delete has expired. An `attach_emojis` bool may be specified to determine whether to attach the given `deletion_emojis` to the message in the given `context`. @@ -95,8 +96,11 @@ async def wait_for_deletion( allow_mods=allow_mods, ) - with contextlib.suppress(asyncio.TimeoutError): + try: await bot.instance.wait_for('reaction_add', check=check, timeout=timeout) + except asyncio.TimeoutError: + await message.clear_reactions() + else: await message.delete() -- cgit v1.2.3 From 741c0dc44e2d8c519564d1477f8d168d82eb572e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 27 Jul 2021 13:52:36 -0700 Subject: Specify DEBUG_MODE via the config file The `SITE_URL` environment variable hasn't been used by anything else for a long time. It wouldn't have been reliable to look for "local" anyway since the Docker hostname may be used. --- bot/constants.py | 2 +- config-default.yml | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index 500803f33..b7c6ffa70 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -687,7 +687,7 @@ class VideoPermission(metaclass=YAMLGetter): # Debug mode -DEBUG_MODE = 'local' in os.environ.get("SITE_URL", "local") +DEBUG_MODE: bool = _CONFIG_YAML["debug"] # Paths BOT_DIR = os.path.dirname(__file__) diff --git a/config-default.yml b/config-default.yml index 811640034..40c6d691e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -1,3 +1,6 @@ +debug: false + + bot: prefix: "!" sentry_dsn: !ENV "BOT_SENTRY_DSN" -- cgit v1.2.3 From 14056b3ede96401d2a3364ca9a0e8f3b3ec72277 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 27 Jul 2021 13:58:17 -0700 Subject: Restrict internal eval to bot owner when in debug mode It's a security risk to let anyone in a test server have essentially full access to the host machine. Resolve #1683 --- bot/exts/utils/internal.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/internal.py b/bot/exts/utils/internal.py index 6f2da3131..5d2cd7611 100644 --- a/bot/exts/utils/internal.py +++ b/bot/exts/utils/internal.py @@ -11,10 +11,10 @@ from io import StringIO from typing import Any, Optional, Tuple import discord -from discord.ext.commands import Cog, Context, group, has_any_role +from discord.ext.commands import Cog, Context, group, has_any_role, is_owner from bot.bot import Bot -from bot.constants import Roles +from bot.constants import DEBUG_MODE, Roles from bot.utils import find_nth_occurrence, send_to_paste_service log = logging.getLogger(__name__) @@ -33,6 +33,9 @@ class Internal(Cog): self.socket_event_total = 0 self.socket_events = Counter() + if DEBUG_MODE: + self.eval.add_check(is_owner().predicate) + @Cog.listener() async def on_socket_response(self, msg: dict) -> None: """When a websocket event is received, increase our counters.""" -- cgit v1.2.3 From b2b9d23469960bcf9c8a0e17af7a6c0d51dda58a Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Wed, 28 Jul 2021 14:45:21 +0200 Subject: Handle non-existent roles in `join_role_stats` --- bot/utils/helpers.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py index b0d17c3b8..cb1d46411 100644 --- a/bot/utils/helpers.py +++ b/bot/utils/helpers.py @@ -2,7 +2,7 @@ from abc import ABCMeta from typing import Dict, List, Optional from discord import Guild -from discord.ext.commands import CogMeta +from discord.ext.commands import BadArgument, CogMeta class CogABCMeta(CogMeta, ABCMeta): @@ -37,5 +37,8 @@ def join_role_stats(role_ids: List[int], name: str, guild: Guild) -> Dict[str, i """Return a dict object with the number of `members` of each role given, and the `name` for this joined group.""" members = [] for role_id in role_ids: - members += guild.get_role(role_id).members + if (role := guild.get_role(role_id)) is None: + raise BadArgument("Unable to fetch role data, the specified role does not exist.") + else: + members += role.members return {name: len(set(members))} -- cgit v1.2.3 From ed00aad9245769137a47e3e536bfa0da93b63917 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Thu, 29 Jul 2021 11:12:56 +0200 Subject: Modify error handling in join_role_stats and move it to the Information Cog --- bot/exts/info/information.py | 17 ++++++++++++++--- bot/utils/helpers.py | 16 ++-------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index eef18298c..54616a1c6 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -3,7 +3,7 @@ import logging import pprint import textwrap from collections import defaultdict -from typing import Any, DefaultDict, Dict, Mapping, Optional, Tuple, Union +from typing import Any, DefaultDict, Dict, List, Mapping, Optional, Tuple, Union import rapidfuzz from discord import AllowedMentions, Colour, Embed, Guild, Message, Role @@ -17,7 +17,6 @@ from bot.decorators import in_whitelist from bot.pagination import LinePaginator from bot.utils.channel import is_mod_channel, is_staff_channel from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check -from bot.utils.helpers import join_role_stats from bot.utils.time import TimestampFormats, discord_timestamp, humanize_delta @@ -43,6 +42,17 @@ class Information(Cog): return channel_counter + @staticmethod + def join_role_stats(role_ids: List[int], name: str, guild: Guild) -> Dict[str, int]: + """Return a dictionary with the number of `members` of each role given, and the `name` for this joined group.""" + members = [] + for role_id in role_ids: + if (role := guild.get_role(role_id)) is None: + raise ValueError(f"Could not fetch data for role {role} for server embed.") + else: + members += role.members + return {name: len(set(members))} + @staticmethod def get_member_counts(guild: Guild) -> Dict[str, int]: """Return the total number of members for certain roles in `guild`.""" @@ -54,7 +64,8 @@ class Information(Cog): ) role_stats = {role.name.title(): len(role.members) for role in roles} role_stats.update( - **join_role_stats([constants.Roles.project_leads, constants.Roles.domain_leads], "Leads", guild)) + **Information.join_role_stats([constants.Roles.project_leads, constants.Roles.domain_leads], "Leads", guild) + ) return role_stats def get_extended_server_info(self, ctx: Context) -> str: diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py index cb1d46411..3501a3933 100644 --- a/bot/utils/helpers.py +++ b/bot/utils/helpers.py @@ -1,8 +1,7 @@ from abc import ABCMeta -from typing import Dict, List, Optional +from typing import Optional -from discord import Guild -from discord.ext.commands import BadArgument, CogMeta +from discord.ext.commands import CogMeta class CogABCMeta(CogMeta, ABCMeta): @@ -31,14 +30,3 @@ def has_lines(string: str, count: int) -> bool: def pad_base64(data: str) -> str: """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" return data + "=" * (-len(data) % 4) - - -def join_role_stats(role_ids: List[int], name: str, guild: Guild) -> Dict[str, int]: - """Return a dict object with the number of `members` of each role given, and the `name` for this joined group.""" - members = [] - for role_id in role_ids: - if (role := guild.get_role(role_id)) is None: - raise BadArgument("Unable to fetch role data, the specified role does not exist.") - else: - members += role.members - return {name: len(set(members))} -- cgit v1.2.3 From 43240643642fae3fe92413189e99d772fdebf562 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Fri, 30 Jul 2021 03:25:25 +0200 Subject: Update join_role_stats, add new custom error - Add new custom error to handle non-existent roles in the Information cog - Update join_role_stats to use built in generics for typing --- bot/errors.py | 12 ++++++++++++ bot/exts/info/information.py | 24 +++++++++++++----------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/bot/errors.py b/bot/errors.py index 46efb6d4f..ce2371f56 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -41,3 +41,15 @@ class BrandingMisconfiguration(RuntimeError): """Raised by the Branding cog when a misconfigured event is encountered.""" pass + + +class NonExistentRoleError(ValueError): + """ + Raised by the Information Cog when encountering a role that does not exist. + + Attributes: + `role_id` -- the ID of the role that does not exist + """ + + def __init__(self, role_id: int): + super().__init__(f"Could not fetch data for role {role_id}") diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 54616a1c6..78a9b2122 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -3,7 +3,7 @@ import logging import pprint import textwrap from collections import defaultdict -from typing import Any, DefaultDict, Dict, List, Mapping, Optional, Tuple, Union +from typing import Any, DefaultDict, Mapping, Optional, Tuple, Union import rapidfuzz from discord import AllowedMentions, Colour, Embed, Guild, Message, Role @@ -14,12 +14,12 @@ from bot.api import ResponseCodeError from bot.bot import Bot from bot.converters import FetchedMember from bot.decorators import in_whitelist +from bot.errors import NonExistentRoleError from bot.pagination import LinePaginator from bot.utils.channel import is_mod_channel, is_staff_channel from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check from bot.utils.time import TimestampFormats, discord_timestamp, humanize_delta - log = logging.getLogger(__name__) @@ -43,25 +43,27 @@ class Information(Cog): return channel_counter @staticmethod - def join_role_stats(role_ids: List[int], name: str, guild: Guild) -> Dict[str, int]: + def join_role_stats(role_ids: list[int], name: str, guild: Guild) -> dict[str, int]: """Return a dictionary with the number of `members` of each role given, and the `name` for this joined group.""" members = [] for role_id in role_ids: if (role := guild.get_role(role_id)) is None: - raise ValueError(f"Could not fetch data for role {role} for server embed.") + raise NonExistentRoleError(role_id) else: members += role.members return {name: len(set(members))} @staticmethod - def get_member_counts(guild: Guild) -> Dict[str, int]: + def get_member_counts(guild: Guild) -> dict[str, int]: """Return the total number of members for certain roles in `guild`.""" - roles = ( - guild.get_role(role_id) for role_id in ( - constants.Roles.helpers, constants.Roles.mod_team, constants.Roles.admins, - constants.Roles.owners, constants.Roles.contributors, - ) - ) + role_ids = [constants.Roles.helpers, constants.Roles.mod_team, constants.Roles.admins, + constants.Roles.owners, constants.Roles.contributors] + roles = [] + for role_id in role_ids: + if (role := guild.get_role(role_id)) is None: + raise NonExistentRoleError(role_id) + else: + roles.append(role) role_stats = {role.name.title(): len(role.members) for role in roles} role_stats.update( **Information.join_role_stats([constants.Roles.project_leads, constants.Roles.domain_leads], "Leads", guild) -- cgit v1.2.3 From a5cbe723435fba4a4db82aa518cef68ab2836f29 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sat, 31 Jul 2021 21:15:18 +0100 Subject: Add partners to the filtering whitelist A partner being filtered is highly unlikely to be correct --- config-default.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 811640034..881a7df76 100644 --- a/config-default.yml +++ b/config-default.yml @@ -260,7 +260,7 @@ guild: contributors: 295488872404484098 help_cooldown: 699189276025421825 muted: &MUTED_ROLE 277914926603829249 - partners: 323426753857191936 + partners: &PY_PARTNER_ROLE 323426753857191936 python_community: &PY_COMMUNITY_ROLE 458226413825294336 sprinters: &SPRINTERS 758422482289426471 voice_verified: 764802720779337729 @@ -342,6 +342,7 @@ filter: - *OWNERS_ROLE - *PY_COMMUNITY_ROLE - *SPRINTERS + - *PY_PARTNER_ROLE keys: -- cgit v1.2.3 From 3c91cfb852de5e4d01e8e1778e366ddc59e57f0f Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Sun, 1 Aug 2021 18:53:04 +0200 Subject: Update join_role_stats and NonExistentError to be clear --- bot/errors.py | 3 ++- bot/exts/info/information.py | 13 +++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/bot/errors.py b/bot/errors.py index ce2371f56..69c588f4a 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -52,4 +52,5 @@ class NonExistentRoleError(ValueError): """ def __init__(self, role_id: int): - super().__init__(f"Could not fetch data for role {role_id}") + self.role_id = role_id + super().__init__(f"Could not fetch data for role {self.role_id}") diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 78a9b2122..62fde5473 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -47,10 +47,10 @@ class Information(Cog): """Return a dictionary with the number of `members` of each role given, and the `name` for this joined group.""" members = [] for role_id in role_ids: - if (role := guild.get_role(role_id)) is None: - raise NonExistentRoleError(role_id) + if (role := guild.get_role(role_id)) is not None: + members.append(role.members) else: - members += role.members + raise NonExistentRoleError(role_id) return {name: len(set(members))} @staticmethod @@ -60,10 +60,11 @@ class Information(Cog): constants.Roles.owners, constants.Roles.contributors] roles = [] for role_id in role_ids: - if (role := guild.get_role(role_id)) is None: - raise NonExistentRoleError(role_id) - else: + if (role := guild.get_role(role_id)) is not None: roles.append(role) + else: + raise NonExistentRoleError(role_id) + role_stats = {role.name.title(): len(role.members) for role in roles} role_stats.update( **Information.join_role_stats([constants.Roles.project_leads, constants.Roles.domain_leads], "Leads", guild) -- cgit v1.2.3 From 587e52e1bd05eaf66035b6089cb37149399c8851 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sun, 1 Aug 2021 19:58:46 +0100 Subject: Add commands to turn automatic review posting on or off --- bot/exts/recruitment/talentpool/_cog.py | 59 ++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 03326cab2..5d90701bf 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -5,12 +5,13 @@ from io import StringIO from typing import Union import discord +from async_rediscache import RedisCache from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent, User from discord.ext.commands import Cog, Context, group, has_any_role from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks +from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES, Webhooks from bot.converters import FetchedMember from bot.exts.moderation.watchchannels._watchchannel import WatchChannel from bot.exts.recruitment.talentpool._review import Reviewer @@ -25,6 +26,8 @@ log = logging.getLogger(__name__) class TalentPool(WatchChannel, Cog, name="Talentpool"): """Relays messages of helper candidates to a watch channel to observe them.""" + talentpool_settings = RedisCache() + def __init__(self, bot: Bot) -> None: super().__init__( bot, @@ -37,7 +40,18 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): ) self.reviewer = Reviewer(self.__class__.__name__, bot, self) - self.bot.loop.create_task(self.reviewer.reschedule_reviews()) + self.bot.loop.create_task(self.schedule_autoreviews()) + + async def schedule_autoreviews(self) -> None: + """Reschedule reviews for active nominations if autoreview is enabled.""" + if await self.autoreview_enabled(): + await self.reviewer.reschedule_reviews() + else: + self.log.trace('Not scheduling reviews as autoreview is disabled.') + + async def autoreview_enabled(self) -> bool: + """Return whether automatic posting of nomination reviews is enabled.""" + return await self.talentpool_settings.get('autoreview_enabled', True) @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) @has_any_role(*MODERATION_ROLES) @@ -45,6 +59,42 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" await ctx.send_help(ctx.command) + @nomination_group.group(name='autoreview', invoke_without_command=True) + @has_any_role(*MODERATION_ROLES) + async def nomination_autoreview_group(self, ctx: Context) -> None: + """Commands for enabling or disabling autoreview.""" + await ctx.send_help(ctx.command) + + @nomination_autoreview_group.command(name='on') + @has_any_role(Roles.admins) + async def autoreview_on(self, ctx: Context) -> None: + """ + Turn on automatic posting of reviews. + + This will post reviews up to one day overdue, older nominations can be + manually reviewed with `!tp post_review `. + """ + await self.talentpool_settings.set('autoreview_enabled', True) + await self.reviewer.reschedule_reviews() + await ctx.send(':white_check_mark: Autoreview turned on') + + @nomination_autoreview_group.command(name='off') + @has_any_role(Roles.admins) + async def autoreview_off(self, ctx: Context) -> None: + """Turn off automatic posting of reviews.""" + await self.talentpool_settings.set('autoreview_enabled', False) + self.reviewer.cancel_all() + await ctx.send(':white_check_mark: Autoreview turned off') + + @has_any_role(*MODERATION_ROLES) + @nomination_autoreview_group.command(name='status') + async def autoreview_status(self, ctx: Context) -> None: + """Show whether automatic posting of reviews is enabled or disabled.""" + if await self.autoreview_enabled(): + await ctx.send('Autoreview is currently enabled') + else: + await ctx.send('Autoreview is currently disabled') + @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",)) @has_any_role(*MODERATION_ROLES) async def watched_command( @@ -190,7 +240,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): self.watched_users[user.id] = response_data - if user.id not in self.reviewer: + if await self.autoreview_enabled() and user.id not in self.reviewer: self.reviewer.schedule_review(user.id) history = await self.bot.api_client.get( @@ -403,7 +453,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): ) self._remove_user(user_id) - self.reviewer.cancel(user_id) + if await self.autoreview_enabled(): + self.reviewer.cancel(user_id) return True -- cgit v1.2.3 From 3bdec8eaddfc8bfc6644f61a3aa7e5842757149c Mon Sep 17 00:00:00 2001 From: wookie184 Date: Mon, 2 Aug 2021 08:45:10 +0100 Subject: Small code improvements and added 'ar' alias --- bot/exts/recruitment/talentpool/_cog.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 5d90701bf..d0558f1f3 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -18,6 +18,7 @@ from bot.exts.recruitment.talentpool._review import Reviewer from bot.pagination import LinePaginator from bot.utils import time +AUTOREVIEW_ENABLED_KEY = 'autoreview_enabled' REASON_MAX_CHARS = 1000 log = logging.getLogger(__name__) @@ -51,7 +52,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): async def autoreview_enabled(self) -> bool: """Return whether automatic posting of nomination reviews is enabled.""" - return await self.talentpool_settings.get('autoreview_enabled', True) + return await self.talentpool_settings.get(AUTOREVIEW_ENABLED_KEY, True) @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True) @has_any_role(*MODERATION_ROLES) @@ -59,7 +60,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" await ctx.send_help(ctx.command) - @nomination_group.group(name='autoreview', invoke_without_command=True) + @nomination_group.group(name='autoreview', aliases=('ar',), invoke_without_command=True) @has_any_role(*MODERATION_ROLES) async def nomination_autoreview_group(self, ctx: Context) -> None: """Commands for enabling or disabling autoreview.""" @@ -71,10 +72,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """ Turn on automatic posting of reviews. - This will post reviews up to one day overdue, older nominations can be + This will post reviews up to one day overdue. Older nominations can be manually reviewed with `!tp post_review `. """ - await self.talentpool_settings.set('autoreview_enabled', True) + await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, True) await self.reviewer.reschedule_reviews() await ctx.send(':white_check_mark: Autoreview turned on') @@ -82,12 +83,12 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @has_any_role(Roles.admins) async def autoreview_off(self, ctx: Context) -> None: """Turn off automatic posting of reviews.""" - await self.talentpool_settings.set('autoreview_enabled', False) + await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, False) self.reviewer.cancel_all() await ctx.send(':white_check_mark: Autoreview turned off') - @has_any_role(*MODERATION_ROLES) @nomination_autoreview_group.command(name='status') + @has_any_role(*MODERATION_ROLES) async def autoreview_status(self, ctx: Context) -> None: """Show whether automatic posting of reviews is enabled or disabled.""" if await self.autoreview_enabled(): -- cgit v1.2.3 From 792c5a474b5d5f0a4ad58c536f86cad5a5bf3e18 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Mon, 2 Aug 2021 17:25:59 +0100 Subject: Rename commands from on/off to enable/disable Added on/off as aliases --- bot/exts/recruitment/talentpool/_cog.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index d0558f1f3..aff2aa023 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -66,26 +66,26 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """Commands for enabling or disabling autoreview.""" await ctx.send_help(ctx.command) - @nomination_autoreview_group.command(name='on') + @nomination_autoreview_group.command(name='enable', aliases=('on',)) @has_any_role(Roles.admins) - async def autoreview_on(self, ctx: Context) -> None: + async def autoreview_enable(self, ctx: Context) -> None: """ - Turn on automatic posting of reviews. + Enable automatic posting of reviews. This will post reviews up to one day overdue. Older nominations can be manually reviewed with `!tp post_review `. """ await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, True) await self.reviewer.reschedule_reviews() - await ctx.send(':white_check_mark: Autoreview turned on') + await ctx.send(':white_check_mark: Autoreview enabled') - @nomination_autoreview_group.command(name='off') + @nomination_autoreview_group.command(name='disable', aliases=('off',)) @has_any_role(Roles.admins) - async def autoreview_off(self, ctx: Context) -> None: - """Turn off automatic posting of reviews.""" + async def autoreview_disable(self, ctx: Context) -> None: + """Disable automatic posting of reviews.""" await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, False) self.reviewer.cancel_all() - await ctx.send(':white_check_mark: Autoreview turned off') + await ctx.send(':white_check_mark: Autoreview disabled') @nomination_autoreview_group.command(name='status') @has_any_role(*MODERATION_ROLES) -- cgit v1.2.3 From 20997b425168890636a04b270e7433fa82c60fa6 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 2 Aug 2021 18:28:05 +0200 Subject: Optimize Information Cog's join_role_stats and get_member counts --- bot/errors.py | 3 ++- bot/exts/info/information.py | 20 ++++++++------------ 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/bot/errors.py b/bot/errors.py index 69c588f4a..5209997f9 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -52,5 +52,6 @@ class NonExistentRoleError(ValueError): """ def __init__(self, role_id: int): + super().__init__(f"Could not fetch data for role {role_id}") + self.role_id = role_id - super().__init__(f"Could not fetch data for role {self.role_id}") diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 62fde5473..2621e15b1 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -43,31 +43,27 @@ class Information(Cog): return channel_counter @staticmethod - def join_role_stats(role_ids: list[int], name: str, guild: Guild) -> dict[str, int]: + def join_role_stats(role_ids: list[int], guild: Guild, name: Optional[str] = None) -> dict[str, int]: """Return a dictionary with the number of `members` of each role given, and the `name` for this joined group.""" - members = [] + members = int() for role_id in role_ids: if (role := guild.get_role(role_id)) is not None: - members.append(role.members) + members += len(role.members) else: raise NonExistentRoleError(role_id) - return {name: len(set(members))} + return {name or role.name.title(): members} @staticmethod def get_member_counts(guild: Guild) -> dict[str, int]: """Return the total number of members for certain roles in `guild`.""" role_ids = [constants.Roles.helpers, constants.Roles.mod_team, constants.Roles.admins, constants.Roles.owners, constants.Roles.contributors] - roles = [] - for role_id in role_ids: - if (role := guild.get_role(role_id)) is not None: - roles.append(role) - else: - raise NonExistentRoleError(role_id) - role_stats = {role.name.title(): len(role.members) for role in roles} + role_stats = {} + for role_id in role_ids: + role_stats.update(Information.join_role_stats([role_id], guild)) role_stats.update( - **Information.join_role_stats([constants.Roles.project_leads, constants.Roles.domain_leads], "Leads", guild) + Information.join_role_stats([constants.Roles.project_leads, constants.Roles.domain_leads], guild, "Leads") ) return role_stats -- cgit v1.2.3 From 8bb2ffc4f7bb324c7940fb88ae1b698f56f8ce67 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 2 Aug 2021 20:42:56 +0200 Subject: Improve code consistency of join_role_stats and NonExistentRoleError --- bot/errors.py | 2 +- bot/exts/info/information.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/errors.py b/bot/errors.py index 5209997f9..5785faa44 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -45,7 +45,7 @@ class BrandingMisconfiguration(RuntimeError): class NonExistentRoleError(ValueError): """ - Raised by the Information Cog when encountering a role that does not exist. + Raised by the Information Cog when encountering a Role that does not exist. Attributes: `role_id` -- the ID of the role that does not exist diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 2621e15b1..b879e1330 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -45,7 +45,7 @@ class Information(Cog): @staticmethod def join_role_stats(role_ids: list[int], guild: Guild, name: Optional[str] = None) -> dict[str, int]: """Return a dictionary with the number of `members` of each role given, and the `name` for this joined group.""" - members = int() + members = 0 for role_id in role_ids: if (role := guild.get_role(role_id)) is not None: members += len(role.members) -- cgit v1.2.3 From c1fcab3ba4767cac481fa32ebaf2db31b108faa4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 2 Aug 2021 19:41:30 -0700 Subject: Fix TypeError when infraction append is not given a reason Fix #1706 --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 4b0cb78a5..3094159cd 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -81,7 +81,7 @@ class ModManagement(commands.Cog): """ old_reason = infraction["reason"] - if old_reason is not None: + if old_reason is not None and reason is not None: add_period = not old_reason.endswith((".", "!", "?")) reason = old_reason + (". " if add_period else " ") + reason -- cgit v1.2.3 From 53f8ea442994a8f5a465d49031894be0ae17748a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 2 Aug 2021 20:11:17 -0700 Subject: Catch 404 error when waiting to delete message Sometimes the message is deleted before the function gets around to it. Fixes BOT-1JD Fixes BOT-1K3 Fixes BOT-1JE --- bot/utils/messages.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 90672fba2..a82096b1c 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -97,11 +97,14 @@ async def wait_for_deletion( ) try: - await bot.instance.wait_for('reaction_add', check=check, timeout=timeout) - except asyncio.TimeoutError: - await message.clear_reactions() - else: - await message.delete() + try: + await bot.instance.wait_for('reaction_add', check=check, timeout=timeout) + except asyncio.TimeoutError: + await message.clear_reactions() + else: + await message.delete() + except discord.NotFound: + log.trace(f"wait_for_deletion: message {message.id} deleted prematurely.") async def send_attachments( -- cgit v1.2.3 From 8bfc0d7a35b0c49a787e498280cffb4841ffa14e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 2 Aug 2021 20:12:48 -0700 Subject: Reduce imports in utils/messages by qualifying names --- bot/utils/messages.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index a82096b1c..abeb04021 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -7,8 +7,6 @@ from io import BytesIO from typing import Callable, List, Optional, Sequence, Union import discord -from discord import Message, MessageType, Reaction, User -from discord.errors import HTTPException from discord.ext.commands import Context import bot @@ -53,7 +51,7 @@ def reaction_check( log.trace(f"Removing reaction {reaction} by {user} on {reaction.message.id}: disallowed user.") scheduling.create_task( reaction.message.remove_reaction(reaction.emoji, user), - suppressed_exceptions=(HTTPException,), + suppressed_exceptions=(discord.HTTPException,), name=f"remove_reaction-{reaction}-{reaction.message.id}-{user}" ) return False @@ -153,7 +151,7 @@ async def send_attachments( large.append(attachment) else: log.info(f"{failure_msg} because it's too large.") - except HTTPException as e: + except discord.HTTPException as e: if link_large and e.status == 413: large.append(attachment) else: @@ -174,8 +172,8 @@ async def send_attachments( async def count_unique_users_reaction( message: discord.Message, - reaction_predicate: Callable[[Reaction], bool] = lambda _: True, - user_predicate: Callable[[User], bool] = lambda _: True, + reaction_predicate: Callable[[discord.Reaction], bool] = lambda _: True, + user_predicate: Callable[[discord.User], bool] = lambda _: True, count_bots: bool = True ) -> int: """ @@ -195,7 +193,7 @@ async def count_unique_users_reaction( return len(unique_users) -async def pin_no_system_message(message: Message) -> bool: +async def pin_no_system_message(message: discord.Message) -> bool: """Pin the given message, wait a couple of seconds and try to delete the system message.""" await message.pin() @@ -203,7 +201,7 @@ async def pin_no_system_message(message: Message) -> bool: await asyncio.sleep(2) # Search for the system message in the last 10 messages async for historical_message in message.channel.history(limit=10): - if historical_message.type == MessageType.pins_add: + if historical_message.type == discord.MessageType.pins_add: await historical_message.delete() return True -- cgit v1.2.3 From 80861fff60b2622f42f91c8821808e3cbb13fd1b Mon Sep 17 00:00:00 2001 From: Zack Didcott <66186954+Zedeldi@users.noreply.github.com> Date: Tue, 3 Aug 2021 03:43:25 +0000 Subject: Add virtual environment (venv) tag (#1702) * Add virtual environment (venv) tag Co-authored-by: bast0006 Co-authored-by: wookie184 Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/resources/tags/venv.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 bot/resources/tags/venv.md diff --git a/bot/resources/tags/venv.md b/bot/resources/tags/venv.md new file mode 100644 index 000000000..a4fc62151 --- /dev/null +++ b/bot/resources/tags/venv.md @@ -0,0 +1,20 @@ +**Virtual Environments** + +Virtual environments are isolated Python environments, which make it easier to keep your system clean and manage dependencies. By default, when activated, only libraries and scripts installed in the virtual environment are accessible, preventing cross-project dependency conflicts, and allowing easy isolation of requirements. + +To create a new virtual environment, you can use the standard library `venv` module: `python3 -m venv .venv` (replace `python3` with `python` or `py` on Windows) + +Then, to activate the new virtual environment: + +**Windows** (PowerShell): `.venv\Scripts\Activate.ps1` +or (Command Prompt): `.venv\Scripts\activate.bat` +**MacOS / Linux** (Bash): `source .venv/bin/activate` + +Packages can then be installed to the virtual environment using `pip`, as normal. + +For more information, take a read of the [documentation](https://docs.python.org/3/library/venv.html). If you run code through your editor, check its documentation on how to make it use your virtual environment. For example, see the [VSCode](https://code.visualstudio.com/docs/python/environments#_select-and-activate-an-environment) or [PyCharm](https://www.jetbrains.com/help/pycharm/creating-virtual-environment.html) docs. + +Tools such as [poetry](https://python-poetry.org/docs/basic-usage/) and [pipenv](https://pipenv.pypa.io/en/latest/) can manage the creation of virtual environments as well as project dependencies, making packaging and installing your project easier. + +**Note:** When using Windows PowerShell, you may need to change the [execution policy](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies) first. This is only required once: +`Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser` -- cgit v1.2.3 From 5aa0022692eefb64a4e69da24e73745d949ba562 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Tue, 3 Aug 2021 12:16:39 +0100 Subject: Remove bot prefix from docstring and change single quotes to double --- bot/exts/recruitment/talentpool/_cog.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index aff2aa023..d2b1d7c02 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -18,7 +18,7 @@ from bot.exts.recruitment.talentpool._review import Reviewer from bot.pagination import LinePaginator from bot.utils import time -AUTOREVIEW_ENABLED_KEY = 'autoreview_enabled' +AUTOREVIEW_ENABLED_KEY = "autoreview_enabled" REASON_MAX_CHARS = 1000 log = logging.getLogger(__name__) @@ -48,7 +48,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): if await self.autoreview_enabled(): await self.reviewer.reschedule_reviews() else: - self.log.trace('Not scheduling reviews as autoreview is disabled.') + self.log.trace("Not scheduling reviews as autoreview is disabled.") async def autoreview_enabled(self) -> bool: """Return whether automatic posting of nomination reviews is enabled.""" @@ -60,41 +60,41 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" await ctx.send_help(ctx.command) - @nomination_group.group(name='autoreview', aliases=('ar',), invoke_without_command=True) + @nomination_group.group(name="autoreview", aliases=("ar",), invoke_without_command=True) @has_any_role(*MODERATION_ROLES) async def nomination_autoreview_group(self, ctx: Context) -> None: """Commands for enabling or disabling autoreview.""" await ctx.send_help(ctx.command) - @nomination_autoreview_group.command(name='enable', aliases=('on',)) + @nomination_autoreview_group.command(name="enable", aliases=("on",)) @has_any_role(Roles.admins) async def autoreview_enable(self, ctx: Context) -> None: """ Enable automatic posting of reviews. This will post reviews up to one day overdue. Older nominations can be - manually reviewed with `!tp post_review `. + manually reviewed with the `tp post_review ` command. """ await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, True) await self.reviewer.reschedule_reviews() - await ctx.send(':white_check_mark: Autoreview enabled') + await ctx.send(":white_check_mark: Autoreview enabled") - @nomination_autoreview_group.command(name='disable', aliases=('off',)) + @nomination_autoreview_group.command(name="disable", aliases=("off",)) @has_any_role(Roles.admins) async def autoreview_disable(self, ctx: Context) -> None: """Disable automatic posting of reviews.""" await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, False) self.reviewer.cancel_all() - await ctx.send(':white_check_mark: Autoreview disabled') + await ctx.send(":white_check_mark: Autoreview disabled") - @nomination_autoreview_group.command(name='status') + @nomination_autoreview_group.command(name="status") @has_any_role(*MODERATION_ROLES) async def autoreview_status(self, ctx: Context) -> None: """Show whether automatic posting of reviews is enabled or disabled.""" if await self.autoreview_enabled(): - await ctx.send('Autoreview is currently enabled') + await ctx.send("Autoreview is currently enabled") else: - await ctx.send('Autoreview is currently disabled') + await ctx.send("Autoreview is currently disabled") @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",)) @has_any_role(*MODERATION_ROLES) -- cgit v1.2.3 From def62f6129e5b24a52f1cc0cafc616edfa44f3b2 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Tue, 3 Aug 2021 12:23:57 +0100 Subject: Add check for redundant change --- bot/exts/recruitment/talentpool/_cog.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index d2b1d7c02..e7ad314e8 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -75,6 +75,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): This will post reviews up to one day overdue. Older nominations can be manually reviewed with the `tp post_review ` command. """ + if await self.autoreview_enabled(): + await ctx.send(":x: Autoreview is already enabled") + return + await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, True) await self.reviewer.reschedule_reviews() await ctx.send(":white_check_mark: Autoreview enabled") @@ -83,6 +87,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @has_any_role(Roles.admins) async def autoreview_disable(self, ctx: Context) -> None: """Disable automatic posting of reviews.""" + if not await self.autoreview_enabled(): + await ctx.send(":x: Autoreview is already disabled") + return + await self.talentpool_settings.set(AUTOREVIEW_ENABLED_KEY, False) self.reviewer.cancel_all() await ctx.send(":white_check_mark: Autoreview disabled") -- cgit v1.2.3 From 62ab2fc8d98e6ff28af8e9ca2944ce705de4ab48 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Tue, 3 Aug 2021 19:25:04 +0300 Subject: Correctly delete from cache * Fixed wrong condition in rescheduler which made the eventual consistency not work. * Mods now correctly removed from cache on role reapplies. --- bot/exts/moderation/modpings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 1ad5005de..29a5c1c8e 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -44,7 +44,7 @@ class ModPings(Cog): log.trace("Applying the moderators role to the mod team where necessary.") for mod in mod_team.members: if mod in pings_on: # Make sure that on-duty mods aren't in the cache. - if mod in pings_off: + if mod.id in pings_off: await self.pings_off_mods.delete(mod.id) continue @@ -59,6 +59,7 @@ class ModPings(Cog): """Reapply the moderator's role to the given moderator.""" log.trace(f"Re-applying role to mod with ID {mod.id}.") await mod.add_roles(self.moderators_role, reason="Pings off period expired.") + await self.pings_off_mods.delete(mod.id) @group(name='modpings', aliases=('modping',), invoke_without_command=True) @has_any_role(*MODERATION_ROLES) -- cgit v1.2.3 From 1e3d8fa4d8e6ba4bdabc96e31bb7c0c4ab82b1d5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 2 Aug 2021 20:38:55 -0700 Subject: Update Sentry SDK to 1.3 --- poetry.lock | 207 ++++++++++++++++++++++++++++++++++++--------------------- pyproject.toml | 2 +- 2 files changed, 131 insertions(+), 78 deletions(-) diff --git a/poetry.lock b/poetry.lock index dac277ed8..a4ce5d1a9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -70,14 +70,6 @@ yarl = "*" [package.extras] develop = ["aiomisc (>=11.0,<12.0)", "async-generator", "coverage (!=4.3)", "coveralls", "pylava", "pytest", "pytest-cov", "tox (>=2.4)"] -[[package]] -name = "appdirs" -version = "1.4.4" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" -optional = false -python-versions = "*" - [[package]] name = "arrow" version = "1.0.3" @@ -134,6 +126,18 @@ docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] +[[package]] +name = "backports.entry-points-selectable" +version = "1.1.0" +description = "Compatibility shim providing selectable entry points for older implementations" +category = "dev" +optional = false +python-versions = ">=2.7" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=4.6)", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] + [[package]] name = "beautifulsoup4" version = "4.9.3" @@ -159,7 +163,7 @@ python-versions = "*" [[package]] name = "cffi" -version = "1.14.5" +version = "1.14.6" description = "Foreign Function Interface for Python calling C code." category = "main" optional = false @@ -184,6 +188,17 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "charset-normalizer" +version = "2.0.4" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "dev" +optional = false +python-versions = ">=3.5.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + [[package]] name = "colorama" version = "0.4.4" @@ -463,7 +478,7 @@ pyreadline = {version = "*", markers = "sys_platform == \"win32\""} [[package]] name = "identify" -version = "2.2.10" +version = "2.2.11" description = "File identification library for Python" category = "dev" optional = false @@ -474,11 +489,11 @@ license = ["editdistance-s"] [[package]] name = "idna" -version = "2.10" +version = "3.2" description = "Internationalized Domain Names in Applications (IDNA)" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.5" [[package]] name = "iniconfig" @@ -596,6 +611,18 @@ python-versions = "*" flake8 = ">=3.9.1" flake8-polyfill = ">=1.0.2,<2" +[[package]] +name = "platformdirs" +version = "2.2.0" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] +test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] + [[package]] name = "pluggy" version = "0.13.1" @@ -779,7 +806,7 @@ testing = ["filelock"] [[package]] name = "python-dateutil" -version = "2.8.1" +version = "2.8.2" description = "Extensions to the standard Python datetime module" category = "main" optional = false @@ -851,25 +878,25 @@ python-versions = "*" [[package]] name = "requests" -version = "2.25.1" +version = "2.26.0" description = "Python HTTP for Humans." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [package.dependencies] certifi = ">=2017.4.17" -chardet = ">=3.0.2,<5" -idna = ">=2.5,<3" +charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} +idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} urllib3 = ">=1.21.1,<1.27" [package.extras] -security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] [[package]] name = "sentry-sdk" -version = "0.20.3" +version = "1.3.1" description = "Python client for Sentry (https://sentry.io)" category = "main" optional = false @@ -888,6 +915,7 @@ chalice = ["chalice (>=1.16.0)"] django = ["django (>=1.8)"] falcon = ["falcon (>=1.4)"] flask = ["flask (>=0.11)", "blinker (>=1.1)"] +httpx = ["httpx (>=0.16.0)"] pure_eval = ["pure-eval", "executing", "asttokens"] pyspark = ["pyspark (>=2.4.4)"] rq = ["rq (>=0.6)"] @@ -987,21 +1015,22 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.4.7" +version = "20.7.0" description = "Virtual Python Environment builder" category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] -appdirs = ">=1.4.3,<2" +"backports.entry-points-selectable" = ">=1.0.4" distlib = ">=0.3.1,<1" filelock = ">=3.0.0,<4" +platformdirs = ">=2,<3" six = ">=1.9.0,<2" [package.extras] docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] -testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] +testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] [[package]] name = "yarl" @@ -1018,7 +1047,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "85160036e3b07c9d5d24a32302462591e82cc3bf3d5490b87550d9c26bc5648d" +content-hash = "f46fe1d2d9e0621e4e06d4c2ba5f6190ec4574ac6ca809abe8bf542a3b55204e" [metadata.files] aio-pika = [ @@ -1076,10 +1105,6 @@ aiormq = [ {file = "aiormq-3.3.1-py3-none-any.whl", hash = "sha256:e584dac13a242589aaf42470fd3006cb0dc5aed6506cbd20357c7ec8bbe4a89e"}, {file = "aiormq-3.3.1.tar.gz", hash = "sha256:8218dd9f7198d6e7935855468326bbacf0089f926c70baa8dd92944cb2496573"}, ] -appdirs = [ - {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, - {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, -] arrow = [ {file = "arrow-1.0.3-py3-none-any.whl", hash = "sha256:3515630f11a15c61dcb4cdd245883270dd334c83f3e639824e65a4b79cc48543"}, {file = "arrow-1.0.3.tar.gz", hash = "sha256:399c9c8ae732270e1aa58ead835a79a40d7be8aa109c579898eb41029b5a231d"}, @@ -1100,6 +1125,10 @@ attrs = [ {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, ] +"backports.entry-points-selectable" = [ + {file = "backports.entry_points_selectable-1.1.0-py2.py3-none-any.whl", hash = "sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc"}, + {file = "backports.entry_points_selectable-1.1.0.tar.gz", hash = "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a"}, +] beautifulsoup4 = [ {file = "beautifulsoup4-4.9.3-py2-none-any.whl", hash = "sha256:4c98143716ef1cb40bf7f39a8e3eec8f8b009509e74904ba3a7b315431577e35"}, {file = "beautifulsoup4-4.9.3-py3-none-any.whl", hash = "sha256:fff47e031e34ec82bf17e00da8f592fe7de69aeea38be00523c04623c04fb666"}, @@ -1110,43 +1139,51 @@ certifi = [ {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, ] cffi = [ - {file = "cffi-1.14.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991"}, - {file = "cffi-1.14.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1"}, - {file = "cffi-1.14.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa"}, - {file = "cffi-1.14.5-cp27-cp27m-win32.whl", hash = "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3"}, - {file = "cffi-1.14.5-cp27-cp27m-win_amd64.whl", hash = "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5"}, - {file = "cffi-1.14.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482"}, - {file = "cffi-1.14.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6"}, - {file = "cffi-1.14.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045"}, - {file = "cffi-1.14.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa"}, - {file = "cffi-1.14.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406"}, - {file = "cffi-1.14.5-cp35-cp35m-win32.whl", hash = "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369"}, - {file = "cffi-1.14.5-cp35-cp35m-win_amd64.whl", hash = "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315"}, - {file = "cffi-1.14.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892"}, - {file = "cffi-1.14.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058"}, - {file = "cffi-1.14.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5"}, - {file = "cffi-1.14.5-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132"}, - {file = "cffi-1.14.5-cp36-cp36m-win32.whl", hash = "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53"}, - {file = "cffi-1.14.5-cp36-cp36m-win_amd64.whl", hash = "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813"}, - {file = "cffi-1.14.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73"}, - {file = "cffi-1.14.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06"}, - {file = "cffi-1.14.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1"}, - {file = "cffi-1.14.5-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49"}, - {file = "cffi-1.14.5-cp37-cp37m-win32.whl", hash = "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62"}, - {file = "cffi-1.14.5-cp37-cp37m-win_amd64.whl", hash = "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4"}, - {file = "cffi-1.14.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053"}, - {file = "cffi-1.14.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0"}, - {file = "cffi-1.14.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e"}, - {file = "cffi-1.14.5-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827"}, - {file = "cffi-1.14.5-cp38-cp38-win32.whl", hash = "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e"}, - {file = "cffi-1.14.5-cp38-cp38-win_amd64.whl", hash = "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396"}, - {file = "cffi-1.14.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea"}, - {file = "cffi-1.14.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322"}, - {file = "cffi-1.14.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c"}, - {file = "cffi-1.14.5-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee"}, - {file = "cffi-1.14.5-cp39-cp39-win32.whl", hash = "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396"}, - {file = "cffi-1.14.5-cp39-cp39-win_amd64.whl", hash = "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d"}, - {file = "cffi-1.14.5.tar.gz", hash = "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c"}, + {file = "cffi-1.14.6-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:22b9c3c320171c108e903d61a3723b51e37aaa8c81255b5e7ce102775bd01e2c"}, + {file = "cffi-1.14.6-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:f0c5d1acbfca6ebdd6b1e3eded8d261affb6ddcf2186205518f1428b8569bb99"}, + {file = "cffi-1.14.6-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:99f27fefe34c37ba9875f224a8f36e31d744d8083e00f520f133cab79ad5e819"}, + {file = "cffi-1.14.6-cp27-cp27m-win32.whl", hash = "sha256:55af55e32ae468e9946f741a5d51f9896da6b9bf0bbdd326843fec05c730eb20"}, + {file = "cffi-1.14.6-cp27-cp27m-win_amd64.whl", hash = "sha256:7bcac9a2b4fdbed2c16fa5681356d7121ecabf041f18d97ed5b8e0dd38a80224"}, + {file = "cffi-1.14.6-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ed38b924ce794e505647f7c331b22a693bee1538fdf46b0222c4717b42f744e7"}, + {file = "cffi-1.14.6-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e22dcb48709fc51a7b58a927391b23ab37eb3737a98ac4338e2448bef8559b33"}, + {file = "cffi-1.14.6-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:aedb15f0a5a5949ecb129a82b72b19df97bbbca024081ed2ef88bd5c0a610534"}, + {file = "cffi-1.14.6-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:48916e459c54c4a70e52745639f1db524542140433599e13911b2f329834276a"}, + {file = "cffi-1.14.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f627688813d0a4140153ff532537fbe4afea5a3dffce1f9deb7f91f848a832b5"}, + {file = "cffi-1.14.6-cp35-cp35m-win32.whl", hash = "sha256:f0010c6f9d1a4011e429109fda55a225921e3206e7f62a0c22a35344bfd13cca"}, + {file = "cffi-1.14.6-cp35-cp35m-win_amd64.whl", hash = "sha256:57e555a9feb4a8460415f1aac331a2dc833b1115284f7ded7278b54afc5bd218"}, + {file = "cffi-1.14.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e8c6a99be100371dbb046880e7a282152aa5d6127ae01783e37662ef73850d8f"}, + {file = "cffi-1.14.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:19ca0dbdeda3b2615421d54bef8985f72af6e0c47082a8d26122adac81a95872"}, + {file = "cffi-1.14.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d950695ae4381ecd856bcaf2b1e866720e4ab9a1498cba61c602e56630ca7195"}, + {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9dc245e3ac69c92ee4c167fbdd7428ec1956d4e754223124991ef29eb57a09d"}, + {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8661b2ce9694ca01c529bfa204dbb144b275a31685a075ce123f12331be790b"}, + {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b315d709717a99f4b27b59b021e6207c64620790ca3e0bde636a6c7f14618abb"}, + {file = "cffi-1.14.6-cp36-cp36m-win32.whl", hash = "sha256:80b06212075346b5546b0417b9f2bf467fea3bfe7352f781ffc05a8ab24ba14a"}, + {file = "cffi-1.14.6-cp36-cp36m-win_amd64.whl", hash = "sha256:a9da7010cec5a12193d1af9872a00888f396aba3dc79186604a09ea3ee7c029e"}, + {file = "cffi-1.14.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4373612d59c404baeb7cbd788a18b2b2a8331abcc84c3ba40051fcd18b17a4d5"}, + {file = "cffi-1.14.6-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:f10afb1004f102c7868ebfe91c28f4a712227fe4cb24974350ace1f90e1febbf"}, + {file = "cffi-1.14.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fd4305f86f53dfd8cd3522269ed7fc34856a8ee3709a5e28b2836b2db9d4cd69"}, + {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d6169cb3c6c2ad50db5b868db6491a790300ade1ed5d1da29289d73bbe40b56"}, + {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d4b68e216fc65e9fe4f524c177b54964af043dde734807586cf5435af84045c"}, + {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33791e8a2dc2953f28b8d8d300dde42dd929ac28f974c4b4c6272cb2955cb762"}, + {file = "cffi-1.14.6-cp37-cp37m-win32.whl", hash = "sha256:0c0591bee64e438883b0c92a7bed78f6290d40bf02e54c5bf0978eaf36061771"}, + {file = "cffi-1.14.6-cp37-cp37m-win_amd64.whl", hash = "sha256:8eb687582ed7cd8c4bdbff3df6c0da443eb89c3c72e6e5dcdd9c81729712791a"}, + {file = "cffi-1.14.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ba6f2b3f452e150945d58f4badd92310449876c4c954836cfb1803bdd7b422f0"}, + {file = "cffi-1.14.6-cp38-cp38-manylinux1_i686.whl", hash = "sha256:64fda793737bc4037521d4899be780534b9aea552eb673b9833b01f945904c2e"}, + {file = "cffi-1.14.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9f3e33c28cd39d1b655ed1ba7247133b6f7fc16fa16887b120c0c670e35ce346"}, + {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26bb2549b72708c833f5abe62b756176022a7b9a7f689b571e74c8478ead51dc"}, + {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb687a11f0a7a1839719edd80f41e459cc5366857ecbed383ff376c4e3cc6afd"}, + {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2ad4d668a5c0645d281dcd17aff2be3212bc109b33814bbb15c4939f44181cc"}, + {file = "cffi-1.14.6-cp38-cp38-win32.whl", hash = "sha256:487d63e1454627c8e47dd230025780e91869cfba4c753a74fda196a1f6ad6548"}, + {file = "cffi-1.14.6-cp38-cp38-win_amd64.whl", hash = "sha256:c33d18eb6e6bc36f09d793c0dc58b0211fccc6ae5149b808da4a62660678b156"}, + {file = "cffi-1.14.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:06c54a68935738d206570b20da5ef2b6b6d92b38ef3ec45c5422c0ebaf338d4d"}, + {file = "cffi-1.14.6-cp39-cp39-manylinux1_i686.whl", hash = "sha256:f174135f5609428cc6e1b9090f9268f5c8935fddb1b25ccb8255a2d50de6789e"}, + {file = "cffi-1.14.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f3ebe6e73c319340830a9b2825d32eb6d8475c1dac020b4f0aa774ee3b898d1c"}, + {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c8d896becff2fa653dc4438b54a5a25a971d1f4110b32bd3068db3722c80202"}, + {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4922cd707b25e623b902c86188aca466d3620892db76c0bdd7b99a3d5e61d35f"}, + {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c9e005e9bd57bc987764c32a1bee4364c44fdc11a3cc20a40b93b444984f2b87"}, + {file = "cffi-1.14.6-cp39-cp39-win32.whl", hash = "sha256:eb9e2a346c5238a30a746893f23a9535e700f8192a68c07c0258e7ece6ff3728"}, + {file = "cffi-1.14.6-cp39-cp39-win_amd64.whl", hash = "sha256:818014c754cd3dba7229c0f5884396264d51ffb87ec86e927ef0be140bfdb0d2"}, + {file = "cffi-1.14.6.tar.gz", hash = "sha256:c9a875ce9d7fe32887784274dd533c57909b7b1dcadcc128a2ac21331a9765dd"}, ] cfgv = [ {file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"}, @@ -1156,6 +1193,10 @@ chardet = [ {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, ] +charset-normalizer = [ + {file = "charset-normalizer-2.0.4.tar.gz", hash = "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3"}, + {file = "charset_normalizer-2.0.4-py3-none-any.whl", hash = "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b"}, +] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, @@ -1339,12 +1380,12 @@ humanfriendly = [ {file = "humanfriendly-9.2.tar.gz", hash = "sha256:f7dba53ac7935fd0b4a2fc9a29e316ddd9ea135fb3052d3d0279d10c18ff9c48"}, ] identify = [ - {file = "identify-2.2.10-py2.py3-none-any.whl", hash = "sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421"}, - {file = "identify-2.2.10.tar.gz", hash = "sha256:5b41f71471bc738e7b586308c3fca172f78940195cb3bf6734c1e66fdac49306"}, + {file = "identify-2.2.11-py2.py3-none-any.whl", hash = "sha256:7abaecbb414e385752e8ce02d8c494f4fbc780c975074b46172598a28f1ab839"}, + {file = "identify-2.2.11.tar.gz", hash = "sha256:a0e700637abcbd1caae58e0463861250095dfe330a8371733a471af706a4a29a"}, ] idna = [ - {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, - {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, + {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, + {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -1472,6 +1513,10 @@ pep8-naming = [ {file = "pep8-naming-0.12.0.tar.gz", hash = "sha256:1f9a3ecb2f3fd83240fd40afdd70acc89695c49c333413e49788f93b61827e12"}, {file = "pep8_naming-0.12.0-py2.py3-none-any.whl", hash = "sha256:2321ac2b7bf55383dd19a6a9c8ae2ebf05679699927a3af33e60dd7d337099d3"}, ] +platformdirs = [ + {file = "platformdirs-2.2.0-py3-none-any.whl", hash = "sha256:4666d822218db6a262bdfdc9c39d21f23b4cfdb08af331a81e92751daf6c866c"}, + {file = "platformdirs-2.2.0.tar.gz", hash = "sha256:632daad3ab546bd8e6af0537d09805cec458dce201bccfe23012df73332e181e"}, +] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, @@ -1591,8 +1636,8 @@ pytest-xdist = [ {file = "pytest_xdist-2.3.0-py3-none-any.whl", hash = "sha256:ed3d7da961070fce2a01818b51f6888327fb88df4379edeb6b9d990e789d9c8d"}, ] python-dateutil = [ - {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, - {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, ] python-dotenv = [ {file = "python-dotenv-0.17.1.tar.gz", hash = "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f"}, @@ -1609,18 +1654,26 @@ pyyaml = [ {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"}, {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"}, {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"}, + {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"}, + {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"}, {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"}, {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"}, {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"}, {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"}, + {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"}, + {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"}, {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"}, {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"}, {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"}, {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"}, + {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"}, + {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"}, {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"}, {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"}, {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, + {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"}, + {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"}, {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, @@ -1736,12 +1789,12 @@ regex = [ {file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"}, ] requests = [ - {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, - {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, + {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, + {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"}, ] sentry-sdk = [ - {file = "sentry-sdk-0.20.3.tar.gz", hash = "sha256:4ae8d1ced6c67f1c8ea51d82a16721c166c489b76876c9f2c202b8a50334b237"}, - {file = "sentry_sdk-0.20.3-py2.py3-none-any.whl", hash = "sha256:e75c8c58932bda8cd293ea8e4b242527129e1caaec91433d21b8b2f20fee030b"}, + {file = "sentry-sdk-1.3.1.tar.gz", hash = "sha256:ebe99144fa9618d4b0e7617e7929b75acd905d258c3c779edcd34c0adfffe26c"}, + {file = "sentry_sdk-1.3.1-py2.py3-none-any.whl", hash = "sha256:f33d34c886d0ba24c75ea8885a8b3a172358853c7cbde05979fc99c29ef7bc52"}, ] sgmllib3k = [ {file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"}, @@ -1784,8 +1837,8 @@ urllib3 = [ {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"}, ] virtualenv = [ - {file = "virtualenv-20.4.7-py2.py3-none-any.whl", hash = "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76"}, - {file = "virtualenv-20.4.7.tar.gz", hash = "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467"}, + {file = "virtualenv-20.7.0-py2.py3-none-any.whl", hash = "sha256:fdfdaaf0979ac03ae7f76d5224a05b58165f3c804f8aa633f3dd6f22fbd435d5"}, + {file = "virtualenv-20.7.0.tar.gz", hash = "sha256:97066a978431ec096d163e72771df5357c5c898ffdd587048f45e0aecc228094"}, ] yarl = [ {file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"}, diff --git a/pyproject.toml b/pyproject.toml index 8eac504c5..2ae79f9e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ python-dateutil = "~=2.8" python-frontmatter = "~=1.0.0" pyyaml = "~=5.1" regex = "==2021.4.4" -sentry-sdk = "~=0.19" +sentry-sdk = "~=1.3" statsd = "~=3.3" [tool.poetry.dev-dependencies] -- cgit v1.2.3 From 5057ff6058c6cdaf4c1ac91544779f615d0e4d39 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Tue, 3 Aug 2021 18:29:57 +0100 Subject: Add comment on RedisCache --- bot/exts/recruitment/talentpool/_cog.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index e7ad314e8..80bd48534 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -27,6 +27,8 @@ log = logging.getLogger(__name__) class TalentPool(WatchChannel, Cog, name="Talentpool"): """Relays messages of helper candidates to a watch channel to observe them.""" + # RedisCache[str, bool] + # Can contain a single key, "autoreview_enabled", with the value a bool indicating if autoreview is enabled. talentpool_settings = RedisCache() def __init__(self, bot: Bot) -> None: -- cgit v1.2.3 From 35047316636fa1da6405954c3a199b4b3d4e0e44 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Aug 2021 12:54:13 -0700 Subject: CodeSnippets: refactor on_message Reduce nesting and code duplication. --- bot/exts/info/code_snippets.py | 73 ++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 35 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 24a9ae28a..9cef16b83 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -222,43 +222,46 @@ class CodeSnippets(Cog): @Cog.listener() async def on_message(self, message: Message) -> None: """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" - if not message.author.bot: - all_snippets = [] - - for pattern, handler in self.pattern_handlers: - for match in pattern.finditer(message.content): - try: - snippet = await handler(**match.groupdict()) - all_snippets.append((match.start(), snippet)) - except ClientResponseError as error: - error_message = error.message # noqa: B306 - log.log( - logging.DEBUG if error.status == 404 else logging.ERROR, - f'Failed to fetch code snippet from {match[0]!r}: {error.status} ' - f'{error_message} for GET {error.request_info.real_url.human_repr()}' - ) - - # Sorts the list of snippets by their match index and joins them into a single message - message_to_send = '\n'.join(map(lambda x: x[1], sorted(all_snippets))) - - if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 15: - await message.edit(suppress=True) - if len(message_to_send) > 1000 and message.channel.id != Channels.bot_commands: - # Redirects to #bot-commands if the snippet contents are too long - await self.bot.wait_until_guild_available() - await message.channel.send(('The snippet you tried to send was too long. Please ' - f'see <#{Channels.bot_commands}> for the full snippet.')) - bot_commands_channel = self.bot.get_channel(Channels.bot_commands) - await wait_for_deletion( - await bot_commands_channel.send(message_to_send), - (message.author.id,) - ) - else: - await wait_for_deletion( - await message.channel.send(message_to_send), - (message.author.id,) + if message.author.bot: + return + + all_snippets = [] + + for pattern, handler in self.pattern_handlers: + for match in pattern.finditer(message.content): + try: + snippet = await handler(**match.groupdict()) + all_snippets.append((match.start(), snippet)) + except ClientResponseError as error: + error_message = error.message # noqa: B306 + log.log( + logging.DEBUG if error.status == 404 else logging.ERROR, + f'Failed to fetch code snippet from {match[0]!r}: {error.status} ' + f'{error_message} for GET {error.request_info.real_url.human_repr()}' ) + # Sorts the list of snippets by their match index and joins them into a single message + message_to_send = '\n'.join(map(lambda x: x[1], sorted(all_snippets))) + destination = message.channel + + if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 15: + await message.edit(suppress=True) + + if len(message_to_send) > 1000 and message.channel.id != Channels.bot_commands: + # Redirects to #bot-commands if the snippet contents are too long + await self.bot.wait_until_guild_available() + destination = self.bot.get_channel(Channels.bot_commands) + + await message.channel.send( + 'The snippet you tried to send was too long. ' + f'Please see {destination.mention} for the full snippet.' + ) + + await wait_for_deletion( + await destination.send(message_to_send), + (message.author.id,) + ) + def setup(bot: Bot) -> None: """Load the CodeSnippets cog.""" -- cgit v1.2.3 From cfbeabb6a18b3ea6c19a81881139b3b389dd50ed Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Aug 2021 12:56:25 -0700 Subject: CodeSnippets: move __init__ to the top of the class definition --- bot/exts/info/code_snippets.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 9cef16b83..cb231350d 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -45,6 +45,17 @@ class CodeSnippets(Cog): Matches each message against a regex and prints the contents of all matched snippets. """ + def __init__(self, bot: Bot): + """Initializes the cog's bot.""" + self.bot = bot + + self.pattern_handlers = [ + (GITHUB_RE, self._fetch_github_snippet), + (GITHUB_GIST_RE, self._fetch_github_gist_snippet), + (GITLAB_RE, self._fetch_gitlab_snippet), + (BITBUCKET_RE, self._fetch_bitbucket_snippet) + ] + async def _fetch_response(self, url: str, response_format: str, **kwargs) -> Any: """Makes http requests using aiohttp.""" async with self.bot.http_session.get(url, raise_for_status=True, **kwargs) as response: @@ -208,17 +219,6 @@ class CodeSnippets(Cog): # Returns an empty codeblock if the snippet is empty return f'{ret}``` ```' - def __init__(self, bot: Bot): - """Initializes the cog's bot.""" - self.bot = bot - - self.pattern_handlers = [ - (GITHUB_RE, self._fetch_github_snippet), - (GITHUB_GIST_RE, self._fetch_github_gist_snippet), - (GITLAB_RE, self._fetch_gitlab_snippet), - (BITBUCKET_RE, self._fetch_bitbucket_snippet) - ] - @Cog.listener() async def on_message(self, message: Message) -> None: """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" -- cgit v1.2.3 From 47532644f3ebeb07fe9a47b7a51e6d4413ecd58c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Aug 2021 13:02:42 -0700 Subject: CodeSnippets: refactor snippet parsing into a separate function --- bot/exts/info/code_snippets.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index cb231350d..8c86b9a93 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -219,16 +219,12 @@ class CodeSnippets(Cog): # Returns an empty codeblock if the snippet is empty return f'{ret}``` ```' - @Cog.listener() - async def on_message(self, message: Message) -> None: - """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" - if message.author.bot: - return - + async def _parse_snippets(self, content: str) -> str: + """Parse message content and return a string with a code block for each URL found.""" all_snippets = [] for pattern, handler in self.pattern_handlers: - for match in pattern.finditer(message.content): + for match in pattern.finditer(content): try: snippet = await handler(**match.groupdict()) all_snippets.append((match.start(), snippet)) @@ -241,7 +237,15 @@ class CodeSnippets(Cog): ) # Sorts the list of snippets by their match index and joins them into a single message - message_to_send = '\n'.join(map(lambda x: x[1], sorted(all_snippets))) + return '\n'.join(map(lambda x: x[1], sorted(all_snippets))) + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" + if message.author.bot: + return + + message_to_send = await self._parse_snippets(message.content) destination = message.channel if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 15: -- cgit v1.2.3 From 900bf69a3178e1cdacfd7492a359d53f3b7db72a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 3 Aug 2021 13:10:05 -0700 Subject: CodeSnippets: don't send snippets if the original message was deleted Fixes BOT-13B --- bot/exts/info/code_snippets.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/code_snippets.py b/bot/exts/info/code_snippets.py index 8c86b9a93..4a90a0668 100644 --- a/bot/exts/info/code_snippets.py +++ b/bot/exts/info/code_snippets.py @@ -4,8 +4,8 @@ import textwrap from typing import Any from urllib.parse import quote_plus +import discord from aiohttp import ClientResponseError -from discord import Message from discord.ext.commands import Cog from bot.bot import Bot @@ -240,7 +240,7 @@ class CodeSnippets(Cog): return '\n'.join(map(lambda x: x[1], sorted(all_snippets))) @Cog.listener() - async def on_message(self, message: Message) -> None: + async def on_message(self, message: discord.Message) -> None: """Checks if the message has a snippet link, removes the embed, then sends the snippet contents.""" if message.author.bot: return @@ -249,7 +249,11 @@ class CodeSnippets(Cog): destination = message.channel if 0 < len(message_to_send) <= 2000 and message_to_send.count('\n') <= 15: - await message.edit(suppress=True) + try: + await message.edit(suppress=True) + except discord.NotFound: + # Don't send snippets if the original message was deleted. + return if len(message_to_send) > 1000 and message.channel.id != Channels.bot_commands: # Redirects to #bot-commands if the snippet contents are too long -- cgit v1.2.3 From a8869b4d60512b173871c886321b261cbc4acca9 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 3 Aug 2021 20:37:00 +0100 Subject: Force utf-8 decoding when querying metabase Some discord usernames contain unicode characters, which causes an decoding error as chardet isn't 100% and can't falsely detect the response text as Windows-1254. --- bot/exts/moderation/metabase.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py index db5f04d83..e9faf7240 100644 --- a/bot/exts/moderation/metabase.py +++ b/bot/exts/moderation/metabase.py @@ -115,12 +115,12 @@ class Metabase(Cog): try: async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp: if extension == "csv": - out = await resp.text() + out = await resp.text(encoding="utf-8") # Save the output for use with int e self.exports[question_id] = list(csv.DictReader(StringIO(out))) elif extension == "json": - out = await resp.json() + out = await resp.json(encoding="utf-8") # Save the output for use with int e self.exports[question_id] = out -- cgit v1.2.3 From c390ca427422c5532848eaf43cd23029ff38f0f6 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 4 Aug 2021 08:48:23 -0700 Subject: Suppress 403 error when sending DEFCON reject DM (#1711) 403 occurs if the user has DMs disabled. Fixes BOT-137 --- bot/exts/moderation/defcon.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index 9801d45ad..6ac077b93 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -9,7 +9,7 @@ from typing import Optional, Union from aioredis import RedisError from async_rediscache import RedisCache from dateutil.relativedelta import relativedelta -from discord import Colour, Embed, Member, User +from discord import Colour, Embed, Forbidden, Member, User from discord.ext import tasks from discord.ext.commands import Cog, Context, group, has_any_role @@ -118,10 +118,12 @@ class Defcon(Cog): try: await member.send(REJECTION_MESSAGE.format(user=member.mention)) - message_sent = True + except Forbidden: + log.debug(f"Cannot send DEFCON rejection DM to {member}: DMs disabled") except Exception: - log.exception(f"Unable to send rejection message to user: {member}") + # Broadly catch exceptions because DM isn't critical, but it's imperative to kick them. + log.exception(f"Error sending DEFCON rejection message to {member}") await member.kick(reason="DEFCON active, user is too new") self.bot.stats.incr("defcon.leaves") -- cgit v1.2.3 From 75862768ebd4b182e713add91cf705c6fcfc70f8 Mon Sep 17 00:00:00 2001 From: Objectivitix <79152594+Objectivitix@users.noreply.github.com> Date: Wed, 4 Aug 2021 13:40:43 -0400 Subject: Reorder user roles in !user command from highest to lowest (#1719) Reorder user roles in !user command from highest to lowest --- bot/exts/info/information.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index bb713eef1..800a68821 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -243,7 +243,9 @@ class Information(Cog): if on_server: joined = discord_timestamp(user.joined_at, TimestampFormats.RELATIVE) - roles = ", ".join(role.mention for role in user.roles[1:]) + # The 0 is for excluding the default @everyone role, + # and the -1 is for reversing the order of the roles to highest to lowest in hierarchy. + roles = ", ".join(role.mention for role in user.roles[:0:-1]) membership = {"Joined": joined, "Verified": not user.pending, "Roles": roles or None} if not is_mod_channel(ctx.channel): membership.pop("Verified") -- cgit v1.2.3 From 1a952752de4b2d9d472f385d3598eb357be0abcd Mon Sep 17 00:00:00 2001 From: Ryu18 Date: Sat, 7 Aug 2021 00:52:52 +0200 Subject: added escape markdown in PythonNews --- bot/exts/info/python_news.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index a7837c93a..651a33d02 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -1,6 +1,7 @@ import logging import typing as t from datetime import date, datetime +import re import discord import feedparser @@ -72,6 +73,12 @@ class PythonNews(Cog): if mail["name"].split("@")[0] in constants.PythonNews.mail_lists: self.webhook_names[mail["name"].split("@")[0]] = mail["display_name"] + @staticmethod + def escape_markdown(content: str) -> str: + """Escape the markdown underlines""" + # taken from discord.utils.escape_markdown + return re.sub(r'[_\\~|]', lambda match: '\\' + match[0], content, 0, re.MULTILINE) + async def post_pep_news(self) -> None: """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" # Wait until everything is ready and http_session available @@ -103,7 +110,7 @@ class PythonNews(Cog): # Build an embed and send a webhook embed = discord.Embed( title=new["title"], - description=new["summary"], + description=self.escape_markdown(new["summary"]), timestamp=new_datetime, url=new["link"], colour=constants.Colours.soft_green @@ -167,7 +174,7 @@ class PythonNews(Cog): ): continue - content = email_information["content"] + content = self.escape_markdown(email_information["content"]) link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist) # Build an embed and send a message to the webhook -- cgit v1.2.3 From 93456475548ff883bd534ab5e0de62a29d9dc936 Mon Sep 17 00:00:00 2001 From: Ryu18 Date: Sat, 7 Aug 2021 01:00:29 +0200 Subject: fix linting issues --- bot/exts/info/python_news.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index 651a33d02..0b6e230b4 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -1,7 +1,7 @@ import logging +import re import typing as t from datetime import date, datetime -import re import discord import feedparser @@ -75,7 +75,7 @@ class PythonNews(Cog): @staticmethod def escape_markdown(content: str) -> str: - """Escape the markdown underlines""" + """Escape the markdown underlines.""" # taken from discord.utils.escape_markdown return re.sub(r'[_\\~|]', lambda match: '\\' + match[0], content, 0, re.MULTILINE) -- cgit v1.2.3 From 357c7181d8b0b11ab38ac63c96bf6667b55b0524 Mon Sep 17 00:00:00 2001 From: Ryu1845 <77058942+Ryu1845@users.noreply.github.com> Date: Sun, 8 Aug 2021 00:47:49 +0200 Subject: Removed comment The code is now almost completely different from discord.py. --- bot/exts/info/python_news.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index 0b6e230b4..8d7ffec88 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -76,7 +76,6 @@ class PythonNews(Cog): @staticmethod def escape_markdown(content: str) -> str: """Escape the markdown underlines.""" - # taken from discord.utils.escape_markdown return re.sub(r'[_\\~|]', lambda match: '\\' + match[0], content, 0, re.MULTILINE) async def post_pep_news(self) -> None: -- cgit v1.2.3 From 5fc4b67323a241193b262765d1e3503bc72f6ced Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 7 Aug 2021 15:54:59 -0700 Subject: Incidents: catch 404s Fixes BOT-ZN --- bot/exts/moderation/incidents.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/incidents.py b/bot/exts/moderation/incidents.py index 0e479d33f..561e0251e 100644 --- a/bot/exts/moderation/incidents.py +++ b/bot/exts/moderation/incidents.py @@ -143,7 +143,14 @@ async def add_signals(incident: discord.Message) -> None: log.trace(f"Skipping emoji as it's already been placed: {signal_emoji}") else: log.trace(f"Adding reaction: {signal_emoji}") - await incident.add_reaction(signal_emoji.value) + try: + await incident.add_reaction(signal_emoji.value) + except discord.NotFound as e: + if e.code != 10008: + raise + + log.trace(f"Couldn't react with signal because message {incident.id} was deleted; skipping incident") + return class Incidents(Cog): @@ -288,14 +295,20 @@ class Incidents(Cog): members_roles: t.Set[int] = {role.id for role in member.roles} if not members_roles & ALLOWED_ROLES: # Intersection is truthy on at least 1 common element log.debug(f"Removing invalid reaction: user {member} is not permitted to send signals") - await incident.remove_reaction(reaction, member) + try: + await incident.remove_reaction(reaction, member) + except discord.NotFound: + log.trace("Couldn't remove reaction because the reaction or its message was deleted") return try: signal = Signal(reaction) except ValueError: log.debug(f"Removing invalid reaction: emoji {reaction} is not a valid signal") - await incident.remove_reaction(reaction, member) + try: + await incident.remove_reaction(reaction, member) + except discord.NotFound: + log.trace("Couldn't remove reaction because the reaction or its message was deleted") return log.trace(f"Received signal: {signal}") @@ -313,7 +326,10 @@ class Incidents(Cog): confirmation_task = self.make_confirmation_task(incident, timeout) log.trace("Deleting original message") - await incident.delete() + try: + await incident.delete() + except discord.NotFound: + log.trace("Couldn't delete message because it was already deleted") log.trace(f"Awaiting deletion confirmation: {timeout=} seconds") try: -- cgit v1.2.3 From b8d959c4ec2b07a49334a6fafb5f203495ec610d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 7 Aug 2021 15:59:54 -0700 Subject: Code block: catch 404s when editing or deleting the message Fixes BOT-J2 --- bot/exts/info/codeblock/_cog.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/bot/exts/info/codeblock/_cog.py b/bot/exts/info/codeblock/_cog.py index 9094d9d15..9a0705d2b 100644 --- a/bot/exts/info/codeblock/_cog.py +++ b/bot/exts/info/codeblock/_cog.py @@ -177,10 +177,13 @@ class CodeBlockCog(Cog, name="Code Block"): if not bot_message: return - if not instructions: - log.info("User's incorrect code block has been fixed. Removing instructions message.") - await bot_message.delete() - del self.codeblock_message_ids[payload.message_id] - else: - log.info("Message edited but still has invalid code blocks; editing the instructions.") - await bot_message.edit(embed=self.create_embed(instructions)) + try: + if not instructions: + log.info("User's incorrect code block was fixed. Removing instructions message.") + await bot_message.delete() + del self.codeblock_message_ids[payload.message_id] + else: + log.info("Message edited but still has invalid code blocks; editing instructions.") + await bot_message.edit(embed=self.create_embed(instructions)) + except discord.NotFound: + log.debug("Could not find instructions message; it was probably deleted.") -- cgit v1.2.3 From dde617e19e570df2a6a57b36679b307cda496217 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 7 Aug 2021 16:03:41 -0700 Subject: Duck pond: abort if reaction's message or author can't be found Fixes BOT-1J7 --- bot/exts/fun/duck_pond.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index c78b9c141..d02912545 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -171,8 +171,14 @@ class DuckPond(Cog): if not self.is_helper_viewable(channel): return - message = await channel.fetch_message(payload.message_id) + try: + message = await channel.fetch_message(payload.message_id) + except discord.NotFound: + return # Message was deleted. + member = discord.utils.get(message.guild.members, id=payload.user_id) + if not member: + return # Member left or wasn't in the cache. # Was the message sent by a human staff member? if not self.is_staff(message.author) or message.author.bot: -- cgit v1.2.3 From c4e0faeb6f1e1430bc75578c49567d884072ce34 Mon Sep 17 00:00:00 2001 From: aru Date: Sun, 8 Aug 2021 09:27:29 -0400 Subject: change dockerfile python version to latest 3.9.x --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index c285898dc..4d8592590 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9.5-slim +FROM python:3.9-slim # Set pip to have no saved cache ENV PIP_NO_CACHE_DIR=false \ -- cgit v1.2.3 From da85add68b993136dbe1c3eb9da33a3a8ab1862b Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 9 Aug 2021 20:46:41 +0200 Subject: Move all converters to converters.py --- bot/converters.py | 78 +++++++++++++++++++++++++++++++++++++++++++- bot/exts/info/source.py | 35 ++------------------ bot/exts/utils/extensions.py | 41 ++--------------------- 3 files changed, 81 insertions(+), 73 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 595809517..23aa9eab8 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging import re import typing as t @@ -11,13 +13,17 @@ import discord from aiohttp import ClientConnectorError from dateutil.relativedelta import relativedelta from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, UserConverter -from discord.utils import DISCORD_EPOCH, snowflake_time +from discord.utils import DISCORD_EPOCH, escape_markdown, snowflake_time +from bot import exts from bot.api import ResponseCodeError from bot.constants import URLs from bot.exts.info.doc import _inventory_parser +from bot.utils.extensions import EXTENSIONS, unqualify from bot.utils.regex import INVITE_RE from bot.utils.time import parse_duration_string +if t.TYPE_CHECKING: + from bot.exts.info.source import SourceType log = logging.getLogger(__name__) @@ -128,6 +134,44 @@ class ValidFilterListType(Converter): return list_type +class Extension(Converter): + """ + Fully qualify the name of an extension and ensure it exists. + + The * and ** values bypass this when used with the reload command. + """ + + async def convert(self, ctx: Context, argument: str) -> str: + """Fully qualify the name of an extension and ensure it exists.""" + # Special values to reload all extensions + if argument == "*" or argument == "**": + return argument + + argument = argument.lower() + + if argument in EXTENSIONS: + return argument + elif (qualified_arg := f"{exts.__name__}.{argument}") in EXTENSIONS: + return qualified_arg + + matches = [] + for ext in EXTENSIONS: + if argument == unqualify(ext): + matches.append(ext) + + if len(matches) > 1: + matches.sort() + names = "\n".join(matches) + raise BadArgument( + f":x: `{argument}` is an ambiguous extension name. " + f"Please use one of the following fully-qualified names.```\n{names}```" + ) + elif matches: + return matches[0] + else: + raise BadArgument(f":x: Could not find the extension `{argument}`.") + + class PackageName(Converter): """ A converter that checks whether the given string is a valid package name. @@ -290,6 +334,38 @@ class TagContentConverter(Converter): return tag_content +class SourceConverter(Converter): + """Convert an argument into a help command, tag, command, or cog.""" + + @staticmethod + async def convert(ctx: Context, argument: str) -> SourceType: + """Convert argument into source object.""" + if argument.lower() == "help": + return ctx.bot.help_command + + cog = ctx.bot.get_cog(argument) + if cog: + return cog + + cmd = ctx.bot.get_command(argument) + if cmd: + return cmd + + tags_cog = ctx.bot.get_cog("Tags") + show_tag = True + + if not tags_cog: + show_tag = False + elif argument.lower() in tags_cog._cache: + return argument.lower() + + escaped_arg = escape_markdown(argument) + + raise BadArgument( + f"Unable to convert '{escaped_arg}' to valid command{', tag,' if show_tag else ''} or Cog." + ) + + class DurationDelta(Converter): """Convert duration strings into dateutil.relativedelta.relativedelta objects.""" diff --git a/bot/exts/info/source.py b/bot/exts/info/source.py index ef07c77a1..8ce25b4e8 100644 --- a/bot/exts/info/source.py +++ b/bot/exts/info/source.py @@ -2,47 +2,16 @@ import inspect from pathlib import Path from typing import Optional, Tuple, Union -from discord import Embed, utils +from discord import Embed from discord.ext import commands from bot.bot import Bot from bot.constants import URLs +from bot.converters import SourceConverter SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, str, commands.ExtensionNotLoaded] -class SourceConverter(commands.Converter): - """Convert an argument into a help command, tag, command, or cog.""" - - @staticmethod - async def convert(ctx: commands.Context, argument: str) -> SourceType: - """Convert argument into source object.""" - if argument.lower() == "help": - return ctx.bot.help_command - - cog = ctx.bot.get_cog(argument) - if cog: - return cog - - cmd = ctx.bot.get_command(argument) - if cmd: - return cmd - - tags_cog = ctx.bot.get_cog("Tags") - show_tag = True - - if not tags_cog: - show_tag = False - elif argument.lower() in tags_cog._cache: - return argument.lower() - - escaped_arg = utils.escape_markdown(argument) - - raise commands.BadArgument( - f"Unable to convert '{escaped_arg}' to valid command{', tag,' if show_tag else ''} or Cog." - ) - - class BotSource(commands.Cog): """Displays information about the bot's source code.""" diff --git a/bot/exts/utils/extensions.py b/bot/exts/utils/extensions.py index 8a1ed98f4..f78664527 100644 --- a/bot/exts/utils/extensions.py +++ b/bot/exts/utils/extensions.py @@ -10,8 +10,9 @@ from discord.ext.commands import Context, group from bot import exts from bot.bot import Bot from bot.constants import Emojis, MODERATION_ROLES, Roles, URLs +from bot.converters import Extension from bot.pagination import LinePaginator -from bot.utils.extensions import EXTENSIONS, unqualify +from bot.utils.extensions import EXTENSIONS log = logging.getLogger(__name__) @@ -29,44 +30,6 @@ class Action(Enum): RELOAD = functools.partial(Bot.reload_extension) -class Extension(commands.Converter): - """ - Fully qualify the name of an extension and ensure it exists. - - The * and ** values bypass this when used with the reload command. - """ - - async def convert(self, ctx: Context, argument: str) -> str: - """Fully qualify the name of an extension and ensure it exists.""" - # Special values to reload all extensions - if argument == "*" or argument == "**": - return argument - - argument = argument.lower() - - if argument in EXTENSIONS: - return argument - elif (qualified_arg := f"{exts.__name__}.{argument}") in EXTENSIONS: - return qualified_arg - - matches = [] - for ext in EXTENSIONS: - if argument == unqualify(ext): - matches.append(ext) - - if len(matches) > 1: - matches.sort() - names = "\n".join(matches) - raise commands.BadArgument( - f":x: `{argument}` is an ambiguous extension name. " - f"Please use one of the following fully-qualified names.```\n{names}```" - ) - elif matches: - return matches[0] - else: - raise commands.BadArgument(f":x: Could not find the extension `{argument}`.") - - class Extensions(commands.Cog): """Extension management commands.""" -- cgit v1.2.3 From dd2c41a6e646c47e1740dcbdd5ada4d7143af856 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 9 Aug 2021 21:22:34 +0200 Subject: Assign converters to their return values during type checking --- bot/converters.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/bot/converters.py b/bot/converters.py index 23aa9eab8..3f6ed5128 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -643,6 +643,26 @@ class Infraction(Converter): return await ctx.bot.api_client.get(f"bot/infractions/{arg}") +if t.TYPE_CHECKING: + ValidDiscordServerInvite = dict # noqa: F811 + ValidFilterListType = str # noqa: F811 + Extension = str # noqa: F811 + PackageName = str # noqa: F811 + ValidURL = str # noqa: F811 + Inventory = t.Tuple[str, _inventory_parser.InventoryDict] # noqa: F811 + Snowflake = int # noqa: F811 + TagNameConverter = str # noqa: F811 + TagContentConverter = str # noqa: F811 + SourceConverter = SourceType # noqa: F811 + DurationDelta = relativedelta # noqa: F811 + Duration = datetime # noqa: F811 + OffTopicName = str # noqa: F811 + ISODateTime = datetime # noqa: F811 + HushDurationConverter = int # noqa: F811 + UserMentionOrID = discord.User # noqa: F811 + FetchedUser = t.Union[discord.User, discord.Object] # noqa: F811 + Infraction = t.Optional[dict] # noqa: F811 + Expiry = t.Union[Duration, ISODateTime] FetchedMember = t.Union[discord.Member, FetchedUser] UserMention = partial(_snowflake_from_regex, RE_USER_MENTION) -- cgit v1.2.3 From 8264fda1bbb28c91b811423684e58472aca1de55 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 9 Aug 2021 21:22:56 +0200 Subject: fix wrong name being interpolated --- bot/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index 3f6ed5128..566e56220 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -609,7 +609,7 @@ def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int: """ match = pattern.match(arg) if not match: - raise BadArgument(f"Mention {str!r} is invalid.") + raise BadArgument(f"Mention {arg!r} is invalid.") return int(match.group(1)) -- cgit v1.2.3 From 3d395b225c2bf0231273409244845be87e851d39 Mon Sep 17 00:00:00 2001 From: stalkerx Date: Mon, 9 Aug 2021 17:31:24 -0500 Subject: Fixed error message to match true value When the limit was raised, the error message was not edited to reflect the change. https://github.com/python-discord/bot/commit/6f45d6896adb3f05962733cec8e5db199def20bc --- bot/pagination.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/pagination.py b/bot/pagination.py index 90d7c84ee..26caa7db0 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -75,7 +75,7 @@ class LinePaginator(Paginator): raise ValueError(f"scale_to_size must be >= max_size. ({scale_to_size} < {max_size})") if scale_to_size > 4000: - raise ValueError(f"scale_to_size must be <= 2,000 characters. ({scale_to_size} > 4000)") + raise ValueError(f"scale_to_size must be <= 4,000 characters. ({scale_to_size} > 4000)") self.scale_to_size = scale_to_size - len(suffix) self.max_lines = max_lines -- cgit v1.2.3 From fa4b4694a6ec33cd1ee5cb2825aaf7c561dc3d90 Mon Sep 17 00:00:00 2001 From: vcokltfre Date: Tue, 10 Aug 2021 15:31:57 +0100 Subject: chore: remove some newlines in the blocking tag --- bot/resources/tags/blocking.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/bot/resources/tags/blocking.md b/bot/resources/tags/blocking.md index 31d91294c..5554d7eba 100644 --- a/bot/resources/tags/blocking.md +++ b/bot/resources/tags/blocking.md @@ -1,9 +1,7 @@ **Why do we need asynchronous programming?** - Imagine that you're coding a Discord bot and every time somebody uses a command, you need to get some information from a database. But there's a catch: the database servers are acting up today and take a whole 10 seconds to respond. If you do **not** use asynchronous methods, your whole bot will stop running until it gets a response from the database. How do you fix this? Asynchronous programming. **What is asynchronous programming?** - An asynchronous program utilises the `async` and `await` keywords. An asynchronous program pauses what it's doing and does something else whilst it waits for some third-party service to complete whatever it's supposed to do. Any code within an `async` context manager or function marked with the `await` keyword indicates to Python, that whilst this operation is being completed, it can do something else. For example: ```py @@ -14,13 +12,10 @@ import discord async def ping(ctx): await ctx.send("Pong!") ``` - **What does the term "blocking" mean?** - A blocking operation is wherever you do something without `await`ing it. This tells Python that this step must be completed before it can do anything else. Common examples of blocking operations, as simple as they may seem, include: outputting text, adding two numbers and appending an item onto a list. Most common Python libraries have an asynchronous version available to use in asynchronous contexts. **`async` libraries** - The standard async library - `asyncio` Asynchronous web requests - `aiohttp` Talking to PostgreSQL asynchronously - `asyncpg` -- cgit v1.2.3 From 10c3b34fb143247c1f40da3392e410ddd54ee818 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 11 Aug 2021 09:01:12 +0100 Subject: Update DORMANT_MSG to be compatible with str.format() --- bot/exts/help_channels/_message.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index befacd263..e4816044c 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -30,14 +30,14 @@ AVAILABLE_TITLE = "Available help channel" AVAILABLE_FOOTER = "Closes after a period of inactivity, or when you send !close." DORMANT_MSG = f""" -This help channel has been marked as **dormant**, and has been moved into the **Help: Dormant** \ +This help channel has been marked as **dormant**, and has been moved into the **{}** \ category at the bottom of the channel list. It is no longer possible to send messages in this \ channel until it becomes available again. If your question wasn't answered yet, you can claim a new help channel from the \ -**Help: Available** category by simply asking your question again. Consider rephrasing the \ +**{}** category by simply asking your question again. Consider rephrasing the \ question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. +through our guide for **[asking a good question]({})**. """ -- cgit v1.2.3 From f038479a785a3ac2b737eba214a4366037bb9575 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 11 Aug 2021 09:04:20 +0100 Subject: Update DORMANT_MSG to allow kwargs in str.format() --- bot/exts/help_channels/_message.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index e4816044c..27b506b33 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -30,14 +30,14 @@ AVAILABLE_TITLE = "Available help channel" AVAILABLE_FOOTER = "Closes after a period of inactivity, or when you send !close." DORMANT_MSG = f""" -This help channel has been marked as **dormant**, and has been moved into the **{}** \ +This help channel has been marked as **dormant**, and has been moved into the **{dormant}** \ category at the bottom of the channel list. It is no longer possible to send messages in this \ channel until it becomes available again. If your question wasn't answered yet, you can claim a new help channel from the \ -**{}** category by simply asking your question again. Consider rephrasing the \ +**{available}** category by simply asking your question again. Consider rephrasing the \ question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for **[asking a good question]({})**. +through our guide for **[asking a good question]({asking_guide})**. """ -- cgit v1.2.3 From ae0d725be15c06ba66a6b3b557d6a09e454056ca Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 11 Aug 2021 09:10:37 +0100 Subject: Update embed sent when channel moves to dormant category --- bot/exts/help_channels/_cog.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 35658d117..6f8472069 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -387,7 +387,14 @@ class HelpChannels(commands.Cog): ) log.trace(f"Sending dormant message for #{channel} ({channel.id}).") - embed = discord.Embed(description=_message.DORMANT_MSG) + available_category = self.bot.get_channel(constants.Categories.help_available) + embed = discord.Embed( + description=_message.DORMANT_MSG.format( + dormant=channel.category.name, + available=available_category.name, + asking_guide=_message.ASKING_GUIDE_URL + ) + ) await channel.send(embed=embed) log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") -- cgit v1.2.3 From d6c92ada2af5cdfcd577ed4a14c4e66ba654842f Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 11 Aug 2021 09:20:49 +0100 Subject: Make DORMANT_MSG a string instead of f-string --- bot/exts/help_channels/_message.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index 27b506b33..cf070be83 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -29,7 +29,7 @@ AVAILABLE_TITLE = "Available help channel" AVAILABLE_FOOTER = "Closes after a period of inactivity, or when you send !close." -DORMANT_MSG = f""" +DORMANT_MSG = """ This help channel has been marked as **dormant**, and has been moved into the **{dormant}** \ category at the bottom of the channel list. It is no longer possible to send messages in this \ channel until it becomes available again. -- cgit v1.2.3 From e77a0d91ec1623e5f0312e906e66077ad3890fbc Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 11 Aug 2021 09:57:02 +0100 Subject: Change bot.get_channel to utils.channels.try_get_channel --- bot/exts/help_channels/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 6f8472069..03e891271 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -387,7 +387,7 @@ class HelpChannels(commands.Cog): ) log.trace(f"Sending dormant message for #{channel} ({channel.id}).") - available_category = self.bot.get_channel(constants.Categories.help_available) + available_category = await channel_utils.try_get_channel(constants.Categories.help_available) embed = discord.Embed( description=_message.DORMANT_MSG.format( dormant=channel.category.name, -- cgit v1.2.3 From 99bfb15037ce7a6f88a403f8c1d2ab36d852874a Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 11 Aug 2021 12:34:14 +0100 Subject: Fetch dormant category rather than use channel.category channel.category doesn't get updated in cache so the category ends up still linking to "In Use", whereas we want the "Dormant". --- bot/exts/help_channels/_cog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 03e891271..afaf9b0bd 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -387,10 +387,11 @@ class HelpChannels(commands.Cog): ) log.trace(f"Sending dormant message for #{channel} ({channel.id}).") + dormant_category = await channel_utils.try_get_channel(constants.Categories.help_dormant) available_category = await channel_utils.try_get_channel(constants.Categories.help_available) embed = discord.Embed( description=_message.DORMANT_MSG.format( - dormant=channel.category.name, + dormant=dormant_category.name, available=available_category.name, asking_guide=_message.ASKING_GUIDE_URL ) -- cgit v1.2.3 From aef5584849c812a16a51475d544cb0d2abc9a1d4 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 11 Aug 2021 16:03:53 +0100 Subject: Remove added punctuation from reminder --- bot/exts/utils/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 7b8c5c4b3..441b0353f 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -181,7 +181,7 @@ class Reminders(Cog): ) # Let's not use a codeblock to keep emojis and mentions working. Embeds are safe anyway. - embed.description = f"Here's your reminder: {reminder['content']}." + embed.description = f"Here's your reminder: {reminder['content']}" if reminder.get("jump_url"): # keep backward compatibility embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})" -- cgit v1.2.3 From 939535d5c7c597741e5b1c5b6f7209aed152d4bc Mon Sep 17 00:00:00 2001 From: dawnofmidnight Date: Wed, 11 Aug 2021 17:34:15 -0400 Subject: fix: update urls in site cog --- bot/exts/info/site.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index fb5b99086..28eb558a6 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -9,7 +9,7 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) -PAGES_URL = f"{URLs.site_schema}{URLs.site}/pages" +BASE_URL = f"{URLs.site_schema}{URLs.site}" class Site(Cog): @@ -43,7 +43,7 @@ class Site(Cog): @site_group.command(name="resources", root_aliases=("resources", "resource")) async def site_resources(self, ctx: Context) -> None: """Info about the site's Resources page.""" - learning_url = f"{PAGES_URL}/resources" + learning_url = f"{BASE_URL}/resources" embed = Embed(title="Resources") embed.set_footer(text=f"{learning_url}") @@ -59,7 +59,7 @@ class Site(Cog): @site_group.command(name="tools", root_aliases=("tools",)) async def site_tools(self, ctx: Context) -> None: """Info about the site's Tools page.""" - tools_url = f"{PAGES_URL}/resources/tools" + tools_url = f"{BASE_URL}/resources/tools" embed = Embed(title="Tools") embed.set_footer(text=f"{tools_url}") @@ -74,7 +74,7 @@ class Site(Cog): @site_group.command(name="help") async def site_help(self, ctx: Context) -> None: """Info about the site's Getting Help page.""" - url = f"{PAGES_URL}/resources/guides/asking-good-questions" + url = f"{BASE_URL}/pages/guides/pydis-guides/asking-good-questions/" embed = Embed(title="Asking Good Questions") embed.set_footer(text=url) @@ -90,7 +90,7 @@ class Site(Cog): @site_group.command(name="faq", root_aliases=("faq",)) async def site_faq(self, ctx: Context) -> None: """Info about the site's FAQ page.""" - url = f"{PAGES_URL}/frequently-asked-questions" + url = f"{BASE_URL}/pages/frequently-asked-questions" embed = Embed(title="FAQ") embed.set_footer(text=url) @@ -107,13 +107,13 @@ class Site(Cog): @site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule")) async def site_rules(self, ctx: Context, rules: Greedy[int]) -> None: """Provides a link to all rules or, if specified, displays specific rule(s).""" - rules_embed = Embed(title='Rules', color=Colour.blurple(), url=f'{PAGES_URL}/rules') + rules_embed = Embed(title='Rules', color=Colour.blurple(), url=f'{BASE_URL}/pages/rules') if not rules: # Rules were not submitted. Return the default description. rules_embed.description = ( "The rules and guidelines that apply to this community can be found on" - f" our [rules page]({PAGES_URL}/rules). We expect" + f" our [rules page]({BASE_URL}/pages/rules). We expect" " all members of the community to have read and understood these." ) -- cgit v1.2.3 From 4d62dadc4d19f0c17d5cda8748c3fc520f2adccf Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Thu, 12 Aug 2021 00:30:39 +0100 Subject: Remove role pings when a helper vote is posted (#1744) --- bot/exts/recruitment/talentpool/_review.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 3a1e66970..74056dbf5 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -15,7 +15,7 @@ from discord.ext.commands import Context from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Colours, Emojis, Guild, Roles +from bot.constants import Channels, Colours, Emojis, Guild from bot.utils.messages import count_unique_users_reaction, pin_no_system_message from bot.utils.scheduling import Scheduler from bot.utils.time import get_time_delta, time_since @@ -118,7 +118,7 @@ class Reviewer: f"I tried to review the user with ID `{user_id}`, but they don't appear to be on the server :pensive:" ), None - opening = f"<@&{Roles.mod_team}> <@&{Roles.admins}>\n{member.mention} ({member}) for Helper!" + opening = f"{member.mention} ({member}) for Helper!" current_nominations = "\n\n".join( f"**<@{entry['actor']}>:** {entry['reason'] or '*no reason given*'}" -- cgit v1.2.3 From 4ecbb2e821f9f0452fddb49161989f68f98dbd38 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 12 Aug 2021 12:38:56 +0100 Subject: fix: Nomination message now checks historic and new style nominations Previously nomination messages had role pings in them, now they don't as we moved them into a thread. Due to this, we need to detect both in the interim of historic nominations existing. A 'proper' fix for this is to store the nomination message IDs when we post them against the nomination object in the site api. We are planing to work on this soon, this commit is a short term fix. --- bot/exts/recruitment/talentpool/_review.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 74056dbf5..4d496a1f7 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -33,10 +33,12 @@ MAX_MESSAGE_SIZE = 2000 # Maximum amount of characters allowed in an embed MAX_EMBED_SIZE = 4000 -# Regex finding the user ID of a user mention -MENTION_RE = re.compile(r"<@!?(\d+?)>") -# Regex matching role pings -ROLE_MENTION_RE = re.compile(r"<@&\d+>") +# Regex for finding the first message of a nomination, and extracting the nominee. +# Historic nominations will have 2 role mentions at the start, new ones won't, optionally match for this. +NOMINATION_MESSAGE_REGEX = re.compile( + r"(?:<@&\d+> <@&\d+>\n)*?<@!?(\d+?)> \(.+#\d{4}\) for Helper!\n\n\*\*Nominated by:\*\*", + re.MULTILINE +) class Reviewer: @@ -142,14 +144,14 @@ class Reviewer: """Archive this vote to #nomination-archive.""" message = await message.fetch() - # We consider the first message in the nomination to contain the two role pings + # We consider the first message in the nomination to contain the user ping, username#discrim, and fixed text messages = [message] - if not len(ROLE_MENTION_RE.findall(message.content)) >= 2: + if not NOMINATION_MESSAGE_REGEX.search(message.content): with contextlib.suppress(NoMoreItems): async for new_message in message.channel.history(before=message.created_at): messages.append(new_message) - if len(ROLE_MENTION_RE.findall(new_message.content)) >= 2: + if NOMINATION_MESSAGE_REGEX.search(new_message.content): break log.debug(f"Found {len(messages)} messages: {', '.join(str(m.id) for m in messages)}") @@ -161,7 +163,7 @@ class Reviewer: content = "".join(parts) # We assume that the first user mentioned is the user that we are voting on - user_id = int(MENTION_RE.search(content).group(1)) + user_id = int(NOMINATION_MESSAGE_REGEX.search(content).group(1)) # Get reaction counts reviewed = await count_unique_users_reaction( -- cgit v1.2.3 From 2ea95a090253b7383f0d8be195ffe809dbfe4d53 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Fri, 13 Aug 2021 23:34:38 +0200 Subject: Update reminders to reply instead of using a jump url to the origin message --- bot/exts/utils/reminders.py | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 441b0353f..32e00bee1 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -8,7 +8,7 @@ from operator import itemgetter import discord from dateutil.parser import isoparse -from discord.ext.commands import Cog, Context, Greedy, group +from discord.ext.commands import Cog, Context, Greedy, HTTPException, group from bot.bot import Bot from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_ROLES @@ -172,32 +172,39 @@ class Reminders(Cog): if not is_valid: # No need to cancel the task too; it'll simply be done once this coroutine returns. return - embed = discord.Embed() - embed.colour = discord.Colour.blurple() - embed.set_author( - icon_url=Icons.remind_blurple, - name="It has arrived!" - ) - - # Let's not use a codeblock to keep emojis and mentions working. Embeds are safe anyway. - embed.description = f"Here's your reminder: {reminder['content']}" - - if reminder.get("jump_url"): # keep backward compatibility - embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})" - if expected_time: embed.colour = discord.Colour.red() embed.set_author( icon_url=Icons.remind_red, name=f"Sorry it should have arrived {time_since(expected_time)} !" ) + else: + embed.colour = discord.Colour.blurple() + embed.set_author( + icon_url=Icons.remind_blurple, + name="It has arrived!" + ) + + # Let's not use a codeblock to keep emojis and mentions working. Embeds are safe anyway. + embed.description = f"Here's your reminder: {reminder['content']}" + # Here the jump URL is in the format of base_url/guild_id/channel_id/message_id additional_mentions = ' '.join( mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"]) ) - await channel.send(content=f"{user.mention} {additional_mentions}", embed=embed) + jump_url = reminder.get("jump_url") + partial_message = channel.get_partial_message(int(jump_url.split("/")[-1])) + try: + await partial_message.reply(content=f"{additional_mentions}", embed=embed) + except HTTPException as e: + log.error( + f"There was an error when trying to reply to a reminder invocation message, {e}, " + "fall back to using jump_url" + ) + embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})" + await channel.send(content=f"{user.mention} {additional_mentions}", embed=embed) log.debug(f"Deleting reminder #{reminder['id']} (the user has been reminded).") await self.bot.api_client.delete(f"bot/reminders/{reminder['id']}") -- cgit v1.2.3 From d6817258a59cdb2d9d3ab31a6e7f394cb0793b93 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Fri, 13 Aug 2021 23:48:53 +0200 Subject: Improve code consitency in remainders --- bot/exts/utils/reminders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 32e00bee1..66a0e6e92 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -8,7 +8,7 @@ from operator import itemgetter import discord from dateutil.parser import isoparse -from discord.ext.commands import Cog, Context, Greedy, HTTPException, group +from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_ROLES @@ -198,7 +198,7 @@ class Reminders(Cog): partial_message = channel.get_partial_message(int(jump_url.split("/")[-1])) try: await partial_message.reply(content=f"{additional_mentions}", embed=embed) - except HTTPException as e: + except discord.HTTPException as e: log.error( f"There was an error when trying to reply to a reminder invocation message, {e}, " "fall back to using jump_url" -- cgit v1.2.3 From 7678982f4ece55b411ef0c155a040845f4c0c8d2 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Sat, 14 Aug 2021 15:13:06 +0200 Subject: remove redundant typehints --- bot/exts/moderation/modpings.py | 1 - bot/exts/moderation/silence.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 29a5c1c8e..80c9f0c38 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -87,7 +87,6 @@ class ModPings(Cog): The duration cannot be longer than 30 days. """ - duration: datetime.datetime delta = duration - datetime.datetime.utcnow() if delta > datetime.timedelta(days=30): await ctx.send(":x: Cannot remove the role for longer than 30 days.") diff --git a/bot/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 8025f3df6..95e2792c3 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -202,8 +202,6 @@ class Silence(commands.Cog): duration: HushDurationConverter ) -> typing.Tuple[TextOrVoiceChannel, Optional[int]]: """Helper method to parse the arguments of the silence command.""" - duration: Optional[int] - if duration_or_channel: if isinstance(duration_or_channel, (TextChannel, VoiceChannel)): channel = duration_or_channel -- cgit v1.2.3 From 94f03bcc5bc6b7264d0fc32c403b60bf7ec9ac20 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Sun, 15 Aug 2021 23:10:59 +0300 Subject: Add default value to csv_file Co-authored-by: Bluenix --- bot/exts/events/code_jams/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index d0c206b5e..78f375416 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -33,7 +33,7 @@ class CodeJams(commands.Cog): await ctx.send_help(ctx.command) @codejam.command() - async def create(self, ctx: commands.Context, csv_file: t.Optional[str]) -> None: + async def create(self, ctx: commands.Context, csv_file: t.Optional[str] = None) -> None: """ Create code-jam teams from a CSV file or a link to one, specifying the team names, leaders and members. -- cgit v1.2.3 From be2c547586e80924bf416785bc354bbc7c392cfd Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 15 Aug 2021 23:45:41 +0300 Subject: Docstring corrections Co-authored-by: Bluenix --- bot/exts/events/code_jams/_channels.py | 2 +- bot/exts/events/code_jams/_cog.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/events/code_jams/_channels.py b/bot/exts/events/code_jams/_channels.py index 8b199a3c2..34ff0ad41 100644 --- a/bot/exts/events/code_jams/_channels.py +++ b/bot/exts/events/code_jams/_channels.py @@ -107,7 +107,7 @@ async def _send_status_update(guild: discord.Guild, message: str) -> None: async def _add_team_leader_roles(members: list[tuple[discord.Member, bool]], team_leaders: discord.Role) -> None: - """Assign team leader role, the jammer role and their team role.""" + """Assign the team leader role to the team leaders.""" for member, is_leader in members: if is_leader: await member.add_roles(team_leaders) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index d0c206b5e..b2ea97c38 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -79,9 +79,9 @@ class CodeJams(commands.Cog): @commands.has_any_role(Roles.admins) async def end(self, ctx: commands.Context) -> None: """ - Deletes all code jam channels. + Delete all code jam channels. - Displays a confirmation message with the categories and channels to be deleted. Pressing the added reaction + A confirmation message is displayed with the categories and channels to be deleted.. Pressing the added reaction deletes those channels. """ def predicate_deletion_emoji_reaction(reaction: discord.Reaction, user: discord.User) -> bool: @@ -196,7 +196,7 @@ class CodeJams(commands.Cog): @codejam.command() @commands.has_any_role(Roles.admins) async def remove(self, ctx: commands.Context, member: Member) -> None: - """Removes the participant from their team. Does not remove the participants or leader roles.""" + """Remove the participant from their team. Does not remove the participants or leader roles.""" channel = self.team_channel(ctx.guild, member) if not channel: await ctx.send(":x: I can't find the team channel for this member.") -- cgit v1.2.3 From a5b761791f1162f2854775d87b00c687bf397a0f Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 15 Aug 2021 23:51:02 +0300 Subject: Add team name to audit log reason Interestingly enough, the reason doesn't seem to be displayed for channel permission overrides. --- bot/exts/events/code_jams/_cog.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index b2ea97c38..64f886f0a 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -202,7 +202,11 @@ class CodeJams(commands.Cog): await ctx.send(":x: I can't find the team channel for this member.") return - await channel.set_permissions(member, overwrite=None, reason="Participant removed from the team.") + await channel.set_permissions( + member, + overwrite=None, + reason=f"Participant removed from the team {self.team_name(channel)}." + ) await ctx.send(f"Removed the participant from `{self.team_name(channel)}`.") @staticmethod -- cgit v1.2.3 From 73187a35c7349c661e50f4a429ae30560ee92ddd Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 15 Aug 2021 23:57:23 +0300 Subject: Add `cj end` timeout message, improve style Co-authored-by: Bluenix --- bot/exts/events/code_jams/_cog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/events/code_jams/_cog.py b/bot/exts/events/code_jams/_cog.py index 64f886f0a..e385f6441 100644 --- a/bot/exts/events/code_jams/_cog.py +++ b/bot/exts/events/code_jams/_cog.py @@ -109,6 +109,7 @@ class CodeJams(commands.Cog): except asyncio.TimeoutError: await message.clear_reaction(DELETION_REACTION) + await ctx.send("Command timed out.", reference=message) return else: @@ -131,7 +132,7 @@ class CodeJams(commands.Cog): def format_category_info(category: discord.CategoryChannel, channels: list[discord.abc.GuildChannel]) -> str: """Displays the category and the channels within it in a readable format.""" - return f"{channel_repr(category)}:" + "".join(f"\n - {channel_repr(channel)}" for channel in channels) + return f"{channel_repr(category)}:\n" + "\n".join(" - " + channel_repr(channel) for channel in channels) deletion_details = "\n\n".join( format_category_info(category, channels) for category, channels in categories.items() -- cgit v1.2.3 From 054d5da2af3e75a8bb29f7088272f8222f2d0b33 Mon Sep 17 00:00:00 2001 From: Ryu18 Date: Mon, 16 Aug 2021 00:53:58 +0200 Subject: apply changes for review https://github.com/python-discord/bot/pull/1725\#pullrequestreview-730223166 --- bot/exts/info/python_news.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index 8d7ffec88..045f9e6f9 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -76,7 +76,7 @@ class PythonNews(Cog): @staticmethod def escape_markdown(content: str) -> str: """Escape the markdown underlines.""" - return re.sub(r'[_\\~|]', lambda match: '\\' + match[0], content, 0, re.MULTILINE) + return re.sub(r"[_|]", lambda match: "\\" + match[0], content) async def post_pep_news(self) -> None: """Fetch new PEPs and when they don't have announcement in #python-news, create it.""" -- cgit v1.2.3 From c5d26a9358099660d18f9409e8d5a9c3fe9fd344 Mon Sep 17 00:00:00 2001 From: Ryu18 Date: Mon, 16 Aug 2021 00:56:31 +0200 Subject: change docstring in escape markdown to reflect actual behavior --- bot/exts/info/python_news.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/python_news.py b/bot/exts/info/python_news.py index 045f9e6f9..63eb4ac17 100644 --- a/bot/exts/info/python_news.py +++ b/bot/exts/info/python_news.py @@ -75,7 +75,7 @@ class PythonNews(Cog): @staticmethod def escape_markdown(content: str) -> str: - """Escape the markdown underlines.""" + """Escape the markdown underlines and spoilers.""" return re.sub(r"[_|]", lambda match: "\\" + match[0], content) async def post_pep_news(self) -> None: -- cgit v1.2.3 From 5f0d7f739109e0b4e6ae7146a81de7a3ce173492 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 16 Aug 2021 01:40:19 +0200 Subject: Enhance security in the Webhook remover. From now on, webhooks that were posted in the chat will be also deleted from Discord in order to eliminate the risk. --- bot/exts/filters/webhook_remover.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index f11fc8912..dc1799f88 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -13,8 +13,8 @@ WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\ ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted a Discord webhook URL. Therefore, your " - "message has been removed. Your webhook may have been **compromised** so " - "please re-create the webhook **immediately**. If you believe this was a " + "message has been removed, alongside with your webhook" + "you can re-create it if you wish to. If you believe this was a " "mistake, please let us know." ) @@ -32,7 +32,7 @@ class WebhookRemover(Cog): """Get current instance of `ModLog`.""" return self.bot.get_cog("ModLog") - async def delete_and_respond(self, msg: Message, redacted_url: str) -> None: + async def delete_and_respond(self, msg: Message, redacted_url: str, webhook_deleted: bool) -> None: """Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`.""" # Don't log this, due internal delete, not by user. Will make different entry. self.mod_log.ignore(Event.message_delete, msg.id) @@ -44,9 +44,12 @@ class WebhookRemover(Cog): return await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention)) - + if webhook_deleted: + delete_state = "The webhook was successfully deleted." + else: + delete_state = "There was an error when deleting the webhook, it might have already been removed." message = ( - f"{format_user(msg.author)} posted a Discord webhook URL to {msg.channel.mention}. " + f"{format_user(msg.author)} posted a Discord webhook URL to {msg.channel.mention}.{delete_state} " f"Webhook URL was `{redacted_url}`" ) log.debug(message) @@ -72,7 +75,12 @@ class WebhookRemover(Cog): matches = WEBHOOK_URL_RE.search(msg.content) if matches: - await self.delete_and_respond(msg, matches[1] + "xxx") + async with self.bot.http_session.delete(msg.content) as resp: + # The Discord API Returns a 204 NO CONTENT repsonse on success. + if resp.status == 204: + await self.delete_and_respond(msg, matches[1] + "xxx", True) + else: + await self.delete_and_respond(msg, matches[1] + "xxx", False) @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: -- cgit v1.2.3 From 8731b19ef771b18bad6418ebb3699b6a3550c60b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 15 Aug 2021 18:41:44 -0700 Subject: HelpChannels: fix incomplete init of available channel set If the cog is reloaded while there are less than the maximum amount of available channels, it makes some channels available until the limit is reached. When a channel is made available, it updates the `available_help_channels` set. The `update_available_help_channels()` function would not update this set if it saw that the set already contains elements. This resulted in only the channels that were just made available being in the set; the set would not contain the channels that were already available when the bot started. Fix this by unconditionally populating the set, but moving it to `init_available()` so it only happens once. Fix BOT-Z1 Fix #1715 --- bot/exts/help_channels/_cog.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index afaf9b0bd..34fae7248 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -267,6 +267,10 @@ class HelpChannels(commands.Cog): for channel in channels[:abs(missing)]: await self.unclaim_channel(channel, closed_on=_channel.ClosingReason.CLEANUP) + self.available_help_channels = { + c for c in self.available_category.channels if not _channel.is_excluded_channel(c) + } + # Getting channels that need to be included in the dynamic message. await self.update_available_help_channels() log.trace("Dynamic available help message updated.") @@ -519,11 +523,6 @@ class HelpChannels(commands.Cog): async def update_available_help_channels(self) -> None: """Updates the dynamic message within #how-to-get-help for available help channels.""" - if not self.available_help_channels: - self.available_help_channels = set( - c for c in self.available_category.channels if not _channel.is_excluded_channel(c) - ) - available_channels = AVAILABLE_HELP_CHANNELS.format( available=", ".join( c.mention for c in sorted(self.available_help_channels, key=attrgetter("position")) -- cgit v1.2.3 From f6a1f00b637fd8e7922913d530cf0d7cc54ec225 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 15 Aug 2021 18:45:25 -0700 Subject: HelpChannels: simplify formatting of category names in dormant msg Remove redundant retrieval of category objects. Include the asking guide URL in the message via an f-string. --- bot/exts/help_channels/_cog.py | 7 ++----- bot/exts/help_channels/_message.py | 8 ++++---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 34fae7248..e71e58e7b 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -391,13 +391,10 @@ class HelpChannels(commands.Cog): ) log.trace(f"Sending dormant message for #{channel} ({channel.id}).") - dormant_category = await channel_utils.try_get_channel(constants.Categories.help_dormant) - available_category = await channel_utils.try_get_channel(constants.Categories.help_available) embed = discord.Embed( description=_message.DORMANT_MSG.format( - dormant=dormant_category.name, - available=available_category.name, - asking_guide=_message.ASKING_GUIDE_URL + dormant=self.dormant_category.name, + available=self.available_category.name, ) ) await channel.send(embed=embed) diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index cf070be83..077b20b47 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -29,15 +29,15 @@ AVAILABLE_TITLE = "Available help channel" AVAILABLE_FOOTER = "Closes after a period of inactivity, or when you send !close." -DORMANT_MSG = """ -This help channel has been marked as **dormant**, and has been moved into the **{dormant}** \ +DORMANT_MSG = f""" +This help channel has been marked as **dormant**, and has been moved into the **{{dormant}}** \ category at the bottom of the channel list. It is no longer possible to send messages in this \ channel until it becomes available again. If your question wasn't answered yet, you can claim a new help channel from the \ -**{available}** category by simply asking your question again. Consider rephrasing the \ +**{{available}}** category by simply asking your question again. Consider rephrasing the \ question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for **[asking a good question]({asking_guide})**. +through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. """ -- cgit v1.2.3 From d388d39487acbbf8071b9b5072955f54caba3bbf Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 16 Aug 2021 18:18:05 +0200 Subject: Improve code consistency in webhook_remover --- bot/exts/filters/webhook_remover.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index dc1799f88..040ae93b6 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -13,8 +13,8 @@ WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\ ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted a Discord webhook URL. Therefore, your " - "message has been removed, alongside with your webhook" - "you can re-create it if you wish to. If you believe this was a " + "message has been removed, alongside with your webhook." + "You can re-create it if you wish to. If you believe this was a " "mistake, please let us know." ) @@ -32,7 +32,7 @@ class WebhookRemover(Cog): """Get current instance of `ModLog`.""" return self.bot.get_cog("ModLog") - async def delete_and_respond(self, msg: Message, redacted_url: str, webhook_deleted: bool) -> None: + async def delete_and_respond(self, msg: Message, redacted_url: str, *, webhook_deleted: bool) -> None: """Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`.""" # Don't log this, due internal delete, not by user. Will make different entry. self.mod_log.ignore(Event.message_delete, msg.id) @@ -49,7 +49,7 @@ class WebhookRemover(Cog): else: delete_state = "There was an error when deleting the webhook, it might have already been removed." message = ( - f"{format_user(msg.author)} posted a Discord webhook URL to {msg.channel.mention}.{delete_state} " + f"{format_user(msg.author)} posted a Discord webhook URL to {msg.channel.mention}. {delete_state} " f"Webhook URL was `{redacted_url}`" ) log.debug(message) @@ -76,11 +76,9 @@ class WebhookRemover(Cog): matches = WEBHOOK_URL_RE.search(msg.content) if matches: async with self.bot.http_session.delete(msg.content) as resp: - # The Discord API Returns a 204 NO CONTENT repsonse on success. - if resp.status == 204: - await self.delete_and_respond(msg, matches[1] + "xxx", True) - else: - await self.delete_and_respond(msg, matches[1] + "xxx", False) + # The Discord API Returns a 204 NO CONTENT response on success. + deleted_successfully = resp.status == 204 + await self.delete_and_respond(msg, matches[1] + "xxx", webhook_deleted=deleted_successfully) @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: -- cgit v1.2.3 From 44adebc02aaedaf96634988019205d6b853d22db Mon Sep 17 00:00:00 2001 From: wookie184 Date: Mon, 16 Aug 2021 17:57:16 +0100 Subject: Remove the admins mention from the modmail tag --- bot/resources/tags/modmail.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/modmail.md b/bot/resources/tags/modmail.md index 412468174..8ac19c8a7 100644 --- a/bot/resources/tags/modmail.md +++ b/bot/resources/tags/modmail.md @@ -6,4 +6,4 @@ It supports attachments, codeblocks, and reactions. As communication happens ove **To use it, simply send a direct message to the bot.** -Should there be an urgent and immediate need for a moderator or admin to look at a channel, feel free to ping the <@&831776746206265384> or <@&267628507062992896> role instead. +Should there be an urgent and immediate need for a moderator to look at a channel, feel free to ping the <@&831776746206265384> role instead. -- cgit v1.2.3 From e8b22358c579cca3447a1db29c93c85f65bb6f56 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 16 Aug 2021 20:19:09 +0200 Subject: Fix up, and improve code consistency in webhook_remover --- bot/exts/filters/webhook_remover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index 040ae93b6..50bb4bef7 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -13,7 +13,7 @@ WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\ ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted a Discord webhook URL. Therefore, your " - "message has been removed, alongside with your webhook." + "message has been removed, and your webhook has been deleted." "You can re-create it if you wish to. If you believe this was a " "mistake, please let us know." ) @@ -75,7 +75,7 @@ class WebhookRemover(Cog): matches = WEBHOOK_URL_RE.search(msg.content) if matches: - async with self.bot.http_session.delete(msg.content) as resp: + async with self.bot.http_session.delete(matches[0]) as resp: # The Discord API Returns a 204 NO CONTENT response on success. deleted_successfully = resp.status == 204 await self.delete_and_respond(msg, matches[1] + "xxx", webhook_deleted=deleted_successfully) -- cgit v1.2.3 From 7d732153a218df95d2e7ade8f44d32a13ebe9be6 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 16 Aug 2021 22:08:05 +0200 Subject: Patch the regEx pattern in webhook_remover to match edge cases The regEx pattern did not match the 'https://' part in case of canary urls Thus, from now on the regEx pattern matches the full canary urls as well. --- bot/exts/filters/webhook_remover.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index 50bb4bef7..b1592ae07 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -9,7 +9,10 @@ from bot.constants import Channels, Colours, Event, Icons from bot.exts.moderation.modlog import ModLog from bot.utils.messages import format_user -WEBHOOK_URL_RE = re.compile(r"((?:https?://)?discord(?:app)?\.com/api/webhooks/\d+/)\S+/?", re.IGNORECASE) +WEBHOOK_URL_RE = re.compile( + r"((?:https?:\/\/)?(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/)\S+\/?", + re.IGNORECASE +) ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted a Discord webhook URL. Therefore, your " -- cgit v1.2.3 From 60b1d16785214be1d8c670d5b5822e9dd0f63719 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 16 Aug 2021 23:07:23 +0200 Subject: Improve output message consistency in webhook_remover Co-authored-by: Bluenix --- bot/exts/filters/webhook_remover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/filters/webhook_remover.py b/bot/exts/filters/webhook_remover.py index b1592ae07..25e267426 100644 --- a/bot/exts/filters/webhook_remover.py +++ b/bot/exts/filters/webhook_remover.py @@ -16,7 +16,7 @@ WEBHOOK_URL_RE = re.compile( ALERT_MESSAGE_TEMPLATE = ( "{user}, looks like you posted a Discord webhook URL. Therefore, your " - "message has been removed, and your webhook has been deleted." + "message has been removed, and your webhook has been deleted. " "You can re-create it if you wish to. If you believe this was a " "mistake, please let us know." ) -- cgit v1.2.3 From 052ceadc25a3e45b00bf8203e65d03c6798d082c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 16 Aug 2021 14:28:40 -0700 Subject: HelpChannels: use utility method to get available channels --- bot/exts/help_channels/_cog.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index e71e58e7b..cfc9cf477 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -267,9 +267,7 @@ class HelpChannels(commands.Cog): for channel in channels[:abs(missing)]: await self.unclaim_channel(channel, closed_on=_channel.ClosingReason.CLEANUP) - self.available_help_channels = { - c for c in self.available_category.channels if not _channel.is_excluded_channel(c) - } + self.available_help_channels = set(_channel.get_category_channels(self.available_category)) # Getting channels that need to be included in the dynamic message. await self.update_available_help_channels() -- cgit v1.2.3 From d697860f22d13063a064ebc5982dea2dc127b0f0 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Tue, 17 Aug 2021 11:51:01 +0200 Subject: Provide jump_url in reminders even when the bot replies When the message is in the same channel and the bot replies, from now on it will also provide a jump_url for the sake of consistency --- bot/exts/utils/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 66a0e6e92..03c7d0323 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -195,6 +195,7 @@ class Reminders(Cog): ) jump_url = reminder.get("jump_url") + embed.description += f"\n[Jump back to when you created the reminder]({jump_url})" partial_message = channel.get_partial_message(int(jump_url.split("/")[-1])) try: await partial_message.reply(content=f"{additional_mentions}", embed=embed) @@ -203,7 +204,6 @@ class Reminders(Cog): f"There was an error when trying to reply to a reminder invocation message, {e}, " "fall back to using jump_url" ) - embed.description += f"\n[Jump back to when you created the reminder]({reminder['jump_url']})" await channel.send(content=f"{user.mention} {additional_mentions}", embed=embed) log.debug(f"Deleting reminder #{reminder['id']} (the user has been reminded).") -- cgit v1.2.3 From 20aea4ff8cf0362d08108535cd8f837cfb74d82b Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Tue, 17 Aug 2021 17:58:13 +0300 Subject: Move max_interval to init The on_message event calculated the max interval value every time for no reason. The value is constant throughout the bot's up time. --- bot/exts/filters/antispam.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 226da2790..da4583e76 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -116,6 +116,13 @@ class AntiSpam(Cog): self.message_deletion_queue = dict() + # Fetch the rule configuration with the highest rule interval. + max_interval_config = max( + AntiSpamConfig.rules.values(), + key=itemgetter('interval') + ) + self.max_interval = max_interval_config['interval'] + self.bot.loop.create_task(self.alert_on_validation_error(), name="AntiSpam.alert_on_validation_error") @property @@ -155,15 +162,8 @@ class AntiSpam(Cog): ): return - # Fetch the rule configuration with the highest rule interval. - max_interval_config = max( - AntiSpamConfig.rules.values(), - key=itemgetter('interval') - ) - max_interval = max_interval_config['interval'] - # Store history messages since `interval` seconds ago in a list to prevent unnecessary API calls. - earliest_relevant_at = datetime.utcnow() - timedelta(seconds=max_interval) + earliest_relevant_at = datetime.utcnow() - timedelta(seconds=self.max_interval) relevant_messages = [ msg async for msg in message.channel.history(after=earliest_relevant_at, oldest_first=False) if not msg.author.bot -- cgit v1.2.3 From fe5c6a57222b5d5a6fab974dde41fc42c66f2173 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 19 Aug 2021 01:25:42 +0100 Subject: Fix linebreak formatting on server command The features part of the embed isn't included in some channels, this leads to there not being a linebreak between voice regions and roles in the embed. By changing it to this, rather than dedent, we specify exact where we want the linebreaks to be. --- bot/exts/info/information.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 167731e64..54c03e139 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -190,13 +190,13 @@ class Information(Cog): f"{constants.Emojis.status_offline} {offline_presences}" ) - embed.description = textwrap.dedent(f""" - Created: {created} - Voice region: {region}\ - {features} - Roles: {num_roles} - Member status: {member_status} - """) + embed.description = ( + f"Created: {created}" + f"\nVoice region: {region}" + f"{features}" + f"\nRoles: {num_roles}" + f"\nMember status: {member_status}" + ) embed.set_thumbnail(url=ctx.guild.icon_url) # Members -- cgit v1.2.3 From 480d26d0959f78d4990a83e46ee0c481e74ea62b Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 19 Aug 2021 01:26:10 +0100 Subject: Add comma separators to member counts in !server --- bot/exts/info/information.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 54c03e139..83ca59bea 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -186,8 +186,8 @@ class Information(Cog): online_presences = py_invite.approximate_presence_count offline_presences = py_invite.approximate_member_count - online_presences member_status = ( - f"{constants.Emojis.status_online} {online_presences} " - f"{constants.Emojis.status_offline} {offline_presences}" + f"{constants.Emojis.status_online} {online_presences:,} " + f"{constants.Emojis.status_offline} {offline_presences:,}" ) embed.description = ( @@ -200,7 +200,7 @@ class Information(Cog): embed.set_thumbnail(url=ctx.guild.icon_url) # Members - total_members = ctx.guild.member_count + total_members = f"{ctx.guild.member_count:,}" member_counts = self.get_member_counts(ctx.guild) member_info = "\n".join(f"{role}: {count}" for role, count in member_counts.items()) embed.add_field(name=f"Members: {total_members}", value=member_info) -- cgit v1.2.3 From 34d7f7cc61be8b76bbc32fce1d6c8cbfc97b4cb8 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 21 Aug 2021 01:28:07 +0300 Subject: AntiSpam modified to work with cache The anti-spam cog now uses a cache instead of reading channel history. The cache is for all channels in the guild, and does not remove deleted messages. That means that the anti-spam logic now works cross-channel and counts deleted messages. The size of the cache is determined via a new field in the config YAML file. The cache was implemented as a separate class, MessageCache, which uses circular buffer logic. This allows for constant time addition and removal form either side, and lookup. The cache does not support removal from the middle of the cache. The cache additionally stores a mapping from message ID's to the index of the message in the cache, to allow constant time lookup by message ID. The commit additionally adds accompanying tests, and renames `cache.py` to `caching.py` to better distinguish it from the new `message_cache.py` and convey that it's for general caching utilities. --- bot/constants.py | 2 + bot/exts/filters/antispam.py | 24 ++-- bot/exts/info/pep.py | 2 +- bot/utils/cache.py | 41 ------- bot/utils/caching.py | 41 +++++++ bot/utils/message_cache.py | 177 +++++++++++++++++++++++++++++ config-default.yml | 2 + tests/bot/utils/test_message_cache.py | 208 ++++++++++++++++++++++++++++++++++ 8 files changed, 447 insertions(+), 50 deletions(-) delete mode 100644 bot/utils/cache.py create mode 100644 bot/utils/caching.py create mode 100644 bot/utils/message_cache.py create mode 100644 tests/bot/utils/test_message_cache.py diff --git a/bot/constants.py b/bot/constants.py index 500803f33..34a814035 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -575,6 +575,8 @@ class Metabase(metaclass=YAMLGetter): class AntiSpam(metaclass=YAMLGetter): section = 'anti_spam' + cache_size: int + clean_offending: bool ping_everyone: bool diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index da4583e76..58cd8dec4 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -3,6 +3,7 @@ import logging from collections.abc import Mapping from dataclasses import dataclass, field from datetime import datetime, timedelta +from itertools import takewhile from operator import attrgetter, itemgetter from typing import Dict, Iterable, List, Set @@ -20,6 +21,7 @@ from bot.converters import Duration from bot.exts.events.code_jams._channels import CATEGORY_NAME as JAM_CATEGORY_NAME from bot.exts.moderation.modlog import ModLog from bot.utils import lock, scheduling +from bot.utils.message_cache import MessageCache from bot.utils.messages import format_user, send_attachments @@ -122,6 +124,7 @@ class AntiSpam(Cog): key=itemgetter('interval') ) self.max_interval = max_interval_config['interval'] + self.cache = MessageCache(AntiSpamConfig.cache_size, newest_first=True) self.bot.loop.create_task(self.alert_on_validation_error(), name="AntiSpam.alert_on_validation_error") @@ -162,12 +165,11 @@ class AntiSpam(Cog): ): return + self.cache.append(message) + # Store history messages since `interval` seconds ago in a list to prevent unnecessary API calls. earliest_relevant_at = datetime.utcnow() - timedelta(seconds=self.max_interval) - relevant_messages = [ - msg async for msg in message.channel.history(after=earliest_relevant_at, oldest_first=False) - if not msg.author.bot - ] + relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, self.cache)) for rule_name in AntiSpamConfig.rules: rule_config = AntiSpamConfig.rules[rule_name] @@ -175,9 +177,10 @@ class AntiSpam(Cog): # Create a list of messages that were sent in the interval that the rule cares about. latest_interesting_stamp = datetime.utcnow() - timedelta(seconds=rule_config['interval']) - messages_for_rule = [ - msg for msg in relevant_messages if msg.created_at > latest_interesting_stamp - ] + messages_for_rule = list( + takewhile(lambda msg: msg.created_at > latest_interesting_stamp, relevant_messages) + ) + result = await rule_function(message, messages_for_rule, rule_config) # If the rule returns `None`, that means the message didn't violate it. @@ -212,7 +215,7 @@ class AntiSpam(Cog): name=f"AntiSpam.punish(message={message.id}, member={member.id}, rule={rule_name})" ) - await self.maybe_delete_messages(channel, relevant_messages) + await self.maybe_delete_messages(channel, messages_for_rule) break @lock.lock_arg("antispam.punish", "member", attrgetter("id")) @@ -264,6 +267,11 @@ class AntiSpam(Cog): deletion_context = self.message_deletion_queue.pop(context_id) await deletion_context.upload_messages(self.bot.user.id, self.mod_log) + @Cog.listener() + async def on_message_edit(self, before: Message, after: Message) -> None: + """Updates the message in the cache, if it's cached.""" + self.cache.update(after) + def validate_config(rules_: Mapping = AntiSpamConfig.rules) -> Dict[str, str]: """Validates the antispam configs.""" diff --git a/bot/exts/info/pep.py b/bot/exts/info/pep.py index 8ac96bbdb..b11b34db0 100644 --- a/bot/exts/info/pep.py +++ b/bot/exts/info/pep.py @@ -9,7 +9,7 @@ from discord.ext.commands import Cog, Context, command from bot.bot import Bot from bot.constants import Keys -from bot.utils.cache import AsyncCache +from bot.utils.caching import AsyncCache log = logging.getLogger(__name__) diff --git a/bot/utils/cache.py b/bot/utils/cache.py deleted file mode 100644 index 68ce15607..000000000 --- a/bot/utils/cache.py +++ /dev/null @@ -1,41 +0,0 @@ -import functools -from collections import OrderedDict -from typing import Any, Callable - - -class AsyncCache: - """ - LRU cache implementation for coroutines. - - Once the cache exceeds the maximum size, keys are deleted in FIFO order. - - An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key. - """ - - def __init__(self, max_size: int = 128): - self._cache = OrderedDict() - self._max_size = max_size - - def __call__(self, arg_offset: int = 0) -> Callable: - """Decorator for async cache.""" - - def decorator(function: Callable) -> Callable: - """Define the async cache decorator.""" - - @functools.wraps(function) - async def wrapper(*args) -> Any: - """Decorator wrapper for the caching logic.""" - key = args[arg_offset:] - - if key not in self._cache: - if len(self._cache) > self._max_size: - self._cache.popitem(last=False) - - self._cache[key] = await function(*args) - return self._cache[key] - return wrapper - return decorator - - def clear(self) -> None: - """Clear cache instance.""" - self._cache.clear() diff --git a/bot/utils/caching.py b/bot/utils/caching.py new file mode 100644 index 000000000..68ce15607 --- /dev/null +++ b/bot/utils/caching.py @@ -0,0 +1,41 @@ +import functools +from collections import OrderedDict +from typing import Any, Callable + + +class AsyncCache: + """ + LRU cache implementation for coroutines. + + Once the cache exceeds the maximum size, keys are deleted in FIFO order. + + An offset may be optionally provided to be applied to the coroutine's arguments when creating the cache key. + """ + + def __init__(self, max_size: int = 128): + self._cache = OrderedDict() + self._max_size = max_size + + def __call__(self, arg_offset: int = 0) -> Callable: + """Decorator for async cache.""" + + def decorator(function: Callable) -> Callable: + """Define the async cache decorator.""" + + @functools.wraps(function) + async def wrapper(*args) -> Any: + """Decorator wrapper for the caching logic.""" + key = args[arg_offset:] + + if key not in self._cache: + if len(self._cache) > self._max_size: + self._cache.popitem(last=False) + + self._cache[key] = await function(*args) + return self._cache[key] + return wrapper + return decorator + + def clear(self) -> None: + """Clear cache instance.""" + self._cache.clear() diff --git a/bot/utils/message_cache.py b/bot/utils/message_cache.py new file mode 100644 index 000000000..b2f8f66bf --- /dev/null +++ b/bot/utils/message_cache.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import typing as t +from math import ceil + +from discord import Message + + +class MessageCache: + """ + A data structure for caching messages. + + The cache is implemented as a circular buffer to allow constant time append, prepend, push, pop, + and lookup by index. The cache therefore does not support removal at an arbitrary index (although it can be + implemented to work in linear time relative to the maximum size). + + The object additionally holds a mapping from Discord message ID's to the index in which the corresponding message + is stored, to allow for constant time lookup by message ID. + + The cache has a size limit operating the same as with a collections.deque, and most of its method names mirror those + of a deque. + + The implementation is transparent to the user: to the user the first element is always at index 0, and there are + only as many elements as were inserted (meaning, without any pre-allocated placeholder values). + """ + + def __init__(self, maxlen: int, *, newest_first: bool = False): + if maxlen <= 0: + raise ValueError("maxlen must be positive") + self.maxlen = maxlen + self.newest_first = newest_first + + self._start = 0 + self._end = 0 + + self._messages: list[t.Optional[Message]] = [None] * self.maxlen + self._message_id_mapping = {} + + def append(self, message: Message) -> None: + """Add the received message to the cache, depending on the order of messages defined by `newest_first`.""" + if self.newest_first: + self._appendleft(message) + else: + self._appendright(message) + + def _appendright(self, message: Message) -> None: + """Add the received message to the end of the cache.""" + if self._is_full(): + del self._message_id_mapping[self._messages[self._start].id] + self._start = (self._start + 1) % self.maxlen + + self._messages[self._end] = message + self._message_id_mapping[message.id] = self._end + self._end = (self._end + 1) % self.maxlen + + def _appendleft(self, message: Message) -> None: + """Add the received message to the beginning of the cache.""" + if self._is_full(): + self._end = (self._end - 1) % self.maxlen + del self._message_id_mapping[self._messages[self._end].id] + + self._start = (self._start - 1) % self.maxlen + self._messages[self._start] = message + self._message_id_mapping[message.id] = self._start + + def pop(self) -> Message: + """Remove the last message in the cache and return it.""" + if self._is_empty(): + raise IndexError("pop from an empty cache") + + self._end = (self._end - 1) % self.maxlen + message = self._messages[self._end] + del self._message_id_mapping[message.id] + self._messages[self._end] = None + + return message + + def popleft(self) -> Message: + """Return the first message in the cache and return it.""" + if self._is_empty(): + raise IndexError("pop from an empty cache") + + message = self._messages[self._start] + del self._message_id_mapping[message.id] + self._messages[self._start] = None + self._start = (self._start + 1) % self.maxlen + + return message + + def clear(self) -> None: + """Remove all messages from the cache.""" + self._messages: list[t.Optional[Message]] = [None] * self.maxlen + self._message_id_mapping = {} + + self._start = 0 + self._end = 0 + + def get_message(self, message_id: int) -> t.Optional[Message]: + """Return the message that has the given message ID, if it is cached.""" + index = self._message_id_mapping.get(message_id, None) + return self._messages[index] if index is not None else None + + def update(self, message: Message) -> bool: + """ + Update a cached message with new contents. + + Return True if the given message had a matching ID in the cache. + """ + index = self._message_id_mapping.get(message.id, None) + if index is None: + return False + self._messages[index] = message + return True + + def __contains__(self, message_id: int) -> bool: + """Return True if the cache contains a message with the given ID .""" + return message_id in self._message_id_mapping + + def __getitem__(self, item: t.Union[int, slice]) -> t.Union[Message, list[Message]]: + """ + Return the message(s) in the index or slice provided. + + This method makes the circular buffer implementation transparent to the user. + Providing 0 will return the message at the position perceived by the user to be the beginning of the cache, + meaning at `self._start`. + """ + if isinstance(item, int): + if item >= len(self) or item < -len(self): + raise IndexError("cache index out of range") + return self._messages[(item + self._start) % self.maxlen] + + elif isinstance(item, slice): + length = len(self) + start, stop, step = item.indices(length) + + # This needs to be checked explicitly now, because otherwise self._start >= self._end is a valid state. + if (start >= stop and step >= 0) or (start <= stop and step <= 0): + return [] + + start = (start + self._start) % self.maxlen + stop = (stop + self._start) % self.maxlen + + # Having empty cells is an implementation detail. To the user the cache contains as many elements as they + # inserted, therefore any empty cells should be ignored. There can only be Nones at the tail. + if ( + (self._start < self._end and not self._start < stop <= self._end) + or (self._start > self._end and self._end < stop <= self._start) + ): + stop = self._end + + if (start < stop and step > 0) or (start > stop and step < 0): + return self._messages[start:stop:step] + # step != 1 may require a start offset in the second slicing. + if step > 0: + offset = ceil((self.maxlen - start) / step) * step + start - self.maxlen + else: + offset = self.maxlen - ((start + 1) % step) + return self._messages[start::step] + self._messages[offset:stop:step] + + else: + raise TypeError(f"cache indices must be integers or slices, not {type(item)}") + + def __len__(self): + """Get the number of non-empty cells in the cache.""" + if self._is_empty(): + return 0 + if self._end > self._start: + return self._end - self._start + return self.maxlen - self._start + self._end + + def _is_empty(self) -> bool: + """Return True if the cache has no messages.""" + return self._messages[self._start] is None + + def _is_full(self) -> bool: + """Return True if every cell in the cache already contains a message.""" + return self._messages[self._end] is not None diff --git a/config-default.yml b/config-default.yml index 881a7df76..2412a7016 100644 --- a/config-default.yml +++ b/config-default.yml @@ -377,6 +377,8 @@ urls: anti_spam: + cache_size: 100 + # Clean messages that violate a rule. clean_offending: true ping_everyone: true diff --git a/tests/bot/utils/test_message_cache.py b/tests/bot/utils/test_message_cache.py new file mode 100644 index 000000000..ff313c6d6 --- /dev/null +++ b/tests/bot/utils/test_message_cache.py @@ -0,0 +1,208 @@ +import unittest + +from bot.utils.message_cache import MessageCache +from tests.helpers import MockMessage + + +# noinspection SpellCheckingInspection +class TestMessageCache(unittest.TestCase): + """Tests for the MessageCache class in the `bot.utils.caching` module.""" + + def test_first_append_sets_the_first_value(self): + """Test if the first append adds the message to the first cell.""" + cache = MessageCache(maxlen=10) + message = MockMessage() + + cache.append(message) + + self.assertEqual(cache[0], message) + + def test_append_adds_in_the_right_order(self): + """Test if two appends are added in the same order if newest_first is False, or in reverse order otherwise.""" + messages = [MockMessage(), MockMessage()] + + cache = MessageCache(maxlen=10, newest_first=False) + for msg in messages: + cache.append(msg) + self.assertListEqual(messages, list(cache)) + + cache = MessageCache(maxlen=10, newest_first=True) + for msg in messages: + cache.append(msg) + self.assertListEqual(messages[::-1], list(cache)) + + def test_appending_over_maxlen_removes_oldest(self): + """Test if three appends to a 2-cell cache leave the two newest messages.""" + cache = MessageCache(maxlen=2) + messages = [MockMessage() for _ in range(3)] + + for msg in messages: + cache.append(msg) + + self.assertListEqual(messages[1:], list(cache)) + + def test_appending_over_maxlen_with_newest_first_removes_oldest(self): + """Test if three appends to a 2-cell cache leave the two newest messages if newest_first is True.""" + cache = MessageCache(maxlen=2, newest_first=True) + messages = [MockMessage() for _ in range(3)] + + for msg in messages: + cache.append(msg) + + self.assertListEqual(messages[:0:-1], list(cache)) + + def test_pop_removes_from_the_end(self): + """Test if a pop removes the right-most message.""" + cache = MessageCache(maxlen=3) + messages = [MockMessage() for _ in range(3)] + + for msg in messages: + cache.append(msg) + msg = cache.pop() + + self.assertEqual(msg, messages[-1]) + self.assertListEqual(messages[:-1], list(cache)) + + def test_popleft_removes_from_the_beginning(self): + """Test if a popleft removes the left-most message.""" + cache = MessageCache(maxlen=3) + messages = [MockMessage() for _ in range(3)] + + for msg in messages: + cache.append(msg) + msg = cache.popleft() + + self.assertEqual(msg, messages[0]) + self.assertListEqual(messages[1:], list(cache)) + + def test_clear(self): + """Test if a clear makes the cache empty.""" + cache = MessageCache(maxlen=5) + messages = [MockMessage() for _ in range(3)] + + for msg in messages: + cache.append(msg) + cache.clear() + + self.assertListEqual(list(cache), []) + self.assertEqual(len(cache), 0) + + def test_get_message_returns_the_message(self): + """Test if get_message returns the cached message.""" + cache = MessageCache(maxlen=5) + message = MockMessage(id=1234) + + cache.append(message) + + self.assertEqual(cache.get_message(1234), message) + + def test_get_message_returns_none(self): + """Test if get_message returns None for an ID of a non-cached message.""" + cache = MessageCache(maxlen=5) + message = MockMessage(id=1234) + + cache.append(message) + + self.assertIsNone(cache.get_message(4321)) + + def test_update_replaces_old_element(self): + """Test if an update replaced the old message with the same ID.""" + cache = MessageCache(maxlen=5) + message = MockMessage(id=1234) + + cache.append(message) + message = MockMessage(id=1234) + cache.update(message) + + self.assertIs(cache.get_message(1234), message) + self.assertEqual(len(cache), 1) + + def test_contains_returns_true_for_cached_message(self): + """Test if contains returns True for an ID of a cached message.""" + cache = MessageCache(maxlen=5) + message = MockMessage(id=1234) + + cache.append(message) + + self.assertIn(1234, cache) + + def test_contains_returns_false_for_non_cached_message(self): + """Test if contains returns False for an ID of a non-cached message.""" + cache = MessageCache(maxlen=5) + message = MockMessage(id=1234) + + cache.append(message) + + self.assertNotIn(4321, cache) + + def test_indexing(self): + """Test if the cache returns the correct messages by index.""" + cache = MessageCache(maxlen=5) + messages = [MockMessage() for _ in range(5)] + + for msg in messages: + cache.append(msg) + + for current_loop in range(-5, 5): + with self.subTest(current_loop=current_loop): + self.assertEqual(cache[current_loop], messages[current_loop]) + + def test_bad_index_raises_index_error(self): + """Test if the cache raises IndexError for invalid indices.""" + cache = MessageCache(maxlen=5) + messages = [MockMessage() for _ in range(3)] + test_cases = (-10, -4, 3, 4, 5) + + for msg in messages: + cache.append(msg) + + for current_loop in test_cases: + with self.subTest(current_loop=current_loop): + with self.assertRaises(IndexError): + cache[current_loop] + + def test_slicing_with_unfilled_cache(self): + """Test if slicing returns the correct messages if the cache is not yet fully filled.""" + cache = MessageCache(maxlen=5) + messages = [MockMessage() for _ in range(4)] + + for msg in messages: + cache.append(msg) + + test_cases = ( + slice(None), slice(2, None), slice(None, 2), slice(None, None, 2), slice(None, None, 3), slice(-1, 2), + slice(-1, 3000), slice(-3, -1), slice(-10, 3), slice(-10, 4, 2), slice(None, None, -1), slice(None, 3, -2), + slice(None, None, -3) + ) + + for current_loop in test_cases: + with self.subTest(current_loop=current_loop): + self.assertListEqual(cache[current_loop], messages[current_loop]) + + def test_slicing_with_overfilled_cache(self): + """Test if slicing returns the correct messages if the cache was appended with more messages it can contain.""" + cache = MessageCache(maxlen=5) + messages = [MockMessage() for _ in range(8)] + + for msg in messages: + cache.append(msg) + messages = messages[3:] + + test_cases = ( + slice(None), slice(2, None), slice(None, 2), slice(None, None, 2), slice(None, None, 3), slice(-1, 2), + slice(-1, 3000), slice(-3, -1), slice(-10, 3), slice(-10, 4, 2), slice(None, None, -1), slice(None, 3, -2), + slice(None, None, -3) + ) + + for current_loop in test_cases: + with self.subTest(current_loop=current_loop): + self.assertListEqual(cache[current_loop], messages[current_loop]) + + def test_length(self): + """Test if len returns the correct number of items in the cache.""" + cache = MessageCache(maxlen=5) + + for current_loop in range(10): + with self.subTest(current_loop=current_loop): + self.assertEqual(len(cache), min(current_loop, 5)) + cache.append(MockMessage()) -- cgit v1.2.3 From e1e104dd7d4cb63cf64436d73a0ae4c4df0f2c9a Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 21 Aug 2021 04:10:35 +0300 Subject: AntiSpam deletes from all spammed channels The anti-spam cog was amended to handle cross-channel spam. --- bot/exts/filters/antispam.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 58cd8dec4..2df93a867 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -1,5 +1,6 @@ import asyncio import logging +from collections import defaultdict from collections.abc import Mapping from dataclasses import dataclass, field from datetime import datetime, timedelta @@ -46,7 +47,8 @@ RULE_FUNCTION_MAPPING = { class DeletionContext: """Represents a Deletion Context for a single spam event.""" - channel: TextChannel + triggered_in: TextChannel + channels: set[TextChannel] members: Dict[int, Member] = field(default_factory=dict) rules: Set[str] = field(default_factory=set) messages: Dict[int, Message] = field(default_factory=dict) @@ -72,10 +74,13 @@ class DeletionContext: async def upload_messages(self, actor_id: int, modlog: ModLog) -> None: """Method that takes care of uploading the queue and posting modlog alert.""" triggered_by_users = ", ".join(format_user(m) for m in self.members.values()) + triggered_in_channel = f"**Triggered in:** {self.triggered_in.mention}\n" if len(self.channels) > 1 else "" + channels_description = ", ".join(channel.mention for channel in self.channels) mod_alert_message = ( f"**Triggered by:** {triggered_by_users}\n" - f"**Channel:** {self.channel.mention}\n" + f"{triggered_in_channel}" + f"**Channels:** {channels_description}\n" f"**Rules:** {', '.join(rule for rule in self.rules)}\n" ) @@ -167,7 +172,6 @@ class AntiSpam(Cog): self.cache.append(message) - # Store history messages since `interval` seconds ago in a list to prevent unnecessary API calls. earliest_relevant_at = datetime.utcnow() - timedelta(seconds=self.max_interval) relevant_messages = list(takewhile(lambda msg: msg.created_at > earliest_relevant_at, self.cache)) @@ -196,7 +200,8 @@ class AntiSpam(Cog): channel = message.channel if channel.id not in self.message_deletion_queue: log.trace(f"Creating queue for channel `{channel.id}`") - self.message_deletion_queue[message.channel.id] = DeletionContext(channel) + affected_channels = set(message.channel for message in messages_for_rule) + self.message_deletion_queue[message.channel.id] = DeletionContext(channel, affected_channels) scheduling.create_task( self._process_deletion_context(message.channel.id), name=f"AntiSpam._process_deletion_context({message.channel.id})" @@ -215,7 +220,7 @@ class AntiSpam(Cog): name=f"AntiSpam.punish(message={message.id}, member={member.id}, rule={rule_name})" ) - await self.maybe_delete_messages(channel, messages_for_rule) + await self.maybe_delete_messages(messages_for_rule) break @lock.lock_arg("antispam.punish", "member", attrgetter("id")) @@ -237,14 +242,18 @@ class AntiSpam(Cog): reason=reason ) - async def maybe_delete_messages(self, channel: TextChannel, messages: List[Message]) -> None: + async def maybe_delete_messages(self, messages: List[Message]) -> None: """Cleans the messages if cleaning is configured.""" if AntiSpamConfig.clean_offending: # If we have more than one message, we can use bulk delete. if len(messages) > 1: message_ids = [message.id for message in messages] self.mod_log.ignore(Event.message_delete, *message_ids) - await channel.delete_messages(messages) + channel_messages = defaultdict(list) + for message in messages: + channel_messages[message.channel].append(message) + for channel, messages in channel_messages.items(): + await channel.delete_messages(messages) # Otherwise, the bulk delete endpoint will throw up. # Delete the message directly instead. -- cgit v1.2.3 From ad3d1974b546cd426537b3c1c692af56a3c3dad1 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 21 Aug 2021 12:47:36 +0300 Subject: Identify DeletionContext by members, not channels Since the anti-spam now works cross-channels, it makes no sense to identify it by the channel in which it was invoked. The DeletionContext class was changed to accept a frozenset of members, and the message_deletion_queue dict uses the frozensets as keys. DeletionContext still accepts a channel on creation, because while it might get added more channels, there's only one channel in which the mute message will be sent. Using members as the key can run into the issue of one member becoming irrelevant to the filter while others still are, resulting in another log message being sent, but it's an unlikely edge case since the users should be muted almost immediately, and we're currently not using any multi-member filters in the first place. --- bot/exts/filters/antispam.py | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 2df93a867..987060779 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -47,20 +47,18 @@ RULE_FUNCTION_MAPPING = { class DeletionContext: """Represents a Deletion Context for a single spam event.""" + members: frozenset[Member] triggered_in: TextChannel - channels: set[TextChannel] - members: Dict[int, Member] = field(default_factory=dict) + channels: set[TextChannel] = field(default_factory=set) rules: Set[str] = field(default_factory=set) messages: Dict[int, Message] = field(default_factory=dict) attachments: List[List[str]] = field(default_factory=list) - async def add(self, rule_name: str, members: Iterable[Member], messages: Iterable[Message]) -> None: + async def add(self, rule_name: str, channels: Iterable[TextChannel], messages: Iterable[Message]) -> None: """Adds new rule violation events to the deletion context.""" self.rules.add(rule_name) - for member in members: - if member.id not in self.members: - self.members[member.id] = member + self.channels.update(channels) for message in messages: if message.id not in self.messages: @@ -73,7 +71,7 @@ class DeletionContext: async def upload_messages(self, actor_id: int, modlog: ModLog) -> None: """Method that takes care of uploading the queue and posting modlog alert.""" - triggered_by_users = ", ".join(format_user(m) for m in self.members.values()) + triggered_by_users = ", ".join(format_user(m) for m in self.members) triggered_in_channel = f"**Triggered in:** {self.triggered_in.mention}\n" if len(self.channels) > 1 else "" channels_description = ", ".join(channel.mention for channel in self.channels) @@ -197,20 +195,19 @@ class AntiSpam(Cog): full_reason = f"`{rule_name}` rule: {reason}" # If there's no spam event going on for this channel, start a new Message Deletion Context - channel = message.channel - if channel.id not in self.message_deletion_queue: - log.trace(f"Creating queue for channel `{channel.id}`") - affected_channels = set(message.channel for message in messages_for_rule) - self.message_deletion_queue[message.channel.id] = DeletionContext(channel, affected_channels) + authors_set = frozenset(members) + if authors_set not in self.message_deletion_queue: + log.trace(f"Creating queue for members `{authors_set}`") + self.message_deletion_queue[authors_set] = DeletionContext(authors_set, message.channel) scheduling.create_task( - self._process_deletion_context(message.channel.id), - name=f"AntiSpam._process_deletion_context({message.channel.id})" + self._process_deletion_context(authors_set), + name=f"AntiSpam._process_deletion_context({authors_set})" ) # Add the relevant of this trigger to the Deletion Context - await self.message_deletion_queue[message.channel.id].add( + await self.message_deletion_queue[authors_set].add( rule_name=rule_name, - members=members, + channels=set(message.channel for message in messages_for_rule), messages=relevant_messages ) @@ -264,7 +261,7 @@ class AntiSpam(Cog): except NotFound: log.info(f"Tried to delete message `{messages[0].id}`, but message could not be found.") - async def _process_deletion_context(self, context_id: int) -> None: + async def _process_deletion_context(self, context_id: frozenset) -> None: """Processes the Deletion Context queue.""" log.trace("Sleeping before processing message deletion queue.") await asyncio.sleep(10) -- cgit v1.2.3 From b4ddc0b7fa3601807bb36139efd06693a3bfe9fc Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 21 Aug 2021 15:13:04 +0300 Subject: Fix MessageCache slicing bugs, improve tests --- bot/utils/message_cache.py | 23 ++++++++++++------ tests/bot/utils/test_message_cache.py | 44 ++++++++++++++++++++--------------- 2 files changed, 41 insertions(+), 26 deletions(-) diff --git a/bot/utils/message_cache.py b/bot/utils/message_cache.py index b2f8f66bf..67da8ecf3 100644 --- a/bot/utils/message_cache.py +++ b/bot/utils/message_cache.py @@ -142,20 +142,29 @@ class MessageCache: # Having empty cells is an implementation detail. To the user the cache contains as many elements as they # inserted, therefore any empty cells should be ignored. There can only be Nones at the tail. - if ( - (self._start < self._end and not self._start < stop <= self._end) - or (self._start > self._end and self._end < stop <= self._start) - ): - stop = self._end + if step > 0: + if ( + (self._start < self._end and not self._start < stop <= self._end) + or (self._start > self._end and self._end < stop <= self._start) + ): + stop = self._end + else: + lower_boundary = (self._start - 1) % self.maxlen + if ( + (self._start < self._end and not self._start - 1 <= stop < self._end) + or (self._start > self._end and self._end < stop < lower_boundary) + ): + stop = lower_boundary if (start < stop and step > 0) or (start > stop and step < 0): return self._messages[start:stop:step] # step != 1 may require a start offset in the second slicing. if step > 0: offset = ceil((self.maxlen - start) / step) * step + start - self.maxlen + return self._messages[start::step] + self._messages[offset:stop:step] else: - offset = self.maxlen - ((start + 1) % step) - return self._messages[start::step] + self._messages[offset:stop:step] + offset = ceil((start + 1) / -step) * -step - start - 1 + return self._messages[start::step] + self._messages[self.maxlen - 1 - offset:stop:step] else: raise TypeError(f"cache indices must be integers or slices, not {type(item)}") diff --git a/tests/bot/utils/test_message_cache.py b/tests/bot/utils/test_message_cache.py index ff313c6d6..5e871cd19 100644 --- a/tests/bot/utils/test_message_cache.py +++ b/tests/bot/utils/test_message_cache.py @@ -163,40 +163,46 @@ class TestMessageCache(unittest.TestCase): def test_slicing_with_unfilled_cache(self): """Test if slicing returns the correct messages if the cache is not yet fully filled.""" - cache = MessageCache(maxlen=5) - messages = [MockMessage() for _ in range(4)] - - for msg in messages: - cache.append(msg) + sizes = (5, 10, 55, 101) - test_cases = ( + slices = ( slice(None), slice(2, None), slice(None, 2), slice(None, None, 2), slice(None, None, 3), slice(-1, 2), slice(-1, 3000), slice(-3, -1), slice(-10, 3), slice(-10, 4, 2), slice(None, None, -1), slice(None, 3, -2), slice(None, None, -3) ) - for current_loop in test_cases: - with self.subTest(current_loop=current_loop): - self.assertListEqual(cache[current_loop], messages[current_loop]) + for size in sizes: + cache = MessageCache(maxlen=size) + messages = [MockMessage() for _ in range(size // 3 * 2)] + + for msg in messages: + cache.append(msg) + + for slice_ in slices: + with self.subTest(current_loop=(size, slice_)): + self.assertListEqual(cache[slice_], messages[slice_]) def test_slicing_with_overfilled_cache(self): """Test if slicing returns the correct messages if the cache was appended with more messages it can contain.""" - cache = MessageCache(maxlen=5) - messages = [MockMessage() for _ in range(8)] - - for msg in messages: - cache.append(msg) - messages = messages[3:] + sizes = (5, 10, 55, 101) - test_cases = ( + slices = ( slice(None), slice(2, None), slice(None, 2), slice(None, None, 2), slice(None, None, 3), slice(-1, 2), slice(-1, 3000), slice(-3, -1), slice(-10, 3), slice(-10, 4, 2), slice(None, None, -1), slice(None, 3, -2), slice(None, None, -3) ) - for current_loop in test_cases: - with self.subTest(current_loop=current_loop): - self.assertListEqual(cache[current_loop], messages[current_loop]) + for size in sizes: + cache = MessageCache(maxlen=size) + messages = [MockMessage() for _ in range(size * 3 // 2)] + + for msg in messages: + cache.append(msg) + messages = messages[size // 2:] + + for slice_ in slices: + with self.subTest(current_loop=(size, slice_)): + self.assertListEqual(cache[slice_], messages[slice_]) def test_length(self): """Test if len returns the correct number of items in the cache.""" -- cgit v1.2.3 From aa2a6b436746f7e8b66f960bf3d41748c784469f Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 21 Aug 2021 16:39:27 +0300 Subject: Clean up code Removed unused import, corrected docstring, and removed unnedded type annotation. --- bot/utils/message_cache.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/utils/message_cache.py b/bot/utils/message_cache.py index 67da8ecf3..6d219c313 100644 --- a/bot/utils/message_cache.py +++ b/bot/utils/message_cache.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import typing as t from math import ceil @@ -10,7 +8,7 @@ class MessageCache: """ A data structure for caching messages. - The cache is implemented as a circular buffer to allow constant time append, prepend, push, pop, + The cache is implemented as a circular buffer to allow constant time append, prepend, pop from either side, and lookup by index. The cache therefore does not support removal at an arbitrary index (although it can be implemented to work in linear time relative to the maximum size). @@ -89,7 +87,7 @@ class MessageCache: def clear(self) -> None: """Remove all messages from the cache.""" - self._messages: list[t.Optional[Message]] = [None] * self.maxlen + self._messages = [None] * self.maxlen self._message_id_mapping = {} self._start = 0 -- cgit v1.2.3 From 0531b1ec1fd55018d358d07f3ab52d0ada3cdaae Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 21 Aug 2021 20:52:12 +0300 Subject: Additional comments and tests for slicing --- bot/utils/message_cache.py | 3 +++ tests/bot/utils/test_message_cache.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/utils/message_cache.py b/bot/utils/message_cache.py index 6d219c313..f5656cdeb 100644 --- a/bot/utils/message_cache.py +++ b/bot/utils/message_cache.py @@ -122,6 +122,9 @@ class MessageCache: Providing 0 will return the message at the position perceived by the user to be the beginning of the cache, meaning at `self._start`. """ + # Keep in mind that for the modulo operator used throughout this function, Python modulo behaves similarly when + # the left operand is negative. E.g -1 % 5 == 4, because the closest number from the bottom that wholly divides + # by 5 is -5. if isinstance(item, int): if item >= len(self) or item < -len(self): raise IndexError("cache index out of range") diff --git a/tests/bot/utils/test_message_cache.py b/tests/bot/utils/test_message_cache.py index 5e871cd19..04bfd28d1 100644 --- a/tests/bot/utils/test_message_cache.py +++ b/tests/bot/utils/test_message_cache.py @@ -168,7 +168,7 @@ class TestMessageCache(unittest.TestCase): slices = ( slice(None), slice(2, None), slice(None, 2), slice(None, None, 2), slice(None, None, 3), slice(-1, 2), slice(-1, 3000), slice(-3, -1), slice(-10, 3), slice(-10, 4, 2), slice(None, None, -1), slice(None, 3, -2), - slice(None, None, -3) + slice(None, None, -3), slice(-1, -10, -2), slice(-3, -7, -1) ) for size in sizes: @@ -189,7 +189,7 @@ class TestMessageCache(unittest.TestCase): slices = ( slice(None), slice(2, None), slice(None, 2), slice(None, None, 2), slice(None, None, 3), slice(-1, 2), slice(-1, 3000), slice(-3, -1), slice(-10, 3), slice(-10, 4, 2), slice(None, None, -1), slice(None, 3, -2), - slice(None, None, -3) + slice(None, None, -3), slice(-1, -10, -2), slice(-3, -7, -1) ) for size in sizes: -- cgit v1.2.3 From 7e8d24f88eb199dc6ba790816d4abaa3269144e0 Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Sun, 22 Aug 2021 11:06:38 +0200 Subject: Modify reminder response messages to be in the correct format --- bot/exts/utils/reminders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 03c7d0323..f0aa280b6 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -18,7 +18,7 @@ from bot.utils.checks import has_any_role_check, has_no_roles_check from bot.utils.lock import lock_arg from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler -from bot.utils.time import TimestampFormats, discord_timestamp, time_since +from bot.utils.time import TimestampFormats, discord_timestamp log = logging.getLogger(__name__) @@ -177,7 +177,7 @@ class Reminders(Cog): embed.colour = discord.Colour.red() embed.set_author( icon_url=Icons.remind_red, - name=f"Sorry it should have arrived {time_since(expected_time)} !" + name="Sorry, your reminder should have arrived earlier!" ) else: embed.colour = discord.Colour.blurple() -- cgit v1.2.3 From 2a00be51f64a3980be3e174778b82329378abdc5 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Sun, 22 Aug 2021 02:45:56 -0700 Subject: Error to info log level on missing reminder message. --- bot/exts/utils/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index f0aa280b6..cc13f6ebe 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -200,7 +200,7 @@ class Reminders(Cog): try: await partial_message.reply(content=f"{additional_mentions}", embed=embed) except discord.HTTPException as e: - log.error( + log.info( f"There was an error when trying to reply to a reminder invocation message, {e}, " "fall back to using jump_url" ) -- cgit v1.2.3 From 9ad34a8f22fc19dba28498174da7f14df14a78c2 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 22 Aug 2021 11:32:54 +0100 Subject: Move metabase error handling to a cog error handler --- bot/exts/moderation/metabase.py | 59 +++++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py index e9faf7240..80dabbbb0 100644 --- a/bot/exts/moderation/metabase.py +++ b/bot/exts/moderation/metabase.py @@ -42,6 +42,25 @@ class Metabase(Cog): self.init_task = self.bot.loop.create_task(self.init_cog()) + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Handle ClientResponseError errors locally to invalidate token if needed.""" + if not isinstance(error.original, ClientResponseError): + return + + if error.original.status == 403: + # User doesn't have access to the given question + log.warning(f"Failed to auth with Metabase for {error.original.url}.") + await ctx.send(f":x: {ctx.author.mention} Failed to auth with Metabase for that question.") + elif error.original.status == 404: + await ctx.send(f":x: {ctx.author.mention} That question could not be found.") + else: + # User credentials are invalid, or the refresh failed. + # Delete the expiry time, to force a refresh on next startup. + await self.session_info.delete("session_expiry") + log.exception("Session token is invalid or refresh failed.") + await ctx.send(f":x: {ctx.author.mention} Session token is invalid or refresh failed.") + error.handled = True + async def init_cog(self) -> None: """Initialise the metabase session.""" expiry_time = await self.session_info.get("session_expiry") @@ -112,32 +131,20 @@ class Metabase(Cog): await self.init_task url = f"{MetabaseConfig.url}/card/{question_id}/query/{extension}" - try: - async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp: - if extension == "csv": - out = await resp.text(encoding="utf-8") - # Save the output for use with int e - self.exports[question_id] = list(csv.DictReader(StringIO(out))) - - elif extension == "json": - out = await resp.json(encoding="utf-8") - # Save the output for use with int e - self.exports[question_id] = out - - # Format it nicely for human eyes - out = json.dumps(out, indent=4, sort_keys=True) - except ClientResponseError as e: - if e.status == 403: - # User doesn't have access to the given question - log.warning(f"Failed to auth with Metabase for question {question_id}.") - await ctx.send(f":x: {ctx.author.mention} Failed to auth with Metabase for that question.") - else: - # User credentials are invalid, or the refresh failed. - # Delete the expiry time, to force a refresh on next startup. - await self.session_info.delete("session_expiry") - log.exception("Session token is invalid or refresh failed.") - await ctx.send(f":x: {ctx.author.mention} Session token is invalid or refresh failed.") - return + + async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp: + if extension == "csv": + out = await resp.text(encoding="utf-8") + # Save the output for use with int e + self.exports[question_id] = list(csv.DictReader(StringIO(out))) + + elif extension == "json": + out = await resp.json(encoding="utf-8") + # Save the output for use with int e + self.exports[question_id] = out + + # Format it nicely for human eyes + out = json.dumps(out, indent=4, sort_keys=True) paste_link = await send_to_paste_service(out, extension=extension) if paste_link: -- cgit v1.2.3 From 30bcc28b2caf90ac6ed0e2f5df530c617ba03b4a Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 22 Aug 2021 11:38:14 +0100 Subject: Change metabase config to base url, rather than api url --- bot/constants.py | 2 +- bot/exts/moderation/metabase.py | 4 ++-- config-default.yml | 10 ++++------ 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 500803f33..12b5c02e5 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -568,7 +568,7 @@ class Metabase(metaclass=YAMLGetter): username: Optional[str] password: Optional[str] - url: str + base_url: str max_session_age: int diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py index 80dabbbb0..bfa94b7c1 100644 --- a/bot/exts/moderation/metabase.py +++ b/bot/exts/moderation/metabase.py @@ -84,7 +84,7 @@ class Metabase(Cog): "username": MetabaseConfig.username, "password": MetabaseConfig.password } - async with self.bot.http_session.post(f"{MetabaseConfig.url}/session", json=data) as resp: + async with self.bot.http_session.post(f"{MetabaseConfig.base_url}/api/session", json=data) as resp: json_data = await resp.json() self.session_token = json_data.get("id") @@ -130,7 +130,7 @@ class Metabase(Cog): # Make sure we have a session token before running anything await self.init_task - url = f"{MetabaseConfig.url}/card/{question_id}/query/{extension}" + url = f"{MetabaseConfig.base_url}/api/card/{question_id}/query/{extension}" async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp: if extension == "csv": diff --git a/config-default.yml b/config-default.yml index 881a7df76..79828dd77 100644 --- a/config-default.yml +++ b/config-default.yml @@ -432,14 +432,12 @@ anti_spam: max: 3 - metabase: - username: !ENV "METABASE_USERNAME" - password: !ENV "METABASE_PASSWORD" - url: "http://metabase.default.svc.cluster.local/api" + username: !ENV "METABASE_USERNAME" + password: !ENV "METABASE_PASSWORD" + base_url: "http://metabase.default.svc.cluster.local" # 14 days, see https://www.metabase.com/docs/latest/operations-guide/environment-variables.html#max_session_age - max_session_age: 20160 - + max_session_age: 20160 big_brother: -- cgit v1.2.3 From ce80b92199f7b81d49845a212dabf2e02b3d4684 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 22 Aug 2021 11:38:28 +0100 Subject: Add alias for metabase export --- bot/exts/moderation/metabase.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py index bfa94b7c1..fa5f60ca6 100644 --- a/bot/exts/moderation/metabase.py +++ b/bot/exts/moderation/metabase.py @@ -105,7 +105,7 @@ class Metabase(Cog): """A group of commands for interacting with metabase.""" await ctx.send_help(ctx.command) - @metabase_group.command(name="extract") + @metabase_group.command(name="extract", aliases=("export",)) async def metabase_extract( self, ctx: Context, -- cgit v1.2.3 From eb5cedea9a432b6b98d5ef831a016f9e33d72cbb Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 22 Aug 2021 11:42:04 +0100 Subject: Trigger typing, to avoid wrapping whole func in a context manager --- bot/exts/moderation/metabase.py | 60 ++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py index fa5f60ca6..d97b355a5 100644 --- a/bot/exts/moderation/metabase.py +++ b/bot/exts/moderation/metabase.py @@ -125,36 +125,36 @@ class Metabase(Cog): Valid extensions are: csv and json. """ - async with ctx.typing(): - - # Make sure we have a session token before running anything - await self.init_task - - url = f"{MetabaseConfig.base_url}/api/card/{question_id}/query/{extension}" - - async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp: - if extension == "csv": - out = await resp.text(encoding="utf-8") - # Save the output for use with int e - self.exports[question_id] = list(csv.DictReader(StringIO(out))) - - elif extension == "json": - out = await resp.json(encoding="utf-8") - # Save the output for use with int e - self.exports[question_id] = out - - # Format it nicely for human eyes - out = json.dumps(out, indent=4, sort_keys=True) - - paste_link = await send_to_paste_service(out, extension=extension) - if paste_link: - message = f":+1: {ctx.author.mention} Here's your link: {paste_link}" - else: - message = f":x: {ctx.author.mention} Link service is unavailible." - await ctx.send( - f"{message}\nYou can also access this data within internal eval by doing: " - f"`bot.get_cog('Metabase').exports[{question_id}]`" - ) + await ctx.trigger_typing() + + # Make sure we have a session token before running anything + await self.init_task + + url = f"{MetabaseConfig.base_url}/api/card/{question_id}/query/{extension}" + + async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp: + if extension == "csv": + out = await resp.text(encoding="utf-8") + # Save the output for use with int e + self.exports[question_id] = list(csv.DictReader(StringIO(out))) + + elif extension == "json": + out = await resp.json(encoding="utf-8") + # Save the output for use with int e + self.exports[question_id] = out + + # Format it nicely for human eyes + out = json.dumps(out, indent=4, sort_keys=True) + + paste_link = await send_to_paste_service(out, extension=extension) + if paste_link: + message = f":+1: {ctx.author.mention} Here's your link: {paste_link}" + else: + message = f":x: {ctx.author.mention} Link service is unavailible." + await ctx.send( + f"{message}\nYou can also access this data within internal eval by doing: " + f"`bot.get_cog('Metabase').exports[{question_id}]`" + ) # This cannot be static (must have a __func__ attribute). async def cog_check(self, ctx: Context) -> bool: -- cgit v1.2.3 From 4a0d6196a1f56433191a2a71b349209f6291fc22 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 22 Aug 2021 11:38:46 +0100 Subject: Add ability to publish metabase questions --- bot/exts/moderation/metabase.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py index d97b355a5..3b454ab18 100644 --- a/bot/exts/moderation/metabase.py +++ b/bot/exts/moderation/metabase.py @@ -156,6 +156,20 @@ class Metabase(Cog): f"`bot.get_cog('Metabase').exports[{question_id}]`" ) + @metabase_group.command(name="publish", aliases=("share",)) + async def metabase_publish(self, ctx: Context, question_id: int) -> None: + """Publically shares the given question and posts the link.""" + await ctx.trigger_typing() + # Make sure we have a session token before running anything + await self.init_task + + url = f"{MetabaseConfig.base_url}/api/card/{question_id}/public_link" + + async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp: + response_json = await resp.json(encoding="utf-8") + sharing_url = f"{MetabaseConfig.base_url}/public/question/{response_json['uuid']}" + await ctx.send(f":+1: {ctx.author.mention} Here's your sharing link: {sharing_url}") + # This cannot be static (must have a __func__ attribute). async def cog_check(self, ctx: Context) -> bool: """Only allow admins inside moderator channels to invoke the commands in this cog.""" -- cgit v1.2.3 From d0d140438c13110bad99a18a7e42e60b325b1176 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Sun, 22 Aug 2021 15:38:44 +0300 Subject: Improve cache iteration speed getitem based iteration included operations that aren't necessary when iterating over the cache continuously. Adding an iter method to the class seems to have improved iteration speed by several orders of magnitude. --- bot/utils/message_cache.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/utils/message_cache.py b/bot/utils/message_cache.py index f5656cdeb..f68d280c9 100644 --- a/bot/utils/message_cache.py +++ b/bot/utils/message_cache.py @@ -170,6 +170,16 @@ class MessageCache: else: raise TypeError(f"cache indices must be integers or slices, not {type(item)}") + def __iter__(self) -> t.Iterator[Message]: + if self._is_empty(): + return + + if self._start < self._end: + yield from self._messages[self._start:self._end] + else: + yield from self._messages[self._start:] + yield from self._messages[:self._end] + def __len__(self): """Get the number of non-empty cells in the cache.""" if self._is_empty(): -- cgit v1.2.3 From 9c199dbbb8683584ef01955197232314eb0cf372 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Sun, 22 Aug 2021 18:55:45 +0100 Subject: Fix edge-case of `user.joined_at` being `None` in userinfo command. --- bot/exts/info/information.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 83ca59bea..85d3c0e73 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -257,7 +257,11 @@ class Information(Cog): badges.append(emoji) if on_server: - joined = discord_timestamp(user.joined_at, TimestampFormats.RELATIVE) + if user.joined_at: + joined = discord_timestamp(user.joined_at, TimestampFormats.RELATIVE) + else: + joined = "Unable to get join date" + # The 0 is for excluding the default @everyone role, # and the -1 is for reversing the order of the roles to highest to lowest in hierarchy. roles = ", ".join(role.mention for role in user.roles[:0:-1]) -- cgit v1.2.3 From 73bc01123efb8dbc1e3efb64545b028d21c044d2 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 11 Aug 2021 16:41:45 +0100 Subject: Remove converters made redundant by dpy V1.6's UserConverter update --- bot/converters.py | 71 +------------------------------- bot/exts/moderation/infraction/_utils.py | 2 - 2 files changed, 1 insertion(+), 72 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 595809517..3df613379 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -439,29 +439,6 @@ class HushDurationConverter(Converter): return duration -def proxy_user(user_id: str) -> discord.Object: - """ - Create a proxy user object from the given id. - - Used when a Member or User object cannot be resolved. - """ - log.trace(f"Attempting to create a proxy user for the user id {user_id}.") - - try: - user_id = int(user_id) - except ValueError: - log.debug(f"Failed to create proxy user {user_id}: could not convert to int.") - raise BadArgument(f"User ID `{user_id}` is invalid - could not convert to an integer.") - - user = discord.Object(user_id) - user.mention = user.id - user.display_name = f"<@{user.id}>" - user.avatar_url_as = lambda static_format: None - user.bot = False - - return user - - class UserMentionOrID(UserConverter): """ Converts to a `discord.User`, but only if a mention or userID is provided. @@ -480,51 +457,6 @@ class UserMentionOrID(UserConverter): raise BadArgument(f"`{argument}` is not a User mention or a User ID.") -class FetchedUser(UserConverter): - """ - Converts to a `discord.User` or, if it fails, a `discord.Object`. - - Unlike the default `UserConverter`, which only does lookups via the global user cache, this - converter attempts to fetch the user via an API call to Discord when the using the cache is - unsuccessful. - - If the fetch also fails and the error doesn't imply the user doesn't exist, then a - `discord.Object` is returned via the `user_proxy` converter. - - The lookup strategy is as follows (in order): - - 1. Lookup by ID. - 2. Lookup by mention. - 3. Lookup by name#discrim - 4. Lookup by name - 5. Lookup via API - 6. Create a proxy user with discord.Object - """ - - async def convert(self, ctx: Context, arg: str) -> t.Union[discord.User, discord.Object]: - """Convert the `arg` to a `discord.User` or `discord.Object`.""" - try: - return await super().convert(ctx, arg) - except BadArgument: - pass - - try: - user_id = int(arg) - log.trace(f"Fetching user {user_id}...") - return await ctx.bot.fetch_user(user_id) - except ValueError: - log.debug(f"Failed to fetch user {arg}: could not convert to int.") - raise BadArgument(f"The provided argument can't be turned into integer: `{arg}`") - except discord.HTTPException as e: - # If the Discord error isn't `Unknown user`, return a proxy instead - if e.code != 10013: - log.info(f"Failed to fetch user, returning a proxy instead: status {e.status}") - return proxy_user(arg) - - log.debug(f"Failed to fetch user {arg}: user does not exist.") - raise BadArgument(f"User `{arg}` does not exist") - - def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int: """ Extract the snowflake from `arg` using a regex `pattern` and return it as an int. @@ -568,5 +500,4 @@ class Infraction(Converter): Expiry = t.Union[Duration, ISODateTime] -FetchedMember = t.Union[discord.Member, FetchedUser] -UserMention = partial(_snowflake_from_regex, RE_USER_MENTION) +FetchedMember = t.Union[discord.Member, discord.User] diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index a4059a6e9..e3fcda730 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -24,8 +24,6 @@ INFRACTION_ICONS = { RULES_URL = "https://pythondiscord.com/pages/rules" # Type aliases -UserObject = t.Union[discord.Member, discord.User] -UserSnowflake = t.Union[UserObject, discord.Object] Infraction = t.Dict[str, t.Union[str, int, bool]] APPEAL_EMAIL = "appeals@pythondiscord.com" -- cgit v1.2.3 From 17f3750cbe53eeb740795d77c192b5822d086a4b Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 11 Aug 2021 16:51:28 +0100 Subject: Update to new converters --- bot/exts/fun/duck_pond.py | 3 ++- bot/exts/moderation/infraction/_scheduler.py | 6 +++--- bot/exts/moderation/infraction/_utils.py | 18 ++++++++---------- bot/exts/moderation/infraction/infractions.py | 5 ++--- bot/exts/moderation/infraction/management.py | 16 +++++----------- bot/exts/recruitment/talentpool/_cog.py | 2 +- 6 files changed, 21 insertions(+), 29 deletions(-) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index c78b9c141..a8d927353 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -8,6 +8,7 @@ from discord.ext.commands import Cog, Context, command from bot import constants from bot.bot import Bot +from bot.converters import FetchedMember from bot.utils.checks import has_any_role from bot.utils.messages import count_unique_users_reaction, send_attachments from bot.utils.webhooks import send_webhook @@ -36,7 +37,7 @@ class DuckPond(Cog): log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") @staticmethod - def is_staff(member: Union[User, Member]) -> bool: + def is_staff(member: FetchedMember) -> bool: """Check if a specific member or user is staff.""" if hasattr(member, "roles"): for role in member.roles: diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 8286d3635..89869820c 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -13,8 +13,8 @@ from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Colours +from bot.converters import FetchedMember from bot.exts.moderation.infraction import _utils -from bot.exts.moderation.infraction._utils import UserSnowflake from bot.exts.moderation.modlog import ModLog from bot.utils import messages, scheduling, time from bot.utils.channel import is_mod_channel @@ -115,7 +115,7 @@ class InfractionScheduler: self, ctx: Context, infraction: _utils.Infraction, - user: UserSnowflake, + user: FetchedMember, action_coro: t.Optional[t.Awaitable] = None, user_reason: t.Optional[str] = None, additional_info: str = "", @@ -264,7 +264,7 @@ class InfractionScheduler: self, ctx: Context, infr_type: str, - user: UserSnowflake, + user: FetchedMember, send_msg: bool = True ) -> None: """ diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index e3fcda730..9c3f9d804 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -7,6 +7,7 @@ from discord.ext.commands import Context from bot.api import ResponseCodeError from bot.constants import Colours, Icons +from bot.converters import FetchedMember from bot.errors import InvalidInfractedUserError log = logging.getLogger(__name__) @@ -43,7 +44,7 @@ INFRACTION_DESCRIPTION_TEMPLATE = ( ) -async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: +async def post_user(ctx: Context, user: FetchedMember) -> t.Optional[dict]: """ Create a new user in the database. @@ -51,9 +52,6 @@ async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: """ log.trace(f"Attempting to add user {user.id} to the database.") - if not isinstance(user, (discord.Member, discord.User)): - log.debug("The user being added to the DB is not a Member or User object.") - payload = { 'discriminator': int(getattr(user, 'discriminator', 0)), 'id': user.id, @@ -73,7 +71,7 @@ async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]: async def post_infraction( ctx: Context, - user: UserSnowflake, + user: FetchedMember, infr_type: str, reason: str, expires_at: datetime = None, @@ -81,7 +79,7 @@ async def post_infraction( active: bool = True ) -> t.Optional[dict]: """Posts an infraction to the API.""" - if isinstance(user, (discord.Member, discord.User)) and user.bot: + if isinstance(user, FetchedMember) and user.bot: log.trace(f"Posting of {infr_type} infraction for {user} to the API aborted. User is a bot.") raise InvalidInfractedUserError(user) @@ -116,7 +114,7 @@ async def post_infraction( async def get_active_infraction( ctx: Context, - user: UserSnowflake, + user: FetchedMember, infr_type: str, send_msg: bool = True ) -> t.Optional[dict]: @@ -151,7 +149,7 @@ async def get_active_infraction( async def notify_infraction( - user: UserObject, + user: FetchedMember, infr_type: str, expires_at: t.Optional[str] = None, reason: t.Optional[str] = None, @@ -187,7 +185,7 @@ async def notify_infraction( async def notify_pardon( - user: UserObject, + user: FetchedMember, title: str, content: str, icon_url: str = Icons.user_verified @@ -205,7 +203,7 @@ async def notify_pardon( return await send_private_embed(user, embed) -async def send_private_embed(user: UserObject, embed: discord.Embed) -> bool: +async def send_private_embed(user: FetchedMember, embed: discord.Embed) -> bool: """ A helper method for sending an embed to a user's DMs. diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index f19323c7c..dfffb2b20 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -14,7 +14,6 @@ from bot.converters import Duration, Expiry, FetchedMember from bot.decorators import respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler -from bot.exts.moderation.infraction._utils import UserSnowflake from bot.utils.messages import format_user log = logging.getLogger(__name__) @@ -320,7 +319,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def apply_ban( self, ctx: Context, - user: UserSnowflake, + user: FetchedMember, reason: t.Optional[str], purge_days: t.Optional[int] = 0, **kwargs @@ -376,7 +375,7 @@ class Infractions(InfractionScheduler, commands.Cog): await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) @respect_role_hierarchy(member_arg=2) - async def apply_voice_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: + async def apply_voice_ban(self, ctx: Context, user: FetchedMember, reason: t.Optional[str], **kwargs) -> None: """Apply a voice ban infraction with kwargs passed to `post_infraction`.""" if await _utils.get_active_infraction(ctx, user, "voice_ban"): return diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 3094159cd..d1437c635 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -12,7 +12,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry, Infraction, Snowflake, UserMention, allowed_strings, proxy_user +from bot.converters import Expiry, FetchedMember, Infraction, allowed_strings from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator @@ -201,27 +201,21 @@ class ModManagement(commands.Cog): # region: Search infractions @infraction_group.group(name="search", aliases=('s',), invoke_without_command=True) - async def infraction_search_group(self, ctx: Context, query: t.Union[UserMention, Snowflake, str]) -> None: + async def infraction_search_group(self, ctx: Context, query: t.Union[FetchedMember, str]) -> None: """Searches for infractions in the database.""" - if isinstance(query, int): - await self.search_user(ctx, discord.Object(query)) + if isinstance(query, FetchedMember): + await self.search_user(ctx, query) else: await self.search_reason(ctx, query) @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx: Context, user: t.Union[discord.User, proxy_user]) -> None: + async def search_user(self, ctx: Context, user: FetchedMember) -> None: """Search for infractions by member.""" infraction_list = await self.bot.api_client.get( 'bot/infractions/expanded', params={'user__id': str(user.id)} ) - user = self.bot.get_user(user.id) - if not user and infraction_list: - # Use the user data retrieved from the DB for the username. - user = infraction_list[0]["user"] - user = escape_markdown(user["name"]) + f"#{user['discriminator']:04}" - embed = discord.Embed( title=f"Infractions for {user} ({len(infraction_list)} total)", colour=discord.Colour.orange() diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 80bd48534..9b09c4f7b 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -417,7 +417,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await ctx.message.add_reaction(Emojis.check_mark) @Cog.listener() - async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None: + async def on_member_ban(self, guild: Guild, user: Union[FetchedMember]) -> None: """Remove `user` from the talent pool after they are banned.""" await self.unwatch(user.id, "User was banned.") -- cgit v1.2.3 From bb2c20fd5acf998d440c835345132bdaee34fb64 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Wed, 11 Aug 2021 17:28:13 +0100 Subject: Remove unused imports --- bot/converters.py | 1 - bot/errors.py | 2 +- bot/exts/fun/duck_pond.py | 2 +- bot/exts/recruitment/talentpool/_cog.py | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 3df613379..1cfbd29b8 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -2,7 +2,6 @@ import logging import re import typing as t from datetime import datetime -from functools import partial from ssl import CertificateError import dateutil.parser diff --git a/bot/errors.py b/bot/errors.py index 5785faa44..d472334d4 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -1,4 +1,4 @@ -from typing import Hashable, Union +from typing import Hashable from discord import Member, User diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index a8d927353..7c09e27f5 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -3,7 +3,7 @@ import logging from typing import Union import discord -from discord import Color, Embed, Member, Message, RawReactionActionEvent, TextChannel, User, errors +from discord import Color, Embed, Message, RawReactionActionEvent, TextChannel, errors from discord.ext.commands import Cog, Context, command from bot import constants diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 9b09c4f7b..304bdafba 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -6,7 +6,7 @@ from typing import Union import discord from async_rediscache import RedisCache -from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent, User +from discord import Color, Embed, Member, PartialMessage, RawReactionActionEvent from discord.ext.commands import Cog, Context, group, has_any_role from bot.api import ResponseCodeError -- cgit v1.2.3 From ad873f91aadb7698c3fd73e1e263ad7bc0ca21e9 Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 11 Aug 2021 18:53:05 +0100 Subject: Rename converter from FetchedMember to MemberOrUser --- bot/converters.py | 2 +- bot/errors.py | 5 ++-- bot/exts/fun/duck_pond.py | 4 +-- bot/exts/info/information.py | 14 +++++----- bot/exts/moderation/infraction/_scheduler.py | 6 ++--- bot/exts/moderation/infraction/_utils.py | 16 ++++++------ bot/exts/moderation/infraction/infractions.py | 34 ++++++++++++------------- bot/exts/moderation/infraction/management.py | 8 +++--- bot/exts/moderation/watchchannels/bigbrother.py | 10 ++++---- bot/exts/recruitment/talentpool/_cog.py | 16 ++++++------ 10 files changed, 58 insertions(+), 57 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 1cfbd29b8..ff9f6e5d9 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -499,4 +499,4 @@ class Infraction(Converter): Expiry = t.Union[Duration, ISODateTime] -FetchedMember = t.Union[discord.Member, discord.User] +MemberOrUser = t.Union[discord.Member, discord.User] diff --git a/bot/errors.py b/bot/errors.py index d472334d4..5186aa3bb 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -1,6 +1,6 @@ from typing import Hashable -from discord import Member, User +from converters import MemberOrUser class LockedResourceError(RuntimeError): @@ -30,7 +30,8 @@ class InvalidInfractedUserError(Exception): `user` -- User or Member which is invalid """ - def __init__(self, user: Union[Member, User], reason: str = "User infracted is a bot."): + def __init__(self, user: MemberOrUser, reason: str = "User infracted is a bot."): + self.user = user self.reason = reason diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 7c09e27f5..0ac985139 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -8,7 +8,7 @@ from discord.ext.commands import Cog, Context, command from bot import constants from bot.bot import Bot -from bot.converters import FetchedMember +from bot.converters import MemberOrUser from bot.utils.checks import has_any_role from bot.utils.messages import count_unique_users_reaction, send_attachments from bot.utils.webhooks import send_webhook @@ -37,7 +37,7 @@ class DuckPond(Cog): log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") @staticmethod - def is_staff(member: FetchedMember) -> bool: + def is_staff(member: MemberOrUser) -> bool: """Check if a specific member or user is staff.""" if hasattr(member, "roles"): for role in member.roles: diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 167731e64..ff3fb9408 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -12,7 +12,7 @@ from discord.ext.commands import BucketType, Cog, Context, Paginator, command, g from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot -from bot.converters import FetchedMember +from bot.converters import MemberOrUser from bot.decorators import in_whitelist from bot.errors import NonExistentRoleError from bot.pagination import LinePaginator @@ -220,7 +220,7 @@ class Information(Cog): await ctx.send(embed=embed) @command(name="user", aliases=["user_info", "member", "member_info", "u"]) - async def user_info(self, ctx: Context, user: FetchedMember = None) -> None: + async def user_info(self, ctx: Context, user: MemberOrUser = None) -> None: """Returns info about a user.""" if user is None: user = ctx.author @@ -235,7 +235,7 @@ class Information(Cog): embed = await self.create_user_embed(ctx, user) await ctx.send(embed=embed) - async def create_user_embed(self, ctx: Context, user: FetchedMember) -> Embed: + async def create_user_embed(self, ctx: Context, user: MemberOrUser) -> Embed: """Creates an embed containing information on the `user`.""" on_server = bool(ctx.guild.get_member(user.id)) @@ -307,7 +307,7 @@ class Information(Cog): return embed - async def basic_user_infraction_counts(self, user: FetchedMember) -> Tuple[str, str]: + async def basic_user_infraction_counts(self, user: MemberOrUser) -> Tuple[str, str]: """Gets the total and active infraction counts for the given `member`.""" infractions = await self.bot.api_client.get( 'bot/infractions', @@ -324,7 +324,7 @@ class Information(Cog): return "Infractions", infraction_output - async def expanded_user_infraction_counts(self, user: FetchedMember) -> Tuple[str, str]: + async def expanded_user_infraction_counts(self, user: MemberOrUser) -> Tuple[str, str]: """ Gets expanded infraction counts for the given `member`. @@ -365,7 +365,7 @@ class Information(Cog): return "Infractions", "\n".join(infraction_output) - async def user_nomination_counts(self, user: FetchedMember) -> Tuple[str, str]: + async def user_nomination_counts(self, user: MemberOrUser) -> Tuple[str, str]: """Gets the active and historical nomination counts for the given `member`.""" nominations = await self.bot.api_client.get( 'bot/nominations', @@ -390,7 +390,7 @@ class Information(Cog): return "Nominations", "\n".join(output) - async def user_messages(self, user: FetchedMember) -> Tuple[Union[bool, str], Tuple[str, str]]: + async def user_messages(self, user: MemberOrUser) -> Tuple[Union[bool, str], Tuple[str, str]]: """ Gets the amount of messages for `member`. diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 89869820c..13f59bb76 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -13,7 +13,7 @@ from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Colours -from bot.converters import FetchedMember +from bot.converters import MemberOrUser from bot.exts.moderation.infraction import _utils from bot.exts.moderation.modlog import ModLog from bot.utils import messages, scheduling, time @@ -115,7 +115,7 @@ class InfractionScheduler: self, ctx: Context, infraction: _utils.Infraction, - user: FetchedMember, + user: MemberOrUser, action_coro: t.Optional[t.Awaitable] = None, user_reason: t.Optional[str] = None, additional_info: str = "", @@ -264,7 +264,7 @@ class InfractionScheduler: self, ctx: Context, infr_type: str, - user: FetchedMember, + user: MemberOrUser, send_msg: bool = True ) -> None: """ diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 9c3f9d804..fe9815600 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -7,7 +7,7 @@ from discord.ext.commands import Context from bot.api import ResponseCodeError from bot.constants import Colours, Icons -from bot.converters import FetchedMember +from bot.converters import MemberOrUser from bot.errors import InvalidInfractedUserError log = logging.getLogger(__name__) @@ -44,7 +44,7 @@ INFRACTION_DESCRIPTION_TEMPLATE = ( ) -async def post_user(ctx: Context, user: FetchedMember) -> t.Optional[dict]: +async def post_user(ctx: Context, user: MemberOrUser) -> t.Optional[dict]: """ Create a new user in the database. @@ -71,7 +71,7 @@ async def post_user(ctx: Context, user: FetchedMember) -> t.Optional[dict]: async def post_infraction( ctx: Context, - user: FetchedMember, + user: MemberOrUser, infr_type: str, reason: str, expires_at: datetime = None, @@ -79,7 +79,7 @@ async def post_infraction( active: bool = True ) -> t.Optional[dict]: """Posts an infraction to the API.""" - if isinstance(user, FetchedMember) and user.bot: + if isinstance(user, MemberOrUser) and user.bot: log.trace(f"Posting of {infr_type} infraction for {user} to the API aborted. User is a bot.") raise InvalidInfractedUserError(user) @@ -114,7 +114,7 @@ async def post_infraction( async def get_active_infraction( ctx: Context, - user: FetchedMember, + user: MemberOrUser, infr_type: str, send_msg: bool = True ) -> t.Optional[dict]: @@ -149,7 +149,7 @@ async def get_active_infraction( async def notify_infraction( - user: FetchedMember, + user: MemberOrUser, infr_type: str, expires_at: t.Optional[str] = None, reason: t.Optional[str] = None, @@ -185,7 +185,7 @@ async def notify_infraction( async def notify_pardon( - user: FetchedMember, + user: MemberOrUser, title: str, content: str, icon_url: str = Icons.user_verified @@ -203,7 +203,7 @@ async def notify_pardon( return await send_private_embed(user, embed) -async def send_private_embed(user: FetchedMember, embed: discord.Embed) -> bool: +async def send_private_embed(user: MemberOrUser, embed: discord.Embed) -> bool: """ A helper method for sending an embed to a user's DMs. diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index dfffb2b20..48ffbd773 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -10,7 +10,7 @@ from discord.ext.commands import Context, command from bot import constants from bot.bot import Bot from bot.constants import Event -from bot.converters import Duration, Expiry, FetchedMember +from bot.converters import Duration, Expiry, MemberOrUser from bot.decorators import respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler @@ -53,7 +53,7 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Permanent infractions @command() - async def warn(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + async def warn(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None: """Warn a user for the given reason.""" if not isinstance(user, Member): await ctx.send(":x: The user doesn't appear to be on the server.") @@ -66,7 +66,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user) @command() - async def kick(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + async def kick(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None: """Kick a user for the given reason.""" if not isinstance(user, Member): await ctx.send(":x: The user doesn't appear to be on the server.") @@ -78,7 +78,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def ban( self, ctx: Context, - user: FetchedMember, + user: MemberOrUser, duration: t.Optional[Expiry] = None, *, reason: t.Optional[str] = None @@ -94,7 +94,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def purgeban( self, ctx: Context, - user: FetchedMember, + user: MemberOrUser, duration: t.Optional[Expiry] = None, *, reason: t.Optional[str] = None @@ -110,7 +110,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def voiceban( self, ctx: Context, - user: FetchedMember, + user: MemberOrUser, duration: t.Optional[Expiry] = None, *, reason: t.Optional[str] @@ -128,7 +128,7 @@ class Infractions(InfractionScheduler, commands.Cog): @command(aliases=["mute"]) async def tempmute( self, ctx: Context, - user: FetchedMember, + user: MemberOrUser, duration: t.Optional[Expiry] = None, *, reason: t.Optional[str] = None @@ -162,7 +162,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def tempban( self, ctx: Context, - user: FetchedMember, + user: MemberOrUser, duration: Expiry, *, reason: t.Optional[str] = None @@ -188,7 +188,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def tempvoiceban( self, ctx: Context, - user: FetchedMember, + user: MemberOrUser, duration: Expiry, *, reason: t.Optional[str] @@ -214,7 +214,7 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Permanent shadow infractions @command(hidden=True) - async def note(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + async def note(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None: """Create a private note for a user with the given reason without notifying the user.""" infraction = await _utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False) if infraction is None: @@ -223,7 +223,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user) @command(hidden=True, aliases=['shadowban', 'sban']) - async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: + async def shadow_ban(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None: """Permanently ban a user for the given reason without notifying the user.""" await self.apply_ban(ctx, user, reason, hidden=True) @@ -234,7 +234,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def shadow_tempban( self, ctx: Context, - user: FetchedMember, + user: MemberOrUser, duration: Expiry, *, reason: t.Optional[str] = None @@ -260,17 +260,17 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Remove infractions (un- commands) @command() - async def unmute(self, ctx: Context, user: FetchedMember) -> None: + async def unmute(self, ctx: Context, user: MemberOrUser) -> None: """Prematurely end the active mute infraction for the user.""" await self.pardon_infraction(ctx, "mute", user) @command() - async def unban(self, ctx: Context, user: FetchedMember) -> None: + async def unban(self, ctx: Context, user: MemberOrUser) -> None: """Prematurely end the active ban infraction for the user.""" await self.pardon_infraction(ctx, "ban", user) @command(aliases=("uvban",)) - async def unvoiceban(self, ctx: Context, user: FetchedMember) -> None: + async def unvoiceban(self, ctx: Context, user: MemberOrUser) -> None: """Prematurely end the active voice ban infraction for the user.""" await self.pardon_infraction(ctx, "voice_ban", user) @@ -319,7 +319,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def apply_ban( self, ctx: Context, - user: FetchedMember, + user: MemberOrUser, reason: t.Optional[str], purge_days: t.Optional[int] = 0, **kwargs @@ -375,7 +375,7 @@ class Infractions(InfractionScheduler, commands.Cog): await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) @respect_role_hierarchy(member_arg=2) - async def apply_voice_ban(self, ctx: Context, user: FetchedMember, reason: t.Optional[str], **kwargs) -> None: + async def apply_voice_ban(self, ctx: Context, user: MemberOrUser, reason: t.Optional[str], **kwargs) -> None: """Apply a voice ban infraction with kwargs passed to `post_infraction`.""" if await _utils.get_active_infraction(ctx, user, "voice_ban"): return diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index d1437c635..68ebab8ba 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -12,7 +12,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry, FetchedMember, Infraction, allowed_strings +from bot.converters import Expiry, MemberOrUser, Infraction, allowed_strings from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator @@ -201,15 +201,15 @@ class ModManagement(commands.Cog): # region: Search infractions @infraction_group.group(name="search", aliases=('s',), invoke_without_command=True) - async def infraction_search_group(self, ctx: Context, query: t.Union[FetchedMember, str]) -> None: + async def infraction_search_group(self, ctx: Context, query: t.Union[MemberOrUser, str]) -> None: """Searches for infractions in the database.""" - if isinstance(query, FetchedMember): + if isinstance(query, MemberOrUser): await self.search_user(ctx, query) else: await self.search_reason(ctx, query) @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx: Context, user: FetchedMember) -> None: + async def search_user(self, ctx: Context, user: MemberOrUser) -> None: """Search for infractions by member.""" infraction_list = await self.bot.api_client.get( 'bot/infractions/expanded', diff --git a/bot/exts/moderation/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py index c6ee844ef..4ee69ec9b 100644 --- a/bot/exts/moderation/watchchannels/bigbrother.py +++ b/bot/exts/moderation/watchchannels/bigbrother.py @@ -6,7 +6,7 @@ from discord.ext.commands import Cog, Context, group, has_any_role from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES, Webhooks -from bot.converters import FetchedMember +from bot.converters import MemberOrUser from bot.exts.moderation.infraction._utils import post_infraction from bot.exts.moderation.watchchannels._watchchannel import WatchChannel @@ -60,7 +60,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): @bigbrother_group.command(name='watch', aliases=('w',), root_aliases=('watch',)) @has_any_role(*MODERATION_ROLES) - async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + async def watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None: """ Relay messages sent by the given `user` to the `#big-brother` channel. @@ -71,11 +71,11 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): @bigbrother_group.command(name='unwatch', aliases=('uw',), root_aliases=('unwatch',)) @has_any_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + async def unwatch_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None: """Stop relaying messages by the given `user`.""" await self.apply_unwatch(ctx, user, reason) - async def apply_watch(self, ctx: Context, user: FetchedMember, reason: str) -> None: + async def apply_watch(self, ctx: Context, user: MemberOrUser, reason: str) -> None: """ Add `user` to watched users and apply a watch infraction with `reason`. @@ -125,7 +125,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await ctx.send(msg) - async def apply_unwatch(self, ctx: Context, user: FetchedMember, reason: str, send_message: bool = True) -> None: + async def apply_unwatch(self, ctx: Context, user: MemberOrUser, reason: str, send_message: bool = True) -> None: """ Remove `user` from watched users and mark their infraction as inactive with `reason`. diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 304bdafba..5c1a1cd3f 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -12,7 +12,7 @@ from discord.ext.commands import Cog, Context, group, has_any_role from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES, Webhooks -from bot.converters import FetchedMember +from bot.converters import MemberOrUser from bot.exts.moderation.watchchannels._watchchannel import WatchChannel from bot.exts.recruitment.talentpool._review import Reviewer from bot.pagination import LinePaginator @@ -178,7 +178,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='forcewatch', aliases=('fw', 'forceadd', 'fa'), root_aliases=("forcenominate",)) @has_any_role(*MODERATION_ROLES) - async def force_watch_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None: + async def force_watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None: """ Adds the given `user` to the talent pool, from any channel. @@ -188,7 +188,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",)) @has_any_role(*STAFF_ROLES) - async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str = '') -> None: + async def watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None: """ Adds the given `user` to the talent pool. @@ -207,7 +207,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await self._watch_user(ctx, user, reason) - async def _watch_user(self, ctx: Context, user: FetchedMember, reason: str) -> None: + async def _watch_user(self, ctx: Context, user: MemberOrUser, reason: str) -> None: """Adds the given user to the talent pool.""" if user.bot: await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") @@ -271,7 +271,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='history', aliases=('info', 'search')) @has_any_role(*MODERATION_ROLES) - async def history_command(self, ctx: Context, user: FetchedMember) -> None: + async def history_command(self, ctx: Context, user: MemberOrUser) -> None: """Shows the specified user's nomination history.""" result = await self.bot.api_client.get( self.api_endpoint, @@ -300,7 +300,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",)) @has_any_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: + async def unwatch_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None: """ Ends the active nomination of the specified user with the given reason. @@ -323,7 +323,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_edit_group.command(name='reason') @has_any_role(*MODERATION_ROLES) - async def edit_reason_command(self, ctx: Context, nomination_id: int, actor: FetchedMember, *, reason: str) -> None: + async def edit_reason_command(self, ctx: Context, nomination_id: int, actor: MemberOrUser, *, reason: str) -> None: """Edits the reason of a specific nominator in a specific active nomination.""" if len(reason) > REASON_MAX_CHARS: await ctx.send(f":x: Maxiumum allowed characters for the reason is {REASON_MAX_CHARS}.") @@ -417,7 +417,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await ctx.message.add_reaction(Emojis.check_mark) @Cog.listener() - async def on_member_ban(self, guild: Guild, user: Union[FetchedMember]) -> None: + async def on_member_ban(self, guild: Guild, user: Union[MemberOrUser]) -> None: """Remove `user` from the talent pool after they are banned.""" await self.unwatch(user.id, "User was banned.") -- cgit v1.2.3 From 4c8d7754ec1879acf43925f3bc2c998d2890385f Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 11 Aug 2021 19:08:20 +0100 Subject: Fix import order --- bot/exts/moderation/infraction/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 68ebab8ba..e2755efc5 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -12,7 +12,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry, MemberOrUser, Infraction, allowed_strings +from bot.converters import Expiry, Infraction, MemberOrUser, allowed_strings from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator -- cgit v1.2.3 From 22268276556d568f6d254fe2715f509c213119e3 Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 11 Aug 2021 19:14:36 +0100 Subject: Fix import --- bot/errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/errors.py b/bot/errors.py index 5186aa3bb..08396ec3e 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -1,6 +1,6 @@ from typing import Hashable -from converters import MemberOrUser +from bot.converters import MemberOrUser class LockedResourceError(RuntimeError): -- cgit v1.2.3 From aa8c455f9ef919cfc21f0a99cae5a878ec57853f Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 11 Aug 2021 19:56:10 +0100 Subject: Fix isinstance check --- bot/exts/moderation/infraction/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index fe9815600..827623936 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -79,7 +79,7 @@ async def post_infraction( active: bool = True ) -> t.Optional[dict]: """Posts an infraction to the API.""" - if isinstance(user, MemberOrUser) and user.bot: + if isinstance(user, (discord.Member, discord.User)) and user.bot: log.trace(f"Posting of {infr_type} infraction for {user} to the API aborted. User is a bot.") raise InvalidInfractedUserError(user) -- cgit v1.2.3 From 4e6af7be36da47cdd083ef27e4757f809b09aafd Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 11 Aug 2021 23:34:19 +0100 Subject: Remove redundant function --- bot/converters.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index ff9f6e5d9..37eb91c7f 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -456,19 +456,6 @@ class UserMentionOrID(UserConverter): raise BadArgument(f"`{argument}` is not a User mention or a User ID.") -def _snowflake_from_regex(pattern: t.Pattern, arg: str) -> int: - """ - Extract the snowflake from `arg` using a regex `pattern` and return it as an int. - - The snowflake is expected to be within the first capture group in `pattern`. - """ - match = pattern.match(arg) - if not match: - raise BadArgument(f"Mention {str!r} is invalid.") - - return int(match.group(1)) - - class Infraction(Converter): """ Attempts to convert a given infraction ID into an infraction. -- cgit v1.2.3 From c90908effae17aba4f656516b306f40c8844bd40 Mon Sep 17 00:00:00 2001 From: Izan Date: Thu, 12 Aug 2021 00:07:11 +0100 Subject: Re-add ability to search infractions of deleted account --- bot/exts/moderation/infraction/management.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index e2755efc5..058bc9db0 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -12,7 +12,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry, Infraction, MemberOrUser, allowed_strings +from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, allowed_strings from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator @@ -201,21 +201,24 @@ class ModManagement(commands.Cog): # region: Search infractions @infraction_group.group(name="search", aliases=('s',), invoke_without_command=True) - async def infraction_search_group(self, ctx: Context, query: t.Union[MemberOrUser, str]) -> None: + async def infraction_search_group(self, ctx: Context, query: t.Union[MemberOrUser, Snowflake, str]) -> None: """Searches for infractions in the database.""" - if isinstance(query, MemberOrUser): - await self.search_user(ctx, query) - else: + if isinstance(query, int): + await self.search_user(ctx, discord.Object(query)) + elif isinstance(query, str): await self.search_reason(ctx, query) + else: + await self.search_user(ctx, query) @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx: Context, user: MemberOrUser) -> None: + async def search_user(self, ctx: Context, user: t.Union[MemberOrUser, discord.Object]) -> None: """Search for infractions by member.""" infraction_list = await self.bot.api_client.get( 'bot/infractions/expanded', params={'user__id': str(user.id)} ) + user_string = str(user if isinstance(user, (discord.Member, discord.User)) else user.id) embed = discord.Embed( title=f"Infractions for {user} ({len(infraction_list)} total)", colour=discord.Colour.orange() -- cgit v1.2.3 From 9770d2ed7d9b0ce92e0284f9c96d0944e331509c Mon Sep 17 00:00:00 2001 From: Izan Date: Fri, 13 Aug 2021 10:16:17 +0100 Subject: Add missing call to `escape_markdown` --- bot/exts/moderation/infraction/management.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 058bc9db0..725e8798c 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -218,9 +218,13 @@ class ModManagement(commands.Cog): params={'user__id': str(user.id)} ) - user_string = str(user if isinstance(user, (discord.Member, discord.User)) else user.id) + if isinstance(user, (discord.Member, discord.User)): + user_str = escape_markdown(user.name) + user.discriminator + else: + user_str = str(user.id) + embed = discord.Embed( - title=f"Infractions for {user} ({len(infraction_list)} total)", + title=f"Infractions for {user_str} ({len(infraction_list)} total)", colour=discord.Colour.orange() ) await self.send_infraction_list(ctx, embed, infraction_list) -- cgit v1.2.3 From 39818408bcd9ca2a65d2459a216c81db39c80d18 Mon Sep 17 00:00:00 2001 From: Izan Date: Fri, 13 Aug 2021 10:18:31 +0100 Subject: Remove redundant fetching of user code --- bot/exts/moderation/infraction/_scheduler.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 13f59bb76..3c5e5d3bf 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -165,17 +165,10 @@ class InfractionScheduler: dm_result = f"{constants.Emojis.failmail} " dm_log_text = "\nDM: **Failed**" - # Sometimes user is a discord.Object; make it a proper user. - try: - if not isinstance(user, (discord.Member, discord.User)): - user = await self.bot.fetch_user(user.id) - except discord.HTTPException as e: - log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})") - else: - # Accordingly display whether the user was successfully notified via DM. - if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon): - dm_result = ":incoming_envelope: " - dm_log_text = "\nDM: Sent" + # Accordingly display whether the user was successfully notified via DM. + if await _utils.notify_infraction(user, infr_type.replace("_", " ").title(), expiry, user_reason, icon): + dm_result = ":incoming_envelope: " + dm_log_text = "\nDM: Sent" end_msg = "" if infraction["actor"] == self.bot.user.id: -- cgit v1.2.3 From 59aeaab40655a3bc91264e24802d2bc6e02d3372 Mon Sep 17 00:00:00 2001 From: Izan Date: Fri, 13 Aug 2021 10:22:02 +0100 Subject: Remove redundant getattr protection --- bot/exts/moderation/infraction/_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 827623936..9d94bca2d 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -53,10 +53,10 @@ async def post_user(ctx: Context, user: MemberOrUser) -> t.Optional[dict]: log.trace(f"Attempting to add user {user.id} to the database.") payload = { - 'discriminator': int(getattr(user, 'discriminator', 0)), + 'discriminator': int(user.discriminator), 'id': user.id, 'in_guild': False, - 'name': getattr(user, 'name', 'Name unknown'), + 'name': user.name, 'roles': [] } -- cgit v1.2.3 From 36d0ea88dc4ac071778355de948e7f06420f1c41 Mon Sep 17 00:00:00 2001 From: Izan Date: Sat, 14 Aug 2021 09:53:53 +0100 Subject: Attempt to get user from DB when discord.Object passed to search_user --- bot/exts/moderation/infraction/management.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 725e8798c..b05b051cc 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -219,9 +219,13 @@ class ModManagement(commands.Cog): ) if isinstance(user, (discord.Member, discord.User)): - user_str = escape_markdown(user.name) + user.discriminator + user_str = escape_markdown(str(user)) else: - user_str = str(user.id) + if infraction_list: + user = infraction_list[0]["user"] + user_str = escape_markdown(user["name"]) + f"#{user['discriminator']:04}" + else: + user_str = str(user.id) embed = discord.Embed( title=f"Infractions for {user_str} ({len(infraction_list)} total)", -- cgit v1.2.3 From df23edb837ac1d50821976605208dc51c54f5c6c Mon Sep 17 00:00:00 2001 From: Izan Date: Fri, 20 Aug 2021 09:12:54 +0100 Subject: Update `infraction_search_group` typehint to `UserMentionOrId` --- bot/exts/moderation/infraction/management.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index b05b051cc..9ff30da59 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -12,7 +12,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, allowed_strings +from bot.converters import Expiry, Infraction, Snowflake, UserMentionOrID, allowed_strings from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator @@ -201,7 +201,7 @@ class ModManagement(commands.Cog): # region: Search infractions @infraction_group.group(name="search", aliases=('s',), invoke_without_command=True) - async def infraction_search_group(self, ctx: Context, query: t.Union[MemberOrUser, Snowflake, str]) -> None: + async def infraction_search_group(self, ctx: Context, query: t.Union[UserMentionOrID, Snowflake, str]) -> None: """Searches for infractions in the database.""" if isinstance(query, int): await self.search_user(ctx, discord.Object(query)) @@ -211,7 +211,7 @@ class ModManagement(commands.Cog): await self.search_user(ctx, query) @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx: Context, user: t.Union[MemberOrUser, discord.Object]) -> None: + async def search_user(self, ctx: Context, user: t.Union[UserMentionOrID, discord.Object]) -> None: """Search for infractions by member.""" infraction_list = await self.bot.api_client.get( 'bot/infractions/expanded', -- cgit v1.2.3 From 4e158b2a64e6f640847f0f7d2f229c96cecb2c46 Mon Sep 17 00:00:00 2001 From: Izan Date: Sat, 21 Aug 2021 22:07:29 +0100 Subject: Revert `search_user` typehint back to `MemberOrUser` --- bot/exts/moderation/infraction/management.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 9ff30da59..641ad0410 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -12,7 +12,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry, Infraction, Snowflake, UserMentionOrID, allowed_strings +from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, UserMentionOrID, allowed_strings from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator @@ -211,7 +211,7 @@ class ModManagement(commands.Cog): await self.search_user(ctx, query) @infraction_search_group.command(name="user", aliases=("member", "id")) - async def search_user(self, ctx: Context, user: t.Union[UserMentionOrID, discord.Object]) -> None: + async def search_user(self, ctx: Context, user: t.Union[MemberOrUser, discord.Object]) -> None: """Search for infractions by member.""" infraction_list = await self.bot.api_client.get( 'bot/infractions/expanded', -- cgit v1.2.3 From 9c41f0bf3c9487a01f7b5aca32c8b288b99ac382 Mon Sep 17 00:00:00 2001 From: Izan Date: Sun, 22 Aug 2021 21:48:34 +0100 Subject: Update outdated comment --- bot/exts/moderation/watchchannels/bigbrother.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py index 4ee69ec9b..3aa253fea 100644 --- a/bot/exts/moderation/watchchannels/bigbrother.py +++ b/bot/exts/moderation/watchchannels/bigbrother.py @@ -94,7 +94,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): await ctx.send(f":x: {user} is already being watched.") return - # FetchedUser instances don't have a roles attribute + # discord.User instances don't have a roles attribute if hasattr(user, "roles") and any(role.id in MODERATION_ROLES for role in user.roles): await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I must be kind to my masters.") return -- cgit v1.2.3 From a831aa011821254387bf079795ecd21b3439051a Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Mon, 23 Aug 2021 08:25:18 +0100 Subject: Update time format in reminder message to DAY_TIME from RELATIVE (#1766) * Update time format in reminder message to DAY_TIME from RELATIVE Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/exts/utils/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index cc13f6ebe..2e60f5030 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -270,7 +270,7 @@ class Reminders(Cog): } ) - mention_string = f"Your reminder will arrive {discord_timestamp(expiration, TimestampFormats.RELATIVE)}" + mention_string = f"Your reminder will arrive on {discord_timestamp(expiration, TimestampFormats.DAY_TIME)}" if mentions: mention_string += f" and will mention {len(mentions)} other(s)" -- cgit v1.2.3 From 26073a9a445bb17408884fb862e91ae5e45dd3c7 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Mon, 23 Aug 2021 10:49:16 +0100 Subject: Update reminders command to use `UserMentionOrID` instead of `discord.Member` to fix greediness issues. (#1768) --- bot/exts/utils/reminders.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 2e60f5030..144f7b537 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -12,7 +12,7 @@ from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_ROLES -from bot.converters import Duration +from bot.converters import Duration, UserMentionOrID from bot.pagination import LinePaginator from bot.utils.checks import has_any_role_check, has_no_roles_check from bot.utils.lock import lock_arg @@ -27,6 +27,7 @@ WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 Mentionable = t.Union[discord.Member, discord.Role] +ReminderMention = t.Union[UserMentionOrID, discord.Role] class Reminders(Cog): @@ -211,14 +212,14 @@ class Reminders(Cog): @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) async def remind_group( - self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str + self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: str ) -> None: """Commands for managing your reminders.""" await self.new_reminder(ctx, mentions=mentions, expiration=expiration, content=content) @remind_group.command(name="new", aliases=("add", "create")) async def new_reminder( - self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str + self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: str ) -> None: """ Set yourself a simple reminder. @@ -363,7 +364,7 @@ class Reminders(Cog): await self.edit_reminder(ctx, id_, {"content": content}) @edit_reminder_group.command(name="mentions", aliases=("pings",)) - async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None: + async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[ReminderMention]) -> None: """Edit one of your reminder's mentions.""" # Remove duplicate mentions mentions = set(mentions) -- cgit v1.2.3 From 0199600317ceba0784c3d6c23627f8d8b84649af Mon Sep 17 00:00:00 2001 From: wookie184 Date: Mon, 23 Aug 2021 17:54:57 +0100 Subject: Ignore bot mentions in antispam mentions rule --- bot/rules/mentions.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/rules/mentions.py b/bot/rules/mentions.py index 79725a4b1..6f5addad1 100644 --- a/bot/rules/mentions.py +++ b/bot/rules/mentions.py @@ -13,7 +13,11 @@ async def apply( if msg.author == last_message.author ) - total_recent_mentions = sum(len(msg.mentions) for msg in relevant_messages) + total_recent_mentions = sum( + not user.bot + for msg in relevant_messages + for user in msg.mentions + ) if total_recent_mentions > config['max']: return ( -- cgit v1.2.3 From eb4aab50132b8f26530160b0ced3791f1e42993f Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Mon, 23 Aug 2021 17:59:59 +0100 Subject: Escape markdown in user's name for `!user` command --- bot/exts/info/information.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index a9ea403f7..8bef6a8cd 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -8,6 +8,7 @@ from typing import Any, DefaultDict, Mapping, Optional, Tuple, Union import rapidfuzz from discord import AllowedMentions, Colour, Embed, Guild, Message, Role from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role +from discord.utils import escape_markdown from bot import constants from bot.api import ResponseCodeError @@ -244,6 +245,7 @@ class Information(Cog): name = str(user) if on_server and user.nick: name = f"{user.nick} ({name})" + name = escape_markdown(name) if user.public_flags.verified_bot: name += f" {constants.Emojis.verified_bot}" -- cgit v1.2.3 From 7daab91633b324901d48653b99fa1bfdf6b093ff Mon Sep 17 00:00:00 2001 From: wookie184 Date: Mon, 23 Aug 2021 18:20:37 +0100 Subject: Fix current tests by using MockMember in mention lists --- tests/bot/rules/test_mentions.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index 6444532f2..a5e42d0a9 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -2,12 +2,14 @@ from typing import Iterable from bot.rules import mentions from tests.bot.rules import DisallowedCase, RuleTest -from tests.helpers import MockMessage +from tests.helpers import MockMember, MockMessage -def make_msg(author: str, total_mentions: int) -> MockMessage: +def make_msg(author: str, total_user_mentions: int, total_bot_mentions: int = 0) -> MockMessage: """Makes a message with `total_mentions` mentions.""" - return MockMessage(author=author, mentions=list(range(total_mentions))) + user_mentions = [MockMember() for _ in range(total_user_mentions)] + bot_mentions = [MockMember(bot=True) for _ in range(total_bot_mentions)] + return MockMessage(author=author, mentions=user_mentions+bot_mentions) class TestMentions(RuleTest): -- cgit v1.2.3 From 26af16e986eab0bd561faf2cdd0828d77c3586be Mon Sep 17 00:00:00 2001 From: D0rs4n <41237606+D0rs4n@users.noreply.github.com> Date: Mon, 23 Aug 2021 19:27:02 +0200 Subject: Remove restriction on snowflake command --- bot/exts/utils/utils.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 98e43c32b..28c7ec27b 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -14,7 +14,6 @@ from bot.converters import Snowflake from bot.decorators import in_whitelist from bot.pagination import LinePaginator from bot.utils import messages -from bot.utils.checks import has_no_roles_check from bot.utils.time import time_since log = logging.getLogger(__name__) @@ -160,9 +159,6 @@ class Utils(Cog): @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) async def snowflake(self, ctx: Context, *snowflakes: Snowflake) -> None: """Get Discord snowflake creation time.""" - if len(snowflakes) > 1 and await has_no_roles_check(ctx, *STAFF_ROLES): - raise BadArgument("Cannot process more than one snowflake in one invocation.") - if not snowflakes: raise BadArgument("At least one snowflake must be provided.") -- cgit v1.2.3 From a6121e6aa6e5aeaa9ae95a8973408e947958c6e0 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Mon, 23 Aug 2021 18:33:35 +0100 Subject: Added some more test cases to ensure bot mentions aren't counted --- tests/bot/rules/test_mentions.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/bot/rules/test_mentions.py b/tests/bot/rules/test_mentions.py index a5e42d0a9..f8805ac48 100644 --- a/tests/bot/rules/test_mentions.py +++ b/tests/bot/rules/test_mentions.py @@ -50,11 +50,27 @@ class TestMentions(RuleTest): [make_msg("bob", 2), make_msg("alice", 3), make_msg("bob", 2)], ("bob",), 4, - ) + ), + DisallowedCase( + [make_msg("bob", 3, 1)], + ("bob",), + 3, + ), ) await self.run_disallowed(cases) + async def test_ignore_bot_mentions(self): + """Messages with an allowed amount of mentions, also containing bot mentions.""" + cases = ( + [make_msg("bob", 0, 3)], + [make_msg("bob", 2, 1)], + [make_msg("bob", 1, 2), make_msg("bob", 1, 2)], + [make_msg("bob", 1, 5), make_msg("alice", 2, 5)] + ) + + await self.run_allowed(cases) + def relevant_messages(self, case: DisallowedCase) -> Iterable[MockMessage]: last_message = case.recent_messages[0] return tuple( -- cgit v1.2.3 From be9bc9c814c4e43bdb6850e871678e9281958091 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 23 Aug 2021 21:39:32 +0200 Subject: Prevent import cycle when importing EXTENSIONS --- bot/errors.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/errors.py b/bot/errors.py index 08396ec3e..2633390a8 100644 --- a/bot/errors.py +++ b/bot/errors.py @@ -1,6 +1,8 @@ -from typing import Hashable +from __future__ import annotations -from bot.converters import MemberOrUser +from typing import Hashable, TYPE_CHECKING +if TYPE_CHECKING: + from bot.converters import MemberOrUser class LockedResourceError(RuntimeError): -- cgit v1.2.3 From 37804ce14c4aeb322ef772b14e7fe55658f80ebf Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 23 Aug 2021 21:48:31 +0200 Subject: Remove TagContentConverter --- bot/converters.py | 20 -------------------- tests/bot/test_converters.py | 38 -------------------------------------- 2 files changed, 58 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 1c0fd673d..0118cc48a 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -314,25 +314,6 @@ class TagNameConverter(Converter): return tag_name -class TagContentConverter(Converter): - """Ensure proposed tag content is not empty and contains at least one non-whitespace character.""" - - @staticmethod - async def convert(ctx: Context, tag_content: str) -> str: - """ - Ensure tag_content is non-empty and contains at least one non-whitespace character. - - If tag_content is valid, return the stripped version. - """ - tag_content = tag_content.strip() - - # The tag contents should not be empty, or filled with whitespace. - if not tag_content: - raise BadArgument("Tag contents should not be empty, or filled with whitespace.") - - return tag_content - - class SourceConverter(Converter): """Convert an argument into a help command, tag, command, or cog.""" @@ -570,7 +551,6 @@ if t.TYPE_CHECKING: Inventory = t.Tuple[str, _inventory_parser.InventoryDict] # noqa: F811 Snowflake = int # noqa: F811 TagNameConverter = str # noqa: F811 - TagContentConverter = str # noqa: F811 SourceConverter = SourceType # noqa: F811 DurationDelta = relativedelta # noqa: F811 Duration = datetime # noqa: F811 diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index 2a1c4e543..6e3a6b898 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -11,7 +11,6 @@ from bot.converters import ( HushDurationConverter, ISODateTime, PackageName, - TagContentConverter, TagNameConverter, ) @@ -26,43 +25,6 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase): cls.fixed_utc_now = datetime.datetime.fromisoformat('2019-01-01T00:00:00') - async def test_tag_content_converter_for_valid(self): - """TagContentConverter should return correct values for valid input.""" - test_values = ( - ('hello', 'hello'), - (' h ello ', 'h ello'), - ) - - for content, expected_conversion in test_values: - with self.subTest(content=content, expected_conversion=expected_conversion): - conversion = await TagContentConverter.convert(self.context, content) - self.assertEqual(conversion, expected_conversion) - - async def test_tag_content_converter_for_invalid(self): - """TagContentConverter should raise the proper exception for invalid input.""" - test_values = ( - ('', "Tag contents should not be empty, or filled with whitespace."), - (' ', "Tag contents should not be empty, or filled with whitespace."), - ) - - for value, exception_message in test_values: - with self.subTest(tag_content=value, exception_message=exception_message): - with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): - await TagContentConverter.convert(self.context, value) - - async def test_tag_name_converter_for_valid(self): - """TagNameConverter should return the correct values for valid tag names.""" - test_values = ( - ('tracebacks', 'tracebacks'), - ('Tracebacks', 'tracebacks'), - (' Tracebacks ', 'tracebacks'), - ) - - for name, expected_conversion in test_values: - with self.subTest(name=name, expected_conversion=expected_conversion): - conversion = await TagNameConverter.convert(self.context, name) - self.assertEqual(conversion, expected_conversion) - async def test_tag_name_converter_for_invalid(self): """TagNameConverter should raise the correct exception for invalid tag names.""" test_values = ( -- cgit v1.2.3 From b6b05251238df2ccff4cab721e5b03e2208aa713 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 23 Aug 2021 13:19:53 -0700 Subject: Enable debug mode by default --- bot/constants.py | 2 +- config-default.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index b7c6ffa70..a6dd3ab65 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -687,7 +687,7 @@ class VideoPermission(metaclass=YAMLGetter): # Debug mode -DEBUG_MODE: bool = _CONFIG_YAML["debug"] +DEBUG_MODE: bool = _CONFIG_YAML["debug"] == "true" # Paths BOT_DIR = os.path.dirname(__file__) diff --git a/config-default.yml b/config-default.yml index 40c6d691e..85532ad28 100644 --- a/config-default.yml +++ b/config-default.yml @@ -1,4 +1,4 @@ -debug: false +debug: !ENV ["BOT_DEBUG", "true"] bot: -- cgit v1.2.3 From 5d448f1b035f44ac551d3ad23ae27bb8897b31de Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Tue, 24 Aug 2021 12:11:51 +0100 Subject: Update nomination messages to display user mention --- bot/exts/recruitment/talentpool/_cog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 5c1a1cd3f..c297f70c2 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -263,7 +263,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): } ) - msg = f"✅ The nomination for {user} has been added to the talent pool" + msg = f"✅ The nomination for {user.mention} has been added to the talent pool" if history: msg += f"\n\n({len(history)} previous nominations in total)" @@ -311,7 +311,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): return if await self.unwatch(user.id, reason): - await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") + await ctx.send(f":white_check_mark: Messages sent by {user.mention} will no longer be relayed") else: await ctx.send(":x: The specified user does not have an active nomination") @@ -344,7 +344,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): return if not any(entry["actor"] == actor.id for entry in nomination["entries"]): - await ctx.send(f":x: {actor} doesn't have an entry in this nomination.") + await ctx.send(f":x: {actor.mention} doesn't have an entry in this nomination.") return self.log.trace(f"Changing reason for nomination with id {nomination_id} of actor {actor} to {repr(reason)}") -- cgit v1.2.3 From 429ca2a4a359a2e1f580100911fe94352fa8192d Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 24 Aug 2021 15:39:41 +0100 Subject: Add constants.STAFF_PARTNERS_COMMUNITY_ROLES --- bot/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/constants.py b/bot/constants.py index 12b5c02e5..5b629a735 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -696,6 +696,7 @@ PROJECT_ROOT = os.path.abspath(os.path.join(BOT_DIR, os.pardir)) # Default role combinations MODERATION_ROLES = Guild.moderation_roles STAFF_ROLES = Guild.staff_roles +STAFF_PARTNERS_COMMUNITY_ROLES = STAFF_ROLES + [Roles.partners, Roles.python_community] # Channel combinations MODERATION_CHANNELS = Guild.moderation_channels -- cgit v1.2.3 From 2c93e2deedddb98efa26e9e513b54df1a457adf6 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 24 Aug 2021 15:51:45 +0100 Subject: Allow partners & members of the python community to use commands in any channel Allow partners & members of the python community to use `!remind`, `!help` `!user`, `!snowflake`, `!ping`, `!charinfo` in any channel --- bot/exts/info/help.py | 4 ++-- bot/exts/info/information.py | 2 +- bot/exts/utils/ping.py | 4 ++-- bot/exts/utils/reminders.py | 6 +++--- bot/exts/utils/utils.py | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 0235bbaf3..21a6cf752 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -10,7 +10,7 @@ from rapidfuzz import fuzz, process from rapidfuzz.utils import default_process from bot import constants -from bot.constants import Channels, STAFF_ROLES +from bot.constants import Channels, STAFF_PARTNERS_COMMUNITY_ROLES from bot.decorators import redirect_output from bot.pagination import LinePaginator from bot.utils.messages import wait_for_deletion @@ -54,7 +54,7 @@ class CustomHelpCommand(HelpCommand): def __init__(self): super().__init__(command_attrs={"help": "Shows help for bot commands"}) - @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_ROLES) + @redirect_output(destination_channel=Channels.bot_commands, bypass_roles=STAFF_PARTNERS_COMMUNITY_ROLES) async def command_callback(self, ctx: Context, *, command: str = None) -> None: """Attempts to match the provided query with a valid command or cog.""" # the only reason we need to tamper with this is because d.py does not support "categories", diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index a9ea403f7..67677a8ad 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -231,7 +231,7 @@ class Information(Cog): return # Will redirect to #bot-commands if it fails. - if in_whitelist_check(ctx, roles=constants.STAFF_ROLES): + if in_whitelist_check(ctx, roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES): embed = await self.create_user_embed(ctx, user) await ctx.send(embed=embed) diff --git a/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index c6d7bd900..cf0e3265e 100644 --- a/bot/exts/utils/ping.py +++ b/bot/exts/utils/ping.py @@ -5,7 +5,7 @@ from discord import Embed from discord.ext import commands from bot.bot import Bot -from bot.constants import Channels, STAFF_ROLES, URLs +from bot.constants import Channels, STAFF_PARTNERS_COMMUNITY_ROLES, URLs from bot.decorators import in_whitelist DESCRIPTIONS = ( @@ -23,7 +23,7 @@ class Latency(commands.Cog): self.bot = bot @commands.command() - @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) + @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_PARTNERS_COMMUNITY_ROLES) async def ping(self, ctx: commands.Context) -> None: """ Gets different measures of latency within the bot. diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 2e60f5030..8aa437895 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -11,7 +11,7 @@ from dateutil.parser import isoparse from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot -from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_ROLES +from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES, STAFF_ROLES from bot.converters import Duration from bot.pagination import LinePaginator from bot.utils.checks import has_any_role_check, has_no_roles_check @@ -225,8 +225,8 @@ class Reminders(Cog): Expiration is parsed per: http://strftime.org/ """ - # If the user is not staff, we need to verify whether or not to make a reminder at all. - if await has_no_roles_check(ctx, *STAFF_ROLES): + # If the user is not staff, partner or part of the python community, we need to verify whether or not to make a reminder at all. + if await has_no_roles_check(ctx, *STAFF_PARTNERS_COMMUNITY_ROLES): # If they don't have permission to set a reminder in this channel if ctx.channel.id not in WHITELISTED_CHANNELS: diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 98e43c32b..c131213e7 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -9,7 +9,7 @@ from discord.ext.commands import BadArgument, Cog, Context, clean_content, comma from discord.utils import snowflake_time from bot.bot import Bot -from bot.constants import Channels, MODERATION_ROLES, Roles, STAFF_ROLES +from bot.constants import Channels, MODERATION_ROLES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES, STAFF_ROLES from bot.converters import Snowflake from bot.decorators import in_whitelist from bot.pagination import LinePaginator @@ -50,7 +50,7 @@ class Utils(Cog): self.bot = bot @command() - @in_whitelist(channels=(Channels.bot_commands, Channels.discord_py), roles=STAFF_ROLES) + @in_whitelist(channels=(Channels.bot_commands, Channels.discord_py), roles=STAFF_PARTNERS_COMMUNITY_ROLES) async def charinfo(self, ctx: Context, *, characters: str) -> None: """Shows you information on up to 50 unicode characters.""" match = re.match(r"<(a?):(\w+):(\d+)>", characters) @@ -157,7 +157,7 @@ class Utils(Cog): await ctx.send(embed=embed) @command(aliases=("snf", "snfl", "sf")) - @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_ROLES) + @in_whitelist(channels=(Channels.bot_commands,), roles=STAFF_PARTNERS_COMMUNITY_ROLES) async def snowflake(self, ctx: Context, *snowflakes: Snowflake) -> None: """Get Discord snowflake creation time.""" if len(snowflakes) > 1 and await has_no_roles_check(ctx, *STAFF_ROLES): -- cgit v1.2.3 From 675b96153bec329c9c18744536e1c0f06210497a Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 24 Aug 2021 16:04:24 +0100 Subject: Allow partners & members of the python community to use `!role` and `!roles` --- bot/exts/info/information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 67677a8ad..f13567d02 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -94,7 +94,7 @@ class Information(Cog): {python_general.mention} cooldown: {python_general.slowmode_delay}s """) - @has_any_role(*constants.STAFF_ROLES) + @has_any_role(*constants.STAFF_PARTNERS_COMMUNITY_ROLES) @command(name="roles") async def roles_info(self, ctx: Context) -> None: """Returns a list of all roles and their corresponding IDs.""" @@ -114,7 +114,7 @@ class Information(Cog): await LinePaginator.paginate(role_list, ctx, embed, empty=False) - @has_any_role(*constants.STAFF_ROLES) + @has_any_role(*constants.STAFF_PARTNERS_COMMUNITY_ROLES) @command(name="role") async def role_info(self, ctx: Context, *roles: Union[Role, str]) -> None: """ -- cgit v1.2.3 From 5be8cd1ad40fcaaa8e7bdbfc7a0619aceca2660e Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 24 Aug 2021 16:07:00 +0100 Subject: Allow partners & members of the python community to bypass cooldown and use `!raw` in any channel --- bot/exts/info/information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index f13567d02..664b6cb13 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -453,9 +453,9 @@ class Information(Cog): # remove trailing whitespace return out.rstrip() - @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) + @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES) @group(invoke_without_command=True) - @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES) + @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES) async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: """Shows information about the raw API response.""" if ctx.author not in message.channel.members: -- cgit v1.2.3 From 5998a8e0df0b133bc8bc0ac1eaf25d0b4681ccb0 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 24 Aug 2021 16:09:39 +0100 Subject: Minor linting fixes --- bot/exts/utils/reminders.py | 7 +++++-- bot/exts/utils/utils.py | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 8aa437895..a97080a4c 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -11,7 +11,10 @@ from dateutil.parser import isoparse from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot -from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES, STAFF_ROLES +from bot.constants import ( + Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, + Roles, STAFF_PARTNERS_COMMUNITY_ROLES, STAFF_ROLES +) from bot.converters import Duration from bot.pagination import LinePaginator from bot.utils.checks import has_any_role_check, has_no_roles_check @@ -136,7 +139,7 @@ class Reminders(Cog): """Converts Role and Member ids to their corresponding objects if possible.""" guild = self.bot.get_guild(Guild.id) for mention_id in mention_ids: - if (mentionable := (guild.get_member(mention_id) or guild.get_role(mention_id))): + if mentionable := (guild.get_member(mention_id) or guild.get_role(mention_id)): yield mentionable def schedule_reminder(self, reminder: dict) -> None: diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index c131213e7..c4a466943 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -55,15 +55,17 @@ class Utils(Cog): """Shows you information on up to 50 unicode characters.""" match = re.match(r"<(a?):(\w+):(\d+)>", characters) if match: - return await messages.send_denial( + await messages.send_denial( ctx, "**Non-Character Detected**\n" "Only unicode characters can be processed, but a custom Discord emoji " "was found. Please remove it and try again." ) + return if len(characters) > 50: - return await messages.send_denial(ctx, f"Too many characters ({len(characters)}/50)") + await messages.send_denial(ctx, f"Too many characters ({len(characters)}/50)") + return def get_info(char: str) -> Tuple[str, str]: digit = f"{ord(char):x}" -- cgit v1.2.3 From dc92d6e767931e1a6be01ff35b5bb60d603274d2 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 24 Aug 2021 16:19:49 +0100 Subject: Ignore partners & members of the python community as well as staff from `!liststream` --- bot/exts/moderation/stream.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index 07ee4099e..e667006ff 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -9,7 +9,7 @@ from async_rediscache import RedisCache from discord.ext import commands from bot.bot import Bot -from bot.constants import Colours, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_ROLES, VideoPermission +from bot.constants import Colours, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES, VideoPermission from bot.converters import Expiry from bot.pagination import LinePaginator from bot.utils.scheduling import Scheduler @@ -193,17 +193,19 @@ class Stream(commands.Cog): @commands.command(aliases=('lstream',)) @commands.has_any_role(*MODERATION_ROLES) async def liststream(self, ctx: commands.Context) -> None: - """Lists all non-staff users who have permission to stream.""" - non_staff_members_with_stream = [ + """ + Lists all users who aren't staff, partners or members of the python community and have permission to stream. + """ + non_staff_partners_community_members_with_stream = [ member for member in ctx.guild.get_role(Roles.video).members - if not any(role.id in STAFF_ROLES for role in member.roles) + if not any(role.id in STAFF_PARTNERS_COMMUNITY_ROLES for role in member.roles) ] # List of tuples (UtcPosixTimestamp, str) # So that the list can be sorted on the UtcPosixTimestamp before the message is passed to the paginator. streamer_info = [] - for member in non_staff_members_with_stream: + for member in non_staff_partners_community_members_with_stream: if revoke_time := await self.task_cache.get(member.id): # Member only has temporary streaming perms revoke_delta = Arrow.utcfromtimestamp(revoke_time).humanize() -- cgit v1.2.3 From b1aafff149eb2fd2262f4b06a950ebad460f7281 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 24 Aug 2021 16:30:16 +0100 Subject: Allow partners & members of the python community to have mentions in `!remind` --- bot/exts/utils/reminders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index a97080a4c..847883fc7 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -13,7 +13,7 @@ from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import ( Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, - Roles, STAFF_PARTNERS_COMMUNITY_ROLES, STAFF_ROLES + Roles, STAFF_PARTNERS_COMMUNITY_ROLES ) from bot.converters import Duration from bot.pagination import LinePaginator @@ -113,7 +113,7 @@ class Reminders(Cog): If mentions aren't allowed, also return the type of mention(s) disallowed. """ - if await has_no_roles_check(ctx, *STAFF_ROLES): + if await has_no_roles_check(ctx, *STAFF_PARTNERS_COMMUNITY_ROLES): return False, "members/roles" elif await has_no_roles_check(ctx, *MODERATION_ROLES): return all(isinstance(mention, discord.Member) for mention in mentions), "roles" -- cgit v1.2.3 From f5246e1e8bf0c88320dba0480560bf4b2d2981d2 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 24 Aug 2021 17:07:49 +0100 Subject: Fix linting --- bot/exts/moderation/stream.py | 9 +++++---- bot/exts/utils/reminders.py | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py index e667006ff..01d2614b0 100644 --- a/bot/exts/moderation/stream.py +++ b/bot/exts/moderation/stream.py @@ -9,7 +9,10 @@ from async_rediscache import RedisCache from discord.ext import commands from bot.bot import Bot -from bot.constants import Colours, Emojis, Guild, MODERATION_ROLES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES, VideoPermission +from bot.constants import ( + Colours, Emojis, Guild, MODERATION_ROLES, Roles, + STAFF_PARTNERS_COMMUNITY_ROLES, VideoPermission +) from bot.converters import Expiry from bot.pagination import LinePaginator from bot.utils.scheduling import Scheduler @@ -193,9 +196,7 @@ class Stream(commands.Cog): @commands.command(aliases=('lstream',)) @commands.has_any_role(*MODERATION_ROLES) async def liststream(self, ctx: commands.Context) -> None: - """ - Lists all users who aren't staff, partners or members of the python community and have permission to stream. - """ + """Lists all users who aren't staff, partners or members of the python community and have stream permissions.""" non_staff_partners_community_members_with_stream = [ member for member in ctx.guild.get_role(Roles.video).members diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 847883fc7..2496ce5cc 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -228,7 +228,8 @@ class Reminders(Cog): Expiration is parsed per: http://strftime.org/ """ - # If the user is not staff, partner or part of the python community, we need to verify whether or not to make a reminder at all. + # If the user is not staff, partner or part of the python community, + # we need to verify whether or not to make a reminder at all. if await has_no_roles_check(ctx, *STAFF_PARTNERS_COMMUNITY_ROLES): # If they don't have permission to set a reminder in this channel -- cgit v1.2.3 From 8e2f515c277484145319d0642f588af9287b6af8 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 24 Aug 2021 17:21:11 +0100 Subject: Fix linting --- bot/exts/utils/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index f91a9fee6..0139a6ad3 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -9,7 +9,7 @@ from discord.ext.commands import BadArgument, Cog, Context, clean_content, comma from discord.utils import snowflake_time from bot.bot import Bot -from bot.constants import Channels, MODERATION_ROLES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES, STAFF_ROLES +from bot.constants import Channels, MODERATION_ROLES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES from bot.converters import Snowflake from bot.decorators import in_whitelist from bot.pagination import LinePaginator -- cgit v1.2.3 From 93386c48ca775ff7897090b3af98303d66d1f116 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 24 Aug 2021 17:47:38 +0100 Subject: Fix tests --- tests/bot/exts/info/test_information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 0aa41d889..d8250befb 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -507,7 +507,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): @unittest.mock.patch("bot.exts.info.information.Information.create_user_embed") async def test_staff_members_can_bypass_channel_restriction(self, create_embed, constants): """Staff members should be able to bypass the bot-commands channel restriction.""" - constants.STAFF_ROLES = [self.moderator_role.id] + constants.STAFF_PARTNERS_COMMUNITY_ROLES = [self.moderator_role.id] ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=200)) await self.cog.user_info(self.cog, ctx) @@ -519,7 +519,7 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): async def test_moderators_can_target_another_member(self, create_embed, constants): """A moderator should be able to use `!user` targeting another user.""" constants.MODERATION_ROLES = [self.moderator_role.id] - constants.STAFF_ROLES = [self.moderator_role.id] + constants.STAFF_PARTNERS_COMMUNITY_ROLES = [self.moderator_role.id] ctx = helpers.MockContext(author=self.moderator, channel=helpers.MockTextChannel(id=50)) await self.cog.user_info(self.cog, ctx, self.target) -- cgit v1.2.3 From ed966d4ace253de5945b88766441551e3a19a21d Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Tue, 24 Aug 2021 23:47:27 +0300 Subject: Fix antispam looking at wrong messages --- bot/exts/filters/antispam.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 987060779..8c075fa95 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -207,7 +207,7 @@ class AntiSpam(Cog): # Add the relevant of this trigger to the Deletion Context await self.message_deletion_queue[authors_set].add( rule_name=rule_name, - channels=set(message.channel for message in messages_for_rule), + channels=set(message.channel for message in relevant_messages), messages=relevant_messages ) @@ -217,7 +217,7 @@ class AntiSpam(Cog): name=f"AntiSpam.punish(message={message.id}, member={member.id}, rule={rule_name})" ) - await self.maybe_delete_messages(messages_for_rule) + await self.maybe_delete_messages(relevant_messages) break @lock.lock_arg("antispam.punish", "member", attrgetter("id")) -- cgit v1.2.3 From 506f1d2ff8cb8927175a45317bc47f1f3f449192 Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 25 Aug 2021 10:57:16 +0100 Subject: Add time units and example to docstring of `!remind` and `!remind new` Supported time units and an example invocation are now displayed in the help message for `!remind` and `!remind new` --- bot/exts/utils/reminders.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 90ad7ef2e..9d567cdbc 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -217,7 +217,20 @@ class Reminders(Cog): async def remind_group( self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: str ) -> None: - """Commands for managing your reminders.""" + """ + Commands for managing your reminders. + + The `expiration` duration of `!remind new` supports the following symbols for each unit of time: + - years: `Y`, `y`, `year`, `years` + - months: `m`, `month`, `months` + - weeks: `w`, `W`, `week`, `weeks` + - days: `d`, `D`, `day`, `days` + - hours: `H`, `h`, `hour`, `hours` + - minutes: `M`, `minute`, `minutes` + - seconds: `S`, `s`, `second`, `seconds` + + For example, to set a reminder that expires in 3 days and 1 minute, you can do `!remind new 3d1M Do something`. + """ await self.new_reminder(ctx, mentions=mentions, expiration=expiration, content=content) @remind_group.command(name="new", aliases=("add", "create")) @@ -227,7 +240,16 @@ class Reminders(Cog): """ Set yourself a simple reminder. - Expiration is parsed per: http://strftime.org/ + The `expiration` duration supports the following symbols for each unit of time: + - years: `Y`, `y`, `year`, `years` + - months: `m`, `month`, `months` + - weeks: `w`, `W`, `week`, `weeks` + - days: `d`, `D`, `day`, `days` + - hours: `H`, `h`, `hour`, `hours` + - minutes: `M`, `minute`, `minutes` + - seconds: `S`, `s`, `second`, `seconds` + + For example, to set a reminder that expires in 3 days and 1 minute, you can do `!remind new 3d1M Do something`. """ # If the user is not staff, partner or part of the python community, # we need to verify whether or not to make a reminder at all. -- cgit v1.2.3 From b3b9d3fb15f8909067be4bd8aedf1d17e51f7968 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Wed, 25 Aug 2021 16:35:45 +0200 Subject: CI: check for dependency licenses Since our project is licensed under the MIT License, we can't be using any dependencies in our project. This commit adds a step to the CI lint phase that will use pip-licenses to verify that all the installed packages are part of an ALLOWED_LICENSE variable. This variable is currently set to be every license we currently use. We opted to use an allowlist instead of a denylist to make sure that new licenses are reviewed by hand and added to that variable. --- .github/workflows/lint-test.yml | 17 ++++ poetry.lock | 201 +++++++++++++++++++++++----------------- pyproject.toml | 1 + 3 files changed, 133 insertions(+), 86 deletions(-) diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index e99e6d181..53d9baa59 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -11,6 +11,16 @@ jobs: lint-test: runs-on: ubuntu-latest env: + # List of licenses that are compatible with the MIT License and + # can be used in our project + ALLOWED_LICENSE: Apache Software License; + BSD License; + GNU Library or Lesser General Public License (LGPL); + MIT License; + Mozilla Public License 2.0 (MPL 2.0); + Public Domain; + Python Software Foundation License + # Dummy values for required bot environment variables BOT_API_KEY: foo BOT_SENTRY_DSN: blah @@ -67,6 +77,13 @@ jobs: pip install poetry poetry install + # Check all the dependencies are compatible with the MIT license. + # If you added a new dependencies that is being rejected, + # please make sure it is compatible with the license for this project, + # and add it to the ALLOWED_LICENSE variable + - name: Check Dependencies License + run: pip-licenses --allow-only="$ALLOWED_LICENSE" + # This step caches our pre-commit environment. To make sure we # do create a new environment when our pre-commit setup changes, # we create a cache key based on relevant factors. diff --git a/poetry.lock b/poetry.lock index a4ce5d1a9..ae8b299f4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -174,7 +174,7 @@ pycparser = "*" [[package]] name = "cfgv" -version = "3.3.0" +version = "3.3.1" description = "Validate configuration and produce human readable error messages." category = "dev" optional = false @@ -317,7 +317,7 @@ testing = ["pre-commit"] [[package]] name = "fakeredis" -version = "1.5.2" +version = "1.6.0" description = "Fake implementation of redis API for testing purposes." category = "main" optional = false @@ -329,7 +329,7 @@ six = ">=1.12" sortedcontainers = "*" [package.extras] -aioredis = ["aioredis (<2)"] +aioredis = ["aioredis"] lua = ["lupa"] [[package]] @@ -437,14 +437,14 @@ flake8 = "*" [[package]] name = "flake8-tidy-imports" -version = "4.3.0" +version = "4.4.1" description = "A flake8 plugin that helps you write tidier imports." category = "dev" optional = false python-versions = ">=3.6" [package.dependencies] -flake8 = ">=3.0,<3.2.0 || >3.2.0,<4" +flake8 = ">=3.8.0,<4" [[package]] name = "flake8-todo" @@ -478,7 +478,7 @@ pyreadline = {version = "*", markers = "sys_platform == \"win32\""} [[package]] name = "identify" -version = "2.2.11" +version = "2.2.13" description = "File identification library for Python" category = "dev" optional = false @@ -601,7 +601,7 @@ codegen = ["lxml"] [[package]] name = "pep8-naming" -version = "0.12.0" +version = "0.12.1" description = "Check PEP-8 naming conventions, plugin for flake8" category = "dev" optional = false @@ -611,6 +611,20 @@ python-versions = "*" flake8 = ">=3.9.1" flake8-polyfill = ">=1.0.2,<2" +[[package]] +name = "pip-licenses" +version = "3.5.1" +description = "Dump the software license list of Python packages installed with pip." +category = "dev" +optional = false +python-versions = "~=3.6" + +[package.dependencies] +PTable = "*" + +[package.extras] +test = ["docutils", "pytest-cov", "pytest-pycodestyle", "pytest-runner"] + [[package]] name = "platformdirs" version = "2.2.0" @@ -636,7 +650,7 @@ dev = ["pre-commit", "tox"] [[package]] name = "pre-commit" -version = "2.13.0" +version = "2.14.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -661,6 +675,14 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.extras] test = ["ipaddress", "mock", "unittest2", "enum34", "pywin32", "wmi"] +[[package]] +name = "ptable" +version = "0.9.2" +description = "A simple Python library for easily displaying tabular data in a visually appealing ASCII table format" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "py" version = "1.10.0" @@ -851,7 +873,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [[package]] name = "rapidfuzz" -version = "1.4.1" +version = "1.5.0" description = "rapid fuzzy string matching" category = "main" optional = false @@ -1015,7 +1037,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.7.0" +version = "20.7.2" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -1047,7 +1069,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "f46fe1d2d9e0621e4e06d4c2ba5f6190ec4574ac6ca809abe8bf542a3b55204e" +content-hash = "dfa0b6e9cc8373cfa5c3bcf35c352b79a08ecca8fe23f21737d8beb6a8c89fa4" [metadata.files] aio-pika = [ @@ -1186,8 +1208,8 @@ cffi = [ {file = "cffi-1.14.6.tar.gz", hash = "sha256:c9a875ce9d7fe32887784274dd533c57909b7b1dcadcc128a2ac21331a9765dd"}, ] cfgv = [ - {file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"}, - {file = "cfgv-3.3.0.tar.gz", hash = "sha256:9e600479b3b99e8af981ecdfc80a0296104ee610cab48a5ae4ffd0b668650eb1"}, + {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, + {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] chardet = [ {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, @@ -1286,8 +1308,8 @@ execnet = [ {file = "execnet-1.9.0.tar.gz", hash = "sha256:8f694f3ba9cc92cab508b152dcfe322153975c29bda272e2fd7f3f00f36e47c5"}, ] fakeredis = [ - {file = "fakeredis-1.5.2-py3-none-any.whl", hash = "sha256:f1ffdb134538e6d7c909ddfb4fc5edeb4a73d0ea07245bc69b8135fbc4144b04"}, - {file = "fakeredis-1.5.2.tar.gz", hash = "sha256:18fc1808d2ce72169d3f11acdb524a00ef96bd29970c6d34cfeb2edb3fc0c020"}, + {file = "fakeredis-1.6.0-py3-none-any.whl", hash = "sha256:3449b306f3a85102b28f8180c24722ef966fcb1e3c744758b6f635ec80321a5c"}, + {file = "fakeredis-1.6.0.tar.gz", hash = "sha256:11ccfc9769d718d37e45b382e64a6ba02586b622afa0371a6bd85766d72255f3"}, ] feedparser = [ {file = "feedparser-6.0.8-py3-none-any.whl", hash = "sha256:1b7f57841d9cf85074deb316ed2c795091a238adb79846bc46dccdaf80f9c59a"}, @@ -1326,8 +1348,8 @@ flake8-string-format = [ {file = "flake8_string_format-0.3.0-py2.py3-none-any.whl", hash = "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af"}, ] flake8-tidy-imports = [ - {file = "flake8-tidy-imports-4.3.0.tar.gz", hash = "sha256:e66d46f58ed108f36da920e7781a728dc2d8e4f9269e7e764274105700c0a90c"}, - {file = "flake8_tidy_imports-4.3.0-py3-none-any.whl", hash = "sha256:d6e64cb565ca9474d13d5cb3f838b8deafb5fed15906998d4a674daf55bd6d89"}, + {file = "flake8-tidy-imports-4.4.1.tar.gz", hash = "sha256:c18b3351b998787db071e766e318da1f0bd9d5cecc69c4022a69e7aa2efb2c51"}, + {file = "flake8_tidy_imports-4.4.1-py3-none-any.whl", hash = "sha256:631a1ba9daaedbe8bb53f6086c5a92b390e98371205259e0e311a378df8c3dc8"}, ] flake8-todo = [ {file = "flake8-todo-0.7.tar.gz", hash = "sha256:6e4c5491ff838c06fe5a771b0e95ee15fc005ca57196011011280fc834a85915"}, @@ -1380,8 +1402,8 @@ humanfriendly = [ {file = "humanfriendly-9.2.tar.gz", hash = "sha256:f7dba53ac7935fd0b4a2fc9a29e316ddd9ea135fb3052d3d0279d10c18ff9c48"}, ] identify = [ - {file = "identify-2.2.11-py2.py3-none-any.whl", hash = "sha256:7abaecbb414e385752e8ce02d8c494f4fbc780c975074b46172598a28f1ab839"}, - {file = "identify-2.2.11.tar.gz", hash = "sha256:a0e700637abcbd1caae58e0463861250095dfe330a8371733a471af706a4a29a"}, + {file = "identify-2.2.13-py2.py3-none-any.whl", hash = "sha256:7199679b5be13a6b40e6e19ea473e789b11b4e3b60986499b1f589ffb03c217c"}, + {file = "identify-2.2.13.tar.gz", hash = "sha256:7bc6e829392bd017236531963d2d937d66fc27cadc643ac0aba2ce9f26157c79"}, ] idna = [ {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, @@ -1510,8 +1532,12 @@ pamqp = [ {file = "pamqp-2.3.0.tar.gz", hash = "sha256:5cd0f5a85e89f20d5f8e19285a1507788031cfca4a9ea6f067e3cf18f5e294e8"}, ] pep8-naming = [ - {file = "pep8-naming-0.12.0.tar.gz", hash = "sha256:1f9a3ecb2f3fd83240fd40afdd70acc89695c49c333413e49788f93b61827e12"}, - {file = "pep8_naming-0.12.0-py2.py3-none-any.whl", hash = "sha256:2321ac2b7bf55383dd19a6a9c8ae2ebf05679699927a3af33e60dd7d337099d3"}, + {file = "pep8-naming-0.12.1.tar.gz", hash = "sha256:bb2455947757d162aa4cad55dba4ce029005cd1692f2899a21d51d8630ca7841"}, + {file = "pep8_naming-0.12.1-py2.py3-none-any.whl", hash = "sha256:4a8daeaeb33cfcde779309fc0c9c0a68a3bbe2ad8a8308b763c5068f86eb9f37"}, +] +pip-licenses = [ + {file = "pip-licenses-3.5.1.tar.gz", hash = "sha256:6c60096cfa1ee04d7db8d374d4326939369d5871368421e7a7d5da026519bc24"}, + {file = "pip_licenses-3.5.1-py3-none-any.whl", hash = "sha256:e44e4e92e6af2b9d11dfafbf22a3052bf9a05fdaa5654befdba9db735832ac7f"}, ] platformdirs = [ {file = "platformdirs-2.2.0-py3-none-any.whl", hash = "sha256:4666d822218db6a262bdfdc9c39d21f23b4cfdb08af331a81e92751daf6c866c"}, @@ -1522,8 +1548,8 @@ pluggy = [ {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] pre-commit = [ - {file = "pre_commit-2.13.0-py2.py3-none-any.whl", hash = "sha256:b679d0fddd5b9d6d98783ae5f10fd0c4c59954f375b70a58cbe1ce9bcf9809a4"}, - {file = "pre_commit-2.13.0.tar.gz", hash = "sha256:764972c60693dc668ba8e86eb29654ec3144501310f7198742a767bec385a378"}, + {file = "pre_commit-2.14.0-py2.py3-none-any.whl", hash = "sha256:ec3045ae62e1aa2eecfb8e86fa3025c2e3698f77394ef8d2011ce0aedd85b2d4"}, + {file = "pre_commit-2.14.0.tar.gz", hash = "sha256:2386eeb4cf6633712c7cc9ede83684d53c8cafca6b59f79c738098b51c6d206c"}, ] psutil = [ {file = "psutil-5.8.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:0066a82f7b1b37d334e68697faba68e5ad5e858279fd6351c8ca6024e8d6ba64"}, @@ -1555,6 +1581,9 @@ psutil = [ {file = "psutil-5.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:f4634b033faf0d968bb9220dd1c793b897ab7f1189956e1aa9eae752527127d3"}, {file = "psutil-5.8.0.tar.gz", hash = "sha256:0c9ccb99ab76025f2f0bbecf341d4656e9c1351db8cc8a03ccd62e318ab4b5c6"}, ] +ptable = [ + {file = "PTable-0.9.2.tar.gz", hash = "sha256:aa7fc151cb40f2dabcd2275ba6f7fd0ff8577a86be3365cd3fb297cbe09cc292"}, +] py = [ {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, @@ -1679,67 +1708,67 @@ pyyaml = [ {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] rapidfuzz = [ - {file = "rapidfuzz-1.4.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:72878878d6744883605b5453c382361716887e9e552f677922f76d93d622d8cb"}, - {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:56a67a5b3f783e9af73940f6945366408b3a2060fc6ab18466e5a2894fd85617"}, - {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f5d396b64f8ae3a793633911a1fb5d634ac25bf8f13d440139fa729131be42d8"}, - {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:4990698233e7eda7face7c09f5874a09760c7524686045cbb10317e3a7f3225f"}, - {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:a87e212855b18a951e79ec71d71dbd856d98cd2019d0c2bd46ec30688a8aa68a"}, - {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1897d2ef03f5b51bc19bdb2d0398ae968766750fa319843733f0a8f12ddde986"}, - {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2014_ppc64le.whl", hash = "sha256:e1fc4fd219057f5f1fa40bb9bc5e880f8ef45bf19350d4f5f15ca2ce7f61c99b"}, - {file = "rapidfuzz-1.4.1-cp35-cp35m-manylinux2014_s390x.whl", hash = "sha256:21300c4d048798985c271a8bf1ed1611902ebd4479fcacda1a3eaaebbad2f744"}, - {file = "rapidfuzz-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:d2659967c6ac74211a87a1109e79253e4bc179641057c64800ef4e2dc0534fdb"}, - {file = "rapidfuzz-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:26ac4bfe564c516e053fc055f1543d2b2433338806738c7582e1f75ed0485f7e"}, - {file = "rapidfuzz-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3b485c98ad1ce3c04556f65aaab5d6d6d72121cde656d43505169c71ae956476"}, - {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:59db06356eaf22c83f44b0dded964736cbb137291cdf2cf7b4974c0983b94932"}, - {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fef95249af9a535854b617a68788c38cd96308d97ee14d44bc598cc73e986167"}, - {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:7d8c186e8270e103d339b26ef498581cf3178470ccf238dfd5fd0e47d80e4c7d"}, - {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:9246b9c5c8992a83a08ac7813c8bbff2e674ad0b681f9b3fb1ec7641eff6c21f"}, - {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:f58c17f7a82b1bcc2ce304942cae14287223e6b6eead7071241273da7d9b9770"}, - {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:ed708620b23a09ac52eaaec0761943c1bbc9a62d19ecd2feb4da8c3f79ef9d37"}, - {file = "rapidfuzz-1.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:bdec9ae5fd8a8d4d8813b4aac3505c027b922b4033a32a7aab66a9b2f03a7b47"}, - {file = "rapidfuzz-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:fc668fd706ad1162ce14f26ca2957b4690d47770d23609756536c918a855ced0"}, - {file = "rapidfuzz-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f9f35df5dd9b02669ff6b1d4a386607ff56982c86a7e57d95eb08c6afbab4ddd"}, - {file = "rapidfuzz-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8427310ea29ce2968e1c6f6779ae5a458b3a4984f9150fc4d16f92b96456f848"}, - {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1430dc745476e3798742ad835f61f6e6bf5d3e9a22cf9cd0288b28b7440a9872"}, - {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1d20311da611c8f4638a09e2bc5e04b327bae010cb265ef9628d9c13c6d5da7b"}, - {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7881965e428cf6fe248d6e702e6d5857da02278ab9b21313bee717c080e443e"}, - {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:f76c965f15861ec4d39e904bd65b84a39121334439ac17bfb8b900d1e6779a93"}, - {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:61167f989415e701ac379de247e6b0a21ea62afc86c54d8a79f485b4f0173c02"}, - {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:645cfb9456229f0bd5752b3eda69f221d825fbb8cbb8855433516bc185111506"}, - {file = "rapidfuzz-1.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:c28be57c9bc47b3d7f484340fab1bec8ed4393dee1090892c2774a4584435eb8"}, - {file = "rapidfuzz-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:3c94b6d3513c693f253ff762112cc4580d3bd377e4abacb96af31a3d606fbe14"}, - {file = "rapidfuzz-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:506d50a066451502ee2f8bf016bc3ba3e3b04eede7a4059d7956248e2dd96179"}, - {file = "rapidfuzz-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:80b375098658bb3db14215a975d354f6573d3943ac2ae0c4627c7760d57ce075"}, - {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ba8f7cbd8fdbd3ae115f4484888f3cb94bc2ac7cbd4eb1ca95a3d4f874261ff8"}, - {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:5fa8570720b0fdfc52f24f5663d66c52ea88ba19cb8b1ff6a39a8bc0b925b33b"}, - {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:f35c8a4c690447fd335bfd77df4da42dfea37cfa06a8ecbf22543d86dc720e12"}, - {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:27f9eef48e212d73e78f0f5ceedc62180b68f6a25fa0752d2ccfaedc3a840bec"}, - {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:31e99216e2a04aec4f281d472b28a683921f1f669a429cf605d11526623eaeed"}, - {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:f22bf7ba6eddd59764457f74c637ab5c3ed976c5fcfaf827e1d320cc0478e12b"}, - {file = "rapidfuzz-1.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:c43ddb354abd00e56f024ce80affb3023fa23206239bb81916d5877cba7f2d1e"}, - {file = "rapidfuzz-1.4.1-cp38-cp38-win32.whl", hash = "sha256:62c1f4ac20c8019ce8d481fb27235306ef3912a8d0b9a60b17905699f43ff072"}, - {file = "rapidfuzz-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:2963f356c70b710dc6337b012ec976ce2fc2b81c2a9918a686838fead6eb4e1d"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c07f301fd549b266410654850c6918318d7dcde8201350e9ac0819f0542cf147"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa4c8b6fc7e93e3a3fb9be9566f1fe7ef920735eadcee248a0d70f3ca8941341"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c200bd813bbd3b146ba0fd284a9ad314bbad9d95ed542813273bdb9d0ee4e796"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2cccc84e1f0c6217747c09cafe93164e57d3644e18a334845a2dfbdd2073cd2c"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f2033e3d61d1e498f618123b54dc7436d50510b0d18fd678d867720e8d7b2f23"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:26b7f48b3ddd9d97cf8482a88f0f6cba47ac13ff16e63386ea7ce06178174770"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:bf18614f87fe3bfff783f0a3d0fad0eb59c92391e52555976e55570a651d2330"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:8cb5c2502ff06028a1468bdf61323b53cc3a37f54b5d62d62c5371795b81086a"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:f37f80c1541d6e0a30547261900086b8c0bac519ebc12c9cd6b61a9a43a7e195"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:c13cd1e840aa93639ac1d131fbfa740a609fd20dfc2a462d5cd7bce747a2398d"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-win32.whl", hash = "sha256:0ec346f271e96c485716c091c8b0b78ba52da33f7c6ebb52a349d64094566c2d"}, - {file = "rapidfuzz-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:5208ce1b1989a10e6fc5b5ef5d0bb7d1ffe5408838f3106abde241aff4dab08c"}, - {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4fa195ea9ca35bacfa2a4319c6d4ab03aa6a283ad2089b70d2dfa0f6a7d9c1bc"}, - {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:6e336cfd8103b0b38e107e01502e9d6bf7c7f04e49b970fb11a4bf6c7a932b94"}, - {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:c798c5b87efe8a7e63f408e07ff3bc03ba8b94f4498a89b48eaab3a9f439d52c"}, - {file = "rapidfuzz-1.4.1-pp36-pypy36_pp73-win32.whl", hash = "sha256:bb16a10b40f5bd3c645f7748fbd36f49699a03f550c010a2c665905cc8937de8"}, - {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2278001924031d9d75f821bff2c5fef565c8376f252562e04d8eec8857475c36"}, - {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-manylinux1_x86_64.whl", hash = "sha256:a89d11f3b5da35fdf3e839186203b9367d56e2be792e8dccb098f47634ec6eb9"}, - {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:f8c79cd11b4778d387366a59aa747f5268433f9d68be37b00d16f4fb08fdf850"}, - {file = "rapidfuzz-1.4.1-pp37-pypy37_pp73-win32.whl", hash = "sha256:4364db793ed4b439f9dd28a335bee14e2a828283d3b93c2d2686cc645eeafdd5"}, - {file = "rapidfuzz-1.4.1.tar.gz", hash = "sha256:de20550178376d21bfe1b34a7dc42ab107bb282ef82069cf6dfe2805a0029e26"}, + {file = "rapidfuzz-1.5.0-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:670a330e90e962de5823e01e8ae1b8903af788325fbce1ef3fd5ece4d22e0ba4"}, + {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:079afafa6e6b00ee799e16d9fc6c6522132cbd7742a7a9e78bd301321e1b5ad6"}, + {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:26cb066e79c9867d313450514bb70124d392ac457640c4ec090d29eb68b75541"}, + {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:542fbe8fb4403af36bfffd53e42cb1ff3f8d969a046208373d004804072b744c"}, + {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:407a5c4d2af813e803b828b004f8686300baf298e9bf90b3388a568b1637a8dc"}, + {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:662b4021951ac9edb9a0d026820529e891cea69c11f280188c5b80fefe6ee257"}, + {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2014_ppc64le.whl", hash = "sha256:03c97beb1c7ce5cb1d12bbb8eb87777e9a5fad23216dab78d6850cafdd3ecaf1"}, + {file = "rapidfuzz-1.5.0-cp35-cp35m-manylinux2014_s390x.whl", hash = "sha256:eaafa0349d47850ed2c3ae121b62e078a63daf1d533b1cd43fca0c675a85a025"}, + {file = "rapidfuzz-1.5.0-cp35-cp35m-win32.whl", hash = "sha256:f0b7e15209208ee74bc264b97e111a3c73e19336eda7255c406e56cc6fbbd384"}, + {file = "rapidfuzz-1.5.0-cp35-cp35m-win_amd64.whl", hash = "sha256:0679af3d85082dcb27e75ea30c5047dbcc99340f38490c7d4769ae16909c246a"}, + {file = "rapidfuzz-1.5.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3a3ef319fd1162e7e38bf11259d86fc6ea3885d2abae6359e5b4dafad62592db"}, + {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:60ea1cee33a5a847aeac91a35865c6f7f35a87613df282bda2e7f984e91526f5"}, + {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2ba6ffe8ac66dbeae91a0b2cb50f4836ec16920f58746eaf46ff3e9c4f9c0ad8"}, + {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:7c101bafb27436affcaa14c631e2bf99d6a7a7860a201ce17ee98447c9c0e7f4"}, + {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:a8f3f374b4e8e80516b955a1da6364c526d480311a5c6be48264cf7dc06d2fba"}, + {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:f2fe161526cce52eae224c2af9ae1b9c475ae3e1001fe76024603b290bc8f719"}, + {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:8b086b2f70571c9bf16ead5f65976414f8e75a1c680220a839b8ddf005743060"}, + {file = "rapidfuzz-1.5.0-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:814cd474c31db0383c69eed5b457571f63521f38829955c842b141b4835f067f"}, + {file = "rapidfuzz-1.5.0-cp36-cp36m-win32.whl", hash = "sha256:0a901aa223a4b051846cb828c33967a6f9c66b8fe0ba7e2a4dc70f6612006988"}, + {file = "rapidfuzz-1.5.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f03a5fa9fe38d7f8d566bff0b66600f488d56700469bf1e5e36078f4b58290b6"}, + {file = "rapidfuzz-1.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:122b7c25792eb27ca59ab23623a922a7290d881d296556d0c23da63ed1691cd5"}, + {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:73509dbfcf556233d62683aed0e5f23282ec7138eeedc3ecda2938ad8e8c969d"}, + {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6e8c4fd87361699e0cf5cf7ff075e4cd70a2698e9f914368f0c3e198c77c755c"}, + {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d627ec73d324d804af4c95909e2fa30b0e59f7efaf69264e553a0e498034404b"}, + {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:c57f3b74942ae0d0869336e613cbd0760de61a462ff441095eb5fca6575cf964"}, + {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:075b8bf76dd4bbc9ccb5177806c9867424d365898415433bf88e7b8e88dc4dfe"}, + {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:8049a500b431724d283ddf97d67fe48aa67b4523d617a203c22fd9da3a496223"}, + {file = "rapidfuzz-1.5.0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:a2d84fde07c32514758d283dd1227453db3ed5372a3e9eae85d0c29b2953f252"}, + {file = "rapidfuzz-1.5.0-cp37-cp37m-win32.whl", hash = "sha256:0e35b9b92a955018ebd09d4d9d70f8e81a0106fe1ed04bc82e3a05166cd04ea5"}, + {file = "rapidfuzz-1.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:8ae7bf62f0382d13e9b36babc897742bac5e7ee04b4e5e94cd67085bfccfd2fd"}, + {file = "rapidfuzz-1.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:466d9c644fa235278ef376eefb1fc4382107b07764fbc3c7280533ad9ce49bb4"}, + {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:d04a8465738363d0b9ee39abb3b289e1198d1f3cbc98bc43b8e21ec8e0b21774"}, + {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2c1ce8e8419ac8462289a6e021b8802701ea0f111ebde7607ba3c9588c3d6f30"}, + {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:f44564a29e96af0925e68733859d8247a692968034e1b37407d9cfa746d3a853"}, + {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d2d1bea50f54387bc1e82b93f6e3a433084e0fa538a7ada8e4d4d7200bae4b83"}, + {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:b409f0f86a316b6132253258185c7b011e779ed2170d1ad83c79515fea7d78c8"}, + {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:bf5a6f4f2eb44f32271e9c2d1e46b657764dbd1b933dd84d7c0433eab48741f8"}, + {file = "rapidfuzz-1.5.0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bbdee2e3c2cee9c59e1d1a3f351760a1b510e96379d14ba2fa2484a79f56d0ea"}, + {file = "rapidfuzz-1.5.0-cp38-cp38-win32.whl", hash = "sha256:575a0eceaf84632f2014fd55a42a0621e448115adf6fcbc2b0e5c7ae1c18b501"}, + {file = "rapidfuzz-1.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:cd6603b94e2a3d56d143a5100f8f3c1d29ad8f5416bdc2a25b079f96eee3c306"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3fa261479e3828eff1f3d0265def8d0d893f2e2f90692d5dae96b3f4ae44d69e"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7a386fe0aad7e89b5017768492ea085d241c32f6dc5a6774b0a309d28f61e720"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68156a67d541bb4584cb31e366fb7de9326f5b77ed07f9882e9b9aaa40b2e5b8"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:b62b2a2d2532d357d1b970107a90e85305bdd8e302995dd251f67a19495033f5"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:190b48ba8e3fbcb1cfc522300dbd6a007f50c13cd71002c95bd3946a63b749f6"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:51f9ac3316e713b4a10554a4d6b75fe6f802dd9b4073082cc98968ace6377cac"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:e00198aa7ca8408616d9821501ff90157c429c952d55a2a53987a9b064f73d49"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:5784c24e2de539064d8d5ce3f68756630b54fc33af31e054373a65bbed68823a"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:712a4d510c466d6ca75138dad53a1cbd8db0da4bbfa5fc431fcebb0a426e5323"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:2647e00e2211ed741aecb4e676461b7202ce46d536c3439ede911b088432b7a4"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-win32.whl", hash = "sha256:0b77ca0dacb129e878c2583295b76e12da890bd091115417d23b4049b02c2566"}, + {file = "rapidfuzz-1.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:dec0d429d117ffd7df1661e5f6ca56bfb6806e117be0b75b5d414df43aa4b6d5"}, + {file = "rapidfuzz-1.5.0-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a533d17d177d11b7c177c849adb728035621462f6ce2baaeb9cf1f42ba3e326c"}, + {file = "rapidfuzz-1.5.0-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:ac9a2d5a47a4a4eab060882a162d3626889abdec69f899a59fe7b9e01ce122c9"}, + {file = "rapidfuzz-1.5.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:0e6e2f02bb67a35d75a5613509bb49f0050c0ec4471a9af14da3ad5488d6d5ff"}, + {file = "rapidfuzz-1.5.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:8c61ced6729146e695ecad403165bf3a07e60b8e8a18df91962b3abf72aae6d5"}, + {file = "rapidfuzz-1.5.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:360415125e967d8682291f00bcea311c738101e0aee4cb90e5572d7e54483f0d"}, + {file = "rapidfuzz-1.5.0-pp37-pypy37_pp73-manylinux1_x86_64.whl", hash = "sha256:2fb9d47fc16a2e8f5e900c8334d823a7307148ea764321f861b876f85a880d57"}, + {file = "rapidfuzz-1.5.0-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:2134ac91e8951d42c9a7de131d767580b8ac50820475221024e5bd63577a376f"}, + {file = "rapidfuzz-1.5.0-pp37-pypy37_pp73-win32.whl", hash = "sha256:04c4fd372e858f25e0898ba27b5bb7ed8dc528b0915b7aa02d20237e9cdd4feb"}, + {file = "rapidfuzz-1.5.0.tar.gz", hash = "sha256:141ee381c16f7e58640ef1f1dbf76beb953d248297a7165f7ba25d81ac1161c7"}, ] redis = [ {file = "redis-3.5.3-py2.py3-none-any.whl", hash = "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24"}, @@ -1837,8 +1866,8 @@ urllib3 = [ {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"}, ] virtualenv = [ - {file = "virtualenv-20.7.0-py2.py3-none-any.whl", hash = "sha256:fdfdaaf0979ac03ae7f76d5224a05b58165f3c804f8aa633f3dd6f22fbd435d5"}, - {file = "virtualenv-20.7.0.tar.gz", hash = "sha256:97066a978431ec096d163e72771df5357c5c898ffdd587048f45e0aecc228094"}, + {file = "virtualenv-20.7.2-py2.py3-none-any.whl", hash = "sha256:e4670891b3a03eb071748c569a87cceaefbf643c5bac46d996c5a45c34aa0f06"}, + {file = "virtualenv-20.7.2.tar.gz", hash = "sha256:9ef4e8ee4710826e98ff3075c9a4739e2cb1040de6a2a8d35db0055840dc96a0"}, ] yarl = [ {file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"}, diff --git a/pyproject.toml b/pyproject.toml index 2ae79f9e4..865fa1d3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ flake8-todo = "~=0.7" pep8-naming = "~=0.9" pre-commit = "~=2.1" taskipy = "~=1.7.0" +pip-licenses = "~=3.5.1" python-dotenv = "~=0.17.1" pytest = "~=6.2.4" pytest-cov = "~=2.12.1" -- cgit v1.2.3 From fe840ca63647d6d6e92760cb4f0f49ac67926954 Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 25 Aug 2021 17:32:06 +0100 Subject: Add time units to the `!remind edit` and `!remind edit duration` help messages --- bot/exts/utils/reminders.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 9d567cdbc..2bed5157f 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -372,7 +372,20 @@ class Reminders(Cog): @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True) async def edit_reminder_group(self, ctx: Context) -> None: - """Commands for modifying your current reminders.""" + """ + Commands for modifying your current reminders. + + The `expiration` duration supports the following symbols for each unit of time: + - years: `Y`, `y`, `year`, `years` + - months: `m`, `month`, `months` + - weeks: `w`, `W`, `week`, `weeks` + - days: `d`, `D`, `day`, `days` + - hours: `H`, `h`, `hour`, `hours` + - minutes: `M`, `minute`, `minutes` + - seconds: `S`, `s`, `second`, `seconds` + + For example, to edit a reminder to expire in 3 days and 1 minute, you can do `!remind edit duration 1234 3d1M`. + """ await ctx.send_help(ctx.command) @edit_reminder_group.command(name="duration", aliases=("time",)) @@ -380,7 +393,16 @@ class Reminders(Cog): """ Edit one of your reminder's expiration. - Expiration is parsed per: http://strftime.org/ + The `expiration` duration supports the following symbols for each unit of time: + - years: `Y`, `y`, `year`, `years` + - months: `m`, `month`, `months` + - weeks: `w`, `W`, `week`, `weeks` + - days: `d`, `D`, `day`, `days` + - hours: `H`, `h`, `hour`, `hours` + - minutes: `M`, `minute`, `minutes` + - seconds: `S`, `s`, `second`, `seconds` + + For example, to edit a reminder to expire in 3 days and 1 minute, you can do `!remind edit duration 1234 3d1M`. """ await self.edit_reminder(ctx, id_, {'expiration': expiration.isoformat()}) -- cgit v1.2.3 From 52bed3d8fab847d7eb9df003b6be7758f6cdbd1d Mon Sep 17 00:00:00 2001 From: Izan Date: Thu, 26 Aug 2021 08:15:32 +0100 Subject: Add message link support to `!user` command --- bot/exts/info/information.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 38b436b7d..ae547b1b8 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -221,8 +221,13 @@ class Information(Cog): await ctx.send(embed=embed) @command(name="user", aliases=["user_info", "member", "member_info", "u"]) - async def user_info(self, ctx: Context, user: MemberOrUser = None) -> None: + async def user_info(self, ctx: Context, user_or_message: Union[MemberOrUser, Message] = None) -> None: """Returns info about a user.""" + if isinstance(user_or_message, Message): + user = user_or_message.author + else: + user = user_or_message + if user is None: user = ctx.author -- cgit v1.2.3 From 78d8bde653187cbf23a2f0ef8a6952058ad95bf4 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 26 Aug 2021 16:39:04 +0100 Subject: Use publically available URL when outputting metabase share URL In production we use the internal URL to call the metabase API, to avoid egress but we still want to output the public url when giving the sharing link. Making it a constant like this makes it easier to change/overwrite in future if needed. --- bot/constants.py | 1 + bot/exts/moderation/metabase.py | 2 +- config-default.yml | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index 80e01b174..4e99df7f3 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -569,6 +569,7 @@ class Metabase(metaclass=YAMLGetter): username: Optional[str] password: Optional[str] base_url: str + public_url: str max_session_age: int diff --git a/bot/exts/moderation/metabase.py b/bot/exts/moderation/metabase.py index 3b454ab18..9eeeec074 100644 --- a/bot/exts/moderation/metabase.py +++ b/bot/exts/moderation/metabase.py @@ -167,7 +167,7 @@ class Metabase(Cog): async with self.bot.http_session.post(url, headers=self.headers, raise_for_status=True) as resp: response_json = await resp.json(encoding="utf-8") - sharing_url = f"{MetabaseConfig.base_url}/public/question/{response_json['uuid']}" + sharing_url = f"{MetabaseConfig.public_url}/public/question/{response_json['uuid']}" await ctx.send(f":+1: {ctx.author.mention} Here's your sharing link: {sharing_url}") # This cannot be static (must have a __func__ attribute). diff --git a/config-default.yml b/config-default.yml index 8e0b97a51..baece5c51 100644 --- a/config-default.yml +++ b/config-default.yml @@ -441,6 +441,7 @@ metabase: username: !ENV "METABASE_USERNAME" password: !ENV "METABASE_PASSWORD" base_url: "http://metabase.default.svc.cluster.local" + public_url: "https://metabase.pythondiscord.com" # 14 days, see https://www.metabase.com/docs/latest/operations-guide/environment-variables.html#max_session_age max_session_age: 20160 -- cgit v1.2.3 From b0ea616add8f6122ba5b98b348a4fa5d41774d21 Mon Sep 17 00:00:00 2001 From: Hunter2807 <46440327+Hunter2807@users.noreply.github.com> Date: Fri, 27 Aug 2021 07:39:26 +0530 Subject: Added bot variables tag (#1784) * Added a new tag with the name bot_var Co-authored-by: Bluenix --- bot/resources/tags/bot_var.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 bot/resources/tags/bot_var.md diff --git a/bot/resources/tags/bot_var.md b/bot/resources/tags/bot_var.md new file mode 100644 index 000000000..6833b3cd8 --- /dev/null +++ b/bot/resources/tags/bot_var.md @@ -0,0 +1,23 @@ +Python allows you to set custom attributes to class instances, like your bot! By adding variables as attributes to your bot you can access them anywhere you access your bot. In the discord.py library, these custom attributes are commonly known as "bot variables" and can be a lifesaver if your bot is divided into many different files. An example on how to use custom attributes on your bot is shown below: + +```py +bot = commands.Bot(command_prefix="!") +# Set an attribute on our bot +bot.test = "I am accessible everywhere!" + +@bot.command() +async def get(ctx: commands.Context): + """A command to get the current value of `test`.""" + # Send what the test attribute is currently set to + await ctx.send(ctx.bot.test) + +@bot.command() +async def setval(ctx: commands.Context, *, new_text: str): + """A command to set a new value of `test`.""" + # Here we change the attribute to what was specified in new_text + bot.test = new_text +``` + +This all applies to cogs as well! You can set attributes to `self` as you wish. + +*Be sure **not** to overwrite attributes discord.py uses, like `cogs` or `users`. Name your attributes carefully!* -- cgit v1.2.3 From 384d5b4b6b945e1aac3e1dbf82c1faa81cebbcf4 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 28 Aug 2021 00:09:28 +0200 Subject: Upgrade pip-licenses to 5.3.2 Thanks to https://github.com/raimon49/pip-licenses/pull/109, we are now able to ignore spaces around the allow-only parameter. Rejoice! --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index ae8b299f4..81b51b8da 100644 --- a/poetry.lock +++ b/poetry.lock @@ -613,7 +613,7 @@ flake8-polyfill = ">=1.0.2,<2" [[package]] name = "pip-licenses" -version = "3.5.1" +version = "3.5.2" description = "Dump the software license list of Python packages installed with pip." category = "dev" optional = false @@ -1069,7 +1069,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.9.*" -content-hash = "dfa0b6e9cc8373cfa5c3bcf35c352b79a08ecca8fe23f21737d8beb6a8c89fa4" +content-hash = "ceddbb2621849f480f736985d71f37cebefd08a9b38bc3943a6f72706258b6ee" [metadata.files] aio-pika = [ @@ -1536,8 +1536,8 @@ pep8-naming = [ {file = "pep8_naming-0.12.1-py2.py3-none-any.whl", hash = "sha256:4a8daeaeb33cfcde779309fc0c9c0a68a3bbe2ad8a8308b763c5068f86eb9f37"}, ] pip-licenses = [ - {file = "pip-licenses-3.5.1.tar.gz", hash = "sha256:6c60096cfa1ee04d7db8d374d4326939369d5871368421e7a7d5da026519bc24"}, - {file = "pip_licenses-3.5.1-py3-none-any.whl", hash = "sha256:e44e4e92e6af2b9d11dfafbf22a3052bf9a05fdaa5654befdba9db735832ac7f"}, + {file = "pip-licenses-3.5.2.tar.gz", hash = "sha256:c5e984f461b34ad04dafa151d0048eb9d049e3d6439966c6440bb6b53ad077b6"}, + {file = "pip_licenses-3.5.2-py3-none-any.whl", hash = "sha256:62deafc82d5dccea1a4cab55172706e02f228abcd67f4d53e382fcb1497e9b62"}, ] platformdirs = [ {file = "platformdirs-2.2.0-py3-none-any.whl", hash = "sha256:4666d822218db6a262bdfdc9c39d21f23b4cfdb08af331a81e92751daf6c866c"}, diff --git a/pyproject.toml b/pyproject.toml index 865fa1d3f..23cbba19b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ flake8-todo = "~=0.7" pep8-naming = "~=0.9" pre-commit = "~=2.1" taskipy = "~=1.7.0" -pip-licenses = "~=3.5.1" +pip-licenses = "~=3.5.2" python-dotenv = "~=0.17.1" pytest = "~=6.2.4" pytest-cov = "~=2.12.1" -- cgit v1.2.3 From 3f2b233f1743da694dea2ae452af0e30bdffff17 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 28 Aug 2021 00:13:12 +0200 Subject: CI: add ISC License (ISCL) to allowed licenses --- .github/workflows/lint-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index 53d9baa59..ac7e47f0e 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -16,6 +16,7 @@ jobs: ALLOWED_LICENSE: Apache Software License; BSD License; GNU Library or Lesser General Public License (LGPL); + ISC License (ISCL); MIT License; Mozilla Public License 2.0 (MPL 2.0); Public Domain; -- cgit v1.2.3 From 570490f58cbbb166a9a1e4b0276cd6f2552a24f1 Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Sun, 29 Aug 2021 01:13:35 +0530 Subject: Replace UserMentionOrID with UnambiguousUser --- bot/converters.py | 30 ++++++++++++++++++++-------- bot/exts/moderation/infraction/management.py | 4 ++-- bot/exts/utils/reminders.py | 4 ++-- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 0118cc48a..c4979972b 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -495,22 +495,36 @@ class HushDurationConverter(Converter): return duration -class UserMentionOrID(UserConverter): +def _is_an_unambiguous_user_argument(argument: str) -> bool: + """Check if the provided argument is a user mention, user id, or username.""" + has_id_or_mention = bool(IDConverter()._get_id_match(argument) or RE_USER_MENTION.match(argument)) + + if not has_id_or_mention: + if argument[0] == '@': + argument = argument[1:] + + # Check to see if the author passed a username (a discriminator exists) + if len(argument) > 5 and argument[-5] == '#': + return True + + return has_id_or_mention + + +class UnambiguousUser(UserConverter): """ - Converts to a `discord.User`, but only if a mention or userID is provided. + Converts to a `discord.User`, but only if a mention, userID or a username is provided. - Unlike the default `UserConverter`, it doesn't allow conversion from a name or name#descrim. + Unlike the default `UserConverter`, it doesn't allow conversion from a name. This is useful in cases where that lookup strategy would lead to ambiguity. """ async def convert(self, ctx: Context, argument: str) -> discord.User: """Convert the `arg` to a `discord.User`.""" - match = self._get_id_match(argument) or RE_USER_MENTION.match(argument) - - if match is not None: + if _is_an_unambiguous_user_argument(argument): return await super().convert(ctx, argument) else: - raise BadArgument(f"`{argument}` is not a User mention or a User ID.") + raise BadArgument(f"`{argument}` is not a User mention, a User ID or a Username in the format" + " `name#discriminator`.") class Infraction(Converter): @@ -557,7 +571,7 @@ if t.TYPE_CHECKING: OffTopicName = str # noqa: F811 ISODateTime = datetime # noqa: F811 HushDurationConverter = int # noqa: F811 - UserMentionOrID = discord.User # noqa: F811 + UnambiguousUser = discord.User # noqa: F811 Infraction = t.Optional[dict] # noqa: F811 Expiry = t.Union[Duration, ISODateTime] diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 641ad0410..7f27896d7 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -12,7 +12,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, UserMentionOrID, allowed_strings +from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, UnambiguousUser, allowed_strings from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator @@ -201,7 +201,7 @@ class ModManagement(commands.Cog): # region: Search infractions @infraction_group.group(name="search", aliases=('s',), invoke_without_command=True) - async def infraction_search_group(self, ctx: Context, query: t.Union[UserMentionOrID, Snowflake, str]) -> None: + async def infraction_search_group(self, ctx: Context, query: t.Union[UnambiguousUser, Snowflake, str]) -> None: """Searches for infractions in the database.""" if isinstance(query, int): await self.search_user(ctx, discord.Object(query)) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 2bed5157f..41b6cac5c 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -15,7 +15,7 @@ from bot.constants import ( Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES ) -from bot.converters import Duration, UserMentionOrID +from bot.converters import Duration, UnambiguousUser from bot.pagination import LinePaginator from bot.utils.checks import has_any_role_check, has_no_roles_check from bot.utils.lock import lock_arg @@ -30,7 +30,7 @@ WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 Mentionable = t.Union[discord.Member, discord.Role] -ReminderMention = t.Union[UserMentionOrID, discord.Role] +ReminderMention = t.Union[UnambiguousUser, discord.Role] class Reminders(Cog): -- cgit v1.2.3 From 70ac9595e5bfc7ceaf2ac9f2be2e8f5f4bdb34d1 Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Sun, 29 Aug 2021 01:43:05 +0530 Subject: Add the UnambiguousMember converter --- bot/converters.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index c4979972b..597f841c4 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -11,7 +11,7 @@ import dateutil.tz import discord from aiohttp import ClientConnectorError from dateutil.relativedelta import relativedelta -from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, UserConverter +from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, MemberConverter, UserConverter from discord.utils import DISCORD_EPOCH, escape_markdown, snowflake_time from bot import exts @@ -510,6 +510,10 @@ def _is_an_unambiguous_user_argument(argument: str) -> bool: return has_id_or_mention +AMBIGUOUS_ARGUMENT_MSG = ("`{argument}` is not a User mention, a User ID or a Username in the format" + " `name#discriminator`.") + + class UnambiguousUser(UserConverter): """ Converts to a `discord.User`, but only if a mention, userID or a username is provided. @@ -523,8 +527,23 @@ class UnambiguousUser(UserConverter): if _is_an_unambiguous_user_argument(argument): return await super().convert(ctx, argument) else: - raise BadArgument(f"`{argument}` is not a User mention, a User ID or a Username in the format" - " `name#discriminator`.") + raise BadArgument(AMBIGUOUS_ARGUMENT_MSG.format(argument=argument)) + + +class UnambiguousMember(MemberConverter): + """ + Converts to a `discord.Member`, but only if a mention, userID or a username is provided. + + Unlike the default `MemberConverter`, it doesn't allow conversion from a name or nickname. + This is useful in cases where that lookup strategy would lead to ambiguity. + """ + + async def convert(self, ctx: Context, argument: str) -> discord.Member: + """Convert the `arg` to a `discord.Member`.""" + if _is_an_unambiguous_user_argument(argument): + return await super().convert(ctx, argument) + else: + raise BadArgument(AMBIGUOUS_ARGUMENT_MSG.format(argument=argument)) class Infraction(Converter): @@ -572,6 +591,7 @@ if t.TYPE_CHECKING: ISODateTime = datetime # noqa: F811 HushDurationConverter = int # noqa: F811 UnambiguousUser = discord.User # noqa: F811 + UnambiguousMember = discord.Member # noqa: F811 Infraction = t.Optional[dict] # noqa: F811 Expiry = t.Union[Duration, ISODateTime] -- cgit v1.2.3 From 1ffe27a82855ea3d49794aa6bf2618c05d673ae6 Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Sun, 29 Aug 2021 01:43:45 +0530 Subject: Fix mismatches in parameter names and docstrings --- bot/converters.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 597f841c4..97919cfb7 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -523,7 +523,7 @@ class UnambiguousUser(UserConverter): """ async def convert(self, ctx: Context, argument: str) -> discord.User: - """Convert the `arg` to a `discord.User`.""" + """Convert the `argument` to a `discord.User`.""" if _is_an_unambiguous_user_argument(argument): return await super().convert(ctx, argument) else: @@ -539,7 +539,7 @@ class UnambiguousMember(MemberConverter): """ async def convert(self, ctx: Context, argument: str) -> discord.Member: - """Convert the `arg` to a `discord.Member`.""" + """Convert the `argument` to a `discord.Member`.""" if _is_an_unambiguous_user_argument(argument): return await super().convert(ctx, argument) else: -- cgit v1.2.3 From 8b1108df2798607ba9c2a8807b022166a482e09c Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Sun, 29 Aug 2021 02:05:55 +0530 Subject: Use unambiguous converters for infraction commands --- bot/converters.py | 1 + bot/exts/moderation/infraction/infractions.py | 30 +++++++++++++-------------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 97919cfb7..57c513246 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -596,3 +596,4 @@ if t.TYPE_CHECKING: Expiry = t.Union[Duration, ISODateTime] MemberOrUser = t.Union[discord.Member, discord.User] +UnambiguousMemberOrUser = t.Union[UnambiguousMember, UnambiguousUser] diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 2f9083c29..eaba97703 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -10,7 +10,7 @@ from discord.ext.commands import Context, command from bot import constants from bot.bot import Bot from bot.constants import Event -from bot.converters import Duration, Expiry, MemberOrUser +from bot.converters import Duration, Expiry, MemberOrUser, UnambiguousMemberOrUser from bot.decorators import respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler @@ -53,7 +53,7 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Permanent infractions @command() - async def warn(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None: + async def warn(self, ctx: Context, user: UnambiguousMemberOrUser, *, reason: t.Optional[str] = None) -> None: """Warn a user for the given reason.""" if not isinstance(user, Member): await ctx.send(":x: The user doesn't appear to be on the server.") @@ -66,7 +66,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user) @command() - async def kick(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None: + async def kick(self, ctx: Context, user: UnambiguousMemberOrUser, *, reason: t.Optional[str] = None) -> None: """Kick a user for the given reason.""" if not isinstance(user, Member): await ctx.send(":x: The user doesn't appear to be on the server.") @@ -78,7 +78,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def ban( self, ctx: Context, - user: MemberOrUser, + user: UnambiguousMemberOrUser, duration: t.Optional[Expiry] = None, *, reason: t.Optional[str] = None @@ -94,7 +94,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def purgeban( self, ctx: Context, - user: MemberOrUser, + user: UnambiguousMemberOrUser, duration: t.Optional[Expiry] = None, *, reason: t.Optional[str] = None @@ -110,7 +110,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def voiceban( self, ctx: Context, - user: MemberOrUser, + user: UnambiguousMemberOrUser, duration: t.Optional[Expiry] = None, *, reason: t.Optional[str] @@ -128,7 +128,7 @@ class Infractions(InfractionScheduler, commands.Cog): @command(aliases=["mute"]) async def tempmute( self, ctx: Context, - user: MemberOrUser, + user: UnambiguousMemberOrUser, duration: t.Optional[Expiry] = None, *, reason: t.Optional[str] = None @@ -162,7 +162,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def tempban( self, ctx: Context, - user: MemberOrUser, + user: UnambiguousMemberOrUser, duration: Expiry, *, reason: t.Optional[str] = None @@ -188,7 +188,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def tempvoiceban( self, ctx: Context, - user: MemberOrUser, + user: UnambiguousMemberOrUser, duration: Expiry, *, reason: t.Optional[str] @@ -214,7 +214,7 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Permanent shadow infractions @command(hidden=True) - async def note(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None: + async def note(self, ctx: Context, user: UnambiguousMemberOrUser, *, reason: t.Optional[str] = None) -> None: """Create a private note for a user with the given reason without notifying the user.""" infraction = await _utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False) if infraction is None: @@ -223,7 +223,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user) @command(hidden=True, aliases=['shadowban', 'sban']) - async def shadow_ban(self, ctx: Context, user: MemberOrUser, *, reason: t.Optional[str] = None) -> None: + async def shadow_ban(self, ctx: Context, user: UnambiguousMemberOrUser, *, reason: t.Optional[str] = None) -> None: """Permanently ban a user for the given reason without notifying the user.""" await self.apply_ban(ctx, user, reason, hidden=True) @@ -234,7 +234,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def shadow_tempban( self, ctx: Context, - user: MemberOrUser, + user: UnambiguousMemberOrUser, duration: Expiry, *, reason: t.Optional[str] = None @@ -260,17 +260,17 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Remove infractions (un- commands) @command() - async def unmute(self, ctx: Context, user: MemberOrUser) -> None: + async def unmute(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: """Prematurely end the active mute infraction for the user.""" await self.pardon_infraction(ctx, "mute", user) @command() - async def unban(self, ctx: Context, user: MemberOrUser) -> None: + async def unban(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: """Prematurely end the active ban infraction for the user.""" await self.pardon_infraction(ctx, "ban", user) @command(aliases=("uvban",)) - async def unvoiceban(self, ctx: Context, user: MemberOrUser) -> None: + async def unvoiceban(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: """Prematurely end the active voice ban infraction for the user.""" await self.pardon_infraction(ctx, "voice_ban", user) -- cgit v1.2.3 From 1dea7637b4c8f7ee63224a992bcb60cfb502caea Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Sun, 29 Aug 2021 14:56:10 +0530 Subject: Make the helper function more readable --- bot/converters.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 57c513246..54ee2a90a 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -499,15 +499,11 @@ def _is_an_unambiguous_user_argument(argument: str) -> bool: """Check if the provided argument is a user mention, user id, or username.""" has_id_or_mention = bool(IDConverter()._get_id_match(argument) or RE_USER_MENTION.match(argument)) - if not has_id_or_mention: - if argument[0] == '@': - argument = argument[1:] + # Check to see if the author passed a username (a discriminator exists) + argument = argument.removeprefix('@') + has_username = len(argument) > 5 and argument[-5] == '#' - # Check to see if the author passed a username (a discriminator exists) - if len(argument) > 5 and argument[-5] == '#': - return True - - return has_id_or_mention + return has_id_or_mention or has_username AMBIGUOUS_ARGUMENT_MSG = ("`{argument}` is not a User mention, a User ID or a Username in the format" -- cgit v1.2.3 From 13af64d4f275b82380b885dbd31fe49a747b69f3 Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Sun, 29 Aug 2021 16:06:21 +0530 Subject: Add more clarity to docstrings Clarify what Discord usernames are and the usage of the word "ambiguity". --- bot/converters.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 54ee2a90a..bd4044c7e 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -496,7 +496,7 @@ class HushDurationConverter(Converter): def _is_an_unambiguous_user_argument(argument: str) -> bool: - """Check if the provided argument is a user mention, user id, or username.""" + """Check if the provided argument is a user mention, user id, or username (name#discrim).""" has_id_or_mention = bool(IDConverter()._get_id_match(argument) or RE_USER_MENTION.match(argument)) # Check to see if the author passed a username (a discriminator exists) @@ -512,10 +512,10 @@ AMBIGUOUS_ARGUMENT_MSG = ("`{argument}` is not a User mention, a User ID or a Us class UnambiguousUser(UserConverter): """ - Converts to a `discord.User`, but only if a mention, userID or a username is provided. + Converts to a `discord.User`, but only if a mention, userID or a username (name#discrim) is provided. Unlike the default `UserConverter`, it doesn't allow conversion from a name. - This is useful in cases where that lookup strategy would lead to ambiguity. + This is useful in cases where that lookup strategy would lead to too much ambiguity. """ async def convert(self, ctx: Context, argument: str) -> discord.User: @@ -528,10 +528,10 @@ class UnambiguousUser(UserConverter): class UnambiguousMember(MemberConverter): """ - Converts to a `discord.Member`, but only if a mention, userID or a username is provided. + Converts to a `discord.Member`, but only if a mention, userID or a username (name#discrim) is provided. Unlike the default `MemberConverter`, it doesn't allow conversion from a name or nickname. - This is useful in cases where that lookup strategy would lead to ambiguity. + This is useful in cases where that lookup strategy would lead to too much ambiguity. """ async def convert(self, ctx: Context, argument: str) -> discord.Member: -- cgit v1.2.3 From d560cdfcaac6b4694ee4b810d9c8df85ec1fae6c Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Mon, 30 Aug 2021 14:14:02 +0530 Subject: Remove the json argument from the raw command. (#1792) --- bot/exts/info/information.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index ae547b1b8..bcf8c10d2 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -460,11 +460,12 @@ class Information(Cog): # remove trailing whitespace return out.rstrip() - @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES) - @group(invoke_without_command=True) - @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES) - async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: - """Shows information about the raw API response.""" + async def send_raw_content(self, ctx: Context, message: Message, json: bool = False) -> None: + """ + Send information about the raw API response for a `discord.Message`. + + If `json` is True, send the information in a copy-pasteable Python format. + """ if ctx.author not in message.channel.members: await ctx.send(":x: You do not have permissions to see the channel this message is in.") return @@ -500,10 +501,17 @@ class Information(Cog): for page in paginator.pages: await ctx.send(page, allowed_mentions=AllowedMentions.none()) + @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES) + @group(invoke_without_command=True) + @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_PARTNERS_COMMUNITY_ROLES) + async def raw(self, ctx: Context, message: Message) -> None: + """Shows information about the raw API response.""" + await self.send_raw_content(ctx, message) + @raw.command() async def json(self, ctx: Context, message: Message) -> None: """Shows information about the raw API response in a copy-pasteable Python format.""" - await ctx.invoke(self.raw, message=message, json=True) + await self.send_raw_content(ctx, message, json=True) def setup(bot: Bot) -> None: -- cgit v1.2.3 From ef9a78d84d2c7759d45f3b1a0e06cf89eb3fd576 Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Mon, 30 Aug 2021 14:53:29 +0530 Subject: Allow dmrelay to only be used in mod channels. --- bot/exts/moderation/dm_relay.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/dm_relay.py b/bot/exts/moderation/dm_relay.py index 1d2206e27..0051db82f 100644 --- a/bot/exts/moderation/dm_relay.py +++ b/bot/exts/moderation/dm_relay.py @@ -5,6 +5,7 @@ from discord.ext.commands import Cog, Context, command, has_any_role from bot.bot import Bot from bot.constants import Emojis, MODERATION_ROLES +from bot.utils.channel import is_mod_channel from bot.utils.services import send_to_paste_service log = logging.getLogger(__name__) @@ -63,8 +64,9 @@ class DMRelay(Cog): await ctx.send(paste_link) async def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return await has_any_role(*MODERATION_ROLES).predicate(ctx) + """Only allow moderators to invoke the commands in this cog in mod channels.""" + return (await has_any_role(*MODERATION_ROLES).predicate(ctx) + and is_mod_channel(ctx.channel)) def setup(bot: Bot) -> None: -- cgit v1.2.3 From dc01b874db2f2909dd90f62847d995e7c94a17eb Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 30 Aug 2021 19:47:36 +0100 Subject: Add concurrency rules to all GitHub workflows This concurrency rule means that workflow actions will be cancelled if a new set of actions are triggered on the same ref. A ref is either a PR or a branch itself. Doing this means that if someone pushes to a PR multiple times in quick succession, workflows won't get queued up. Instead, only the workflows from the most recent changes will run, as this is all we really care about anyway. I see the benefits of this as twofold. 1. The author gets faster feedback about the most recent change pushed, rather than have to wait for all previous runs to completed 2. Other contributors don't need to wait for a queue of redundant workflows to finish. --- .github/workflows/build.yml | 4 ++++ .github/workflows/deploy.yml | 4 ++++ .github/workflows/lint-test.yml | 3 +++ .github/workflows/sentry_release.yml | 4 ++++ .github/workflows/status_embed.yaml | 4 ++++ 5 files changed, 19 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 84a671917..f8f2c8888 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,6 +8,10 @@ on: types: - completed +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8b809b777..0f030ebd7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,6 +8,10 @@ on: types: - completed +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: environment: production diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index e99e6d181..c4f87d522 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -6,6 +6,9 @@ on: - main pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: lint-test: diff --git a/.github/workflows/sentry_release.yml b/.github/workflows/sentry_release.yml index f6a1e1f0e..48f5e50f4 100644 --- a/.github/workflows/sentry_release.yml +++ b/.github/workflows/sentry_release.yml @@ -5,6 +5,10 @@ on: branches: - main +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: create_sentry_release: runs-on: ubuntu-latest diff --git a/.github/workflows/status_embed.yaml b/.github/workflows/status_embed.yaml index b6a71b887..4178c366d 100644 --- a/.github/workflows/status_embed.yaml +++ b/.github/workflows/status_embed.yaml @@ -9,6 +9,10 @@ on: types: - completed +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: status_embed: # We need to send a status embed whenever the workflow -- cgit v1.2.3 From c2d1c974bb95d75b275260e869ec67d3e92d1ae1 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 30 Aug 2021 21:23:54 +0100 Subject: Change all references to watch to nominate instead --- bot/exts/recruitment/talentpool/_cog.py | 42 ++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 8db2d7eac..99567d55e 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -25,7 +25,7 @@ log = logging.getLogger(__name__) class TalentPool(Cog, name="Talentpool"): - """Relays messages of helper candidates to a watch channel to observe them.""" + """Used to nominate potential helper candidates.""" # RedisCache[str, bool] # Can contain a single key, "autoreview_enabled", with the value a bool indicating if autoreview is enabled. @@ -59,7 +59,7 @@ class TalentPool(Cog, name="Talentpool"): params=self.api_default_params ) except ResponseCodeError as err: - log.exception("Failed to fetch the watched users from the API", exc_info=err) + log.exception("Failed to fetch the currently nominated users from the API", exc_info=err) return False self.cache = defaultdict(dict) @@ -120,7 +120,11 @@ class TalentPool(Cog, name="Talentpool"): else: await ctx.send("Autoreview is currently disabled") - @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",)) + @nomination_group.command( + name="nominess", + aliases=("nominated", "all", "list", "watched"), + root_aliases=("nominees",) + ) @has_any_role(*MODERATION_ROLES) async def list_command( self, @@ -201,19 +205,23 @@ class TalentPool(Cog, name="Talentpool"): """ await ctx.invoke(self.list_command, oldest_first=True, update_cache=update_cache) - @nomination_group.command(name='forcewatch', aliases=('fw', 'forceadd', 'fa'), root_aliases=("forcenominate",)) + @nomination_group.command( + name="forcenominate", + aliases=("fw", "forceadd", "fa", "fn", "forcewatch"), + root_aliases=("forcenominate",) + ) @has_any_role(*MODERATION_ROLES) - async def force_watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None: + async def force_nominate_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None: """ Adds the given `user` to the talent pool, from any channel. A `reason` for adding the user to the talent pool is optional. """ - await self._watch_user(ctx, user, reason) + await self._nominate_user(ctx, user, reason) - @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",)) + @nomination_group.command(name='nominate', aliases=("w", "add", "a", "watch"), root_aliases=("nominate",)) @has_any_role(*STAFF_ROLES) - async def watch_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None: + async def nominate_command(self, ctx: Context, user: MemberOrUser, *, reason: str = '') -> None: """ Adds the given `user` to the talent pool. @@ -224,18 +232,18 @@ class TalentPool(Cog, name="Talentpool"): if any(role.id in MODERATION_ROLES for role in ctx.author.roles): await ctx.send( f":x: Nominations should be run in the <#{Channels.nominations}> channel. " - "Use `!tp forcewatch` to override this check." + "Use `!tp forcenominate` to override this check." ) else: await ctx.send(f":x: Nominations must be run in the <#{Channels.nominations}> channel") return - await self._watch_user(ctx, user, reason) + await self._nominate_user(ctx, user, reason) - async def _watch_user(self, ctx: Context, user: MemberOrUser, reason: str) -> None: + async def _nominate_user(self, ctx: Context, user: MemberOrUser, reason: str) -> None: """Adds the given user to the talent pool.""" if user.bot: - await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. I only watch humans.") + await ctx.send(f":x: I'm sorry {ctx.author}, I'm afraid I can't do that. Only humans can be nominated.") return if isinstance(user, Member) and any(role.id in STAFF_ROLES for role in user.roles): @@ -325,7 +333,7 @@ class TalentPool(Cog, name="Talentpool"): @nomination_group.command(name='end', aliases=('unwatch',), root_aliases=("unnominate",)) @has_any_role(*MODERATION_ROLES) - async def unwatch_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None: + async def end_nomination_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None: """ Ends the active nomination of the specified user with the given reason. @@ -335,7 +343,7 @@ class TalentPool(Cog, name="Talentpool"): await ctx.send(f":x: Maximum allowed characters for the end reason is {REASON_MAX_CHARS}.") return - if await self.unwatch(user.id, reason): + if await self.end_nomination(user.id, reason): await ctx.send(f":white_check_mark: Messages sent by {user.mention} will no longer be relayed") else: await ctx.send(":x: The specified user does not have an active nomination") @@ -444,7 +452,7 @@ class TalentPool(Cog, name="Talentpool"): @Cog.listener() async def on_member_ban(self, guild: Guild, user: Union[MemberOrUser]) -> None: """Remove `user` from the talent pool after they are banned.""" - await self.unwatch(user.id, "User was banned.") + await self.end_nomination(user.id, "User was banned.") @Cog.listener() async def on_raw_reaction_add(self, payload: RawReactionActionEvent) -> None: @@ -466,7 +474,7 @@ class TalentPool(Cog, name="Talentpool"): log.info(f"Archiving nomination {message.id}") await self.reviewer.archive_vote(message, emoji == Emojis.incident_actioned) - async def unwatch(self, user_id: int, reason: str) -> bool: + async def end_nomination(self, user_id: int, reason: str) -> bool: """End the active nomination of a user with the given reason and return True on success.""" active_nomination = await self.bot.api_client.get( 'bot/nominations', @@ -536,7 +544,7 @@ class TalentPool(Cog, name="Talentpool"): {entries_string} End date: {end_date} - Unwatch reason: {nomination_object["end_reason"]} + Unnominate reason: {nomination_object["end_reason"]} =============== """ ) -- cgit v1.2.3 From 20aa8b23a386ad2d570a1d771825331b07074bb1 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 30 Aug 2021 21:27:54 +0100 Subject: Remove code that is no longer hit --- bot/exts/recruitment/talentpool/_cog.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 99567d55e..78f9b189b 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -47,10 +47,6 @@ class TalentPool(Cog, name="Talentpool"): """Return whether automatic posting of nomination reviews is enabled.""" return await self.talentpool_settings.get(AUTOREVIEW_ENABLED_KEY, True) - # Stores talentpool users in cache - self.cache = defaultdict(dict) - self.api_default_params = {'active': 'true', 'ordering': '-inserted_at'} - async def refresh_cache(self) -> bool: """Updates TalentPool users cache.""" try: -- cgit v1.2.3 From ffed769fbb1f450ac966564cc0ba75c2406e18ae Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 30 Aug 2021 21:39:20 +0100 Subject: Update comment to reference the new TalentPool cache name --- bot/exts/recruitment/talentpool/_review.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index dfdcdcffe..1cc9c900c 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -103,10 +103,10 @@ class Reviewer: """Format a generic review of a user and return it with the reviewed emoji.""" log.trace(f"Formatting the review of {user_id}") - # Since `watched_users` is a defaultdict, we should take care + # Since `cache` is a defaultdict, we should take care # not to accidentally insert the IDs of users that have no - # active nominated by using the `watched_users.get(user_id)` - # instead of `watched_users[user_id]`. + # active nominated by using the `cache.get(user_id)` + # instead of `cache[user_id]`. nomination = self._pool.cache.get(user_id) if not nomination: log.trace(f"There doesn't appear to be an active nomination for {user_id}") -- cgit v1.2.3 From 6a35307ada47a1c3091b23fb3a33dea78fe63c15 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 30 Aug 2021 21:44:48 +0100 Subject: Update api endpoint name in talent pool review --- bot/exts/recruitment/talentpool/_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index 1cc9c900c..3ffbf93f3 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -97,7 +97,7 @@ class Reviewer: if update_database: nomination = self._pool.cache.get(user_id) - await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) + await self.bot.api_client.patch(f"bot/nominations/{nomination['id']}", json={"reviewed": True}) async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji]]: """Format a generic review of a user and return it with the reviewed emoji.""" -- cgit v1.2.3 From 892fafcd653b35c53ba48d7af42decd50b7fdda9 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 30 Aug 2021 21:58:59 +0100 Subject: Update unnominate message to remove reference to relaying messages. --- bot/exts/recruitment/talentpool/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 78f9b189b..4a77de24c 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -340,7 +340,7 @@ class TalentPool(Cog, name="Talentpool"): return if await self.end_nomination(user.id, reason): - await ctx.send(f":white_check_mark: Messages sent by {user.mention} will no longer be relayed") + await ctx.send(f":white_check_mark: Successfully un-nominated {user}") else: await ctx.send(":x: The specified user does not have an active nomination") -- cgit v1.2.3 From bcb664e19c3e417c17f00562180393f601c43a5a Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 30 Aug 2021 22:04:09 +0100 Subject: Add back missing default params for TalentPool cog --- bot/exts/recruitment/talentpool/_cog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 4a77de24c..38fefddd4 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -34,6 +34,7 @@ class TalentPool(Cog, name="Talentpool"): def __init__(self, bot: Bot) -> None: self.bot = bot self.reviewer = Reviewer(self.__class__.__name__, bot, self) + self.api_default_params = {'active': 'true', 'ordering': '-inserted_at'} self.bot.loop.create_task(self.schedule_autoreviews()) async def schedule_autoreviews(self) -> None: -- cgit v1.2.3 From ea6bd12f09c7978db24184cf09b91e1d643cffba Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 30 Aug 2021 22:04:46 +0100 Subject: use global logger in talent pool, over a self var that has been deleted --- bot/exts/recruitment/talentpool/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 38fefddd4..f932fc003 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -42,7 +42,7 @@ class TalentPool(Cog, name="Talentpool"): if await self.autoreview_enabled(): await self.reviewer.reschedule_reviews() else: - self.log.trace("Not scheduling reviews as autoreview is disabled.") + log.trace("Not scheduling reviews as autoreview is disabled.") async def autoreview_enabled(self) -> bool: """Return whether automatic posting of nomination reviews is enabled.""" -- cgit v1.2.3 From 5ca1fec1e9a2bb767863701bee1ffcad729331ec Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 30 Aug 2021 22:46:26 +0100 Subject: Rename list nominated user command for clarity --- bot/exts/recruitment/talentpool/_cog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index f932fc003..6dc36058b 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -137,9 +137,9 @@ class TalentPool(Cog, name="Talentpool"): The optional kwarg `update_cache` can be used to update the user cache using the API before listing the users. """ - await self.list_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + await self.list_nominated_users(ctx, oldest_first=oldest_first, update_cache=update_cache) - async def list_users( + async def list_nominated_users( self, ctx: Context, oldest_first: bool = False, -- cgit v1.2.3 From 1b04bdfec2ebf1399c93bf43b9bce735b47a8279 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 30 Aug 2021 23:47:52 +0200 Subject: Make the base_url argument in doc set optional --- bot/exts/info/doc/_cog.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/doc/_cog.py b/bot/exts/info/doc/_cog.py index fb9b2584a..a2119a53d 100644 --- a/bot/exts/info/doc/_cog.py +++ b/bot/exts/info/doc/_cog.py @@ -150,6 +150,8 @@ class DocCog(commands.Cog): self.update_or_reschedule_inventory(api_package_name, base_url, inventory_url), ) else: + if not base_url: + base_url = self.base_url_from_inventory_url(inventory_url) self.update_single(api_package_name, base_url, package) def ensure_unique_symbol_name(self, package_name: str, group_name: str, symbol_name: str) -> str: @@ -352,6 +354,11 @@ class DocCog(commands.Cog): msg = await ctx.send(embed=doc_embed) await wait_for_deletion(msg, (ctx.author.id,)) + @staticmethod + def base_url_from_inventory_url(inventory_url: str) -> str: + """Get a base url from the url to an objects inventory by removing the last path segment.""" + return inventory_url.removesuffix("/").rsplit("/", maxsplit=1)[0] + "/" + @docs_group.command(name="setdoc", aliases=("s",)) @commands.has_any_role(*MODERATION_ROLES) @lock(NAMESPACE, COMMAND_LOCK_SINGLETON, raise_error=True) @@ -359,21 +366,21 @@ class DocCog(commands.Cog): self, ctx: commands.Context, package_name: PackageName, - base_url: ValidURL, inventory: Inventory, + base_url: ValidURL = "", ) -> None: """ Adds a new documentation metadata object to the site's database. The database will update the object, should an existing item with the specified `package_name` already exist. + If the base url is not specified, a default created by removing the last segment of the inventory url is used. Example: !docs setdoc \ python \ - https://docs.python.org/3/ \ https://docs.python.org/3/objects.inv """ - if not base_url.endswith("/"): + if base_url and not base_url.endswith("/"): raise commands.BadArgument("The base url must end with a slash.") inventory_url, inventory_dict = inventory body = { @@ -388,6 +395,8 @@ class DocCog(commands.Cog): + "\n".join(f"{key}: {value}" for key, value in body.items()) ) + if not base_url: + base_url = self.base_url_from_inventory_url(inventory_url) self.update_single(package_name, base_url, inventory_dict) await ctx.send(f"Added the package `{package_name}` to the database and updated the inventories.") -- cgit v1.2.3 From 9ef8306c2bc8ab0285da62ba2c7c42e003f015e2 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 30 Aug 2021 22:51:47 +0100 Subject: Fix spelling of a TalentPool command name --- bot/exts/recruitment/talentpool/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 6dc36058b..c0b3f8348 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -118,7 +118,7 @@ class TalentPool(Cog, name="Talentpool"): await ctx.send("Autoreview is currently disabled") @nomination_group.command( - name="nominess", + name="nominees", aliases=("nominated", "all", "list", "watched"), root_aliases=("nominees",) ) -- cgit v1.2.3 From fce68e91a0dc8a58ffead61d933d2ded7e32e0f6 Mon Sep 17 00:00:00 2001 From: Hunter2807 <46440327+Hunter2807@users.noreply.github.com> Date: Wed, 1 Sep 2021 00:46:57 +0530 Subject: Rewording botvars.md tag (#1786) * Reword botvar.md Co-authored-by: Steele Farnsworth <32915757+swfarnsworth@users.noreply.github.com> --- bot/resources/tags/bot_var.md | 23 ----------------------- bot/resources/tags/botvar.md | 23 +++++++++++++++++++++++ 2 files changed, 23 insertions(+), 23 deletions(-) delete mode 100644 bot/resources/tags/bot_var.md create mode 100644 bot/resources/tags/botvar.md diff --git a/bot/resources/tags/bot_var.md b/bot/resources/tags/bot_var.md deleted file mode 100644 index 6833b3cd8..000000000 --- a/bot/resources/tags/bot_var.md +++ /dev/null @@ -1,23 +0,0 @@ -Python allows you to set custom attributes to class instances, like your bot! By adding variables as attributes to your bot you can access them anywhere you access your bot. In the discord.py library, these custom attributes are commonly known as "bot variables" and can be a lifesaver if your bot is divided into many different files. An example on how to use custom attributes on your bot is shown below: - -```py -bot = commands.Bot(command_prefix="!") -# Set an attribute on our bot -bot.test = "I am accessible everywhere!" - -@bot.command() -async def get(ctx: commands.Context): - """A command to get the current value of `test`.""" - # Send what the test attribute is currently set to - await ctx.send(ctx.bot.test) - -@bot.command() -async def setval(ctx: commands.Context, *, new_text: str): - """A command to set a new value of `test`.""" - # Here we change the attribute to what was specified in new_text - bot.test = new_text -``` - -This all applies to cogs as well! You can set attributes to `self` as you wish. - -*Be sure **not** to overwrite attributes discord.py uses, like `cogs` or `users`. Name your attributes carefully!* diff --git a/bot/resources/tags/botvar.md b/bot/resources/tags/botvar.md new file mode 100644 index 000000000..3db6ae7ac --- /dev/null +++ b/bot/resources/tags/botvar.md @@ -0,0 +1,23 @@ +Python allows you to set custom attributes to most objects, like your bot! By storing things as attributes of the bot object, you can access them anywhere you access your bot. In the discord.py library, these custom attributes are commonly known as "bot variables" and can be a lifesaver if your bot is divided into many different files. An example on how to use custom attributes on your bot is shown below: + +```py +bot = commands.Bot(command_prefix="!") +# Set an attribute on our bot +bot.test = "I am accessible everywhere!" + +@bot.command() +async def get(ctx: commands.Context): + """A command to get the current value of `test`.""" + # Send what the test attribute is currently set to + await ctx.send(ctx.bot.test) + +@bot.command() +async def setval(ctx: commands.Context, *, new_text: str): + """A command to set a new value of `test`.""" + # Here we change the attribute to what was specified in new_text + bot.test = new_text +``` + +This all applies to cogs as well! You can set attributes to `self` as you wish. + +*Be sure **not** to overwrite attributes discord.py uses, like `cogs` or `users`. Name your attributes carefully!* -- cgit v1.2.3 From 75fa14d75e357d91a36666f60ac75c2f6f7e1f96 Mon Sep 17 00:00:00 2001 From: TizzySaurus <47674925+TizzySaurus@users.noreply.github.com> Date: Tue, 31 Aug 2021 22:54:22 +0100 Subject: Add support for searching infractions by infraction id (#1787) * Add support for searching infractions by infraction id Can now search by infraction id via `!infraction {id}`. --- bot/exts/moderation/infraction/management.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 7f27896d7..223a124d8 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -11,6 +11,7 @@ from discord.ext.commands import Context from discord.utils import escape_markdown from bot import constants +from bot.api import ResponseCodeError from bot.bot import Bot from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, UnambiguousUser, allowed_strings from bot.exts.moderation.infraction.infractions import Infractions @@ -44,9 +45,25 @@ class ModManagement(commands.Cog): # region: Edit infraction commands @commands.group(name='infraction', aliases=('infr', 'infractions', 'inf', 'i'), invoke_without_command=True) - async def infraction_group(self, ctx: Context) -> None: - """Infraction manipulation commands.""" - await ctx.send_help(ctx.command) + async def infraction_group(self, ctx: Context, infr_id: int = None) -> None: + """Infraction manipulation commands. If `infr_id` is passed then this command fetches that infraction.""" + if infr_id is None: + await ctx.send_help(ctx.command) + return + + try: + infraction_list = [await self.bot.api_client.get(f"bot/infractions/{infr_id}/expanded")] + except ResponseCodeError as e: + if e.status == 404: + await ctx.send(f":x: No infraction with ID `{infr_id}` could be found.") + return + raise e + + embed = discord.Embed( + title=f"Infraction #{infr_id}", + colour=discord.Colour.orange() + ) + await self.send_infraction_list(ctx, embed, infraction_list) @infraction_group.command(name="append", aliases=("amend", "add", "a")) async def infraction_append( @@ -210,7 +227,7 @@ class ModManagement(commands.Cog): else: await self.search_user(ctx, query) - @infraction_search_group.command(name="user", aliases=("member", "id")) + @infraction_search_group.command(name="user", aliases=("member", "userid")) async def search_user(self, ctx: Context, user: t.Union[MemberOrUser, discord.Object]) -> None: """Search for infractions by member.""" infraction_list = await self.bot.api_client.get( -- cgit v1.2.3 From 990bc67c7d02027d1eb3fc30654585bf77bc0d17 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Wed, 1 Sep 2021 10:23:05 +0100 Subject: Add an extra alias to unnominate --- bot/exts/recruitment/talentpool/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index c0b3f8348..477fff2fd 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -328,7 +328,7 @@ class TalentPool(Cog, name="Talentpool"): max_size=1000 ) - @nomination_group.command(name='end', aliases=('unwatch',), root_aliases=("unnominate",)) + @nomination_group.command(name="end", aliases=("unwatch", "unnominate"), root_aliases=("unnominate",)) @has_any_role(*MODERATION_ROLES) async def end_nomination_command(self, ctx: Context, user: MemberOrUser, *, reason: str) -> None: """ -- cgit v1.2.3 From 97017f00ecdb70359ff8cf6f4a8fe970c6aa7ad5 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Fri, 3 Sep 2021 00:50:50 -0700 Subject: `Unnominate reason` to `Unnomination reason` --- bot/exts/recruitment/talentpool/_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index 477fff2fd..c2257c84b 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -541,7 +541,7 @@ class TalentPool(Cog, name="Talentpool"): {entries_string} End date: {end_date} - Unnominate reason: {nomination_object["end_reason"]} + Unnomination reason: {nomination_object["end_reason"]} =============== """ ) -- cgit v1.2.3 From a06367e1691d8ef39e2cf8f8e0d15e5922d29d01 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Fri, 3 Sep 2021 09:42:44 +0100 Subject: Pop user from talent pool cache when unnominated --- bot/exts/recruitment/talentpool/_cog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/recruitment/talentpool/_cog.py b/bot/exts/recruitment/talentpool/_cog.py index c2257c84b..a317c6645 100644 --- a/bot/exts/recruitment/talentpool/_cog.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -493,6 +493,7 @@ class TalentPool(Cog, name="Talentpool"): json={'end_reason': reason, 'active': False} ) + self.cache.pop(user_id) if await self.autoreview_enabled(): self.reviewer.cancel(user_id) -- cgit v1.2.3 From da697eef39bf6a786d8293312d41b9bd634c542d Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 4 Sep 2021 09:41:47 +0100 Subject: Update in accordance with python-discord/kubernetes#95 --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8b809b777..f8a8292a4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -38,6 +38,6 @@ jobs: uses: Azure/k8s-deploy@v1 with: manifests: | - bot/deployment.yaml + namespaces/default/bot/deployment.yaml images: 'ghcr.io/python-discord/bot:${{ steps.sha_tag.outputs.tag }}' kubectl-version: 'latest' -- cgit v1.2.3 From 66f1ca3ca60b4db46b58dcc8d30a4f45e1f8d122 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sat, 4 Sep 2021 14:49:30 +0100 Subject: Pin platform in Dockerfile Some of our deps don't have wheels for atm processors. With Mac's M1 chips becomming more common, we should make it easier for those users to build our environments. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 4d8592590..30bf8a361 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9-slim +FROM --platform=linux/amd64 python:3.9-slim # Set pip to have no saved cache ENV PIP_NO_CACHE_DIR=false \ -- cgit v1.2.3 From b4c7d61361f7ba567e6638d3721f7b3bbcf334d9 Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Sat, 4 Sep 2021 23:14:15 +0100 Subject: Server command now uses correct TalentPool attr (#1810) This was attr changed when we removed the concept of a user being 'watched' while removing the talentpool. --- bot/exts/info/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index bcf8c10d2..51d47b75c 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -72,7 +72,7 @@ class Information(Cog): """Return additional server info only visible in moderation channels.""" talentpool_info = "" if cog := self.bot.get_cog("Talentpool"): - talentpool_info = f"Nominated: {len(cog.watched_users)}\n" + talentpool_info = f"Nominated: {len(cog.cache)}\n" bb_info = "" if cog := self.bot.get_cog("Big Brother"): -- cgit v1.2.3