diff options
| author | 2020-09-21 14:36:26 +0800 | |
|---|---|---|
| committer | 2020-09-21 14:44:31 +0800 | |
| commit | c118751d0d95c498a0d17d9ff9ffbde04abd9317 (patch) | |
| tree | 62df17c55a4b0f23337ebfe254bc1922e2273076 | |
| parent | Categorise most of the uncategorised extensions (diff) | |
| parent | Merge pull request #1158 from python-discord/config-update (diff) | |
Merge branch 'master' into feat/backend/160/cog-subdirs
46 files changed, 1257 insertions, 811 deletions
| diff --git a/bot/__init__.py b/bot/__init__.py index d63086fe2..3ee70c4e9 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -2,10 +2,14 @@ import asyncio  import logging  import os  import sys +from functools import partial, partialmethod  from logging import Logger, handlers  from pathlib import Path  import coloredlogs +from discord.ext import commands + +from bot.command import Command  TRACE_LEVEL = logging.TRACE = 5  logging.addLevelName(TRACE_LEVEL, "TRACE") @@ -66,3 +70,9 @@ logging.getLogger(__name__)  # On Windows, the selector event loop is required for aiodns.  if os.name == "nt":      asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + +# Monkey-patch discord.py decorators to use the Command subclass which supports root aliases. +# Must be patched before any cogs are added. +commands.command = partial(commands.command, cls=Command) +commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=Command) diff --git a/bot/bot.py b/bot/bot.py index 756449293..d25074fd9 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -130,6 +130,26 @@ class Bot(commands.Bot):          super().add_cog(cog)          log.info(f"Cog loaded: {cog.qualified_name}") +    def add_command(self, command: commands.Command) -> None: +        """Add `command` as normal and then add its root aliases to the bot.""" +        super().add_command(command) +        self._add_root_aliases(command) + +    def remove_command(self, name: str) -> Optional[commands.Command]: +        """ +        Remove a command/alias as normal and then remove its root aliases from the bot. + +        Individual root aliases cannot be removed by this function. +        To remove them, either remove the entire command or manually edit `bot.all_commands`. +        """ +        command = super().remove_command(name) +        if command is None: +            # Even if it's a root alias, there's no way to get the Bot instance to remove the alias. +            return + +        self._remove_root_aliases(command) +        return command +      def clear(self) -> None:          """          Clears the internal state of the bot and recreates the connector and sessions. @@ -235,3 +255,24 @@ class Bot(commands.Bot):              scope.set_extra("kwargs", kwargs)              log.exception(f"Unhandled exception in {event}.") + +    def _add_root_aliases(self, command: commands.Command) -> None: +        """Recursively add root aliases for `command` and any of its subcommands.""" +        if isinstance(command, commands.Group): +            for subcommand in command.commands: +                self._add_root_aliases(subcommand) + +        for alias in getattr(command, "root_aliases", ()): +            if alias in self.all_commands: +                raise commands.CommandRegistrationError(alias, alias_conflict=True) + +            self.all_commands[alias] = command + +    def _remove_root_aliases(self, command: commands.Command) -> None: +        """Recursively remove root aliases for `command` and any of its subcommands.""" +        if isinstance(command, commands.Group): +            for subcommand in command.commands: +                self._remove_root_aliases(subcommand) + +        for alias in getattr(command, "root_aliases", ()): +            self.all_commands.pop(alias, None) diff --git a/bot/command.py b/bot/command.py new file mode 100644 index 000000000..0fb900f7b --- /dev/null +++ b/bot/command.py @@ -0,0 +1,18 @@ +from discord.ext import commands + + +class Command(commands.Command): +    """ +    A `discord.ext.commands.Command` subclass which supports root aliases. + +    A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as +    top-level commands rather than being aliases of the command's group. It's stored as an attribute +    also named `root_aliases`. +    """ + +    def __init__(self, *args, **kwargs): +        super().__init__(*args, **kwargs) +        self.root_aliases = kwargs.get("root_aliases", []) + +        if not isinstance(self.root_aliases, (list, tuple)): +            raise TypeError("Root aliases of a command must be a list or a tuple of strings.") diff --git a/bot/constants.py b/bot/constants.py index d01dcb0fc..17f14fec0 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -268,6 +268,17 @@ class Emojis(metaclass=YAMLGetter):      status_idle: str      status_dnd: str +    badge_staff: str +    badge_partner: str +    badge_hypesquad: str +    badge_bug_hunter: str +    badge_hypesquad_bravery: str +    badge_hypesquad_brilliance: str +    badge_hypesquad_balance: str +    badge_early_supporter: str +    badge_bug_hunter_level_2: str +    badge_verified_bot_developer: str +      incident_actioned: str      incident_unactioned: str      incident_investigating: str @@ -450,6 +461,7 @@ class Roles(metaclass=YAMLGetter):      partners: int      python_community: int      team_leaders: int +    unverified: int      verified: int  # This is the Developers role on PyDis, here named verified for readability reasons. @@ -457,6 +469,7 @@ class Guild(metaclass=YAMLGetter):      section = "guild"      id: int +    invite: str  # Discord invite, gets embedded in chat      moderation_channels: List[int]      moderation_roles: List[int]      modlog_blacklist: List[int] @@ -503,14 +516,6 @@ class Reddit(metaclass=YAMLGetter):      secret: Optional[str] -class Wolfram(metaclass=YAMLGetter): -    section = "wolfram" - -    user_limit_day: int -    guild_limit_day: int -    key: Optional[str] - -  class AntiSpam(metaclass=YAMLGetter):      section = 'anti_spam' @@ -575,6 +580,16 @@ class PythonNews(metaclass=YAMLGetter):      webhook: int +class Verification(metaclass=YAMLGetter): +    section = "verification" + +    unverified_after: int +    kicked_after: int +    reminder_frequency: int +    bot_message_delete_delay: int +    kick_confirmation_threshold: float + +  class Event(Enum):      """      Event names. This does not include every event (for example, raw diff --git a/bot/exts/backend/alias.py b/bot/exts/backend/alias.py index 77867b933..c6ba8d6f3 100644 --- a/bot/exts/backend/alias.py +++ b/bot/exts/backend/alias.py @@ -3,13 +3,12 @@ import logging  from discord import Colour, Embed  from discord.ext.commands import ( -    Cog, Command, Context, Greedy, +    Cog, Command, Context,      clean_content, command, group,  )  from bot.bot import Bot -from bot.converters import FetchedMember, TagNameConverter -from bot.exts.utils.extensions import Extension +from bot.converters import TagNameConverter  from bot.pagination import LinePaginator  log = logging.getLogger(__name__) @@ -51,56 +50,6 @@ class Alias (Cog):              ctx, embed, empty=False, max_lines=20          ) -    @command(name="resources", aliases=("resource",), hidden=True) -    async def site_resources_alias(self, ctx: Context) -> None: -        """Alias for invoking <prefix>site resources.""" -        await self.invoke(ctx, "site resources") - -    @command(name="tools", hidden=True) -    async def site_tools_alias(self, ctx: Context) -> None: -        """Alias for invoking <prefix>site tools.""" -        await self.invoke(ctx, "site tools") - -    @command(name="watch", hidden=True) -    async def bigbrother_watch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: -        """Alias for invoking <prefix>bigbrother watch [user] [reason].""" -        await self.invoke(ctx, "bigbrother watch", user, reason=reason) - -    @command(name="unwatch", hidden=True) -    async def bigbrother_unwatch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: -        """Alias for invoking <prefix>bigbrother unwatch [user] [reason].""" -        await self.invoke(ctx, "bigbrother unwatch", user, reason=reason) - -    @command(name="home", hidden=True) -    async def site_home_alias(self, ctx: Context) -> None: -        """Alias for invoking <prefix>site home.""" -        await self.invoke(ctx, "site home") - -    @command(name="faq", hidden=True) -    async def site_faq_alias(self, ctx: Context) -> None: -        """Alias for invoking <prefix>site faq.""" -        await self.invoke(ctx, "site faq") - -    @command(name="rules", aliases=("rule",), hidden=True) -    async def site_rules_alias(self, ctx: Context, rules: Greedy[int], *_: str) -> None: -        """Alias for invoking <prefix>site rules.""" -        await self.invoke(ctx, "site rules", *rules) - -    @command(name="reload", hidden=True) -    async def extensions_reload_alias(self, ctx: Context, *extensions: Extension) -> None: -        """Alias for invoking <prefix>extensions reload [extensions...].""" -        await self.invoke(ctx, "extensions reload", *extensions) - -    @command(name="defon", hidden=True) -    async def defcon_enable_alias(self, ctx: Context) -> None: -        """Alias for invoking <prefix>defcon enable.""" -        await self.invoke(ctx, "defcon enable") - -    @command(name="defoff", hidden=True) -    async def defcon_disable_alias(self, ctx: Context) -> None: -        """Alias for invoking <prefix>defcon disable.""" -        await self.invoke(ctx, "defcon disable") -      @command(name="exception", hidden=True)      async def tags_get_traceback_alias(self, ctx: Context) -> None:          """Alias for invoking <prefix>tags get traceback.""" @@ -132,21 +81,6 @@ class Alias (Cog):          """Alias for invoking <prefix>docs get [symbol]."""          await self.invoke(ctx, "docs get", symbol) -    @command(name="nominate", hidden=True) -    async def nomination_add_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: -        """Alias for invoking <prefix>talentpool add [user] [reason].""" -        await self.invoke(ctx, "talentpool add", user, reason=reason) - -    @command(name="unnominate", hidden=True) -    async def nomination_end_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: -        """Alias for invoking <prefix>nomination end [user] [reason].""" -        await self.invoke(ctx, "nomination end", user, reason=reason) - -    @command(name="nominees", hidden=True) -    async def nominees_alias(self, ctx: Context) -> None: -        """Alias for invoking <prefix>tp watched.""" -        await self.invoke(ctx, "talentpool watched") -  def setup(bot: Bot) -> None:      """Load the Alias cog.""" diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index c76bd2c60..7894ec48f 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/antimalware.py @@ -55,6 +55,10 @@ class AntiMalware(Cog):          if not message.attachments or not message.guild:              return +        # Ignore webhook and bot messages +        if message.webhook_id or message.author.bot: +            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 STAFF_ROLES for role in message.author.roles): diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 3c5f13ebf..2e7e32d9a 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -27,14 +27,18 @@ log = logging.getLogger(__name__)  RULE_FUNCTION_MAPPING = {      'attachments': rules.apply_attachments,      'burst': rules.apply_burst, -    'burst_shared': rules.apply_burst_shared, +    # burst shared is temporarily disabled due to a bug +    # 'burst_shared': rules.apply_burst_shared,      'chars': rules.apply_chars,      'discord_emojis': rules.apply_discord_emojis,      'duplicates': rules.apply_duplicates,      'links': rules.apply_links,      'mentions': rules.apply_mentions,      'newlines': rules.apply_newlines, -    'role_mentions': rules.apply_role_mentions +    'role_mentions': rules.apply_role_mentions, +    # the everyone filter is temporarily disabled until +    # it has been improved. +    # 'everyone_ping': rules.apply_everyone_ping,  } @@ -219,7 +223,6 @@ class AntiSpam(Cog):              # Get context and make sure the bot becomes the actor of infraction by patching the `author` attributes              context = await self.bot.get_context(msg)              context.author = self.bot.user -            context.message.author = self.bot.user              # Since we're going to invoke the tempmute command directly, we need to manually call the converter.              dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S") diff --git a/bot/exts/filters/filtering.py b/bot/exts/filters/filtering.py index 2ae476d8a..2751ed7f6 100644 --- a/bot/exts/filters/filtering.py +++ b/bot/exts/filters/filtering.py @@ -11,6 +11,7 @@ from discord import Colour, HTTPException, Member, Message, NotFound, TextChanne  from discord.ext.commands import Cog  from discord.utils import escape_markdown +from bot.api import ResponseCodeError  from bot.bot import Bot  from bot.constants import (      Channels, Colours, @@ -301,9 +302,16 @@ class Filtering(Cog):                                  'delete_date': delete_date                              } -                            await self.bot.api_client.post('bot/offensive-messages', json=data) -                            self.schedule_msg_delete(data) -                            log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") +                            try: +                                await self.bot.api_client.post('bot/offensive-messages', json=data) +                            except ResponseCodeError as e: +                                if e.status == 400 and "already exists" in e.response_json.get("id", [""])[0]: +                                    log.debug(f"Offensive message {msg.id} already exists.") +                                else: +                                    log.error(f"Offensive message {msg.id} failed to post: {e}") +                            else: +                                self.schedule_msg_delete(data) +                                log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}")                          if is_private:                              channel_str = "via DM" diff --git a/bot/exts/help_channels.py b/bot/exts/help_channels.py index 57094751e..0f9cac89e 100644 --- a/bot/exts/help_channels.py +++ b/bot/exts/help_channels.py @@ -36,7 +36,7 @@ the **Help: Dormant** category.  Try to write the best question you can by providing a detailed description and telling us what \  you've tried already. For more information on asking a good question, \ -check out our guide on [asking good questions]({ASKING_GUIDE_URL}). +check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**.  """  DORMANT_MSG = f""" @@ -47,7 +47,7 @@ channel until it becomes available again.  If your question wasn't answered yet, you can claim a new help channel from the \  **Help: Available** category by simply asking your question again. Consider rephrasing the \  question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for [asking a good question]({ASKING_GUIDE_URL}). +through our guide for **[asking a good question]({ASKING_GUIDE_URL})**.  """  CoroutineFunc = t.Callable[..., t.Coroutine] diff --git a/bot/exts/info/doc.py b/bot/exts/info/doc.py index 204cffb37..30c793c75 100644 --- a/bot/exts/info/doc.py +++ b/bot/exts/info/doc.py @@ -23,6 +23,7 @@ from bot.constants import MODERATION_ROLES, RedirectOutput  from bot.converters import ValidPythonIdentifier, ValidURL  from bot.decorators import with_role  from bot.pagination import LinePaginator +from bot.utils.messages import wait_for_deletion  log = logging.getLogger(__name__) @@ -391,7 +392,8 @@ class Doc(commands.Cog):                      await error_message.delete(delay=NOT_FOUND_DELETE_DELAY)                      await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY)              else: -                await ctx.send(embed=doc_embed) +                msg = await ctx.send(embed=doc_embed) +                await wait_for_deletion(msg, (ctx.author.id,), client=self.bot)      @docs_group.command(name='set', aliases=('s',))      @with_role(*MODERATION_ROLES) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 3d1d6fd10..99d503f5c 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -1,50 +1,28 @@  import itertools  import logging -from asyncio import TimeoutError  from collections import namedtuple  from contextlib import suppress  from typing import List, Union -from discord import Colour, Embed, Member, Message, NotFound, Reaction, User +from discord import Colour, Embed  from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand  from fuzzywuzzy import fuzz, process  from fuzzywuzzy.utils import full_process  from bot import constants -from bot.constants import Channels, Emojis, STAFF_ROLES +from bot.constants import Channels, STAFF_ROLES  from bot.decorators import redirect_output  from bot.pagination import LinePaginator +from bot.utils.messages import wait_for_deletion  log = logging.getLogger(__name__)  COMMANDS_PER_PAGE = 8 -DELETE_EMOJI = Emojis.trashcan  PREFIX = constants.Bot.prefix  Category = namedtuple("Category", ["name", "description", "cogs"]) -async def help_cleanup(bot: Bot, author: Member, message: Message) -> None: -    """ -    Runs the cleanup for the help command. - -    Adds the :trashcan: reaction that, when clicked, will delete the help message. -    After a 300 second timeout, the reaction will be removed. -    """ -    def check(reaction: Reaction, user: User) -> bool: -        """Checks the reaction is :trashcan:, the author is original author and messages are the same.""" -        return str(reaction) == DELETE_EMOJI and user.id == author.id and reaction.message.id == message.id - -    await message.add_reaction(DELETE_EMOJI) - -    with suppress(NotFound): -        try: -            await bot.wait_for("reaction_add", check=check, timeout=300) -            await message.delete() -        except TimeoutError: -            await message.remove_reaction(DELETE_EMOJI, bot.user) - -  class HelpQueryNotFound(ValueError):      """      Raised when a HelpSession Query doesn't match a command or cog. @@ -189,7 +167,9 @@ class CustomHelpCommand(HelpCommand):          command_details = f"**```{PREFIX}{name} {command.signature}```**\n"          # show command aliases -        aliases = ", ".join(f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases) +        aliases = [f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases] +        aliases += [f"`{alias}`" for alias in getattr(command, "root_aliases", ())] +        aliases = ", ".join(sorted(aliases))          if aliases:              command_details += f"**Can also use:** {aliases}\n\n" @@ -206,7 +186,7 @@ class CustomHelpCommand(HelpCommand):          """Send help for a single command."""          embed = await self.command_formatting(command)          message = await self.context.send(embed=embed) -        await help_cleanup(self.context.bot, self.context.author, message) +        await wait_for_deletion(message, (self.context.author.id,), self.context.bot)      @staticmethod      def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]: @@ -245,7 +225,7 @@ class CustomHelpCommand(HelpCommand):              embed.description += f"\n**Subcommands:**\n{command_details}"          message = await self.context.send(embed=embed) -        await help_cleanup(self.context.bot, self.context.author, message) +        await wait_for_deletion(message, (self.context.author.id,), self.context.bot)      async def send_cog_help(self, cog: Cog) -> None:          """Send help for a cog.""" @@ -261,7 +241,7 @@ class CustomHelpCommand(HelpCommand):              embed.description += f"\n\n**Commands:**\n{command_details}"          message = await self.context.send(embed=embed) -        await help_cleanup(self.context.bot, self.context.author, message) +        await wait_for_deletion(message, (self.context.author.id,), self.context.bot)      @staticmethod      def _category_key(command: Command) -> str: diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 8982196d1..55ecb2836 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -4,9 +4,9 @@ import pprint  import textwrap  from collections import Counter, defaultdict  from string import Template -from typing import Any, Mapping, Optional, Union +from typing import Any, Mapping, Optional, Tuple, Union -from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils +from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status, utils  from discord.abc import GuildChannel  from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group  from discord.utils import escape_markdown @@ -20,6 +20,12 @@ from bot.utils.time import time_since  log = logging.getLogger(__name__) +STATUS_EMOTES = { +    Status.offline: constants.Emojis.status_offline, +    Status.dnd: constants.Emojis.status_dnd, +    Status.idle: constants.Emojis.status_idle +} +  class Information(Cog):      """A cog with commands for generating embeds with server info, such as server stats and user info.""" @@ -211,53 +217,88 @@ class Information(Cog):          # Custom status          custom_status = ''          for activity in user.activities: -            # Check activity.state for None value if user has a custom status set -            # This guards against a custom status with an emoji but no text, which will cause -            # escape_markdown to raise an exception -            # This can be reworked after a move to d.py 1.3.0+, which adds a CustomActivity class -            if activity.name == 'Custom Status' and activity.state: -                state = escape_markdown(activity.state) -                custom_status = f'Status: {state}\n' +            if isinstance(activity, CustomActivity): +                state = "" + +                if activity.name: +                    state = escape_markdown(activity.name) + +                emoji = "" +                if activity.emoji: +                    # If an emoji is unicode use the emoji, else write the emote like :abc: +                    if not activity.emoji.id: +                        emoji += activity.emoji.name + " " +                    else: +                        emoji += f"`:{activity.emoji.name}:` " + +                custom_status = f'Status: {emoji}{state}\n'          name = str(user)          if user.nick:              name = f"{user.nick} ({name})" +        badges = [] + +        for badge, is_set in user.public_flags: +            if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)): +                badges.append(emoji) +          joined = time_since(user.joined_at, max_units=3)          roles = ", ".join(role.mention for role in user.roles[1:]) -        description = [ -            textwrap.dedent(f""" -                **User Information** -                Created: {created} -                Profile: {user.mention} -                ID: {user.id} -                {custom_status} -                **Member Information** -                Joined: {joined} -                Roles: {roles or None} -            """).strip() +        desktop_status = STATUS_EMOTES.get(user.desktop_status, constants.Emojis.status_online) +        web_status = STATUS_EMOTES.get(user.web_status, constants.Emojis.status_online) +        mobile_status = STATUS_EMOTES.get(user.mobile_status, constants.Emojis.status_online) + +        fields = [ +            ( +                "User information", +                textwrap.dedent(f""" +                    Created: {created} +                    Profile: {user.mention} +                    ID: {user.id} +                    {custom_status} +                """).strip() +            ), +            ( +                "Member information", +                textwrap.dedent(f""" +                    Joined: {joined} +                    Roles: {roles or None} +                """).strip() +            ), +            ( +                "Status", +                textwrap.dedent(f""" +                    {desktop_status} Desktop +                    {web_status} Web +                    {mobile_status} Mobile +                """).strip() +            )          ]          # Show more verbose output in moderation channels for infractions and nominations          if ctx.channel.id in constants.MODERATION_CHANNELS: -            description.append(await self.expanded_user_infraction_counts(user)) -            description.append(await self.user_nomination_counts(user)) +            fields.append(await self.expanded_user_infraction_counts(user)) +            fields.append(await self.user_nomination_counts(user))          else: -            description.append(await self.basic_user_infraction_counts(user)) +            fields.append(await self.basic_user_infraction_counts(user))          # Let's build the embed now          embed = Embed(              title=name, -            description="\n\n".join(description) +            description=" ".join(badges)          ) +        for field_name, field_content in fields: +            embed.add_field(name=field_name, value=field_content, inline=False) +          embed.set_thumbnail(url=user.avatar_url_as(static_format="png"))          embed.colour = user.top_role.colour if roles else Colour.blurple()          return embed -    async def basic_user_infraction_counts(self, member: Member) -> str: +    async def basic_user_infraction_counts(self, member: Member) -> Tuple[str, str]:          """Gets the total and active infraction counts for the given `member`."""          infractions = await self.bot.api_client.get(              'bot/infractions', @@ -270,11 +311,11 @@ class Information(Cog):          total_infractions = len(infractions)          active_infractions = sum(infraction['active'] for infraction in infractions) -        infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}" +        infraction_output = f"Total: {total_infractions}\nActive: {active_infractions}" -        return infraction_output +        return "Infractions", infraction_output -    async def expanded_user_infraction_counts(self, member: Member) -> str: +    async def expanded_user_infraction_counts(self, member: Member) -> Tuple[str, str]:          """          Gets expanded infraction counts for the given `member`. @@ -288,9 +329,9 @@ class Information(Cog):              }          ) -        infraction_output = ["**Infractions**"] +        infraction_output = []          if not infractions: -            infraction_output.append("This user has never received an infraction.") +            infraction_output.append("No infractions")          else:              # Count infractions split by `type` and `active` status for this user              infraction_types = set() @@ -313,9 +354,9 @@ class Information(Cog):                  infraction_output.append(line) -        return "\n".join(infraction_output) +        return "Infractions", "\n".join(infraction_output) -    async def user_nomination_counts(self, member: Member) -> str: +    async def user_nomination_counts(self, member: Member) -> Tuple[str, str]:          """Gets the active and historical nomination counts for the given `member`."""          nominations = await self.bot.api_client.get(              'bot/nominations', @@ -324,21 +365,21 @@ class Information(Cog):              }          ) -        output = ["**Nominations**"] +        output = []          if not nominations: -            output.append("This user has never been nominated.") +            output.append("No nominations")          else:              count = len(nominations)              is_currently_nominated = any(nomination["active"] for nomination in nominations)              nomination_noun = "nomination" if count == 1 else "nominations"              if is_currently_nominated: -                output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).") +                output.append(f"This user is **currently** nominated\n({count} {nomination_noun} in total)")              else:                  output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.") -        return "\n".join(output) +        return "Nominations", "\n".join(output)      def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str:          """Format a mapping to be readable to a human.""" @@ -376,7 +417,7 @@ class Information(Cog):          return out.rstrip()      @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) -    @group(invoke_without_command=True) +    @group(invoke_without_command=True, enabled=False)      @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES)      async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None:          """Shows information about the raw API response.""" @@ -411,7 +452,7 @@ class Information(Cog):          for page in paginator.pages:              await ctx.send(page) -    @raw.command() +    @raw.command(enabled=False)      async def json(self, ctx: Context, message: Message) -> None:          """Shows information about the raw API response in a copy-pasteable Python format."""          await ctx.invoke(self.raw, message=message, json=True) diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py index d853ab2ea..5d9e2c20b 100644 --- a/bot/exts/info/reddit.py +++ b/bot/exts/info/reddit.py @@ -10,6 +10,7 @@ from aiohttp import BasicAuth, ClientError  from discord import Colour, Embed, TextChannel  from discord.ext.commands import Cog, Context, group  from discord.ext.tasks import loop +from discord.utils import escape_markdown  from bot.bot import Bot  from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks @@ -187,6 +188,8 @@ class Reddit(Cog):              author = data["author"]              title = textwrap.shorten(data["title"], width=64, placeholder="...") +            # Normal brackets interfere with Markdown. +            title = escape_markdown(title).replace("[", "⦋").replace("]", "⦌")              link = self.URL + data["permalink"]              embed.description += ( diff --git a/bot/exts/info/site.py b/bot/exts/info/site.py index ac29daa1d..2d3a3d9f3 100644 --- a/bot/exts/info/site.py +++ b/bot/exts/info/site.py @@ -23,7 +23,7 @@ class Site(Cog):          """Commands for getting info about our website."""          await ctx.send_help(ctx.command) -    @site_group.command(name="home", aliases=("about",)) +    @site_group.command(name="home", aliases=("about",), root_aliases=("home",))      async def site_main(self, ctx: Context) -> None:          """Info about the website itself."""          url = f"{URLs.site_schema}{URLs.site}/" @@ -40,7 +40,7 @@ class Site(Cog):          await ctx.send(embed=embed) -    @site_group.command(name="resources") +    @site_group.command(name="resources", root_aliases=("resources", "resource"))      async def site_resources(self, ctx: Context) -> None:          """Info about the site's Resources page."""          learning_url = f"{PAGES_URL}/resources" @@ -56,7 +56,7 @@ class Site(Cog):          await ctx.send(embed=embed) -    @site_group.command(name="tools") +    @site_group.command(name="tools", root_aliases=("tools",))      async def site_tools(self, ctx: Context) -> None:          """Info about the site's Tools page."""          tools_url = f"{PAGES_URL}/resources/tools" @@ -87,7 +87,7 @@ class Site(Cog):          await ctx.send(embed=embed) -    @site_group.command(name="faq") +    @site_group.command(name="faq", root_aliases=("faq",))      async def site_faq(self, ctx: Context) -> None:          """Info about the site's FAQ page."""          url = f"{PAGES_URL}/frequently-asked-questions" @@ -104,7 +104,7 @@ class Site(Cog):          await ctx.send(embed=embed) -    @site_group.command(aliases=['r', 'rule'], name='rules') +    @site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule"))      async def site_rules(self, ctx: Context, *rules: int) -> None:          """Provides a link to all rules or, if specified, displays specific rule(s)."""          rules_embed = Embed(title='Rules', color=Colour.blurple()) diff --git a/bot/exts/info/tags.py b/bot/exts/info/tags.py index 3d76c5c08..d01647312 100644 --- a/bot/exts/info/tags.py +++ b/bot/exts/info/tags.py @@ -236,7 +236,7 @@ class Tags(Cog):                  await wait_for_deletion(                      await ctx.send(embed=Embed.from_dict(tag['embed'])),                      [ctx.author.id], -                    client=self.bot +                    self.bot                  )              elif founds and len(tag_name) >= 3:                  await wait_for_deletion( @@ -247,7 +247,7 @@ class Tags(Cog):                          )                      ),                      [ctx.author.id], -                    client=self.bot +                    self.bot                  )          else: diff --git a/bot/exts/info/wolfram.py b/bot/exts/info/wolfram.py deleted file mode 100644 index e6cae3bb8..000000000 --- a/bot/exts/info/wolfram.py +++ /dev/null @@ -1,280 +0,0 @@ -import logging -from io import BytesIO -from typing import Callable, List, Optional, Tuple -from urllib import parse - -import discord -from dateutil.relativedelta import relativedelta -from discord import Embed -from discord.ext import commands -from discord.ext.commands import BucketType, Cog, Context, check, group - -from bot.bot import Bot -from bot.constants import Colours, STAFF_ROLES, Wolfram -from bot.pagination import ImagePaginator -from bot.utils.time import humanize_delta - -log = logging.getLogger(__name__) - -APPID = Wolfram.key -DEFAULT_OUTPUT_FORMAT = "JSON" -QUERY = "http://api.wolframalpha.com/v2/{request}?{data}" -WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1" - -MAX_PODS = 20 - -# Allows for 10 wolfram calls pr user pr day -usercd = commands.CooldownMapping.from_cooldown(Wolfram.user_limit_day, 60*60*24, BucketType.user) - -# Allows for max api requests / days in month per day for the entire guild (Temporary) -guildcd = commands.CooldownMapping.from_cooldown(Wolfram.guild_limit_day, 60*60*24, BucketType.guild) - - -async def send_embed( -        ctx: Context, -        message_txt: str, -        colour: int = Colours.soft_red, -        footer: str = None, -        img_url: str = None, -        f: discord.File = None -) -> None: -    """Generate & send a response embed with Wolfram as the author.""" -    embed = Embed(colour=colour) -    embed.description = message_txt -    embed.set_author(name="Wolfram Alpha", -                     icon_url=WOLF_IMAGE, -                     url="https://www.wolframalpha.com/") -    if footer: -        embed.set_footer(text=footer) - -    if img_url: -        embed.set_image(url=img_url) - -    await ctx.send(embed=embed, file=f) - - -def custom_cooldown(*ignore: List[int]) -> Callable: -    """ -    Implement per-user and per-guild cooldowns for requests to the Wolfram API. - -    A list of roles may be provided to ignore the per-user cooldown -    """ -    async def predicate(ctx: Context) -> bool: -        if ctx.invoked_with == 'help': -            # if the invoked command is help we don't want to increase the ratelimits since it's not actually -            # invoking the command/making a request, so instead just check if the user/guild are on cooldown. -            guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0  # if guild is on cooldown -            if not any(r.id in ignore for r in ctx.author.roles):  # check user bucket if user is not ignored -                return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0 -            return guild_cooldown - -        user_bucket = usercd.get_bucket(ctx.message) - -        if all(role.id not in ignore for role in ctx.author.roles): -            user_rate = user_bucket.update_rate_limit() - -            if user_rate: -                # Can't use api; cause: member limit -                delta = relativedelta(seconds=int(user_rate)) -                cooldown = humanize_delta(delta) -                message = ( -                    "You've used up your limit for Wolfram|Alpha requests.\n" -                    f"Cooldown: {cooldown}" -                ) -                await send_embed(ctx, message) -                return False - -        guild_bucket = guildcd.get_bucket(ctx.message) -        guild_rate = guild_bucket.update_rate_limit() - -        # Repr has a token attribute to read requests left -        log.debug(guild_bucket) - -        if guild_rate: -            # Can't use api; cause: guild limit -            message = ( -                "The max limit of requests for the server has been reached for today.\n" -                f"Cooldown: {int(guild_rate)}" -            ) -            await send_embed(ctx, message) -            return False - -        return True -    return check(predicate) - - -async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tuple]]: -    """Get the Wolfram API pod pages for the provided query.""" -    async with ctx.channel.typing(): -        url_str = parse.urlencode({ -            "input": query, -            "appid": APPID, -            "output": DEFAULT_OUTPUT_FORMAT, -            "format": "image,plaintext" -        }) -        request_url = QUERY.format(request="query", data=url_str) - -        async with bot.http_session.get(request_url) as response: -            json = await response.json(content_type='text/plain') - -        result = json["queryresult"] - -        if result["error"]: -            # API key not set up correctly -            if result["error"]["msg"] == "Invalid appid": -                message = "Wolfram API key is invalid or missing." -                log.warning( -                    "API key seems to be missing, or invalid when " -                    f"processing a wolfram request: {url_str}, Response: {json}" -                ) -                await send_embed(ctx, message) -                return - -            message = "Something went wrong internally with your request, please notify staff!" -            log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}") -            await send_embed(ctx, message) -            return - -        if not result["success"]: -            message = f"I couldn't find anything for {query}." -            await send_embed(ctx, message) -            return - -        if not result["numpods"]: -            message = "Could not find any results." -            await send_embed(ctx, message) -            return - -        pods = result["pods"] -        pages = [] -        for pod in pods[:MAX_PODS]: -            subs = pod.get("subpods") - -            for sub in subs: -                title = sub.get("title") or sub.get("plaintext") or sub.get("id", "") -                img = sub["img"]["src"] -                pages.append((title, img)) -        return pages - - -class Wolfram(Cog): -    """Commands for interacting with the Wolfram|Alpha API.""" - -    def __init__(self, bot: Bot): -        self.bot = bot - -    @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True) -    @custom_cooldown(*STAFF_ROLES) -    async def wolfram_command(self, ctx: Context, *, query: str) -> None: -        """Requests all answers on a single image, sends an image of all related pods.""" -        url_str = parse.urlencode({ -            "i": query, -            "appid": APPID, -        }) -        query = QUERY.format(request="simple", data=url_str) - -        # Give feedback that the bot is working. -        async with ctx.channel.typing(): -            async with self.bot.http_session.get(query) as response: -                status = response.status -                image_bytes = await response.read() - -            f = discord.File(BytesIO(image_bytes), filename="image.png") -            image_url = "attachment://image.png" - -            if status == 501: -                message = "Failed to get response" -                footer = "" -                color = Colours.soft_red -            elif status == 400: -                message = "No input found" -                footer = "" -                color = Colours.soft_red -            elif status == 403: -                message = "Wolfram API key is invalid or missing." -                footer = "" -                color = Colours.soft_red -            else: -                message = "" -                footer = "View original for a bigger picture." -                color = Colours.soft_orange - -            # Sends a "blank" embed if no request is received, unsure how to fix -            await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f) - -    @wolfram_command.command(name="page", aliases=("pa", "p")) -    @custom_cooldown(*STAFF_ROLES) -    async def wolfram_page_command(self, ctx: Context, *, query: str) -> None: -        """ -        Requests a drawn image of given query. - -        Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. -        """ -        pages = await get_pod_pages(ctx, self.bot, query) - -        if not pages: -            return - -        embed = Embed() -        embed.set_author(name="Wolfram Alpha", -                         icon_url=WOLF_IMAGE, -                         url="https://www.wolframalpha.com/") -        embed.colour = Colours.soft_orange - -        await ImagePaginator.paginate(pages, ctx, embed) - -    @wolfram_command.command(name="cut", aliases=("c",)) -    @custom_cooldown(*STAFF_ROLES) -    async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None: -        """ -        Requests a drawn image of given query. - -        Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. -        """ -        pages = await get_pod_pages(ctx, self.bot, query) - -        if not pages: -            return - -        if len(pages) >= 2: -            page = pages[1] -        else: -            page = pages[0] - -        await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1]) - -    @wolfram_command.command(name="short", aliases=("sh", "s")) -    @custom_cooldown(*STAFF_ROLES) -    async def wolfram_short_command(self, ctx: Context, *, query: str) -> None: -        """Requests an answer to a simple question.""" -        url_str = parse.urlencode({ -            "i": query, -            "appid": APPID, -        }) -        query = QUERY.format(request="result", data=url_str) - -        # Give feedback that the bot is working. -        async with ctx.channel.typing(): -            async with self.bot.http_session.get(query) as response: -                status = response.status -                response_text = await response.text() - -            if status == 501: -                message = "Failed to get response" -                color = Colours.soft_red -            elif status == 400: -                message = "No input found" -                color = Colours.soft_red -            elif response_text == "Error 1: Invalid appid": -                message = "Wolfram API key is invalid or missing." -                color = Colours.soft_red -            else: -                message = response_text -                color = Colours.soft_orange - -            await send_embed(ctx, message, color) - - -def setup(bot: Bot) -> None: -    """Load the Wolfram cog.""" -    bot.add_cog(Wolfram(bot)) diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index b75a4dcfe..6e4008777 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -9,7 +9,7 @@ from discord import Colour, Embed, Member  from discord.ext.commands import Cog, Context, group  from bot.bot import Bot -from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles +from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_ROLES, Roles  from bot.decorators import with_role  from bot.exts.moderation.modlog import ModLog @@ -119,7 +119,7 @@ class Defcon(Cog):                  )      @group(name='defcon', aliases=('dc',), invoke_without_command=True) -    @with_role(Roles.admins, Roles.owners) +    @with_role(*MODERATION_ROLES)      async def defcon_group(self, ctx: Context) -> None:          """Check the DEFCON status or run a subcommand."""          await ctx.send_help(ctx.command) @@ -162,8 +162,8 @@ class Defcon(Cog):              self.bot.stats.gauge("defcon.threshold", days) -    @defcon_group.command(name='enable', aliases=('on', 'e')) -    @with_role(Roles.admins, Roles.owners) +    @defcon_group.command(name='enable', aliases=('on', 'e'), root_aliases=("defon",)) +    @with_role(*MODERATION_ROLES)      async def enable_command(self, ctx: Context) -> None:          """          Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! @@ -175,8 +175,8 @@ class Defcon(Cog):          await self._defcon_action(ctx, days=0, action=Action.ENABLED)          await self.update_channel_topic() -    @defcon_group.command(name='disable', aliases=('off', 'd')) -    @with_role(Roles.admins, Roles.owners) +    @defcon_group.command(name='disable', aliases=('off', 'd'), root_aliases=("defoff",)) +    @with_role(*MODERATION_ROLES)      async def disable_command(self, ctx: Context) -> None:          """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!"""          self.enabled = False @@ -184,7 +184,7 @@ class Defcon(Cog):          await self.update_channel_topic()      @defcon_group.command(name='status', aliases=('s',)) -    @with_role(Roles.admins, Roles.owners) +    @with_role(*MODERATION_ROLES)      async def status_command(self, ctx: Context) -> None:          """Check the current status of DEFCON mode."""          embed = Embed( @@ -196,7 +196,7 @@ class Defcon(Cog):          await ctx.send(embed=embed)      @defcon_group.command(name='days') -    @with_role(Roles.admins, Roles.owners) +    @with_role(*MODERATION_ROLES)      async def days_command(self, ctx: Context, days: int) -> None:          """Set how old an account must be to join the server, in days, with DEFCON mode enabled."""          self.days = timedelta(days=days) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index da0babcfc..cf48ef2ac 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -161,6 +161,7 @@ class InfractionScheduler:                      self.schedule_expiration(infraction)              except discord.HTTPException as e:                  # Accordingly display that applying the infraction failed. +                # Don't use ctx.message.author; antispam only patches ctx.author.                  confirm_msg = ":x: failed to apply"                  expiry_msg = ""                  log_content = ctx.author.mention @@ -190,6 +191,7 @@ class InfractionScheduler:          await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.")          # Send a log message to the mod log. +        # Don't use ctx.message.author for the actor; antispam only patches ctx.author.          log.trace(f"Sending apply mod log for infraction #{id_}.")          await self.mod_log.send_log_message(              icon_url=icon, @@ -198,7 +200,7 @@ class InfractionScheduler:              thumbnail=user.avatar_url_as(static_format="png"),              text=textwrap.dedent(f"""                  Member: {user.mention} (`{user.id}`) -                Actor: {ctx.message.author}{dm_log_text}{expiry_log_text} +                Actor: {ctx.author}{dm_log_text}{expiry_log_text}                  Reason: {reason}              """),              content=log_content, @@ -242,7 +244,7 @@ class InfractionScheduler:          log_text = await self.deactivate_infraction(response[0], send_log=False)          log_text["Member"] = f"{user.mention}(`{user.id}`)" -        log_text["Actor"] = str(ctx.message.author) +        log_text["Actor"] = str(ctx.author)          log_content = None          id_ = response[0]['id']          footer = f"ID: {id_}" diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index fb55287b6..f21272102 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -70,7 +70,7 @@ async def post_infraction(      log.trace(f"Posting {infr_type} infraction for {user} to the API.")      payload = { -        "actor": ctx.message.author.id, +        "actor": ctx.author.id,  # Don't use ctx.message.author; antispam only patches ctx.author.          "hidden": hidden,          "reason": reason,          "type": infr_type, diff --git a/bot/exts/moderation/modlog.py b/bot/exts/moderation/modlog.py index c86f04b9d..b0d9b5b2b 100644 --- a/bot/exts/moderation/modlog.py +++ b/bot/exts/moderation/modlog.py @@ -120,6 +120,10 @@ class ModLog(Cog, name="ModLog"):              else:                  content = "@everyone" +        # Truncate content to 2000 characters and append an ellipsis. +        if content and len(content) > 2000: +            content = content[:2000 - 3] + "..." +          channel = self.bot.get_channel(channel_id)          log_message = await channel.send(              content=content, diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 0db3e800d..53fa0730b 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -1,19 +1,36 @@ +import asyncio  import logging +import typing as t  from contextlib import suppress +from datetime import datetime, timedelta -from discord import Colour, Forbidden, Message, NotFound, Object -from discord.ext.commands import Cog, Context, command +import discord +from discord.ext import tasks +from discord.ext.commands import Cog, Context, command, group +from discord.utils import snowflake_time  from bot import constants  from bot.bot import Bot -from bot.decorators import in_whitelist, without_role +from bot.decorators import in_whitelist, with_role, without_role  from bot.exts.moderation.modlog import ModLog  from bot.utils.checks import InWhitelistCheckFailure, without_role_check +from bot.utils.redis_cache import RedisCache  log = logging.getLogger(__name__) -WELCOME_MESSAGE = f""" -Hello! Welcome to the server, and thanks for verifying yourself! +# Sent via DMs once user joins the guild +ON_JOIN_MESSAGE = f""" +Hello! Welcome to Python Discord! + +As a new user, you have read-only access to a few select channels to give you a taste of what our server is like. + +In order to see the rest of the channels and to send messages, you first have to accept our rules. To do so, \ +please visit <#{constants.Channels.verification}>. Thank you! +""" + +# Sent via DMs once user verifies +VERIFIED_MESSAGE = f""" +Thanks for verifying yourself!  For your records, these are the documents you accepted: @@ -32,29 +49,471 @@ If you'd like to unsubscribe from the announcement notifications, simply send `!  <#{constants.Channels.bot_commands}>.  """ -BOT_MESSAGE_DELETE_DELAY = 10 +# Sent via DMs to users kicked for failing to verify +KICKED_MESSAGE = f""" +Hi! You have been automatically kicked from Python Discord as you have failed to accept our rules \ +within `{constants.Verification.kicked_after}` days. If this was an accident, please feel free to join us again! + +{constants.Guild.invite} +""" + +# Sent periodically in the verification channel +REMINDER_MESSAGE = f""" +<@&{constants.Roles.unverified}> + +Welcome to Python Discord! Please read the documents mentioned above and type `!accept` to gain permissions \ +to send messages in the community! + +You will be kicked if you don't verify within `{constants.Verification.kicked_after}` days. +""".strip() + +# An async function taking a Member param +Request = t.Callable[[discord.Member], t.Awaitable] + + +class StopExecution(Exception): +    """Signals that a task should halt immediately & alert admins.""" + +    def __init__(self, reason: discord.HTTPException) -> None: +        super().__init__() +        self.reason = reason + + +class Limit(t.NamedTuple): +    """Composition over config for throttling requests.""" + +    batch_size: int  # Amount of requests after which to pause +    sleep_secs: int  # Sleep this many seconds after each batch + + +def mention_role(role_id: int) -> discord.AllowedMentions: +    """Construct an allowed mentions instance that allows pinging `role_id`.""" +    return discord.AllowedMentions(roles=[discord.Object(role_id)]) + + +def is_verified(member: discord.Member) -> bool: +    """ +    Check whether `member` is considered verified. + +    Members are considered verified if they have at least 1 role other than +    the default role (@everyone) and the @Unverified role. +    """ +    unverified_roles = { +        member.guild.get_role(constants.Roles.unverified), +        member.guild.default_role, +    } +    return len(set(member.roles) - unverified_roles) > 0  class Verification(Cog): -    """User verification and role self-management.""" +    """ +    User verification and role management. + +    There are two internal tasks in this cog: + +    * `update_unverified_members` +        * Unverified members are given the @Unverified role after configured `unverified_after` days +        * Unverified members are kicked after configured `kicked_after` days +    * `ping_unverified` +        * Periodically ping the @Unverified role in the verification channel + +    Statistics are collected in the 'verification.' namespace. -    def __init__(self, bot: Bot): +    Moderators+ can use the `verification` command group to start or stop both internal +    tasks, if necessary. Settings are persisted in Redis across sessions. + +    Additionally, this cog offers the !accept, !subscribe and !unsubscribe commands, +    and keeps the verification channel clean by deleting messages. +    """ + +    # Persist task settings & last sent `REMINDER_MESSAGE` id +    # RedisCache[ +    #   "tasks_running": int (0 or 1), +    #   "last_reminder": int (discord.Message.id), +    # ] +    task_cache = RedisCache() + +    def __init__(self, bot: Bot) -> None: +        """Start internal tasks."""          self.bot = bot +        self.bot.loop.create_task(self._maybe_start_tasks()) + +    def cog_unload(self) -> None: +        """ +        Cancel internal tasks. + +        This is necessary, as tasks are not automatically cancelled on cog unload. +        """ +        self._stop_tasks(gracefully=False)      @property      def mod_log(self) -> ModLog:          """Get currently loaded ModLog cog instance."""          return self.bot.get_cog("ModLog") +    async def _maybe_start_tasks(self) -> None: +        """ +        Poll Redis to check whether internal tasks should start. + +        Redis must be interfaced with from an async function. +        """ +        log.trace("Checking whether background tasks should begin") +        setting: t.Optional[int] = await self.task_cache.get("tasks_running")  # This can be None if never set + +        if setting: +            log.trace("Background tasks will be started") +            self.update_unverified_members.start() +            self.ping_unverified.start() + +    def _stop_tasks(self, *, gracefully: bool) -> None: +        """ +        Stop the update users & ping @Unverified tasks. + +        If `gracefully` is True, the tasks will be able to finish their current iteration. +        Otherwise, they are cancelled immediately. +        """ +        log.info(f"Stopping internal tasks ({gracefully=})") +        if gracefully: +            self.update_unverified_members.stop() +            self.ping_unverified.stop() +        else: +            self.update_unverified_members.cancel() +            self.ping_unverified.cancel() + +    # region: automatically update unverified users + +    async def _verify_kick(self, n_members: int) -> bool: +        """ +        Determine whether `n_members` is a reasonable amount of members to kick. + +        First, `n_members` is checked against the size of the PyDis guild. If `n_members` are +        more than the configured `kick_confirmation_threshold` of the guild, the operation +        must be confirmed by staff in #core-dev. Otherwise, the operation is seen as safe. +        """ +        log.debug(f"Checking whether {n_members} members are safe to kick") + +        await self.bot.wait_until_guild_available()  # Ensure cache is populated before we grab the guild +        pydis = self.bot.get_guild(constants.Guild.id) + +        percentage = n_members / len(pydis.members) +        if percentage < constants.Verification.kick_confirmation_threshold: +            log.debug(f"Kicking {percentage:.2%} of the guild's population is seen as safe") +            return True + +        # Since `n_members` is a suspiciously large number, we will ask for confirmation +        log.debug("Amount of users is too large, requesting staff confirmation") + +        core_dev_channel = pydis.get_channel(constants.Channels.dev_core) +        core_dev_ping = f"<@&{constants.Roles.core_developers}>" + +        confirmation_msg = await core_dev_channel.send( +            f"{core_dev_ping} Verification determined that `{n_members}` members should be kicked as they haven't " +            f"verified in `{constants.Verification.kicked_after}` days. This is `{percentage:.2%}` of the guild's " +            f"population. Proceed?", +            allowed_mentions=mention_role(constants.Roles.core_developers), +        ) + +        options = (constants.Emojis.incident_actioned, constants.Emojis.incident_unactioned) +        for option in options: +            await confirmation_msg.add_reaction(option) + +        core_dev_ids = [member.id for member in pydis.get_role(constants.Roles.core_developers).members] + +        def check(reaction: discord.Reaction, user: discord.User) -> bool: +            """Check whether `reaction` is a valid reaction to `confirmation_msg`.""" +            return ( +                reaction.message.id == confirmation_msg.id  # Reacted to `confirmation_msg` +                and str(reaction.emoji) in options  # With one of `options` +                and user.id in core_dev_ids  # By a core developer +            ) + +        timeout = 60 * 5  # Seconds, i.e. 5 minutes +        try: +            choice, _ = await self.bot.wait_for("reaction_add", check=check, timeout=timeout) +        except asyncio.TimeoutError: +            log.debug("Staff prompt not answered, aborting operation") +            return False +        finally: +            with suppress(discord.HTTPException): +                await confirmation_msg.clear_reactions() + +        result = str(choice) == constants.Emojis.incident_actioned +        log.debug(f"Received answer: {choice}, result: {result}") + +        # Edit the prompt message to reflect the final choice +        if result is True: +            result_msg = f":ok_hand: {core_dev_ping} Request to kick `{n_members}` members was authorized!" +        else: +            result_msg = f":warning: {core_dev_ping} Request to kick `{n_members}` members was denied!" + +        with suppress(discord.HTTPException): +            await confirmation_msg.edit(content=result_msg) + +        return result + +    async def _alert_admins(self, exception: discord.HTTPException) -> None: +        """ +        Ping @Admins with information about `exception`. + +        This is used when a critical `exception` caused a verification task to abort. +        """ +        await self.bot.wait_until_guild_available() +        log.info(f"Sending admin alert regarding exception: {exception}") + +        admins_channel = self.bot.get_guild(constants.Guild.id).get_channel(constants.Channels.admins) +        ping = f"<@&{constants.Roles.admins}>" + +        await admins_channel.send( +            f"{ping} Aborted updating unverified users due to the following exception:\n" +            f"```{exception}```\n" +            f"Internal tasks will be stopped.", +            allowed_mentions=mention_role(constants.Roles.admins), +        ) + +    async def _send_requests(self, members: t.Collection[discord.Member], request: Request, limit: Limit) -> int: +        """ +        Pass `members` one by one to `request` handling Discord exceptions. + +        This coroutine serves as a generic `request` executor for kicking members and adding +        roles, as it allows us to define the error handling logic in one place only. + +        Any `request` has the ability to completely abort the execution by raising `StopExecution`. +        In such a case, the @Admins will be alerted of the reason attribute. + +        To avoid rate-limits, pass a `limit` configuring the batch size and the amount of seconds +        to sleep between batches. + +        Returns the amount of successful requests. Failed requests are logged at info level. +        """ +        log.info(f"Sending {len(members)} requests") +        n_success, bad_statuses = 0, set() + +        for progress, member in enumerate(members, start=1): +            if is_verified(member):  # Member could have verified in the meantime +                continue +            try: +                await request(member) +            except StopExecution as stop_execution: +                await self._alert_admins(stop_execution.reason) +                await self.task_cache.set("tasks_running", 0) +                self._stop_tasks(gracefully=True)  # Gracefully finish current iteration, then stop +                break +            except discord.HTTPException as http_exc: +                bad_statuses.add(http_exc.status) +            else: +                n_success += 1 + +            if progress % limit.batch_size == 0: +                log.trace(f"Processed {progress} requests, pausing for {limit.sleep_secs} seconds") +                await asyncio.sleep(limit.sleep_secs) + +        if bad_statuses: +            log.info(f"Failed to send {len(members) - n_success} requests due to following statuses: {bad_statuses}") + +        return n_success + +    async def _kick_members(self, members: t.Collection[discord.Member]) -> int: +        """ +        Kick `members` from the PyDis guild. + +        Due to strict ratelimits on sending messages (120 requests / 60 secs), we sleep for a second +        after each 2 requests to allow breathing room for other features. + +        Note that this is a potentially destructive operation. Returns the amount of successful requests. +        """ +        log.info(f"Kicking {len(members)} members (not verified after {constants.Verification.kicked_after} days)") + +        async def kick_request(member: discord.Member) -> None: +            """Send `KICKED_MESSAGE` to `member` and kick them from the guild.""" +            try: +                await member.send(KICKED_MESSAGE) +            except discord.Forbidden as exc_403: +                log.trace(f"DM dispatch failed on 403 error with code: {exc_403.code}") +                if exc_403.code != 50_007:  # 403 raised for any other reason than disabled DMs +                    raise StopExecution(reason=exc_403) +            await member.kick(reason=f"User has not verified in {constants.Verification.kicked_after} days") + +        n_kicked = await self._send_requests(members, kick_request, Limit(batch_size=2, sleep_secs=1)) +        self.bot.stats.incr("verification.kicked", count=n_kicked) + +        return n_kicked + +    async def _give_role(self, members: t.Collection[discord.Member], role: discord.Role) -> int: +        """ +        Give `role` to all `members`. + +        We pause for a second after batches of 25 requests to ensure ratelimits aren't exceeded. + +        Returns the amount of successful requests. +        """ +        log.info( +            f"Assigning {role} role to {len(members)} members (not verified " +            f"after {constants.Verification.unverified_after} days)" +        ) + +        async def role_request(member: discord.Member) -> None: +            """Add `role` to `member`.""" +            await member.add_roles(role, reason=f"Not verified after {constants.Verification.unverified_after} days") + +        return await self._send_requests(members, role_request, Limit(batch_size=25, sleep_secs=1)) + +    async def _check_members(self) -> t.Tuple[t.Set[discord.Member], t.Set[discord.Member]]: +        """ +        Check in on the verification status of PyDis members. + +        This coroutine finds two sets of users: +        * Not verified after configured `unverified_after` days, should be given the @Unverified role +        * Not verified after configured `kicked_after` days, should be kicked from the guild + +        These sets are always disjoint, i.e. share no common members. +        """ +        await self.bot.wait_until_guild_available()  # Ensure cache is ready +        pydis = self.bot.get_guild(constants.Guild.id) + +        unverified = pydis.get_role(constants.Roles.unverified) +        current_dt = datetime.utcnow()  # Discord timestamps are UTC + +        # Users to be given the @Unverified role, and those to be kicked, these should be entirely disjoint +        for_role, for_kick = set(), set() + +        log.debug("Checking verification status of guild members") +        for member in pydis.members: + +            # Skip verified members, bots, and members for which we do not know their join date, +            # this should be extremely rare but docs mention that it can happen +            if is_verified(member) or member.bot or member.joined_at is None: +                continue + +            # At this point, we know that `member` is an unverified user, and we will decide what +            # to do with them based on time passed since their join date +            since_join = current_dt - member.joined_at + +            if since_join > timedelta(days=constants.Verification.kicked_after): +                for_kick.add(member)  # User should be removed from the guild + +            elif ( +                since_join > timedelta(days=constants.Verification.unverified_after) +                and unverified not in member.roles +            ): +                for_role.add(member)  # User should be given the @Unverified role + +        log.debug(f"Found {len(for_role)} users for {unverified} role, {len(for_kick)} users to be kicked") +        return for_role, for_kick + +    @tasks.loop(minutes=30) +    async def update_unverified_members(self) -> None: +        """ +        Periodically call `_check_members` and update unverified members accordingly. + +        After each run, a summary will be sent to the modlog channel. If a suspiciously high +        amount of members to be kicked is found, the operation is guarded by `_verify_kick`. +        """ +        log.info("Updating unverified guild members") + +        await self.bot.wait_until_guild_available() +        unverified = self.bot.get_guild(constants.Guild.id).get_role(constants.Roles.unverified) + +        for_role, for_kick = await self._check_members() + +        if not for_role: +            role_report = f"Found no users to be assigned the {unverified.mention} role." +        else: +            n_roles = await self._give_role(for_role, unverified) +            role_report = f"Assigned {unverified.mention} role to `{n_roles}`/`{len(for_role)}` members." + +        if not for_kick: +            kick_report = "Found no users to be kicked." +        elif not await self._verify_kick(len(for_kick)): +            kick_report = f"Not authorized to kick `{len(for_kick)}` members." +        else: +            n_kicks = await self._kick_members(for_kick) +            kick_report = f"Kicked `{n_kicks}`/`{len(for_kick)}` members from the guild." + +        await self.mod_log.send_log_message( +            icon_url=self.bot.user.avatar_url, +            colour=discord.Colour.blurple(), +            title="Verification system", +            text=f"{kick_report}\n{role_report}", +        ) + +    # endregion +    # region: periodically ping @Unverified + +    @tasks.loop(hours=constants.Verification.reminder_frequency) +    async def ping_unverified(self) -> None: +        """ +        Delete latest `REMINDER_MESSAGE` and send it again. + +        This utilizes RedisCache to persist the latest reminder message id. +        """ +        await self.bot.wait_until_guild_available() +        verification = self.bot.get_guild(constants.Guild.id).get_channel(constants.Channels.verification) + +        last_reminder: t.Optional[int] = await self.task_cache.get("last_reminder") + +        if last_reminder is not None: +            log.trace(f"Found verification reminder message in cache, deleting: {last_reminder}") + +            with suppress(discord.HTTPException):  # If something goes wrong, just ignore it +                await self.bot.http.delete_message(verification.id, last_reminder) + +        log.trace("Sending verification reminder") +        new_reminder = await verification.send( +            REMINDER_MESSAGE, allowed_mentions=mention_role(constants.Roles.unverified), +        ) + +        await self.task_cache.set("last_reminder", new_reminder.id) + +    @ping_unverified.before_loop +    async def _before_first_ping(self) -> None: +        """ +        Sleep until `REMINDER_MESSAGE` should be sent again. + +        If latest reminder is not cached, exit instantly. Otherwise, wait wait until the +        configured `reminder_frequency` has passed. +        """ +        last_reminder: t.Optional[int] = await self.task_cache.get("last_reminder") + +        if last_reminder is None: +            log.trace("Latest verification reminder message not cached, task will not wait") +            return + +        # Convert cached message id into a timestamp +        time_since = datetime.utcnow() - snowflake_time(last_reminder) +        log.trace(f"Time since latest verification reminder: {time_since}") + +        to_sleep = timedelta(hours=constants.Verification.reminder_frequency) - time_since +        log.trace(f"Time to sleep until next ping: {to_sleep}") + +        # Delta can be negative if `reminder_frequency` has already passed +        secs = max(to_sleep.total_seconds(), 0) +        await asyncio.sleep(secs) + +    # endregion +    # region: listeners +      @Cog.listener() -    async def on_message(self, message: Message) -> None: +    async def on_member_join(self, member: discord.Member) -> None: +        """Attempt to send initial direct message to each new member.""" +        if member.guild.id != constants.Guild.id: +            return  # Only listen for PyDis events + +        log.trace(f"Sending on join message to new member: {member.id}") +        with suppress(discord.Forbidden): +            await member.send(ON_JOIN_MESSAGE) + +    @Cog.listener() +    async def on_message(self, message: discord.Message) -> None:          """Check new message event for messages to the checkpoint channel & process."""          if message.channel.id != constants.Channels.verification:              return  # Only listen for #checkpoint messages +        if message.content == REMINDER_MESSAGE: +            return  # Ignore bots own verification reminder +          if message.author.bot:              # They're a bot, delete their message after the delay. -            await message.delete(delay=BOT_MESSAGE_DELETE_DELAY) +            await message.delete(delay=constants.Verification.bot_message_delete_delay)              return          # if a user mentions a role or guild member @@ -74,7 +533,7 @@ class Verification(Cog):              # Send pretty mod log embed to mod-alerts              await self.mod_log.send_log_message(                  icon_url=constants.Icons.filtering, -                colour=Colour(constants.Colours.soft_red), +                colour=discord.Colour(constants.Colours.soft_red),                  title=f"User/Role mentioned in {message.channel.name}",                  text=embed_text,                  thumbnail=message.author.avatar_url_as(static_format="png"), @@ -103,23 +562,117 @@ class Verification(Cog):          )          log.trace(f"Deleting the message posted by {ctx.author}") -        with suppress(NotFound): +        with suppress(discord.NotFound):              await ctx.message.delete() +    # endregion +    # region: task management commands + +    @with_role(*constants.MODERATION_ROLES) +    @group(name="verification") +    async def verification_group(self, ctx: Context) -> None: +        """Manage internal verification tasks.""" +        if ctx.invoked_subcommand is None: +            await ctx.send_help(ctx.command) + +    @verification_group.command(name="status") +    async def status_cmd(self, ctx: Context) -> None: +        """Check whether verification tasks are running.""" +        log.trace("Checking status of verification tasks") + +        if self.update_unverified_members.is_running(): +            update_status = f"{constants.Emojis.incident_actioned} Member update task is running." +        else: +            update_status = f"{constants.Emojis.incident_unactioned} Member update task is **not** running." + +        mention = f"<@&{constants.Roles.unverified}>" +        if self.ping_unverified.is_running(): +            ping_status = f"{constants.Emojis.incident_actioned} Ping {mention} task is running." +        else: +            ping_status = f"{constants.Emojis.incident_unactioned} Ping {mention} task is **not** running." + +        embed = discord.Embed( +            title="Verification system", +            description=f"{update_status}\n{ping_status}", +            colour=discord.Colour.blurple(), +        ) +        await ctx.send(embed=embed) + +    @verification_group.command(name="start") +    async def start_cmd(self, ctx: Context) -> None: +        """Start verification tasks if they are not already running.""" +        log.info("Starting verification tasks") + +        if not self.update_unverified_members.is_running(): +            self.update_unverified_members.start() + +        if not self.ping_unverified.is_running(): +            self.ping_unverified.start() + +        await self.task_cache.set("tasks_running", 1) + +        colour = discord.Colour.blurple() +        await ctx.send(embed=discord.Embed(title="Verification system", description="Done. :ok_hand:", colour=colour)) + +    @verification_group.command(name="stop", aliases=["kill"]) +    async def stop_cmd(self, ctx: Context) -> None: +        """Stop verification tasks.""" +        log.info("Stopping verification tasks") + +        self._stop_tasks(gracefully=False) +        await self.task_cache.set("tasks_running", 0) + +        colour = discord.Colour.blurple() +        await ctx.send(embed=discord.Embed(title="Verification system", description="Tasks canceled.", colour=colour)) + +    # endregion +    # region: accept and subscribe commands + +    def _bump_verified_stats(self, verified_member: discord.Member) -> None: +        """ +        Increment verification stats for `verified_member`. + +        Each member falls into one of the three categories: +            * Verified within 24 hours after joining +            * Does not have @Unverified role yet +            * Does have @Unverified role + +        Stats for member kicking are handled separately. +        """ +        if verified_member.joined_at is None:  # Docs mention this can happen +            return + +        if (datetime.utcnow() - verified_member.joined_at) < timedelta(hours=24): +            category = "accepted_on_day_one" +        elif constants.Roles.unverified not in [role.id for role in verified_member.roles]: +            category = "accepted_before_unverified" +        else: +            category = "accepted_after_unverified" + +        log.trace(f"Bumping verification stats in category: {category}") +        self.bot.stats.incr(f"verification.{category}") +      @command(name='accept', aliases=('verify', 'verified', 'accepted'), hidden=True)      @without_role(constants.Roles.verified)      @in_whitelist(channels=(constants.Channels.verification,))      async def accept_command(self, ctx: Context, *_) -> None:  # We don't actually care about the args          """Accept our rules and gain access to the rest of the server."""          log.debug(f"{ctx.author} called !accept. Assigning the 'Developer' role.") -        await ctx.author.add_roles(Object(constants.Roles.verified), reason="Accepted the rules") +        await ctx.author.add_roles(discord.Object(constants.Roles.verified), reason="Accepted the rules") + +        self._bump_verified_stats(ctx.author)  # This checks for @Unverified so make sure it's not yet removed + +        if constants.Roles.unverified in [role.id for role in ctx.author.roles]: +            log.debug(f"Removing Unverified role from: {ctx.author}") +            await ctx.author.remove_roles(discord.Object(constants.Roles.unverified)) +          try: -            await ctx.author.send(WELCOME_MESSAGE) -        except Forbidden: +            await ctx.author.send(VERIFIED_MESSAGE) +        except discord.Forbidden:              log.info(f"Sending welcome message failed for {ctx.author}.")          finally:              log.trace(f"Deleting accept message by {ctx.author}.") -            with suppress(NotFound): +            with suppress(discord.NotFound):                  self.mod_log.ignore(constants.Event.message_delete, ctx.message.id)                  await ctx.message.delete() @@ -139,7 +692,7 @@ class Verification(Cog):              return          log.debug(f"{ctx.author} called !subscribe. Assigning the 'Announcements' role.") -        await ctx.author.add_roles(Object(constants.Roles.announcements), reason="Subscribed to announcements") +        await ctx.author.add_roles(discord.Object(constants.Roles.announcements), reason="Subscribed to announcements")          log.trace(f"Deleting the message posted by {ctx.author}.") @@ -163,7 +716,9 @@ class Verification(Cog):              return          log.debug(f"{ctx.author} called !unsubscribe. Removing the 'Announcements' role.") -        await ctx.author.remove_roles(Object(constants.Roles.announcements), reason="Unsubscribed from announcements") +        await ctx.author.remove_roles( +            discord.Object(constants.Roles.announcements), reason="Unsubscribed from announcements" +        )          log.trace(f"Deleting the message posted by {ctx.author}.") @@ -171,6 +726,9 @@ class Verification(Cog):              f"{ctx.author.mention} Unsubscribed from <#{constants.Channels.announcements}> notifications."          ) +    # endregion +    # region: miscellaneous +      # This cannot be static (must have a __func__ attribute).      async def cog_command_error(self, ctx: Context, error: Exception) -> None:          """Check for & ignore any InWhitelistCheckFailure.""" @@ -185,6 +743,8 @@ class Verification(Cog):          else:              return True +    # endregion +  def setup(bot: Bot) -> None:      """Load the Verification cog.""" diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index 013d3ee03..7118dee02 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -15,6 +15,8 @@ from discord.ext.commands import Cog, Context  from bot.api import ResponseCodeError  from bot.bot import Bot  from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons +from bot.exts.filters.token_remover import TokenRemover +from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE  from bot.exts.moderation.modlog import ModLog  from bot.pagination import LinePaginator  from bot.utils import CogABCMeta, messages @@ -226,14 +228,16 @@ class WatchChannel(metaclass=CogABCMeta):              await self.send_header(msg) -        cleaned_content = msg.clean_content - -        if cleaned_content: +        if TokenRemover.find_token_in_message(msg) or WEBHOOK_URL_RE.search(msg.content): +            cleaned_content = "Content is censored because it contains a bot or webhook token." +        elif cleaned_content := msg.clean_content:              # Put all non-media URLs in a code block to prevent embeds              media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")}              for url in URL_RE.findall(cleaned_content):                  if url not in media_urls:                      cleaned_content = cleaned_content.replace(url, f"`{url}`") + +        if cleaned_content:              await self.webhook_send(                  cleaned_content,                  username=msg.author.display_name, diff --git a/bot/exts/moderation/watchchannels/bigbrother.py b/bot/exts/moderation/watchchannels/bigbrother.py index bfba19820..d7127b5c4 100644 --- a/bot/exts/moderation/watchchannels/bigbrother.py +++ b/bot/exts/moderation/watchchannels/bigbrother.py @@ -59,7 +59,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):          """          await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) -    @bigbrother_group.command(name='watch', aliases=('w',)) +    @bigbrother_group.command(name='watch', aliases=('w',), root_aliases=('watch',))      @with_role(*MODERATION_ROLES)      async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:          """ @@ -70,7 +70,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):          """          await self.apply_watch(ctx, user, reason) -    @bigbrother_group.command(name='unwatch', aliases=('uw',)) +    @bigbrother_group.command(name='unwatch', aliases=('uw',), root_aliases=('unwatch',))      @with_role(*MODERATION_ROLES)      async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:          """Stop relaying messages by the given `user`.""" @@ -131,8 +131,8 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"):          active_watches = await self.bot.api_client.get(              self.api_endpoint,              params=ChainMap( +                {"user__id": str(user.id)},                  self.api_default_params, -                {"user__id": str(user.id)}              )          )          if active_watches: diff --git a/bot/exts/moderation/watchchannels/talentpool.py b/bot/exts/moderation/watchchannels/talentpool.py index f65f9d664..3724e94e6 100644 --- a/bot/exts/moderation/watchchannels/talentpool.py +++ b/bot/exts/moderation/watchchannels/talentpool.py @@ -1,8 +1,9 @@  import logging  import textwrap  from collections import ChainMap +from typing import Union -from discord import Color, Embed, Member +from discord import Color, Embed, Member, User  from discord.ext.commands import Cog, Context, group  from bot.api import ResponseCodeError @@ -36,7 +37,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):          """Highlights the activity of helper nominees by relaying their messages to the talent pool channel."""          await ctx.send_help(ctx.command) -    @nomination_group.command(name='watched', aliases=('all', 'list')) +    @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",))      @with_role(*MODERATION_ROLES)      async def watched_command(          self, ctx: Context, oldest_first: bool = False, update_cache: bool = True @@ -62,7 +63,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):          """          await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) -    @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) +    @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",))      @with_role(*STAFF_ROLES)      async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:          """ @@ -156,7 +157,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):              max_size=1000          ) -    @nomination_group.command(name='unwatch', aliases=('end', )) +    @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",))      @with_role(*MODERATION_ROLES)      async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None:          """ @@ -164,25 +165,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):          Providing a `reason` is required.          """ -        active_nomination = await self.bot.api_client.get( -            self.api_endpoint, -            params=ChainMap( -                self.api_default_params, -                {"user__id": str(user.id)} -            ) -        ) - -        if not active_nomination: +        if await self.unwatch(user.id, reason): +            await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") +        else:              await ctx.send(":x: The specified user does not have an active nomination") -            return - -        [nomination] = active_nomination -        await self.bot.api_client.patch( -            f"{self.api_endpoint}/{nomination['id']}", -            json={'end_reason': reason, 'active': False} -        ) -        await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") -        self._remove_user(user.id)      @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True)      @with_role(*MODERATION_ROLES) @@ -220,6 +206,36 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):          await ctx.send(f":white_check_mark: Updated the {field} of the nomination!") +    @Cog.listener() +    async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None: +        """Remove `user` from the talent pool after they are banned.""" +        await self.unwatch(user.id, "User was banned.") + +    async def unwatch(self, user_id: int, reason: str) -> bool: +        """End the active nomination of a user with the given reason and return True on success.""" +        active_nomination = await self.bot.api_client.get( +            self.api_endpoint, +            params=ChainMap( +                {"user__id": str(user_id)}, +                self.api_default_params, +            ) +        ) + +        if not active_nomination: +            log.debug(f"No active nominate exists for {user_id=}") +            return False + +        log.info(f"Ending nomination: {user_id=} {reason=}") + +        nomination = active_nomination[0] +        await self.bot.api_client.patch( +            f"{self.api_endpoint}/{nomination['id']}", +            json={'end_reason': reason, 'active': False} +        ) +        self._remove_user(user_id) + +        return True +      def _nomination_to_string(self, nomination_object: dict) -> str:          """Creates a string representation of a nomination."""          guild = self.bot.get_guild(Guild.id) diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py index 866fd2b68..66f340a99 100644 --- a/bot/exts/utils/bot.py +++ b/bot/exts/utils/bot.py @@ -11,6 +11,7 @@ from bot.bot import Bot  from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs  from bot.decorators import with_role  from bot.exts.filters.token_remover import TokenRemover +from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE  from bot.utils.messages import wait_for_deletion  log = logging.getLogger(__name__) @@ -240,6 +241,7 @@ class BotCog(Cog, name="Bot"):              and not msg.author.bot              and len(msg.content.splitlines()) > 3              and not TokenRemover.find_token_in_message(msg) +            and not WEBHOOK_URL_RE.search(msg.content)          )          if parse_codeblock:  # no token in the msg @@ -337,7 +339,7 @@ class BotCog(Cog, name="Bot"):                          self.codeblock_message_ids[msg.id] = bot_message.id                          self.bot.loop.create_task( -                            wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) +                            wait_for_deletion(bot_message, (msg.author.id,), self.bot)                          )                      else:                          return diff --git a/bot/exts/utils/eval.py b/bot/exts/utils/eval.py index eb8bfb1cf..23e5998d8 100644 --- a/bot/exts/utils/eval.py +++ b/bot/exts/utils/eval.py @@ -15,6 +15,7 @@ from bot.bot import Bot  from bot.constants import Roles  from bot.decorators import with_role  from bot.interpreter import Interpreter +from bot.utils import find_nth_occurrence, send_to_paste_service  log = logging.getLogger(__name__) @@ -171,6 +172,30 @@ async def func():  # (None,) -> Any              res = traceback.format_exc()          out, embed = self._format(code, res) +        out = out.rstrip("\n")  # Strip empty lines from output + +        # Truncate output to max 15 lines or 1500 characters +        newline_truncate_index = find_nth_occurrence(out, "\n", 15) + +        if newline_truncate_index is None or newline_truncate_index > 1500: +            truncate_index = 1500 +        else: +            truncate_index = newline_truncate_index + +        if len(out) > truncate_index: +            paste_link = await send_to_paste_service(self.bot.http_session, out, extension="py") +            if paste_link is not None: +                paste_text = f"full contents at {paste_link}" +            else: +                paste_text = "failed to upload contents to paste service." + +            await ctx.send( +                f"```py\n{out[:truncate_index]}\n```" +                f"... response truncated; {paste_text}", +                embed=embed +            ) +            return +          await ctx.send(f"```py\n{out}```", embed=embed)      @group(name='internal', aliases=('int',)) diff --git a/bot/exts/utils/extensions.py b/bot/exts/utils/extensions.py index 65b5c3630..123f356e8 100644 --- a/bot/exts/utils/extensions.py +++ b/bot/exts/utils/extensions.py @@ -119,7 +119,7 @@ class Extensions(commands.Cog):          await ctx.send(msg) -    @extensions_group.command(name="reload", aliases=("r",)) +    @extensions_group.command(name="reload", aliases=("r",), root_aliases=("reload",))      async def reload_command(self, ctx: Context, *extensions: Extension) -> None:          r"""          Reload extensions given their fully qualified or unqualified names. diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 670493bcf..08bce2153 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -12,10 +12,10 @@ from dateutil.relativedelta import relativedelta  from discord.ext.commands import Cog, Context, Greedy, group  from bot.bot import Bot -from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES +from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_ROLES  from bot.converters import Duration  from bot.pagination import LinePaginator -from bot.utils.checks import without_role_check +from bot.utils.checks import with_role_check, without_role_check  from bot.utils.messages import send_denial  from bot.utils.scheduling import Scheduler  from bot.utils.time import humanize_delta @@ -396,6 +396,8 @@ class Reminders(Cog):      async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None:          """Edits a reminder with the given payload, then sends a confirmation message.""" +        if not await self._can_modify(ctx, id_): +            return          reminder = await self._edit_reminder(id_, payload)          # Parse the reminder expiration back into a datetime @@ -413,6 +415,8 @@ class Reminders(Cog):      @remind_group.command("delete", aliases=("remove", "cancel"))      async def delete_reminder(self, ctx: Context, id_: int) -> None:          """Delete one of your active reminders.""" +        if not await self._can_modify(ctx, id_): +            return          await self._delete_reminder(id_)          await self._send_confirmation(              ctx, @@ -421,6 +425,24 @@ class Reminders(Cog):              delivery_dt=None,          ) +    async def _can_modify(self, ctx: Context, reminder_id: t.Union[str, int]) -> bool: +        """ +        Check whether the reminder can be modified by the ctx author. + +        The check passes when the user is an admin, or if they created the reminder. +        """ +        if with_role_check(ctx, Roles.admins): +            return True + +        api_response = await self.bot.api_client.get(f"bot/reminders/{reminder_id}") +        if not api_response["author"] == ctx.author.id: +            log.debug(f"{ctx.author} is not the reminder author and does not pass the check.") +            await send_denial(ctx, "You can't modify reminders of other users!") +            return False + +        log.debug(f"{ctx.author} is the reminder author and passes the check.") +        return True +  def setup(bot: Bot) -> None:      """Load the Reminders cog.""" diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index 52c8b6f88..03bf454ac 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -14,6 +14,7 @@ from discord.ext.commands import Cog, Context, command, guild_only  from bot.bot import Bot  from bot.constants import Categories, Channels, Roles, URLs  from bot.decorators import in_whitelist +from bot.utils import send_to_paste_service  from bot.utils.messages import wait_for_deletion  log = logging.getLogger(__name__) @@ -71,17 +72,7 @@ class Snekbox(Cog):          if len(output) > MAX_PASTE_LEN:              log.info("Full output is too long to upload")              return "too long to upload" - -        url = URLs.paste_service.format(key="documents") -        try: -            async with self.bot.http_session.post(url, data=output, raise_for_status=True) as resp: -                data = await resp.json() - -            if "key" in data: -                return URLs.paste_service.format(key=data["key"]) -        except Exception: -            # 400 (Bad Request) means there are too many characters -            log.exception("Failed to upload full output to paste service!") +        return await send_to_paste_service(self.bot.http_session, output, extension="txt")      @staticmethod      def prepare_input(code: str) -> str: @@ -220,9 +211,7 @@ class Snekbox(Cog):                  response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.")              else:                  response = await ctx.send(msg) -            self.bot.loop.create_task( -                wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) -            ) +            self.bot.loop.create_task(wait_for_deletion(response, (ctx.author.id,), ctx.bot))              log.info(f"{ctx.author}'s job had a return code of {results['returncode']}")          return response diff --git a/bot/pagination.py b/bot/pagination.py index bab98cacf..182b2fa76 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -374,167 +374,3 @@ class LinePaginator(Paginator):          log.debug("Ending pagination and clearing reactions.")          with suppress(discord.NotFound):              await message.clear_reactions() - - -class ImagePaginator(Paginator): -    """ -    Helper class that paginates images for embeds in messages. - -    Close resemblance to LinePaginator, except focuses on images over text. - -    Refer to ImagePaginator.paginate for documentation on how to use. -    """ - -    def __init__(self, prefix: str = "", suffix: str = ""): -        super().__init__(prefix, suffix) -        self._current_page = [prefix] -        self.images = [] -        self._pages = [] -        self._count = 0 - -    def add_line(self, line: str = '', *, empty: bool = False) -> None: -        """Adds a line to each page.""" -        if line: -            self._count = len(line) -        else: -            self._count = 0 -        self._current_page.append(line) -        self.close_page() - -    def add_image(self, image: str = None) -> None: -        """Adds an image to a page.""" -        self.images.append(image) - -    @classmethod -    async def paginate( -        cls, -        pages: t.List[t.Tuple[str, str]], -        ctx: Context, embed: discord.Embed, -        prefix: str = "", -        suffix: str = "", -        timeout: int = 300, -        exception_on_empty_embed: bool = False -    ) -> t.Optional[discord.Message]: -        """ -        Use a paginator and set of reactions to provide pagination over a set of title/image pairs. - -        The reactions are used to switch page, or to finish with pagination. - -        When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may -        be used to change page, or to remove pagination from the message. - -        Note: Pagination will be removed automatically if no reaction is added for five minutes (300 seconds). - -        Example: -        >>> embed = discord.Embed() -        >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) -        >>> await ImagePaginator.paginate(pages, ctx, embed) -        """ -        def check_event(reaction_: discord.Reaction, member: discord.Member) -> bool: -            """Checks each reaction added, if it matches our conditions pass the wait_for.""" -            return all(( -                # Reaction is on the same message sent -                reaction_.message.id == message.id, -                # The reaction is part of the navigation menu -                str(reaction_.emoji) in PAGINATION_EMOJI, -                # The reactor is not a bot -                not member.bot -            )) - -        paginator = cls(prefix=prefix, suffix=suffix) -        current_page = 0 - -        if not pages: -            if exception_on_empty_embed: -                log.exception("Pagination asked for empty image list") -                raise EmptyPaginatorEmbed("No images to paginate") - -            log.debug("No images to add to paginator, adding '(no images to display)' message") -            pages.append(("(no images to display)", "")) - -        for text, image_url in pages: -            paginator.add_line(text) -            paginator.add_image(image_url) - -        embed.description = paginator.pages[current_page] -        image = paginator.images[current_page] - -        if image: -            embed.set_image(url=image) - -        if len(paginator.pages) <= 1: -            return await ctx.send(embed=embed) - -        embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") -        message = await ctx.send(embed=embed) - -        for emoji in PAGINATION_EMOJI: -            await message.add_reaction(emoji) - -        while True: -            # Start waiting for reactions -            try: -                reaction, user = await ctx.bot.wait_for("reaction_add", timeout=timeout, check=check_event) -            except asyncio.TimeoutError: -                log.debug("Timed out waiting for a reaction") -                break  # We're done, no reactions for the last 5 minutes - -            # Deletes the users reaction -            await message.remove_reaction(reaction.emoji, user) - -            # Delete reaction press - [:trashcan:] -            if str(reaction.emoji) == DELETE_EMOJI: -                log.debug("Got delete reaction") -                return await message.delete() - -            # First reaction press - [:track_previous:] -            if reaction.emoji == FIRST_EMOJI: -                if current_page == 0: -                    log.debug("Got first page reaction, but we're on the first page - ignoring") -                    continue - -                current_page = 0 -                reaction_type = "first" - -            # Last reaction press - [:track_next:] -            if reaction.emoji == LAST_EMOJI: -                if current_page >= len(paginator.pages) - 1: -                    log.debug("Got last page reaction, but we're on the last page - ignoring") -                    continue - -                current_page = len(paginator.pages) - 1 -                reaction_type = "last" - -            # Previous reaction press - [:arrow_left: ] -            if reaction.emoji == LEFT_EMOJI: -                if current_page <= 0: -                    log.debug("Got previous page reaction, but we're on the first page - ignoring") -                    continue - -                current_page -= 1 -                reaction_type = "previous" - -            # Next reaction press - [:arrow_right:] -            if reaction.emoji == RIGHT_EMOJI: -                if current_page >= len(paginator.pages) - 1: -                    log.debug("Got next page reaction, but we're on the last page - ignoring") -                    continue - -                current_page += 1 -                reaction_type = "next" - -            # Magic happens here, after page and reaction_type is set -            embed.description = paginator.pages[current_page] - -            image = paginator.images[current_page] -            if image: -                embed.set_image(url=image) - -            embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") -            log.debug(f"Got {reaction_type} page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") - -            await message.edit(embed=embed) - -        log.debug("Ending pagination and clearing reactions.") -        with suppress(discord.NotFound): -            await message.clear_reactions() diff --git a/bot/resources/tags/ask.md b/bot/resources/tags/ask.md deleted file mode 100644 index e2c2a88f6..000000000 --- a/bot/resources/tags/ask.md +++ /dev/null @@ -1,9 +0,0 @@ -Asking good questions will yield a much higher chance of a quick response: - -• Don't ask to ask your question, just go ahead and tell us your problem.   -• Don't ask if anyone is knowledgeable in some area, filtering serves no purpose.   -• Try to solve the problem on your own first, we're not going to write code for you.   -• Show us the code you've tried and any errors or unexpected results it's giving.   -• Be patient while we're helping you. - -You can find a much more detailed explanation [on our website](https://pythondiscord.com/pages/asking-good-questions/). diff --git a/bot/rules/__init__.py b/bot/rules/__init__.py index a01ceae73..8a69cadee 100644 --- a/bot/rules/__init__.py +++ b/bot/rules/__init__.py @@ -10,3 +10,4 @@ from .links import apply as apply_links  from .mentions import apply as apply_mentions  from .newlines import apply as apply_newlines  from .role_mentions import apply as apply_role_mentions +from .everyone_ping import apply as apply_everyone_ping diff --git a/bot/rules/burst_shared.py b/bot/rules/burst_shared.py index bbe9271b3..0e66df69c 100644 --- a/bot/rules/burst_shared.py +++ b/bot/rules/burst_shared.py @@ -2,11 +2,20 @@ from typing import Dict, Iterable, List, Optional, Tuple  from discord import Member, Message +from bot.constants import Channels +  async def apply(      last_message: Message, recent_messages: List[Message], config: Dict[str, int]  ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: -    """Detects repeated messages sent by multiple users.""" +    """ +    Detects repeated messages sent by multiple users. + +    This filter never triggers in the verification channel. +    """ +    if last_message.channel.id == Channels.verification: +        return +      total_recent = len(recent_messages)      if total_recent > config['max']: diff --git a/bot/rules/discord_emojis.py b/bot/rules/discord_emojis.py index 5bab514f2..6e47f0197 100644 --- a/bot/rules/discord_emojis.py +++ b/bot/rules/discord_emojis.py @@ -5,6 +5,7 @@ from discord import Member, Message  DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>") +CODE_BLOCK_RE = re.compile(r"```.*?```", flags=re.DOTALL)  async def apply( @@ -17,8 +18,9 @@ async def apply(          if msg.author == last_message.author      ) +    # Get rid of code blocks in the message before searching for emojis.      total_emojis = sum( -        len(DISCORD_EMOJI_RE.findall(msg.content)) +        len(DISCORD_EMOJI_RE.findall(CODE_BLOCK_RE.sub("", msg.content)))          for msg in relevant_messages      ) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py new file mode 100644 index 000000000..89d9fe570 --- /dev/null +++ b/bot/rules/everyone_ping.py @@ -0,0 +1,41 @@ +import random +import re +from typing import Dict, Iterable, List, Optional, Tuple + +from discord import Embed, Member, Message + +from bot.constants import Colours, Guild, NEGATIVE_REPLIES + +# Generate regex for checking for pings: +guild_id = Guild.id +EVERYONE_RE_INLINE_CODE = re.compile(rf"^(?!`).*@everyone.*(?!`)$|^(?!`).*<@&{guild_id}>.*(?!`)$") +EVERYONE_RE_MULTILINE_CODE = re.compile(rf"^(?!```).*@everyone.*(?!```)$|^(?!```).*<@&{guild_id}>.*(?!```)$") + + +async def apply( +    last_message: Message, +    recent_messages: List[Message], +    config: Dict[str, int], +) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: +    """Detects if a user has sent an '@everyone' ping.""" +    relevant_messages = tuple(msg for msg in recent_messages if msg.author == last_message.author) + +    everyone_messages_count = 0 +    for msg in relevant_messages: +        num_everyone_pings_inline = len(re.findall(EVERYONE_RE_INLINE_CODE, msg.content)) +        num_everyone_pings_multiline = len(re.findall(EVERYONE_RE_MULTILINE_CODE, msg.content)) +        if num_everyone_pings_inline and num_everyone_pings_multiline: +            everyone_messages_count += 1 + +    if everyone_messages_count > config["max"]: +        # Send the channel an embed giving the user more info: +        embed_text = f"Please don't try to ping {last_message.guild.member_count:,} people." +        embed = Embed(title=random.choice(NEGATIVE_REPLIES), description=embed_text, colour=Colours.soft_red) +        await last_message.channel.send(embed=embed) + +        return ( +            "pinged the everyone role", +            (last_message.author,), +            relevant_messages, +        ) +    return None diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 5a6e1811b..3e93fcb06 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,18 +1,5 @@ -from abc import ABCMeta - -from discord.ext.commands import CogMeta - +from bot.utils.helpers import CogABCMeta, find_nth_occurrence, pad_base64  from bot.utils.redis_cache import RedisCache +from bot.utils.services import send_to_paste_service -__all__ = ['RedisCache', 'CogABCMeta'] - - -class CogABCMeta(CogMeta, ABCMeta): -    """Metaclass for ABCs meant to be implemented as Cogs.""" - -    pass - - -def pad_base64(data: str) -> str: -    """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" -    return data + "=" * (-len(data) % 4) +__all__ = ['RedisCache', 'CogABCMeta', 'find_nth_occurrence', 'pad_base64', 'send_to_paste_service'] diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py new file mode 100644 index 000000000..d9b60af07 --- /dev/null +++ b/bot/utils/helpers.py @@ -0,0 +1,23 @@ +from abc import ABCMeta +from typing import Optional + +from discord.ext.commands import CogMeta + + +class CogABCMeta(CogMeta, ABCMeta): +    """Metaclass for ABCs meant to be implemented as Cogs.""" + + +def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]: +    """Return index of `n`th occurrence of `substring` in `string`, or None if not found.""" +    index = 0 +    for _ in range(n): +        index = string.find(substring, index+1) +        if index == -1: +            return None +    return index + + +def pad_base64(data: str) -> str: +    """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" +    return data + "=" * (-len(data) % 4) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 670289941..aa8f17f75 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -19,25 +19,20 @@ log = logging.getLogger(__name__)  async def wait_for_deletion(      message: Message,      user_ids: Sequence[Snowflake], +    client: Client,      deletion_emojis: Sequence[str] = (Emojis.trashcan,),      timeout: float = 60 * 5,      attach_emojis: bool = True, -    client: Optional[Client] = None  ) -> None:      """      Wait for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message.      An `attach_emojis` bool may be specified to determine whether to attach the given -    `deletion_emojis` to the message in the given `context` - -    A `client` instance may be optionally specified, otherwise client will be taken from the -    guild of the message. +    `deletion_emojis` to the message in the given `context`.      """ -    if message.guild is None and client is None: +    if message.guild is None:          raise ValueError("Message must be sent on a guild") -    bot = client or message.guild.me -      if attach_emojis:          for emoji in deletion_emojis:              await message.add_reaction(emoji) @@ -51,7 +46,7 @@ async def wait_for_deletion(          )      with contextlib.suppress(asyncio.TimeoutError): -        await bot.wait_for('reaction_add', check=check, timeout=timeout) +        await client.wait_for('reaction_add', check=check, timeout=timeout)          await message.delete() diff --git a/bot/utils/services.py b/bot/utils/services.py new file mode 100644 index 000000000..087b9f969 --- /dev/null +++ b/bot/utils/services.py @@ -0,0 +1,54 @@ +import logging +from typing import Optional + +from aiohttp import ClientConnectorError, ClientSession + +from bot.constants import URLs + +log = logging.getLogger(__name__) + +FAILED_REQUEST_ATTEMPTS = 3 + + +async def send_to_paste_service(http_session: ClientSession, contents: str, *, extension: str = "") -> Optional[str]: +    """ +    Upload `contents` to the paste service. + +    `http_session` should be the current running ClientSession from aiohttp +    `extension` is added to the output URL + +    When an error occurs, `None` is returned, otherwise the generated URL with the suffix. +    """ +    extension = extension and f".{extension}" +    log.debug(f"Sending contents of size {len(contents.encode())} bytes to paste service.") +    paste_url = URLs.paste_service.format(key="documents") +    for attempt in range(1, FAILED_REQUEST_ATTEMPTS + 1): +        try: +            async with http_session.post(paste_url, data=contents) as response: +                response_json = await response.json() +        except ClientConnectorError: +            log.warning( +                f"Failed to connect to paste service at url {paste_url}, " +                f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." +            ) +            continue +        except Exception: +            log.exception( +                f"An unexpected error has occurred during handling of the request, " +                f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." +            ) +            continue + +        if "message" in response_json: +            log.warning( +                f"Paste service returned error {response_json['message']} with status code {response.status}, " +                f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." +            ) +            continue +        elif "key" in response_json: +            log.info(f"Successfully uploaded contents to paste service behind key {response_json['key']}.") +            return URLs.paste_service.format(key=response_json['key']) + extension +        log.warning( +            f"Got unexpected JSON response from paste service: {response_json}\n" +            f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." +        ) diff --git a/config-default.yml b/config-default.yml index e3ba9fb05..58651f548 100644 --- a/config-default.yml +++ b/config-default.yml @@ -38,6 +38,17 @@ style:          status_dnd:     "<:status_dnd:470326272082313216>"          status_offline: "<:status_offline:470326266537705472>" +        badge_staff: "<:discord_staff:743882896498098226>" +        badge_partner: "<:partner:748666453242413136>" +        badge_hypesquad: "<:hypesquad_events:743882896892362873>" +        badge_bug_hunter: "<:bug_hunter_lvl1:743882896372269137>" +        badge_hypesquad_bravery: "<:hypesquad_bravery:743882896745693335>" +        badge_hypesquad_brilliance: "<:hypesquad_brilliance:743882896938631248>" +        badge_hypesquad_balance: "<:hypesquad_balance:743882896460480625>" +        badge_early_supporter: "<:early_supporter:743882896909140058>" +        badge_bug_hunter_level_2: "<:bug_hunter_lvl2:743882896611344505>" +        badge_verified_bot_developer: "<:verified_bot_dev:743882897299210310>" +          incident_actioned:      "<:incident_actioned:719645530128646266>"          incident_unactioned:    "<:incident_unactioned:719645583245180960>"          incident_investigating: "<:incident_investigating:719645658671480924>" @@ -65,9 +76,10 @@ style:          ducky_maul:     &DUCKY_MAUL     640137724958867467          ducky_santa:    &DUCKY_SANTA    655360331002019870 -        upvotes:        "<:upvotes:638729835245731840>" -        comments:       "<:comments:638729835073765387>" -        user:           "<:user:638729835442602003>" +        # emotes used for #reddit +        upvotes:        "<:reddit_upvotes:755845219890757644>" +        comments:       "<:reddit_comments:755845255001014384>" +        user:           "<:reddit_users:755845303822974997>"      icons:          crown_blurple: "https://cdn.discordapp.com/emojis/469964153289965568.png" @@ -123,6 +135,7 @@ style:  guild:      id: 267624335836053506 +    invite: "https://discord.gg/python"      categories:          help_available:                     691405807388196926 @@ -225,8 +238,8 @@ guild:          partners:                               323426753857191936          python_community:   &PY_COMMUNITY_ROLE  458226413825294336 -        # This is the Developers role on PyDis, here named verified for readability reasons -        verified:                               352427296948486144 +        unverified:                             739794855945044069 +        verified:                               352427296948486144  # @Developers on PyDis          # Staff          admins:             &ADMINS_ROLE    267628507062992896 @@ -341,9 +354,13 @@ anti_spam:              interval: 10              max: 7 -        burst_shared: -            interval: 10 -            max: 20 +        # Burst shared it (temporarily) disabled to prevent +        # the bug that triggers multiple infractions/DMs per +        # user. It also tends to catch a lot of innocent users +        # now that we're so big. +        # burst_shared: +        #    interval: 10 +        #    max: 20          chars:              interval: 5 @@ -374,6 +391,12 @@ anti_spam:              interval: 10              max: 3 +        # The everyone ping filter is temporarily disabled +        # until we've fixed a couple of bugs. +        # everyone_ping: +        #    interval: 10 +        #    max: 0 +  reddit:      subreddits: @@ -382,13 +405,6 @@ reddit:      secret:    !ENV "REDDIT_SECRET" -wolfram: -    # Max requests per day. -    user_limit_day: 10 -    guild_limit_day: 67 -    key: !ENV "WOLFRAM_API_KEY" - -  big_brother:      log_delay: 15      header_message_limit: 15 @@ -475,5 +491,18 @@ python_news:      channel: *PYNEWS_CHANNEL      webhook: *PYNEWS_WEBHOOK + +verification: +    unverified_after: 3  # Days after which non-Developers receive the @Unverified role +    kicked_after: 30  # Days after which non-Developers get kicked from the guild +    reminder_frequency: 28  # Hours between @Unverified pings +    bot_message_delete_delay: 10  # Seconds before deleting bots response in #verification + +    # Number in range [0, 1] determining the percentage of unverified users that are safe +    # to be kicked from the guild in one batch, any larger amount will require staff confirmation, +    # set this to 0 to require explicit approval for batches of any size +    kick_confirmation_threshold: 0.01  # 1% + +  config:      required_keys: ['bot.token'] diff --git a/tests/bot/exts/filters/test_antimalware.py b/tests/bot/exts/filters/test_antimalware.py index 960894e5c..3393c6cdc 100644 --- a/tests/bot/exts/filters/test_antimalware.py +++ b/tests/bot/exts/filters/test_antimalware.py @@ -23,6 +23,8 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase):          }          self.cog = antimalware.AntiMalware(self.bot)          self.message = MockMessage() +        self.message.webhook_id = None +        self.message.author.bot = None          self.whitelist = [".first", ".second", ".third"]      async def test_message_with_allowed_attachment(self): @@ -48,6 +50,26 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase):          self.message.delete.assert_not_called() +    async def test_webhook_message_with_illegal_extension(self): +        """A webhook message containing an illegal extension should be ignored.""" +        attachment = MockAttachment(filename="python.disallowed") +        self.message.webhook_id = 697140105563078727 +        self.message.attachments = [attachment] + +        await self.cog.on_message(self.message) + +        self.message.delete.assert_not_called() + +    async def test_bot_message_with_illegal_extension(self): +        """A bot message containing an illegal extension should be ignored.""" +        attachment = MockAttachment(filename="python.disallowed") +        self.message.author.bot = 409107086526644234 +        self.message.attachments = [attachment] + +        await self.cog.on_message(self.message) + +        self.message.delete.assert_not_called() +      async def test_message_with_illegal_extension_gets_deleted(self):          """A message containing an illegal extension should send an embed."""          attachment = MockAttachment(filename="python.disallowed") diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index be47d42ef..ba8d5d608 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -215,10 +215,10 @@ class UserInfractionHelperMethodTests(unittest.TestCase):              with self.subTest(method=method, api_response=api_response, expected_lines=expected_lines):                  self.bot.api_client.get.return_value = api_response -                expected_output = "\n".join(default_header + expected_lines) +                expected_output = "\n".join(expected_lines)                  actual_output = asyncio.run(method(self.member)) -                self.assertEqual(expected_output, actual_output) +                self.assertEqual((default_header, expected_output), actual_output)      def test_basic_user_infraction_counts_returns_correct_strings(self):          """The method should correctly list both the total and active number of non-hidden infractions.""" @@ -249,7 +249,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase):              },          ) -        header = ["**Infractions**"] +        header = "Infractions"          self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) @@ -258,7 +258,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase):          test_values = (              {                  "api response": [], -                "expected_lines": ["This user has never received an infraction."], +                "expected_lines": ["No infractions"],              },              # Shows non-hidden inactive infraction as expected              { @@ -304,7 +304,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase):              },          ) -        header = ["**Infractions**"] +        header = "Infractions"          self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) @@ -313,15 +313,15 @@ class UserInfractionHelperMethodTests(unittest.TestCase):          test_values = (              {                  "api response": [], -                "expected_lines": ["This user has never been nominated."], +                "expected_lines": ["No nominations"],              },              {                  "api response": [{'active': True}], -                "expected_lines": ["This user is **currently** nominated (1 nomination in total)."], +                "expected_lines": ["This user is **currently** nominated", "(1 nomination in total)"],              },              {                  "api response": [{'active': True}, {'active': False}], -                "expected_lines": ["This user is **currently** nominated (2 nominations in total)."], +                "expected_lines": ["This user is **currently** nominated", "(2 nominations in total)"],              },              {                  "api response": [{'active': False}], @@ -334,7 +334,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase):          ) -        header = ["**Nominations**"] +        header = "Nominations"          self._method_subtests(self.cog.user_nomination_counts, test_values, header) @@ -350,7 +350,10 @@ class UserEmbedTests(unittest.TestCase):          self.bot.api_client.get = unittest.mock.AsyncMock()          self.cog = information.Information(self.bot) -    @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) +    @unittest.mock.patch( +        f"{COG_PATH}.basic_user_infraction_counts", +        new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) +    )      def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self):          """The embed should use the string representation of the user if they don't have a nick."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -362,7 +365,10 @@ class UserEmbedTests(unittest.TestCase):          self.assertEqual(embed.title, "Mr. Hemlock") -    @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) +    @unittest.mock.patch( +        f"{COG_PATH}.basic_user_infraction_counts", +        new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) +    )      def test_create_user_embed_uses_nick_in_title_if_available(self):          """The embed should use the nick if it's available."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -374,7 +380,10 @@ class UserEmbedTests(unittest.TestCase):          self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") -    @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) +    @unittest.mock.patch( +        f"{COG_PATH}.basic_user_infraction_counts", +        new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) +    )      def test_create_user_embed_ignores_everyone_role(self):          """Created `!user` embeds should not contain mention of the @everyone-role."""          ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -386,8 +395,8 @@ class UserEmbedTests(unittest.TestCase):          embed = asyncio.run(self.cog.create_user_embed(ctx, user)) -        self.assertIn("&Admins", embed.description) -        self.assertNotIn("&Everyone", embed.description) +        self.assertIn("&Admins", embed.fields[1].value) +        self.assertNotIn("&Everyone", embed.fields[1].value)      @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock)      @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) @@ -398,8 +407,8 @@ class UserEmbedTests(unittest.TestCase):          moderators_role = helpers.MockRole(name='Moderators')          moderators_role.colour = 100 -        infraction_counts.return_value = "expanded infractions info" -        nomination_counts.return_value = "nomination info" +        infraction_counts.return_value = ("Infractions", "expanded infractions info") +        nomination_counts.return_value = ("Nominations", "nomination info")          user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role)          embed = asyncio.run(self.cog.create_user_embed(ctx, user)) @@ -409,20 +418,19 @@ class UserEmbedTests(unittest.TestCase):          self.assertEqual(              textwrap.dedent(f""" -                **User Information**                  Created: {"1 year ago"}                  Profile: {user.mention}                  ID: {user.id} +            """).strip(), +            embed.fields[0].value +        ) -                **Member Information** +        self.assertEqual( +            textwrap.dedent(f"""                  Joined: {"1 year ago"}                  Roles: &Moderators - -                expanded infractions info - -                nomination info              """).strip(), -            embed.description +            embed.fields[1].value          )      @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) @@ -433,7 +441,7 @@ class UserEmbedTests(unittest.TestCase):          moderators_role = helpers.MockRole(name='Moderators')          moderators_role.colour = 100 -        infraction_counts.return_value = "basic infractions info" +        infraction_counts.return_value = ("Infractions", "basic infractions info")          user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role)          embed = asyncio.run(self.cog.create_user_embed(ctx, user)) @@ -442,21 +450,30 @@ class UserEmbedTests(unittest.TestCase):          self.assertEqual(              textwrap.dedent(f""" -                **User Information**                  Created: {"1 year ago"}                  Profile: {user.mention}                  ID: {user.id} +            """).strip(), +            embed.fields[0].value +        ) -                **Member Information** +        self.assertEqual( +            textwrap.dedent(f"""                  Joined: {"1 year ago"}                  Roles: &Moderators - -                basic infractions info              """).strip(), -            embed.description +            embed.fields[1].value +        ) + +        self.assertEqual( +            "basic infractions info", +            embed.fields[3].value          ) -    @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) +    @unittest.mock.patch( +        f"{COG_PATH}.basic_user_infraction_counts", +        new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) +    )      def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self):          """The embed should be created with the colour of the top role, if a top role is available."""          ctx = helpers.MockContext() @@ -469,7 +486,10 @@ class UserEmbedTests(unittest.TestCase):          self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) -    @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) +    @unittest.mock.patch( +        f"{COG_PATH}.basic_user_infraction_counts", +        new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) +    )      def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self):          """The embed should be created with a blurple colour if the user has no assigned roles."""          ctx = helpers.MockContext() @@ -479,7 +499,10 @@ class UserEmbedTests(unittest.TestCase):          self.assertEqual(embed.colour, discord.Colour.blurple()) -    @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) +    @unittest.mock.patch( +        f"{COG_PATH}.basic_user_infraction_counts", +        new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) +    )      def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self):          """The embed thumbnail should be set to the user's avatar in `png` format."""          ctx = helpers.MockContext() diff --git a/tests/bot/exts/test_cogs.py b/tests/bot/exts/test_cogs.py index 775c40722..f8e120262 100644 --- a/tests/bot/exts/test_cogs.py +++ b/tests/bot/exts/test_cogs.py @@ -54,6 +54,7 @@ class CommandNameTests(unittest.TestCase):          """Return a list of all qualified names, including aliases, for the `command`."""          names = [f"{command.full_parent_name} {alias}".strip() for alias in command.aliases]          names.append(command.qualified_name) +        names += getattr(command, "root_aliases", [])          return names diff --git a/tests/bot/exts/utils/test_snekbox.py b/tests/bot/exts/utils/test_snekbox.py index f7b861035..c272a4756 100644 --- a/tests/bot/exts/utils/test_snekbox.py +++ b/tests/bot/exts/utils/test_snekbox.py @@ -1,5 +1,4 @@  import asyncio -import logging  import unittest  from unittest.mock import AsyncMock, MagicMock, Mock, call, create_autospec, patch @@ -39,43 +38,14 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase):          result = await self.cog.upload_output("-" * (snekbox.MAX_PASTE_LEN + 1))          self.assertEqual(result, "too long to upload") -    async def test_upload_output(self): +    @patch("bot.exts.utils.snekbox.send_to_paste_service") +    async def test_upload_output(self, mock_paste_util):          """Upload the eval output to the URLs.paste_service.format(key="documents") endpoint.""" -        key = "MarkDiamond" -        resp = MagicMock() -        resp.json = AsyncMock(return_value={"key": key}) - -        context_manager = MagicMock() -        context_manager.__aenter__.return_value = resp -        self.bot.http_session.post.return_value = context_manager - -        self.assertEqual( -            await self.cog.upload_output("My awesome output"), -            constants.URLs.paste_service.format(key=key) -        ) -        self.bot.http_session.post.assert_called_with( -            constants.URLs.paste_service.format(key="documents"), -            data="My awesome output", -            raise_for_status=True +        await self.cog.upload_output("Test output.") +        mock_paste_util.assert_called_once_with( +            self.bot.http_session, "Test output.", extension="txt"          ) -    async def test_upload_output_gracefully_fallback_if_exception_during_request(self): -        """Output upload gracefully fallback if the upload fail.""" -        resp = MagicMock() -        resp.json = AsyncMock(side_effect=Exception) - -        context_manager = MagicMock() -        context_manager.__aenter__.return_value = resp -        self.bot.http_session.post.return_value = context_manager - -        log = logging.getLogger("bot.exts.utils.snekbox") -        with self.assertLogs(logger=log, level='ERROR'): -            await self.cog.upload_output('My awesome output!') - -    async def test_upload_output_gracefully_fallback_if_no_key_in_response(self): -        """Output upload gracefully fallback if there is no key entry in the response body.""" -        self.assertEqual((await self.cog.upload_output('My awesome output!')), None) -      def test_prepare_input(self):          cases = (              ('print("Hello world!")', 'print("Hello world!")', 'non-formatted'), diff --git a/tests/bot/test_pagination.py b/tests/bot/test_pagination.py index ce880d457..630f2516d 100644 --- a/tests/bot/test_pagination.py +++ b/tests/bot/test_pagination.py @@ -44,18 +44,3 @@ class LinePaginatorTests(TestCase):          self.paginator.add_line('x' * (self.paginator.scale_to_size + 1))          # Note: item at index 1 is the truncated line, index 0 is prefix          self.assertEqual(self.paginator._current_page[1], 'x' * self.paginator.scale_to_size) - - -class ImagePaginatorTests(TestCase): -    """Tests functionality of the `ImagePaginator`.""" - -    def setUp(self): -        """Create a paginator for the test method.""" -        self.paginator = pagination.ImagePaginator() - -    def test_add_image_appends_image(self): -        """`add_image` appends the image to the image list.""" -        image = 'lemon' -        self.paginator.add_image(image) - -        assert self.paginator.images == [image] diff --git a/tests/bot/utils/test_services.py b/tests/bot/utils/test_services.py new file mode 100644 index 000000000..5e0855704 --- /dev/null +++ b/tests/bot/utils/test_services.py @@ -0,0 +1,74 @@ +import logging +import unittest +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from aiohttp import ClientConnectorError + +from bot.utils.services import FAILED_REQUEST_ATTEMPTS, send_to_paste_service + + +class PasteTests(unittest.IsolatedAsyncioTestCase): +    def setUp(self) -> None: +        self.http_session = MagicMock() + +    @patch("bot.utils.services.URLs.paste_service", "https://paste_service.com/{key}") +    async def test_url_and_sent_contents(self): +        """Correct url was used and post was called with expected data.""" +        response = MagicMock( +            json=AsyncMock(return_value={"key": ""}) +        ) +        self.http_session.post().__aenter__.return_value = response +        self.http_session.post.reset_mock() +        await send_to_paste_service(self.http_session, "Content") +        self.http_session.post.assert_called_once_with("https://paste_service.com/documents", data="Content") + +    @patch("bot.utils.services.URLs.paste_service", "https://paste_service.com/{key}") +    async def test_paste_returns_correct_url_on_success(self): +        """Url with specified extension is returned on successful requests.""" +        key = "paste_key" +        test_cases = ( +            (f"https://paste_service.com/{key}.txt", "txt"), +            (f"https://paste_service.com/{key}.py", "py"), +            (f"https://paste_service.com/{key}", ""), +        ) +        response = MagicMock( +            json=AsyncMock(return_value={"key": key}) +        ) +        self.http_session.post().__aenter__.return_value = response + +        for expected_output, extension in test_cases: +            with self.subTest(msg=f"Send contents with extension {repr(extension)}"): +                self.assertEqual( +                    await send_to_paste_service(self.http_session, "", extension=extension), +                    expected_output +                ) + +    async def test_request_repeated_on_json_errors(self): +        """Json with error message and invalid json are handled as errors and requests repeated.""" +        test_cases = ({"message": "error"}, {"unexpected_key": None}, {}) +        self.http_session.post().__aenter__.return_value = response = MagicMock() +        self.http_session.post.reset_mock() + +        for error_json in test_cases: +            with self.subTest(error_json=error_json): +                response.json = AsyncMock(return_value=error_json) +                result = await send_to_paste_service(self.http_session, "") +                self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) +                self.assertIsNone(result) + +            self.http_session.post.reset_mock() + +    async def test_request_repeated_on_connection_errors(self): +        """Requests are repeated in the case of connection errors.""" +        self.http_session.post = MagicMock(side_effect=ClientConnectorError(Mock(), Mock())) +        result = await send_to_paste_service(self.http_session, "") +        self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) +        self.assertIsNone(result) + +    async def test_general_error_handled_and_request_repeated(self): +        """All `Exception`s are handled, logged and request repeated.""" +        self.http_session.post = MagicMock(side_effect=Exception) +        result = await send_to_paste_service(self.http_session, "") +        self.assertEqual(self.http_session.post.call_count, FAILED_REQUEST_ATTEMPTS) +        self.assertLogs("bot.utils", logging.ERROR) +        self.assertIsNone(result) | 
