diff options
| author | 2021-07-20 00:08:48 -0700 | |
|---|---|---|
| committer | 2021-07-20 00:08:48 -0700 | |
| commit | 0d48490f34259a7160b2b095350d06b43d630a5e (patch) | |
| tree | b77047046d0bed083454c793f0a9de5c950ae5ae | |
| parent | Tests: remove stale patch of time_since (diff) | |
| parent | Merge pull request #1685 from NIRDERIi/main (diff) | |
Merge branch 'main' into new-discord-features
| -rw-r--r-- | bot/constants.py | 16 | ||||
| -rw-r--r-- | bot/converters.py | 6 | ||||
| -rw-r--r-- | bot/errors.py | 2 | ||||
| -rw-r--r-- | bot/exts/backend/error_handler.py | 31 | ||||
| -rw-r--r-- | bot/exts/filters/antimalware.py | 5 | ||||
| -rw-r--r-- | bot/exts/filters/antispam.py | 2 | ||||
| -rw-r--r-- | bot/exts/filters/filtering.py | 7 | ||||
| -rw-r--r-- | bot/exts/info/help.py | 17 | ||||
| -rw-r--r-- | bot/exts/info/information.py | 6 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/_utils.py | 4 | ||||
| -rw-r--r-- | bot/exts/moderation/silence.py | 329 | ||||
| -rw-r--r-- | bot/exts/utils/jams.py | 171 | ||||
| -rw-r--r-- | bot/exts/utils/ping.py | 28 | ||||
| -rw-r--r-- | bot/exts/utils/utils.py | 2 | ||||
| -rw-r--r-- | bot/pagination.py | 4 | ||||
| -rw-r--r-- | bot/resources/tags/for-else.md | 17 | ||||
| -rw-r--r-- | config-default.yml | 19 | ||||
| -rw-r--r-- | poetry.lock | 143 | ||||
| -rw-r--r-- | pyproject.toml | 3 | ||||
| -rw-r--r-- | tests/README.md | 31 | ||||
| -rw-r--r-- | tests/bot/exts/backend/test_error_handler.py | 88 | ||||
| -rw-r--r-- | tests/bot/exts/info/test_help.py | 23 | ||||
| -rw-r--r-- | tests/bot/exts/moderation/test_silence.py | 600 | ||||
| -rw-r--r-- | tests/bot/exts/utils/test_jams.py | 137 | ||||
| -rw-r--r-- | tests/bot/test_converters.py | 2 | ||||
| -rw-r--r-- | tests/helpers.py | 47 | 
26 files changed, 1334 insertions, 406 deletions
diff --git a/bot/constants.py b/bot/constants.py index 3d960f22b..500803f33 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" @@ -439,6 +441,7 @@ class Channels(metaclass=YAMLGetter):      discord_py: int      esoteric: int      voice_gate: int +    code_jam_planning: int      admins: int      admin_spam: int @@ -456,15 +459,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 @@ -497,8 +502,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 @@ -506,7 +513,6 @@ class Roles(metaclass=YAMLGetter):      project_leads: int      jammers: int -    team_leaders: int  class Guild(metaclass=YAMLGetter): 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:          * <duration>,          * <duration>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/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..578c372c3 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -3,14 +3,14 @@ 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  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) @@ -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/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 2f0771396..3f891b2c6 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 661d6c9a2..16aaf11cf 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 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: +                            continue +                      # Does the filter only need the message content or the full message?                      if _filter["content_only"]:                          payload = msg.content diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 3a05b2c8a..0235bbaf3 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 @@ -125,16 +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() - -        # 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 = [] - -        return HelpQueryNotFound(f'Query "{string}" not found.', dict(result)) +        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.', {choice[0]: choice[1] for choice in 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 2c89d39e8..b9fcb6b40 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/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 92e0596df..a4059a6e9 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/exts/moderation/silence.py b/bot/exts/moderation/silence.py index 2a7ca932e..8025f3df6 100644 --- a/bot/exts/moderation/silence.py +++ b/bot/exts/moderation/silence.py @@ -1,36 +1,46 @@  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, OrderedDict, Union  from async_rediscache import RedisCache -from discord import TextChannel +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_arg +from bot.utils.lock import LockedResourceError, lock, lock_arg  from bot.utils.scheduling import Scheduler  log = logging.getLogger(__name__)  LOCK_NAMESPACE = "silence" -MSG_SILENCE_FAIL = f"{Emojis.cross_mark} current channel is already silenced." -MSG_SILENCE_PERMANENT = f"{Emojis.check_mark} silenced current channel indefinitely." -MSG_SILENCE_SUCCESS = f"{Emojis.check_mark} silenced current channel for {{duration}} minute(s)." +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"{Emojis.cross_mark} current channel was not silenced." +MSG_UNSILENCE_FAIL = f"{constants.Emojis.cross_mark} {{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} {{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 {{channel}}." + +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.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, +}  class SilenceNotifier(tasks.Loop): @@ -41,7 +51,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: TextOrVoiceChannel) -> None:          """Add channel to `_silenced_channels` and start loop if not launched."""          if not self._silenced_channels:              self.start() @@ -68,7 +78,15 @@ 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}" +            ) + + +async def _select_lock_channel(args: OrderedDict[str, any]) -> TextOrVoiceChannel: +    """Passes the channel to be silenced to the resource lock.""" +    channel, _ = Silence.parse_silence_args(args["ctx"], args["duration_or_channel"], args["duration"]) +    return channel  class Silence(commands.Cog): @@ -92,88 +110,192 @@ 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._everyone_role = guild.default_role -        self._mod_alerts_channel = self.bot.get_channel(Channels.mod_alerts) -        self.notifier = SilenceNotifier(self.bot.get_channel(Channels.mod_log)) +        self._verified_voice_role = guild.get_role(constants.Roles.voice_verified) + +        self._mod_alerts_channel = self.bot.get_channel(constants.Channels.mod_alerts) + +        self.notifier = SilenceNotifier(self.bot.get_channel(constants.Channels.mod_log))          await self._reschedule() +    async def send_message( +        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`.""" +        # Reply to invocation channel +        source_reply = message +        if source_channel != target_channel: +            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 +        if alert_target: +            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.format(channel=target_channel.mention)) + +            elif source_channel != target_channel: +                await target_channel.send(message.format(channel="current channel")) +      @commands.command(aliases=("hush",)) -    @lock_arg(LOCK_NAMESPACE, "ctx", attrgetter("channel"), raise_error=True) -    async def silence(self, ctx: Context, duration: HushDurationConverter = 10) -> None: +    @lock(LOCK_NAMESPACE, _select_lock_channel, raise_error=True) +    async def silence( +        self, +        ctx: Context, +        duration_or_channel: typing.Union[TextOrVoiceChannel, HushDurationConverter] = None, +        duration: HushDurationConverter = 10, +        *, +        kick: bool = False +    ) -> 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 +        channel, duration = self.parse_silence_args(ctx, duration_or_channel, duration) -        channel_info = f"#{ctx.channel} ({ctx.channel.id})" +        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, kick=kick):              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, alert_target=False)              return -        await self._schedule_unsilence(ctx, duration) +        if isinstance(channel, VoiceChannel): +            if kick: +                await self._kick_voice_members(channel) +            else: +                await self._force_voice_sync(channel) + +        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, alert_target=True) +          else:              log.info(f"Silenced {channel_info} for {duration} minute(s).") -            await ctx.send(MSG_SILENCE_SUCCESS.format(duration=duration)) - -    @commands.command(aliases=("unhush",)) -    async def unsilence(self, ctx: Context) -> None: -        """ -        Unsilence the current channel. - -        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) - -    @lock_arg(LOCK_NAMESPACE, "channel", raise_error=True) -    async def _unsilence_wrapper(self, channel: TextChannel) -> None: -        """Unsilence `channel` and send a success/failure message.""" -        if not await self._unsilence(channel): -            overwrite = channel.overwrites_for(self._everyone_role) -            if overwrite.send_messages is False or overwrite.add_reactions is False: -                await channel.send(MSG_UNSILENCE_MANUAL) +            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, 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              else: -                await channel.send(MSG_UNSILENCE_FAIL) +                channel = ctx.channel +                duration = duration_or_channel          else: -            await channel.send(MSG_UNSILENCE_SUCCESS) +            channel = ctx.channel + +        if duration == -1: +            duration = None -    async def _set_silence_overwrites(self, channel: TextChannel) -> bool: +        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.""" -        overwrite = channel.overwrites_for(self._everyone_role) -        prev_overwrites = dict(send_messages=overwrite.send_messages, add_reactions=overwrite.add_reactions) +        # 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 -        overwrite.update(send_messages=False, add_reactions=False) -        await channel.set_permissions(self._everyone_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 -    async def _schedule_unsilence(self, ctx: Context, 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(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: +    @commands.command(aliases=("unhush",)) +    async def unsilence(self, ctx: Context, *, channel: TextOrVoiceChannel = None) -> None: +        """ +        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 +        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: TextOrVoiceChannel, ctx: Optional[Context] = None) -> None: +        """ +        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 + +        if not await self._unsilence(channel): +            if isinstance(channel, VoiceChannel): +                overwrite = channel.overwrites_for(self._verified_voice_role) +                has_channel_overwrites = overwrite.speak is False +            else: +                overwrite = channel.overwrites_for(self._everyone_role) +                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 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) + +        else: +            await self.send_message(MSG_UNSILENCE_SUCCESS, msg_channel, channel, alert_target=True) + +    async def _unsilence(self, channel: TextOrVoiceChannel) -> bool:          """          Unsilence `channel`. @@ -183,19 +305,34 @@ 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 -        overwrite = channel.overwrites_for(self._everyone_role) +        # 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) +            overwrite.update(send_messages=None, add_reactions=None, speak=None, connect=None)          else:              overwrite.update(**json.loads(prev_overwrites)) -        await channel.set_permissions(self._everyone_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}).")          self.scheduler.cancel(channel.id) @@ -203,15 +340,81 @@ 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:              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._everyone_role.mention} are at their desired values." +                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 +    @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 + +        if afk_channel is None: +            overwrites = { +                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 #{afk_channel} ({afk_channel.id})") + +        return afk_channel + +    @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}).") + +        for member in channel.members: +            # 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.trace(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.") + +    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 any(role.id in constants.MODERATION_ROLES for role in member.roles): +                    continue + +                try: +                    await member.move_to(afk_channel, reason="Muting VC member.") +                    log.trace(f"Moved {member.name} to afk channel.") + +                    await member.move_to(channel, reason="Muting VC member.") +                    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 + +        finally: +            # Delete VC channel if it was created. +            if delete_channel: +                await afk_channel.delete(reason="Deleting temporary mute channel.") +      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(): @@ -247,7 +450,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/bot/exts/utils/jams.py b/bot/exts/utils/jams.py index 98fbcb303..87ae847f6 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,124 +22,153 @@ 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="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) -    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.          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 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.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/bot/exts/utils/ping.py b/bot/exts/utils/ping.py index 750ff46d2..c6d7bd900 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." +            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 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/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 2831e30cc..98e43c32b 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -50,7 +50,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) diff --git a/bot/pagination.py b/bot/pagination.py index 865acce41..90d7c84ee 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/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. diff --git a/config-default.yml b/config-default.yml index f4fdc7606..811640034 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 @@ -188,6 +189,7 @@ guild:          bot_commands:       &BOT_CMD        267659945086812160          esoteric:                           470884583684964352          voice_gate:                         764802555427029012 +        code_jam_planning:                  490217981872177157          # Staff          admins:             &ADMINS         365960823622991872 @@ -212,16 +214,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 @@ -264,8 +268,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 @@ -274,7 +280,6 @@ guild:          # Code Jam          jammers:        737249140966162473 -        team_leaders:   737250302834638889          # Streaming          video:          764245844798079016 diff --git a/poetry.lock b/poetry.lock index 2041824e2..dac277ed8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -44,18 +44,6 @@ yarl = ">=1.0,<2.0"  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"  description = "asyncio (PEP 3156) Redis support" @@ -455,17 +443,6 @@ python-versions = "*"  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"  description = "Python wrapper for hiredis" @@ -497,11 +474,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 +564,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 +586,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]] @@ -845,6 +823,14 @@ 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"  description = "Python client for Redis key-value store" @@ -865,14 +851,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 +1018,7 @@ multidict = ">=4.0"  [metadata]  lock-version = "1.1"  python-versions = "3.9.*" -content-hash = "feec7372374cc4025f407b64b2e5b45c2c9c8d49c4538b91dc372f2bad89a624" +content-hash = "85160036e3b07c9d5d24a32302462591e82cc3bf3d5490b87550d9c26bc5648d"  [metadata.files]  aio-pika = [ @@ -1076,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"}, @@ -1303,10 +1291,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 +1343,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 +1461,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 +1625,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 +1736,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..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"] } @@ -21,7 +20,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" 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 <path/to/file.py> +``` + +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)). diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index bd4fb5942..2b0549b98 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -4,12 +4,12 @@ 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  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): @@ -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"              }          ) @@ -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.""" 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) diff --git a/tests/bot/exts/moderation/test_silence.py b/tests/bot/exts/moderation/test_silence.py index fa5fc9e81..59a5893ef 100644 --- a/tests/bot/exts/moderation/test_silence.py +++ b/tests/bot/exts/moderation/test_silence.py @@ -1,15 +1,26 @@  import asyncio +import itertools  import unittest  from datetime import datetime, timezone +from typing import List, Tuple  from unittest import mock -from unittest.mock import Mock +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, MockTextChannel, autospec +from tests.helpers import ( +    MockBot, +    MockContext, +    MockGuild, +    MockMember, +    MockRole, +    MockTextChannel, +    MockVoiceChannel, +    autospec +)  redis_session = None  redis_loop = asyncio.get_event_loop() @@ -149,7 +160,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() @@ -159,6 +170,170 @@ class SilenceCogTests(unittest.IsolatedAsyncioTestCase):          role_check.assert_called_once_with(*(1, 2, 3))          role_check.return_value.predicate.assert_awaited_once_with(ctx) +    async def test_force_voice_sync(self): +        """Tests the _force_voice_sync helper function.""" +        await self.cog._async_init() + +        # 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 member in moderation_members: +                member.move_to.assert_not_called() +            else: +                self.assertEqual(member.move_to.call_count, 2) +                calls = member.move_to.call_args_list + +                # 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.""" +        await self.cog._async_init() + +        channel = MockVoiceChannel(guild=MockGuild(afk_channel=None)) +        new_channel = MockVoiceChannel(delete=AsyncMock()) +        channel.guild.create_voice_channel.return_value = new_channel + +        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_awaited_once_with("mute-temp", overwrites=overwrites) + +        # Check bot deleted channel +        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.""" +        await self.cog._async_init() + +        # 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 member in moderation_members: +                member.move_to.assert_not_called() +            else: +                self.assertEqual((None,), member.move_to.call_args_list[0].args) + +    @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. +        """ +        erroneous_member = MockMember(move_to=AsyncMock(side_effect=Exception())) +        members = [MockMember(), erroneous_member] + +        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() +        _, members = self.create_erroneous_members() + +        await self.cog._kick_voice_members(MockVoiceChannel(members=members)) +        for member in members: +            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.""" +        await self.cog._async_init() +        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 == 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): @@ -235,6 +410,16 @@ 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.""" @@ -242,7 +427,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) @@ -252,56 +437,127 @@ 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(send_messages=True, 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.""" +        """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() + +        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): -                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) +                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, target, duration) +                        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): +        """Tests if silence command calls sync on a voice channel.""" +        channel = MockVoiceChannel() +        await self.cog.silence.callback(self.cog, ctx, channel, 10, kick=False) + +        sync.assert_awaited_once_with(self.cog, channel) +        kick.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, channel, 10, kick=True) + +        kick.assert_awaited_once_with(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, channel, 10, kick=False) + +        sync.assert_not_called() +        kick.assert_not_called() + +    @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, channel, 10, kick=True) + +        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."""          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))                  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.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._everyone_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) +    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_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)          # Remove 'send_messages' & 'add_reactions' keys because they were changed by the method.          del prev_overwrite_dict['send_messages'] @@ -311,6 +567,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): @@ -320,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): @@ -332,8 +602,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_awaited_once_with(self.text_channel.id, overwrite_json)      @autospec(silence, "datetime")      async def test_cached_unsilence_time(self, datetime_mock): @@ -343,7 +613,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) @@ -351,26 +621,33 @@ 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) -        await self.cog.silence.callback(self.cog, ctx, None) +        ctx = MockContext(channel=self.text_channel) +        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):          """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)          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.""" -        ctx = MockContext(channel=self.channel) -        await self.cog.silence.callback(self.cog, ctx, None) +        ctx = MockContext(channel=self.text_channel) +        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): @@ -391,9 +668,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.""" @@ -401,88 +682,128 @@ 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)),          ) -        for was_unsilenced, message, overwrite in test_cases: + +        targets = (None, MockTextChannel()) + +        for (was_unsilenced, message, overwrite), target in itertools.product(test_cases, targets):              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) +            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) + +                        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."""          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(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.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.overwrite.send_messages) -        self.assertIsNone(self.overwrite.add_reactions) +        self.assertIsNone(self.voice_overwrite.connect) +        self.assertIsNone(self.voice_overwrite.speak) -    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.text_channel) +        self.cog._mod_alerts_channel.send.assert_awaited_once() -        await self.cog._unsilence(self.channel) +    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()      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'] @@ -491,3 +812,114 @@ class UnsilenceTests(unittest.IsolatedAsyncioTestCase):                  del new_overwrite_dict['add_reactions']                  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 = ( +            (MockTextChannel(), self.cog.bot.get_guild(Guild.id).default_role), +            (MockVoiceChannel(), self.cog.bot.get_guild(Guild.id).get_role(Roles.voice_verified)) +        ) + +        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) + + +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() + +    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_awaited_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_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.""" +        message = "Current. The following should be replaced: {channel}." +        await self.cog.send_message(message, *self.text_channels, alert_target=False) + +        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: {channel}." +        await self.cog.send_message(message, *self.text_channels, alert_target=True) + +        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.""" +        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_awaited_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.""" +        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 {channel}." +            await self.cog.send_message(message, self.text_channels[0], self.voice_channel, alert_target=True) + +        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) + +        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 {channel}." +            await self.cog.send_message(message, self.text_channels[1], self.voice_channel, alert_target=True) + +            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) +            self.bot.get_channel.assert_called_once_with(self.text_channels[1].id) 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/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: diff --git a/tests/helpers.py b/tests/helpers.py index e3dc5fe5b..3978076ed 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()} @@ -361,6 +380,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 +443,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)  |