diff options
| author | 2021-06-27 21:37:06 +0300 | |
|---|---|---|
| committer | 2021-06-27 21:38:36 +0300 | |
| commit | a5eef150105343a80aab096c04b8383c02d0ee33 (patch) | |
| tree | c08acc9e27620beabfd0d26d1f396bee4f5f0ca1 | |
| parent | Renamed Test Task In Documentation (diff) | |
| parent | Merge pull request #1658 from python-discord/wookie184-voiceverify-alias (diff) | |
Merge branch 'main' into xdist
Signed-off-by: Hassan Abouelela <[email protected]>
# Conflicts:
#	poetry.lock
Diffstat (limited to '')
| -rw-r--r-- | bot/constants.py | 2 | ||||
| -rw-r--r-- | bot/exts/filters/antimalware.py | 11 | ||||
| -rw-r--r-- | bot/exts/filters/filtering.py | 28 | ||||
| -rw-r--r-- | bot/exts/help_channels/_caches.py | 9 | ||||
| -rw-r--r-- | bot/exts/help_channels/_cog.py | 94 | ||||
| -rw-r--r-- | bot/exts/help_channels/_message.py | 40 | ||||
| -rw-r--r-- | bot/exts/info/information.py | 5 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/_utils.py | 2 | ||||
| -rw-r--r-- | bot/exts/moderation/voice_gate.py | 6 | ||||
| -rw-r--r-- | bot/exts/recruitment/talentpool/_review.py | 22 | ||||
| -rw-r--r-- | bot/exts/utils/bot.py | 2 | ||||
| -rw-r--r-- | bot/exts/utils/utils.py | 3 | ||||
| -rw-r--r-- | bot/pagination.py | 13 | ||||
| -rw-r--r-- | bot/resources/tags/dunder-methods.md | 28 | ||||
| -rw-r--r-- | bot/utils/messages.py | 2 | ||||
| -rw-r--r-- | bot/utils/scheduling.py | 19 | ||||
| -rw-r--r-- | config-default.yml | 8 | ||||
| -rw-r--r-- | docker-compose.yml | 2 | ||||
| -rw-r--r-- | poetry.lock | 47 | ||||
| -rw-r--r-- | pyproject.toml | 2 | ||||
| -rw-r--r-- | tests/bot/exts/filters/test_antimalware.py | 45 | ||||
| -rw-r--r-- | tests/bot/exts/moderation/infraction/test_utils.py | 4 | 
22 files changed, 276 insertions, 118 deletions
diff --git a/bot/constants.py b/bot/constants.py index ab55da482..3d960f22b 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -433,6 +433,8 @@ class Channels(metaclass=YAMLGetter):      off_topic_1: int      off_topic_2: int +    black_formatter: int +      bot_commands: int      discord_py: int      esoteric: int diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index 26f00e91f..89e539e7b 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/antimalware.py @@ -15,9 +15,11 @@ PY_EMBED_DESCRIPTION = (      f"please use a code-pasting service such as {URLs.site_schema}{URLs.site_paste}"  ) +TXT_LIKE_FILES = {".txt", ".csv", ".json"}  TXT_EMBED_DESCRIPTION = (      "**Uh-oh!** It looks like your message got zapped by our spam filter. " -    "We currently don't allow `.txt` attachments, so here are some tips to help you travel safely: \n\n" +    "We currently don't allow `{blocked_extension}` attachments, " +    "so here are some tips to help you travel safely: \n\n"      "• If you attempted to send a message longer than 2000 characters, try shortening your message "      "to fit within the character limit or use a pasting service (see below) \n\n"      "• If you tried to show someone your code, you can use codeblocks \n(run `!code-blocks` in " @@ -70,10 +72,13 @@ class AntiMalware(Cog):          if ".py" in extensions_blocked:              # Short-circuit on *.py files to provide a pastebin link              embed.description = PY_EMBED_DESCRIPTION -        elif ".txt" in extensions_blocked: +        elif extensions := TXT_LIKE_FILES.intersection(extensions_blocked):              # Work around Discord AutoConversion of messages longer than 2000 chars to .txt              cmd_channel = self.bot.get_channel(Channels.bot_commands) -            embed.description = TXT_EMBED_DESCRIPTION.format(cmd_channel_mention=cmd_channel.mention) +            embed.description = TXT_EMBED_DESCRIPTION.format( +                blocked_extension=extensions.pop(), +                cmd_channel_mention=cmd_channel.mention +            )          elif extensions_blocked:              meta_channel = self.bot.get_channel(Channels.meta)              embed.description = DISALLOWED_EMBED_DESCRIPTION.format( diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 464732453..661d6c9a2 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -103,19 +103,6 @@ class Filtering(Cog):                  ),                  "schedule_deletion": False              }, -            "filter_everyone_ping": { -                "enabled": Filter.filter_everyone_ping, -                "function": self._has_everyone_ping, -                "type": "filter", -                "content_only": True, -                "user_notification": Filter.notify_user_everyone_ping, -                "notification_msg": ( -                    "Please don't try to ping `@everyone` or `@here`. " -                    f"Your message has been removed. {staff_mistake_str}" -                ), -                "schedule_deletion": False, -                "ping_everyone": False -            },              "watch_regex": {                  "enabled": Filter.watch_regex,                  "function": self._has_watch_regex_match, @@ -129,7 +116,20 @@ class Filtering(Cog):                  "type": "watchlist",                  "content_only": False,                  "schedule_deletion": False -            } +            }, +            "filter_everyone_ping": { +                "enabled": Filter.filter_everyone_ping, +                "function": self._has_everyone_ping, +                "type": "filter", +                "content_only": True, +                "user_notification": Filter.notify_user_everyone_ping, +                "notification_msg": ( +                    "Please don't try to ping `@everyone` or `@here`. " +                    f"Your message has been removed. {staff_mistake_str}" +                ), +                "schedule_deletion": False, +                "ping_everyone": False +            },          }          self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) diff --git a/bot/exts/help_channels/_caches.py b/bot/exts/help_channels/_caches.py index c5e4ee917..8d45c2466 100644 --- a/bot/exts/help_channels/_caches.py +++ b/bot/exts/help_channels/_caches.py @@ -24,3 +24,12 @@ question_messages = RedisCache(namespace="HelpChannels.question_messages")  # This cache keeps track of the dynamic message ID for  # the continuously updated message in the #How-to-get-help channel.  dynamic_message = RedisCache(namespace="HelpChannels.dynamic_message") + +# This cache keeps track of who has help-dms on. +# RedisCache[discord.User.id, bool] +help_dm = RedisCache(namespace="HelpChannels.help_dm") + +# This cache tracks member who are participating and opted in to help channel dms. +# serialise the set as a comma separated string to allow usage with redis +# RedisCache[discord.TextChannel.id, str[set[discord.User.id]]] +session_participants = RedisCache(namespace="HelpChannels.session_participants") diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index 5c410a0a1..35658d117 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -12,6 +12,7 @@ from discord.ext import commands  from bot import constants  from bot.bot import Bot +from bot.constants import Channels, RedirectOutput  from bot.exts.help_channels import _caches, _channel, _message, _name, _stats  from bot.utils import channel as channel_utils, lock, scheduling @@ -424,6 +425,7 @@ class HelpChannels(commands.Cog):      ) -> None:          """Actual implementation of `unclaim_channel`. See that for full documentation."""          await _caches.claimants.delete(channel.id) +        await _caches.session_participants.delete(channel.id)          claimant = self.bot.get_guild(constants.Guild.id).get_member(claimant_id)          if claimant is None: @@ -466,7 +468,9 @@ class HelpChannels(commands.Cog):          if channel_utils.is_in_category(message.channel, constants.Categories.help_available):              if not _channel.is_excluded_channel(message.channel):                  await self.claim_channel(message) -        else: + +        elif channel_utils.is_in_category(message.channel, constants.Categories.help_in_use): +            await self.notify_session_participants(message)              await _message.update_message_caches(message)      @commands.Cog.listener() @@ -535,3 +539,91 @@ class HelpChannels(commands.Cog):          )          self.dynamic_message = new_dynamic_message["id"]          await _caches.dynamic_message.set("message_id", self.dynamic_message) + +    @staticmethod +    def _serialise_session_participants(participants: set[int]) -> str: +        """Convert a set to a comma separated string.""" +        return ','.join(str(p) for p in participants) + +    @staticmethod +    def _deserialise_session_participants(s: str) -> set[int]: +        """Convert a comma separated string into a set.""" +        return set(int(user_id) for user_id in s.split(",") if user_id != "") + +    @lock.lock_arg(NAMESPACE, "message", attrgetter("channel.id")) +    @lock.lock_arg(NAMESPACE, "message", attrgetter("author.id")) +    async def notify_session_participants(self, message: discord.Message) -> None: +        """ +        Check if the message author meets the requirements to be notified. + +        If they meet the requirements they are notified. +        """ +        if await _caches.claimants.get(message.channel.id) == message.author.id: +            return  # Ignore messages sent by claimants + +        if not await _caches.help_dm.get(message.author.id): +            return  # Ignore message if user is opted out of help dms + +        if (await self.bot.get_context(message)).command == self.close_command: +            return  # Ignore messages that are closing the channel + +        session_participants = self._deserialise_session_participants( +            await _caches.session_participants.get(message.channel.id) or "" +        ) + +        if message.author.id not in session_participants: +            session_participants.add(message.author.id) + +            embed = discord.Embed( +                title="Currently Helping", +                description=f"You're currently helping in {message.channel.mention}", +                color=constants.Colours.soft_green, +                timestamp=message.created_at +            ) +            embed.add_field(name="Conversation", value=f"[Jump to message]({message.jump_url})") + +            try: +                await message.author.send(embed=embed) +            except discord.Forbidden: +                log.trace( +                    f"Failed to send helpdm message to {message.author.id}. DMs Closed/Blocked. " +                    "Removing user from helpdm." +                ) +                bot_commands_channel = self.bot.get_channel(Channels.bot_commands) +                await _caches.help_dm.delete(message.author.id) +                await bot_commands_channel.send( +                    f"{message.author.mention} {constants.Emojis.cross_mark} " +                    "To receive updates on help channels you're active in, enable your DMs.", +                    delete_after=RedirectOutput.delete_delay +                ) +                return + +            await _caches.session_participants.set( +                message.channel.id, +                self._serialise_session_participants(session_participants) +            ) + +    @commands.command(name="helpdm") +    async def helpdm_command( +        self, +        ctx: commands.Context, +        state_bool: bool +    ) -> None: +        """ +        Allows user to toggle "Helping" dms. + +        If this is set to on the user will receive a dm for the channel they are participating in. + +        If this is set to off the user will not receive a dm for channel that they are participating in. +        """ +        state_str = "ON" if state_bool else "OFF" + +        if state_bool == await _caches.help_dm.get(ctx.author.id, False): +            await ctx.send(f"{constants.Emojis.cross_mark} {ctx.author.mention} Help DMs are already {state_str}") +            return + +        if state_bool: +            await _caches.help_dm.set(ctx.author.id, True) +        else: +            await _caches.help_dm.delete(ctx.author.id) +        await ctx.send(f"{constants.Emojis.ok_hand} {ctx.author.mention} Help DMs {state_str}!") diff --git a/bot/exts/help_channels/_message.py b/bot/exts/help_channels/_message.py index afd698ffe..befacd263 100644 --- a/bot/exts/help_channels/_message.py +++ b/bot/exts/help_channels/_message.py @@ -9,22 +9,20 @@ from arrow import Arrow  import bot  from bot import constants  from bot.exts.help_channels import _caches -from bot.utils.channel import is_in_category  log = logging.getLogger(__name__)  ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/"  AVAILABLE_MSG = f""" -**Send your question here to claim the channel** -This channel will be dedicated to answering your question only. Others will try to answer and help you solve the issue. +Send your question here to claim the channel. -**Keep in mind:** -• It's always ok to just ask your question. You don't need permission. -• Explain what you expect to happen and what actually happens. -• Include a code sample and error message, if you got any. +**Remember to:** +• **Ask** your Python question, not if you can ask or if there's an expert who can help. +• **Show** a code sample as text (rather than a screenshot) and the error message, if you got one. +• **Explain** what you expect to happen and what actually happens. -For more tips, check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**. +For more tips, check out our guide on [asking good questions]({ASKING_GUIDE_URL}).  """  AVAILABLE_TITLE = "Available help channel" @@ -47,23 +45,21 @@ async def update_message_caches(message: discord.Message) -> None:      """Checks the source of new content in a help channel and updates the appropriate cache."""      channel = message.channel -    # Confirm the channel is an in use help channel -    if is_in_category(channel, constants.Categories.help_in_use): -        log.trace(f"Checking if #{channel} ({channel.id}) has had a reply.") +    log.trace(f"Checking if #{channel} ({channel.id}) has had a reply.") -        claimant_id = await _caches.claimants.get(channel.id) -        if not claimant_id: -            # The mapping for this channel doesn't exist, we can't do anything. -            return +    claimant_id = await _caches.claimants.get(channel.id) +    if not claimant_id: +        # The mapping for this channel doesn't exist, we can't do anything. +        return -        # datetime.timestamp() would assume it's local, despite d.py giving a (naïve) UTC time. -        timestamp = Arrow.fromdatetime(message.created_at).timestamp() +    # datetime.timestamp() would assume it's local, despite d.py giving a (naïve) UTC time. +    timestamp = Arrow.fromdatetime(message.created_at).timestamp() -        # Overwrite the appropriate last message cache depending on the author of the message -        if message.author.id == claimant_id: -            await _caches.claimant_last_message_times.set(channel.id, timestamp) -        else: -            await _caches.non_claimant_last_message_times.set(channel.id, timestamp) +    # Overwrite the appropriate last message cache depending on the author of the message +    if message.author.id == claimant_id: +        await _caches.claimant_last_message_times.set(channel.id, timestamp) +    else: +        await _caches.non_claimant_last_message_times.set(channel.id, timestamp)  async def get_last_message(channel: discord.TextChannel) -> t.Optional[discord.Message]: diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 834fee1b4..1b1243118 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -241,8 +241,6 @@ class Information(Cog):              if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)):                  badges.append(emoji) -        activity = await self.user_messages(user) -          if on_server:              joined = time_since(user.joined_at, max_units=3)              roles = ", ".join(role.mention for role in user.roles[1:]) @@ -272,8 +270,7 @@ class Information(Cog):          # Show more verbose output in moderation channels for infractions and nominations          if is_mod_channel(ctx.channel): -            fields.append(activity) - +            fields.append(await self.user_messages(user))              fields.append(await self.expanded_user_infraction_counts(user))              fields.append(await self.user_nomination_counts(user))          else: diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index a98b4828b..e4eb7f79c 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -164,7 +164,7 @@ async def notify_infraction(      text = INFRACTION_DESCRIPTION_TEMPLATE.format(          type=infr_type.title(), -        expires=expires_at or "N/A", +        expires=f"{expires_at} UTC" if expires_at else "N/A",          reason=reason or "No reason provided."      ) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 94b23a344..8494a1e2e 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -118,7 +118,7 @@ class VoiceGate(Cog):          await self.redis_cache.set(member.id, message.id)          return True, message.channel -    @command(aliases=('voiceverify',)) +    @command(aliases=("voiceverify", "voice-verify",))      @has_no_roles(Roles.voice_verified)      @in_whitelist(channels=(Channels.voice_gate,), redirect=None)      async def voice_verify(self, ctx: Context, *_) -> None: @@ -254,6 +254,10 @@ class VoiceGate(Cog):              log.trace("User not in a voice channel. Ignore.")              return +        if isinstance(after.channel, discord.StageChannel): +            log.trace("User joined a stage channel. Ignore.") +            return +          # To avoid race conditions, checking if the user should receive a notification          # and sending it if appropriate is delegated to an atomic helper          notification_sent, message_channel = await self._ping_newcomer(member) diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py index b9ff61986..0cb786e4b 100644 --- a/bot/exts/recruitment/talentpool/_review.py +++ b/bot/exts/recruitment/talentpool/_review.py @@ -75,7 +75,7 @@ class Reviewer:      async def post_review(self, user_id: int, update_database: bool) -> None:          """Format the review of a user and post it to the nomination voting channel.""" -        review, seen_emoji = await self.make_review(user_id) +        review, reviewed_emoji = await self.make_review(user_id)          if not review:              return @@ -88,8 +88,8 @@ class Reviewer:          await pin_no_system_message(messages[0])          last_message = messages[-1] -        if seen_emoji: -            for reaction in (seen_emoji, "\N{THUMBS UP SIGN}", "\N{THUMBS DOWN SIGN}"): +        if reviewed_emoji: +            for reaction in (reviewed_emoji, "\N{THUMBS UP SIGN}", "\N{THUMBS DOWN SIGN}"):                  await last_message.add_reaction(reaction)          if update_database: @@ -97,7 +97,7 @@ class Reviewer:              await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True})      async def make_review(self, user_id: int) -> typing.Tuple[str, Optional[Emoji]]: -        """Format a generic review of a user and return it with the seen emoji.""" +        """Format a generic review of a user and return it with the reviewed emoji."""          log.trace(f"Formatting the review of {user_id}")          # Since `watched_users` is a defaultdict, we should take care @@ -127,15 +127,15 @@ class Reviewer:          review_body = await self._construct_review_body(member) -        seen_emoji = self._random_ducky(guild) +        reviewed_emoji = self._random_ducky(guild)          vote_request = (              "*Refer to their nomination and infraction histories for further details*.\n" -            f"*Please react {seen_emoji} if you've seen this post." -            " Then react :+1: for approval, or :-1: for disapproval*." +            f"*Please react {reviewed_emoji} once you have reviewed this user," +            " and react :+1: for approval, or :-1: for disapproval*."          )          review = "\n\n".join((opening, current_nominations, review_body, vote_request)) -        return review, seen_emoji +        return review, reviewed_emoji      async def archive_vote(self, message: PartialMessage, passed: bool) -> None:          """Archive this vote to #nomination-archive.""" @@ -163,7 +163,7 @@ class Reviewer:          user_id = int(MENTION_RE.search(content).group(1))          # Get reaction counts -        seen = await count_unique_users_reaction( +        reviewed = await count_unique_users_reaction(              messages[0],              lambda r: "ducky" in str(r) or str(r) == "\N{EYES}",              count_bots=False @@ -188,7 +188,7 @@ class Reviewer:          embed_content = (              f"{result} on {timestamp}\n" -            f"With {seen} {Emojis.ducky_dave} {upvotes} :+1: {downvotes} :-1:\n\n" +            f"With {reviewed} {Emojis.ducky_dave} {upvotes} :+1: {downvotes} :-1:\n\n"              f"{stripped_content}"          ) @@ -357,7 +357,7 @@ class Reviewer:      @staticmethod      def _random_ducky(guild: Guild) -> Union[Emoji, str]: -        """Picks a random ducky emoji to be used to mark the vote as seen. If no duckies found returns :eyes:.""" +        """Picks a random ducky emoji. If no duckies found returns :eyes:."""          duckies = [emoji for emoji in guild.emojis if emoji.name.startswith("ducky")]          if not duckies:              return ":eyes:" diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py index a4c828f95..d84709616 100644 --- a/bot/exts/utils/bot.py +++ b/bot/exts/utils/bot.py @@ -44,6 +44,8 @@ class BotCog(Cog, name="Bot"):          """Repeat the given message in either a specified channel or the current channel."""          if channel is None:              await ctx.send(text) +        elif not channel.permissions_for(ctx.author).send_messages: +            await ctx.send("You don't have permission to speak in that channel.")          else:              await channel.send(text) diff --git a/bot/exts/utils/utils.py b/bot/exts/utils/utils.py index 4c39a7c2a..3b8564aee 100644 --- a/bot/exts/utils/utils.py +++ b/bot/exts/utils/utils.py @@ -40,6 +40,7 @@ If the implementation is hard to explain, it's a bad idea.  If the implementation is easy to explain, it may be a good idea.  Namespaces are one honking great idea -- let's do more of those!  """ +LEADS_AND_COMMUNITY = (Roles.project_leads, Roles.domain_leads, Roles.partners, Roles.python_community)  class Utils(Cog): @@ -185,7 +186,7 @@ class Utils(Cog):          )      @command(aliases=("poll",)) -    @has_any_role(*MODERATION_ROLES, Roles.project_leads, Roles.domain_leads) +    @has_any_role(*MODERATION_ROLES, *LEADS_AND_COMMUNITY)      async def vote(self, ctx: Context, title: clean_content(fix_channel_mentions=True), *options: str) -> None:          """          Build a quick voting poll with matching reactions with the provided options. diff --git a/bot/pagination.py b/bot/pagination.py index c5c84afd9..1c5b94b07 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -51,22 +51,25 @@ class LinePaginator(Paginator):          suffix: str = '```',          max_size: int = 2000,          scale_to_size: int = 2000, -        max_lines: t.Optional[int] = None +        max_lines: t.Optional[int] = None, +        linesep: str = "\n"      ) -> None:          """          This function overrides the Paginator.__init__ from inside discord.ext.commands.          It overrides in order to allow us to configure the maximum number of lines per page.          """ -        self.prefix = prefix -        self.suffix = suffix -          # Embeds that exceed 2048 characters will result in an HTTPException          # (Discord API limit), so we've set a limit of 2000          if max_size > 2000:              raise ValueError(f"max_size must be <= 2,000 characters. ({max_size} > 2000)") -        self.max_size = max_size - len(suffix) +        super().__init__( +            prefix, +            suffix, +            max_size - len(suffix), +            linesep +        )          if scale_to_size < max_size:              raise ValueError(f"scale_to_size must be >= max_size. ({scale_to_size} < {max_size})") diff --git a/bot/resources/tags/dunder-methods.md b/bot/resources/tags/dunder-methods.md new file mode 100644 index 000000000..be2b97b7b --- /dev/null +++ b/bot/resources/tags/dunder-methods.md @@ -0,0 +1,28 @@ +**Dunder methods** + +Double-underscore methods, or "dunder" methods, are special methods defined in a class that are invoked implicitly. Like the name suggests, they are prefixed and suffixed with dunders. You've probably already seen some, such as the `__init__` dunder method, also known as the "constructor" of a class, which is implicitly invoked when you instantiate an instance of a class. + +When you create a new class, there will be default dunder methods inherited from the `object` class. However, we can override them by redefining these methods within the new class. For example, the default `__init__` method from `object` doesn't take any arguments, so we almost always override that to fit our needs. + +Other common dunder methods to override are `__str__` and `__repr__`. `__repr__` is the developer-friendly string representation of an object - usually the syntax to recreate it - and is implicitly called on arguments passed into the `repr` function. `__str__` is the user-friendly string representation of an object, and is called by the `str` function. Note here that, if not overriden, the default `__str__` invokes `__repr__` as a fallback. + +```py +class Foo: +    def __init__(self, value):  # constructor +        self.value = value +    def __str__(self): +        return f"This is a Foo object, with a value of {self.value}!"  # string representation +    def __repr__(self): +        return f"Foo({self.value!r})"  # way to recreate this object + + +bar = Foo(5) + +# print also implicitly calls __str__ +print(bar)  # Output: This is a Foo object, with a value of 5! + +# dev-friendly representation +print(repr(bar))  # Output: Foo(5) +``` + +Another example: did you know that when you use the `<left operand> + <right operand>` syntax, you're implicitly calling `<left operand>.__add__(<right operand>)`? The same applies to other operators, and you can look at the [`operator` built-in module documentation](https://docs.python.org/3/library/operator.html) for more information! diff --git a/bot/utils/messages.py b/bot/utils/messages.py index b6f6c1f66..d4a921161 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -54,7 +54,7 @@ def reaction_check(          log.trace(f"Removing reaction {reaction} by {user} on {reaction.message.id}: disallowed user.")          scheduling.create_task(              reaction.message.remove_reaction(reaction.emoji, user), -            HTTPException,  # Suppress the HTTPException if adding the reaction fails +            suppressed_exceptions=(HTTPException,),              name=f"remove_reaction-{reaction}-{reaction.message.id}-{user}"          )          return False diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 2dc485f24..bb83b5c0d 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -161,9 +161,22 @@ class Scheduler:                  self._log.error(f"Error in task #{task_id} {id(done_task)}!", exc_info=exception) -def create_task(coro: t.Awaitable, *suppressed_exceptions: t.Type[Exception], **kwargs) -> asyncio.Task: -    """Wrapper for `asyncio.create_task` which logs exceptions raised in the task.""" -    task = asyncio.create_task(coro, **kwargs) +def create_task( +    coro: t.Awaitable, +    *, +    suppressed_exceptions: tuple[t.Type[Exception]] = (), +    event_loop: t.Optional[asyncio.AbstractEventLoop] = None, +    **kwargs, +) -> asyncio.Task: +    """ +    Wrapper for creating asyncio `Task`s which logs exceptions raised in the task. + +    If the loop kwarg is provided, the task is created from that event loop, otherwise the running loop is used. +    """ +    if event_loop is not None: +        task = event_loop.create_task(coro, **kwargs) +    else: +        task = asyncio.create_task(coro, **kwargs)      task.add_done_callback(partial(_log_task_exception, suppressed_exceptions=suppressed_exceptions))      return task diff --git a/config-default.yml b/config-default.yml index 55388247c..f4fdc7606 100644 --- a/config-default.yml +++ b/config-default.yml @@ -176,6 +176,9 @@ guild:          user_log:                           528976905546760203          voice_log:                          640292421988646961 +        # Open Source Projects +        black_formatter:    &BLACK_FORMATTER 846434317021741086 +          # Off-topic          off_topic_0:    291284109232308226          off_topic_1:    463035241142026251 @@ -195,6 +198,7 @@ guild:          incidents:                          714214212200562749          incidents_archive:                  720668923636351037          mod_alerts:                         473092532147060736 +        mods:               &MODS           305126844661760000          nominations:                        822920136150745168          nomination_voting:                  822853512709931008          organisation:       &ORGANISATION   551789653284356126 @@ -231,6 +235,7 @@ guild:      moderation_channels:          - *ADMINS          - *ADMIN_SPAM +        - *MODS      # Modlog cog ignores events which occur in these channels      modlog_blacklist: @@ -244,6 +249,7 @@ guild:      reminder_whitelist:          - *BOT_CMD          - *DEV_CONTRIB +        - *BLACK_FORMATTER      roles:          announcements:                          463658397560995840 @@ -392,7 +398,7 @@ anti_spam:          chars:              interval: 5 -            max: 3_000 +            max: 4_200          discord_emojis:              interval: 10 diff --git a/docker-compose.yml b/docker-compose.yml index 1761d8940..0f0355dac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,7 @@ services:    postgres:      << : *logging      << : *restart_policy -    image: postgres:12-alpine +    image: postgres:13-alpine      environment:        POSTGRES_DB: pysite        POSTGRES_PASSWORD: pysite diff --git a/poetry.lock b/poetry.lock index 290746cc3..f1d158a68 100644 --- a/poetry.lock +++ b/poetry.lock @@ -83,14 +83,6 @@ yarl = "*"  develop = ["aiomisc (>=11.0,<12.0)", "async-generator", "coverage (!=4.3)", "coveralls", "pylava", "pytest", "pytest-cov", "tox (>=2.4)"]  [[package]] -name = "apipkg" -version = "1.5" -description = "apipkg: namespace control and lazy-import mechanism" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[[package]]  name = "appdirs"  version = "1.4.4"  description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." @@ -269,7 +261,7 @@ murmur = ["mmh3"]  [[package]]  name = "discord.py" -version = "1.6.0" +version = "1.7.3"  description = "A Python wrapper for the Discord API"  category = "main"  optional = false @@ -311,15 +303,12 @@ dev = ["pytest", "coverage", "coveralls"]  [[package]]  name = "execnet" -version = "1.8.1" +version = "1.9.0"  description = "execnet: rapid multi-Python deployment"  category = "dev"  optional = false  python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -[package.dependencies] -apipkg = ">=1.4" -  [package.extras]  testing = ["pre-commit"] @@ -342,7 +331,7 @@ lua = ["lupa"]  [[package]]  name = "feedparser" -version = "6.0.2" +version = "6.0.8"  description = "Universal feed parser, handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds"  category = "main"  optional = false @@ -486,7 +475,7 @@ python-versions = ">=3.6"  [[package]]  name = "humanfriendly" -version = "9.1" +version = "9.2"  description = "Human friendly output for text interfaces using Python"  category = "main"  optional = false @@ -993,7 +982,7 @@ python-versions = "*"  [[package]]  name = "urllib3" -version = "1.26.5" +version = "1.26.6"  description = "HTTP library with thread-safe connection pooling, file post, and more."  category = "main"  optional = false @@ -1037,7 +1026,7 @@ multidict = ">=4.0"  [metadata]  lock-version = "1.1"  python-versions = "3.9.*" -content-hash = "c1163e748d2fabcbcc267ea0eeccf4be6dfe5a468d769b6e5bc9023e8ab0a2bf" +content-hash = "040b5fa5c6f398bbcc6dfd6b27bc729032989fc5853881d21c032e92b2395a82"  [metadata.files]  aio-pika = [ @@ -1099,10 +1088,6 @@ aiormq = [      {file = "aiormq-3.3.1-py3-none-any.whl", hash = "sha256:e584dac13a242589aaf42470fd3006cb0dc5aed6506cbd20357c7ec8bbe4a89e"},      {file = "aiormq-3.3.1.tar.gz", hash = "sha256:8218dd9f7198d6e7935855468326bbacf0089f926c70baa8dd92944cb2496573"},  ] -apipkg = [ -    {file = "apipkg-1.5-py2.py3-none-any.whl", hash = "sha256:58587dd4dc3daefad0487f6d9ae32b4542b185e1c36db6993290e7c41ca2b47c"}, -    {file = "apipkg-1.5.tar.gz", hash = "sha256:37228cda29411948b422fae072f57e31d3396d2ee1c9783775980ee9c9990af6"}, -]  appdirs = [      {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},      {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, @@ -1254,8 +1239,8 @@ deepdiff = [      {file = "deepdiff-4.3.2.tar.gz", hash = "sha256:91360be1d9d93b1d9c13ae9c5048fa83d9cff17a88eb30afaa0d7ff2d0fee17d"},  ]  "discord.py" = [ -    {file = "discord.py-1.6.0-py3-none-any.whl", hash = "sha256:3df148daf6fbcc7ab5b11042368a3cd5f7b730b62f09fb5d3cbceff59bcfbb12"}, -    {file = "discord.py-1.6.0.tar.gz", hash = "sha256:ba8be99ff1b8c616f7b6dcb700460d0222b29d4c11048e74366954c465fdd05f"}, +    {file = "discord.py-1.7.3-py3-none-any.whl", hash = "sha256:c6f64db136de0e18e090f6752ea68bdd4ab0a61b82dfe7acecefa22d6477bb0c"}, +    {file = "discord.py-1.7.3.tar.gz", hash = "sha256:462cd0fe307aef8b29cbfa8dd613e548ae4b2cb581d46da9ac0d46fb6ea19408"},  ]  distlib = [      {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, @@ -1268,16 +1253,16 @@ emoji = [      {file = "emoji-0.6.0.tar.gz", hash = "sha256:e42da4f8d648f8ef10691bc246f682a1ec6b18373abfd9be10ec0b398823bd11"},  ]  execnet = [ -    {file = "execnet-1.8.1-py2.py3-none-any.whl", hash = "sha256:e840ce25562e414ee5684864d510dbeeb0bce016bc89b22a6e5ce323b5e6552f"}, -    {file = "execnet-1.8.1.tar.gz", hash = "sha256:7e3c2cdb6389542a91e9855a9cc7545fbed679e96f8808bcbb1beb325345b189"}, +    {file = "execnet-1.9.0-py2.py3-none-any.whl", hash = "sha256:a295f7cc774947aac58dde7fdc85f4aa00c42adf5d8f5468fc630c1acf30a142"}, +    {file = "execnet-1.9.0.tar.gz", hash = "sha256:8f694f3ba9cc92cab508b152dcfe322153975c29bda272e2fd7f3f00f36e47c5"},  ]  fakeredis = [      {file = "fakeredis-1.5.2-py3-none-any.whl", hash = "sha256:f1ffdb134538e6d7c909ddfb4fc5edeb4a73d0ea07245bc69b8135fbc4144b04"},      {file = "fakeredis-1.5.2.tar.gz", hash = "sha256:18fc1808d2ce72169d3f11acdb524a00ef96bd29970c6d34cfeb2edb3fc0c020"},  ]  feedparser = [ -    {file = "feedparser-6.0.2-py3-none-any.whl", hash = "sha256:f596c4b34fb3e2dc7e6ac3a8191603841e8d5d267210064e94d4238737452ddd"}, -    {file = "feedparser-6.0.2.tar.gz", hash = "sha256:1b00a105425f492f3954fd346e5b524ca9cef3a4bbf95b8809470e9857aa1074"}, +    {file = "feedparser-6.0.8-py3-none-any.whl", hash = "sha256:1b7f57841d9cf85074deb316ed2c795091a238adb79846bc46dccdaf80f9c59a"}, +    {file = "feedparser-6.0.8.tar.gz", hash = "sha256:5ce0410a05ab248c8c7cfca3a0ea2203968ee9ff4486067379af4827a59f9661"},  ]  filelock = [      {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, @@ -1366,8 +1351,8 @@ hiredis = [      {file = "hiredis-2.0.0.tar.gz", hash = "sha256:81d6d8e39695f2c37954d1011c0480ef7cf444d4e3ae24bc5e89ee5de360139a"},  ]  humanfriendly = [ -    {file = "humanfriendly-9.1-py2.py3-none-any.whl", hash = "sha256:d5c731705114b9ad673754f3317d9fa4c23212f36b29bdc4272a892eafc9bc72"}, -    {file = "humanfriendly-9.1.tar.gz", hash = "sha256:066562956639ab21ff2676d1fda0b5987e985c534fc76700a19bd54bcb81121d"}, +    {file = "humanfriendly-9.2-py2.py3-none-any.whl", hash = "sha256:332da98c24cc150efcc91b5508b19115209272bfdf4b0764a56795932f854271"}, +    {file = "humanfriendly-9.2.tar.gz", hash = "sha256:f7dba53ac7935fd0b4a2fc9a29e316ddd9ea135fb3052d3d0279d10c18ff9c48"},  ]  identify = [      {file = "identify-2.2.10-py2.py3-none-any.whl", hash = "sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421"}, @@ -1748,8 +1733,8 @@ typing-extensions = [      {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"},  ]  urllib3 = [ -    {file = "urllib3-1.26.5-py2.py3-none-any.whl", hash = "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c"}, -    {file = "urllib3-1.26.5.tar.gz", hash = "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098"}, +    {file = "urllib3-1.26.6-py2.py3-none-any.whl", hash = "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4"}, +    {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"},  ]  virtualenv = [      {file = "virtualenv-20.4.7-py2.py3-none-any.whl", hash = "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76"}, diff --git a/pyproject.toml b/pyproject.toml index 40de23487..8368f80eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ beautifulsoup4 = "~=4.9"  colorama = { version = "~=0.4.3", markers = "sys_platform == 'win32'" }  coloredlogs = "~=14.0"  deepdiff = "~=4.0" -"discord.py" = "~=1.6.0" +"discord.py" = "~=1.7.3"  emoji = "~=0.6"  feedparser = "~=6.0.2"  fuzzywuzzy = "~=0.17" diff --git a/tests/bot/exts/filters/test_antimalware.py b/tests/bot/exts/filters/test_antimalware.py index 3393c6cdc..06d78de9d 100644 --- a/tests/bot/exts/filters/test_antimalware.py +++ b/tests/bot/exts/filters/test_antimalware.py @@ -104,24 +104,39 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase):          self.assertEqual(embed.description, antimalware.PY_EMBED_DESCRIPTION)      async def test_txt_file_redirect_embed_description(self): -        """A message containing a .txt file should result in the correct embed.""" -        attachment = MockAttachment(filename="python.txt") -        self.message.attachments = [attachment] -        self.message.channel.send = AsyncMock() -        antimalware.TXT_EMBED_DESCRIPTION = Mock() -        antimalware.TXT_EMBED_DESCRIPTION.format.return_value = "test" - -        await self.cog.on_message(self.message) -        self.message.channel.send.assert_called_once() -        args, kwargs = self.message.channel.send.call_args -        embed = kwargs.pop("embed") -        cmd_channel = self.bot.get_channel(Channels.bot_commands) +        """A message containing a .txt/.json/.csv file should result in the correct embed.""" +        test_values = ( +            ("text", ".txt"), +            ("json", ".json"), +            ("csv", ".csv"), +        ) -        self.assertEqual(embed.description, antimalware.TXT_EMBED_DESCRIPTION.format.return_value) -        antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with(cmd_channel_mention=cmd_channel.mention) +        for file_name, disallowed_extension in test_values: +            with self.subTest(file_name=file_name, disallowed_extension=disallowed_extension): + +                attachment = MockAttachment(filename=f"{file_name}{disallowed_extension}") +                self.message.attachments = [attachment] +                self.message.channel.send = AsyncMock() +                antimalware.TXT_EMBED_DESCRIPTION = Mock() +                antimalware.TXT_EMBED_DESCRIPTION.format.return_value = "test" + +                await self.cog.on_message(self.message) +                self.message.channel.send.assert_called_once() +                args, kwargs = self.message.channel.send.call_args +                embed = kwargs.pop("embed") +                cmd_channel = self.bot.get_channel(Channels.bot_commands) + +                self.assertEqual( +                    embed.description, +                    antimalware.TXT_EMBED_DESCRIPTION.format.return_value +                ) +                antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with( +                    blocked_extension=disallowed_extension, +                    cmd_channel_mention=cmd_channel.mention +                )      async def test_other_disallowed_extension_embed_description(self): -        """Test the description for a non .py/.txt disallowed extension.""" +        """Test the description for a non .py/.txt/.json/.csv disallowed extension."""          attachment = MockAttachment(filename="python.disallowed")          self.message.attachments = [attachment]          self.message.channel.send = AsyncMock() diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index ee9ff650c..50a717bb5 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -137,7 +137,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                      title=utils.INFRACTION_TITLE,                      description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(                          type="Ban", -                        expires="2020-02-26 09:20 (23 hours and 59 minutes)", +                        expires="2020-02-26 09:20 (23 hours and 59 minutes) UTC",                          reason="No reason provided."                      ),                      colour=Colours.soft_red, @@ -193,7 +193,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                      title=utils.INFRACTION_TITLE,                      description=utils.INFRACTION_DESCRIPTION_TEMPLATE.format(                          type="Mute", -                        expires="2020-02-26 09:20 (23 hours and 59 minutes)", +                        expires="2020-02-26 09:20 (23 hours and 59 minutes) UTC",                          reason="Test"                      ),                      colour=Colours.soft_red,  |