diff options
Diffstat (limited to '')
| -rw-r--r-- | .github/CODEOWNERS | 19 | ||||
| -rw-r--r-- | bot/constants.py | 1 | ||||
| -rw-r--r-- | bot/exts/info/codeblock/_parsing.py | 3 | ||||
| -rw-r--r-- | bot/exts/info/information.py | 10 | ||||
| -rw-r--r-- | bot/exts/moderation/dm_relay.py | 156 | ||||
| -rw-r--r-- | bot/exts/moderation/infraction/_utils.py | 46 | ||||
| -rw-r--r-- | bot/exts/moderation/watchchannels/_watchchannel.py | 78 | ||||
| -rw-r--r-- | bot/exts/recruitment/__init__.py | 0 | ||||
| -rw-r--r-- | bot/exts/recruitment/talentpool/__init__.py | 8 | ||||
| -rw-r--r-- | bot/exts/recruitment/talentpool/_cog.py (renamed from bot/exts/moderation/watchchannels/talentpool.py) | 83 | ||||
| -rw-r--r-- | bot/exts/recruitment/talentpool/_review.py | 324 | ||||
| -rw-r--r-- | bot/resources/tags/customhelp.md | 3 | ||||
| -rw-r--r-- | bot/resources/tags/intents.md | 19 | ||||
| -rw-r--r-- | bot/utils/services.py | 9 | ||||
| -rw-r--r-- | bot/utils/time.py | 8 | ||||
| -rw-r--r-- | config-default.yml | 3 | ||||
| -rw-r--r-- | tests/bot/exts/moderation/infraction/test_utils.py | 12 | ||||
| -rw-r--r-- | tests/bot/utils/test_services.py | 4 | 
18 files changed, 601 insertions, 185 deletions
| diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7217cb443..1df05e990 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,14 +4,15 @@  **/bot/exts/moderation/*silence.py      @MarkKoz  bot/exts/info/codeblock/**              @MarkKoz  bot/exts/utils/extensions.py            @MarkKoz -bot/exts/utils/snekbox.py               @MarkKoz @Akarys42 +bot/exts/utils/snekbox.py               @MarkKoz @Akarys42 @jb3  bot/exts/help_channels/**               @MarkKoz @Akarys42 -bot/exts/moderation/**                  @Akarys42 @mbaruh @Den4200 @ks129 -bot/exts/info/**                        @Akarys42 @Den4200 -bot/exts/info/information.py            @mbaruh -bot/exts/filters/**                     @mbaruh +bot/exts/moderation/**                  @Akarys42 @mbaruh @Den4200 @ks129 @jb3 +bot/exts/info/**                        @Akarys42 @Den4200 @jb3 +bot/exts/info/information.py            @mbaruh @jb3 +bot/exts/filters/**                     @mbaruh @jb3  bot/exts/fun/**                         @ks129 -bot/exts/utils/**                       @ks129 +bot/exts/utils/**                       @ks129 @jb3 +bot/exts/recruitment/**                 @wookie184  # Rules  bot/rules/**                            @mbaruh @@ -29,9 +30,9 @@ tests/bot/exts/test_cogs.py             @MarkKoz  tests/**                                @Akarys42  # CI & Docker -.github/workflows/**                    @MarkKoz @Akarys42 @SebastiaanZ @Den4200 -Dockerfile                              @MarkKoz @Akarys42 @Den4200 -docker-compose.yml                      @MarkKoz @Akarys42 @Den4200 +.github/workflows/**                    @MarkKoz @Akarys42 @SebastiaanZ @Den4200 @jb3 +Dockerfile                              @MarkKoz @Akarys42 @Den4200 @jb3 +docker-compose.yml                      @MarkKoz @Akarys42 @Den4200 @jb3  # Tools  Pipfile*                                @Akarys42 diff --git a/bot/constants.py b/bot/constants.py index b4d702e1d..883cd531b 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -438,6 +438,7 @@ class Channels(metaclass=YAMLGetter):      mods: int      mod_alerts: int      mod_spam: int +    nomination_voting: int      organisation: int      admin_announcements: int diff --git a/bot/exts/info/codeblock/_parsing.py b/bot/exts/info/codeblock/_parsing.py index e35fbca22..73fd11b94 100644 --- a/bot/exts/info/codeblock/_parsing.py +++ b/bot/exts/info/codeblock/_parsing.py @@ -103,6 +103,9 @@ def _is_python_code(content: str) -> bool:      """Return True if `content` is valid Python consisting of more than just expressions."""      log.trace("Checking if content is Python code.")      try: +        # Remove null bytes because they cause ast.parse to raise a ValueError. +        content = content.replace("\x00", "") +          # Attempt to parse the message into an AST node.          # Invalid Python code will raise a SyntaxError.          tree = ast.parse(content) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index c54ca96bf..0555544ce 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -6,7 +6,7 @@ from collections import defaultdict  from typing import Any, DefaultDict, Dict, Mapping, Optional, Tuple, Union  import fuzzywuzzy -from discord import Colour, Embed, Guild, Message, Role +from discord import AllowedMentions, Colour, Embed, Guild, Message, Role  from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role  from bot import constants @@ -447,9 +447,9 @@ class Information(Cog):          def add_content(title: str, content: str) -> None:              paginator.add_line(f'== {title} ==\n') -            # replace backticks as it breaks out of code blocks. Spaces seemed to be the most reasonable solution. -            # we hope it's not close to 2000 -            paginator.add_line(content.replace('```', '`` `')) +            # Replace backticks as it breaks out of code blocks. +            # An invisible character seemed to be the most reasonable solution. We hope it's not close to 2000. +            paginator.add_line(content.replace('`', '`\u200b'))              paginator.close_page()          if message.content: @@ -468,7 +468,7 @@ class Information(Cog):                  add_content(title, transformer(item))          for page in paginator.pages: -            await ctx.send(page) +            await ctx.send(page, allowed_mentions=AllowedMentions.none())      @raw.command()      async def json(self, ctx: Context, message: Message) -> None: diff --git a/bot/exts/moderation/dm_relay.py b/bot/exts/moderation/dm_relay.py index 6d081741c..a03230b3d 100644 --- a/bot/exts/moderation/dm_relay.py +++ b/bot/exts/moderation/dm_relay.py @@ -1,132 +1,68 @@  import logging -from typing import Optional +import textwrap  import discord -from async_rediscache import RedisCache -from discord import Color -from discord.ext import commands -from discord.ext.commands import Cog +from discord.ext.commands import Cog, Context, command, has_any_role -from bot import constants  from bot.bot import Bot -from bot.converters import UserMentionOrID -from bot.utils.checks import in_whitelist_check -from bot.utils.messages import send_attachments -from bot.utils.webhooks import send_webhook +from bot.constants import Emojis, MODERATION_ROLES +from bot.utils.services import send_to_paste_service  log = logging.getLogger(__name__)  class DMRelay(Cog): -    """Relay direct messages to and from the bot.""" - -    # RedisCache[str, t.Union[discord.User.id, discord.Member.id]] -    dm_cache = RedisCache() +    """Inspect messages sent to the bot."""      def __init__(self, bot: Bot):          self.bot = bot -        self.webhook_id = constants.Webhooks.dm_log -        self.webhook = None -        self.bot.loop.create_task(self.fetch_webhook()) - -    @commands.command(aliases=("reply",)) -    async def send_dm(self, ctx: commands.Context, member: Optional[UserMentionOrID], *, message: str) -> None: -        """ -        Allows you to send a DM to a user from the bot. - -        If `member` is not provided, it will send to the last user who DM'd the bot. - -        This feature should be used extremely sparingly. Use ModMail if you need to have a serious -        conversation with a user. This is just for responding to extraordinary DMs, having a little -        fun with users, and telling people they are DMing the wrong bot. - -        NOTE: This feature will be removed if it is overused. -        """ -        if not member: -            user_id = await self.dm_cache.get("last_user") -            member = ctx.guild.get_member(user_id) if user_id else None - -        # If we still don't have a Member at this point, give up -        if not member: -            log.debug("This bot has never gotten a DM, or the RedisCache has been cleared.") -            await ctx.message.add_reaction("❌") + +    @command(aliases=("relay", "dr")) +    async def dmrelay(self, ctx: Context, user: discord.User, limit: int = 100) -> None: +        """Relays the direct message history between the bot and given user.""" +        log.trace(f"Relaying DMs with {user.name} ({user.id})") + +        if user.bot: +            await ctx.send(f"{Emojis.cross_mark} No direct message history with bots.")              return -        if member.id == self.bot.user.id: -            log.debug("Not sending message to bot user") -            return await ctx.send("🚫 I can't send messages to myself!") - -        try: -            await member.send(message) -        except discord.errors.Forbidden: -            log.debug("User has disabled DMs.") -            await ctx.message.add_reaction("❌") -        else: -            await ctx.message.add_reaction("✅") -            self.bot.stats.incr("dm_relay.dm_sent") - -    async def fetch_webhook(self) -> None: -        """Fetches the webhook object, so we can post to it.""" -        await self.bot.wait_until_guild_available() - -        try: -            self.webhook = await self.bot.fetch_webhook(self.webhook_id) -        except discord.HTTPException: -            log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") - -    @Cog.listener() -    async def on_message(self, message: discord.Message) -> None: -        """Relays the message's content and attachments to the dm_log channel.""" -        # Only relay DMs from humans -        if message.author.bot or message.guild or self.webhook is None: +        output = "" +        async for msg in user.history(limit=limit, oldest_first=True): +            created_at = msg.created_at.strftime(r"%Y-%m-%d %H:%M") + +            # Metadata (author, created_at, id) +            output += f"{msg.author} [{created_at}] ({msg.id}): " + +            # Content +            if msg.content: +                output += msg.content + "\n" + +            # Embeds +            if (embeds := len(msg.embeds)) > 0: +                output += f"<{embeds} embed{'s' if embeds > 1 else ''}>\n" + +            # Attachments +            attachments = "\n".join(a.url for a in msg.attachments) +            if attachments: +                output += attachments + "\n" + +        if not output: +            await ctx.send(f"{Emojis.cross_mark} No direct message history with {user.mention}.")              return -        if message.clean_content: -            await send_webhook( -                webhook=self.webhook, -                content=message.clean_content, -                username=f"{message.author.display_name} ({message.author.id})", -                avatar_url=message.author.avatar_url -            ) -            await self.dm_cache.set("last_user", message.author.id) -            self.bot.stats.incr("dm_relay.dm_received") - -        # Handle any attachments -        if message.attachments: -            try: -                await send_attachments( -                    message, -                    self.webhook, -                    username=f"{message.author.display_name} ({message.author.id})" -                ) -            except (discord.errors.Forbidden, discord.errors.NotFound): -                e = discord.Embed( -                    description=":x: **This message contained an attachment, but it could not be retrieved**", -                    color=Color.red() -                ) -                await send_webhook( -                    webhook=self.webhook, -                    embed=e, -                    username=f"{message.author.display_name} ({message.author.id})", -                    avatar_url=message.author.avatar_url -                ) -            except discord.HTTPException: -                log.exception("Failed to send an attachment to the webhook") - -    async def cog_check(self, ctx: commands.Context) -> bool: +        metadata = textwrap.dedent(f"""\ +            User: {user} ({user.id}) +            Channel ID: {user.dm_channel.id}\n +        """) + +        paste_link = await send_to_paste_service(metadata + output, extension="txt") +        await ctx.send(paste_link) + +    async def cog_check(self, ctx: Context) -> bool:          """Only allow moderators to invoke the commands in this cog.""" -        checks = [ -            await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx), -            in_whitelist_check( -                ctx, -                channels=[constants.Channels.dm_log], -                redirect=None, -                fail_silently=True, -            ) -        ] -        return all(checks) +        return await has_any_role(*MODERATION_ROLES).predicate(ctx)  def setup(bot: Bot) -> None: -    """Load the DMRelay  cog.""" +    """Load the DMRelay cog."""      bot.add_cog(DMRelay(bot)) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index e766c1e5c..a98b4828b 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -22,7 +22,6 @@ INFRACTION_ICONS = {      "voice_ban": (Icons.voice_state_red, Icons.voice_state_green),  }  RULES_URL = "https://pythondiscord.com/pages/rules" -APPEALABLE_INFRACTIONS = ("ban", "mute", "voice_ban")  # Type aliases  UserObject = t.Union[discord.Member, discord.User] @@ -31,8 +30,12 @@ Infraction = t.Dict[str, t.Union[str, int, bool]]  APPEAL_EMAIL = "[email protected]" -INFRACTION_TITLE = f"Please review our rules over at {RULES_URL}" -INFRACTION_APPEAL_FOOTER = f"To appeal this infraction, send an e-mail to {APPEAL_EMAIL}" +INFRACTION_TITLE = "Please review our rules" +INFRACTION_APPEAL_EMAIL_FOOTER = f"To appeal this infraction, send an e-mail to {APPEAL_EMAIL}" +INFRACTION_APPEAL_MODMAIL_FOOTER = ( +    'If you would like to discuss or appeal this infraction, ' +    'send a message to the ModMail bot' +)  INFRACTION_AUTHOR_NAME = "Infraction information"  INFRACTION_DESCRIPTION_TEMPLATE = ( @@ -71,13 +74,13 @@ async def post_user(ctx: Context, user: UserSnowflake) -> t.Optional[dict]:  async def post_infraction( -    ctx: Context, -    user: UserSnowflake, -    infr_type: str, -    reason: str, -    expires_at: datetime = None, -    hidden: bool = False, -    active: bool = True +        ctx: Context, +        user: UserSnowflake, +        infr_type: str, +        reason: str, +        expires_at: datetime = None, +        hidden: bool = False, +        active: bool = True  ) -> t.Optional[dict]:      """Posts an infraction to the API."""      if isinstance(user, (discord.Member, discord.User)) and user.bot: @@ -150,11 +153,11 @@ async def get_active_infraction(  async def notify_infraction( -    user: UserObject, -    infr_type: str, -    expires_at: t.Optional[str] = None, -    reason: t.Optional[str] = None, -    icon_url: str = Icons.token_removed +        user: UserObject, +        infr_type: str, +        expires_at: t.Optional[str] = None, +        reason: t.Optional[str] = None, +        icon_url: str = Icons.token_removed  ) -> bool:      """DM a user about their new infraction and return True if the DM is successful."""      log.trace(f"Sending {user} a DM about their {infr_type} infraction.") @@ -178,17 +181,18 @@ async def notify_infraction(      embed.title = INFRACTION_TITLE      embed.url = RULES_URL -    if infr_type in APPEALABLE_INFRACTIONS: -        embed.set_footer(text=INFRACTION_APPEAL_FOOTER) +    embed.set_footer( +        text=INFRACTION_APPEAL_EMAIL_FOOTER if infr_type == 'Ban' else INFRACTION_APPEAL_MODMAIL_FOOTER +    )      return await send_private_embed(user, embed)  async def notify_pardon( -    user: UserObject, -    title: str, -    content: str, -    icon_url: str = Icons.user_verified +        user: UserObject, +        title: str, +        content: str, +        icon_url: str = Icons.user_verified  ) -> bool:      """DM a user about their pardoned infraction and return True if the DM is successful."""      log.trace(f"Sending {user} a DM about their pardoned infraction.") diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index 0793a66af..9f26c34f2 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -5,9 +5,8 @@ import textwrap  from abc import abstractmethod  from collections import defaultdict, deque  from dataclasses import dataclass -from typing import Optional +from typing import Any, Dict, Optional -import dateutil.parser  import discord  from discord import Color, DMChannel, Embed, HTTPException, Message, errors  from discord.ext.commands import Cog, Context @@ -20,7 +19,7 @@ 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 -from bot.utils.time import time_since +from bot.utils.time import get_time_delta  log = logging.getLogger(__name__) @@ -136,7 +135,10 @@ class WatchChannel(metaclass=CogABCMeta):          if not await self.fetch_user_cache():              await self.modlog.send_log_message(                  title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel", -                text="Could not retrieve the list of watched users from the API and messages will not be relayed.", +                text=( +                    "Could not retrieve the list of watched users from the API. " +                    "Messages will not be relayed, and reviews not rescheduled." +                ),                  ping_everyone=True,                  icon_url=Icons.token_removed,                  colour=Color.red() @@ -280,7 +282,7 @@ class WatchChannel(metaclass=CogABCMeta):          actor = actor.display_name if actor else self.watched_users[user_id]['actor']          inserted_at = self.watched_users[user_id]['inserted_at'] -        time_delta = self._get_time_delta(inserted_at) +        time_delta = get_time_delta(inserted_at)          reason = self.watched_users[user_id]['reason'] @@ -308,35 +310,61 @@ class WatchChannel(metaclass=CogABCMeta):          The optional kwarg `update_cache` specifies whether the cache should          be refreshed by polling the API.          """ -        if update_cache: -            if not await self.fetch_user_cache(): -                await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") -                update_cache = False +        watched_data = await self.prepare_watched_users_data(ctx, oldest_first, update_cache) -        lines = [] -        for user_id, user_data in self.watched_users.items(): -            inserted_at = user_data['inserted_at'] -            time_delta = self._get_time_delta(inserted_at) -            lines.append(f"• <@{user_id}> (added {time_delta})") - -        if oldest_first: -            lines.reverse() +        if update_cache and not watched_data["updated"]: +            await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") -        lines = lines or ("There's nothing here yet.",) +        lines = watched_data["info"].values() or ("There's nothing here yet.",)          embed = Embed( -            title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})", +            title=watched_data["title"],              color=Color.blue()          )          await LinePaginator.paginate(lines, ctx, embed, empty=False) -    @staticmethod -    def _get_time_delta(time_string: str) -> str: -        """Returns the time in human-readable time delta format.""" -        date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None) -        time_delta = time_since(date_time, precision="minutes", max_units=1) +    async def prepare_watched_users_data( +        self, ctx: Context, oldest_first: bool = False, update_cache: bool = True +    ) -> Dict[str, Any]: +        """ +        Prepare overview information of watched users to list. + +        The optional kwarg `oldest_first` orders the list by oldest entry. + +        The optional kwarg `update_cache` specifies whether the cache should +        be refreshed by polling the API. + +        Returns a dictionary with a "title" key for the list's title, and a "info" key with +        information about each user. + +        The dictionary additionally has an "updated" field which is true if a cache update was +        requested and it succeeded. +        """ +        list_data = {} +        if update_cache: +            if not await self.fetch_user_cache(): +                update_cache = False +        list_data["updated"] = update_cache + +        watched_iter = self.watched_users.items() +        if oldest_first: +            watched_iter = reversed(watched_iter) + +        list_data["info"] = {} +        for user_id, user_data in watched_iter: +            member = ctx.guild.get_member(user_id) +            line = f"• `{user_id}`" +            if member: +                line += f" ({member.name}#{member.discriminator})" +            inserted_at = user_data['inserted_at'] +            line += f", added {get_time_delta(inserted_at)}" +            if not member:  # Cross off users who left the server. +                line = f"~~{line}~~" +            list_data["info"][user_id] = line + +        list_data["title"] = f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})" -        return time_delta +        return list_data      def _remove_user(self, user_id: int) -> None:          """Removes a user from a watch channel.""" diff --git a/bot/exts/recruitment/__init__.py b/bot/exts/recruitment/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/bot/exts/recruitment/__init__.py diff --git a/bot/exts/recruitment/talentpool/__init__.py b/bot/exts/recruitment/talentpool/__init__.py new file mode 100644 index 000000000..52d27eb99 --- /dev/null +++ b/bot/exts/recruitment/talentpool/__init__.py @@ -0,0 +1,8 @@ +from bot.bot import Bot + + +def setup(bot: Bot) -> None: +    """Load the TalentPool cog.""" +    from bot.exts.recruitment.talentpool._cog import TalentPool + +    bot.add_cog(TalentPool(bot)) diff --git a/bot/exts/moderation/watchchannels/talentpool.py b/bot/exts/recruitment/talentpool/_cog.py index d75688fa6..b809cea17 100644 --- a/bot/exts/moderation/watchchannels/talentpool.py +++ b/bot/exts/recruitment/talentpool/_cog.py @@ -11,6 +11,7 @@ from bot.bot import Bot  from bot.constants import Channels, Guild, MODERATION_ROLES, STAFF_ROLES, Webhooks  from bot.converters import FetchedMember  from bot.exts.moderation.watchchannels._watchchannel import WatchChannel +from bot.exts.recruitment.talentpool._review import Reviewer  from bot.pagination import LinePaginator  from bot.utils import time @@ -33,6 +34,9 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):              disable_header=True,          ) +        self.reviewer = Reviewer(self.__class__.__name__, bot, self) +        self.bot.loop.create_task(self.reviewer.reschedule_reviews()) +      @group(name='talentpool', aliases=('tp', 'talent', 'nomination', 'n'), invoke_without_command=True)      @has_any_role(*MODERATION_ROLES)      async def nomination_group(self, ctx: Context) -> None: @@ -42,7 +46,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):      @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",))      @has_any_role(*MODERATION_ROLES)      async def watched_command( -        self, ctx: Context, oldest_first: bool = False, update_cache: bool = True +        self, +        ctx: Context, +        oldest_first: bool = False, +        update_cache: bool = True      ) -> None:          """          Shows the users that are currently being monitored in the talent pool. @@ -54,6 +61,47 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):          """          await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) +    async def list_watched_users( +        self, +        ctx: Context, +        oldest_first: bool = False, +        update_cache: bool = True +    ) -> None: +        """ +        Gives an overview of the nominated users list. + +        It specifies the users' mention, name, how long ago they were nominated, and whether their +        review was scheduled or already posted. + +        The optional kwarg `oldest_first` orders the list by oldest entry. + +        The optional kwarg `update_cache` specifies whether the cache should +        be refreshed by polling the API. +        """ +        # TODO Once the watch channel is removed, this can be done in a smarter way, without splitting and overriding +        # the list_watched_users function. +        watched_data = await self.prepare_watched_users_data(ctx, oldest_first, update_cache) + +        if update_cache and not watched_data["updated"]: +            await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache") + +        lines = [] +        for user_id, line in watched_data["info"].items(): +            if self.watched_users[user_id]['reviewed']: +                line += " *(reviewed)*" +            elif user_id in self.reviewer: +                line += " *(scheduled)*" +            lines.append(line) + +        if not lines: +            lines = ("There's nothing here yet.",) + +        embed = Embed( +            title=watched_data["title"], +            color=Color.blue() +        ) +        await LinePaginator.paginate(lines, ctx, embed, empty=False) +      @nomination_group.command(name='oldest')      @has_any_role(*MODERATION_ROLES)      async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: @@ -115,7 +163,9 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):                  resp.raise_for_status()          self.watched_users[user.id] = response_data -        msg = f":white_check_mark: The nomination for {user} has been added to the talent pool" + +        if user.id not in self.reviewer: +            self.reviewer.schedule_review(user.id)          history = await self.bot.api_client.get(              self.api_endpoint, @@ -126,6 +176,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):              }          ) +        msg = f"✅ The nomination for {user} has been added to the talent pool"          if history:              msg += f"\n\n({len(history)} previous nominations in total)" @@ -249,6 +300,24 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):          await self.fetch_user_cache()  # Update cache.          await ctx.send(":white_check_mark: Updated the end reason of the nomination!") +    @nomination_group.command(aliases=('mr',)) +    @has_any_role(*MODERATION_ROLES) +    async def mark_reviewed(self, ctx: Context, user_id: int) -> None: +        """Mark a user's nomination as reviewed and cancel the review task.""" +        if not await self.reviewer.mark_reviewed(ctx, user_id): +            return +        await ctx.send(f"✅ The user with ID `{user_id}` was marked as reviewed.") + +    @nomination_group.command(aliases=('review',)) +    @has_any_role(*MODERATION_ROLES) +    async def post_review(self, ctx: Context, user_id: int) -> None: +        """Post the automatic review for the user ahead of time.""" +        if not await self.reviewer.mark_reviewed(ctx, user_id): +            return + +        await self.reviewer.post_review(user_id, update_database=False) +        await ctx.message.add_reaction("✅") +      @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.""" @@ -277,6 +346,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):          )          self._remove_user(user_id) +        self.reviewer.cancel(user_id) +          return True      def _nomination_to_string(self, nomination_object: dict) -> str: @@ -329,7 +400,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"):          return lines.strip() - -def setup(bot: Bot) -> None: -    """Load the TalentPool cog.""" -    bot.add_cog(TalentPool(bot)) +    def cog_unload(self) -> None: +        """Cancels all review tasks on cog unload.""" +        super().cog_unload() +        self.reviewer.cancel_all() diff --git a/bot/exts/recruitment/talentpool/_review.py b/bot/exts/recruitment/talentpool/_review.py new file mode 100644 index 000000000..fb3461238 --- /dev/null +++ b/bot/exts/recruitment/talentpool/_review.py @@ -0,0 +1,324 @@ +import asyncio +import logging +import random +import textwrap +import typing +from collections import Counter +from datetime import datetime, timedelta +from typing import List, Optional, Union + +from dateutil.parser import isoparse +from dateutil.relativedelta import relativedelta +from discord import Emoji, Member, Message, TextChannel +from discord.ext.commands import Context + +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.constants import Channels, Guild, Roles +from bot.utils.scheduling import Scheduler +from bot.utils.time import get_time_delta, humanize_delta, time_since + +if typing.TYPE_CHECKING: +    from bot.exts.recruitment.talentpool._cog import TalentPool + +log = logging.getLogger(__name__) + +# Maximum amount of days before an automatic review is posted. +MAX_DAYS_IN_POOL = 30 + +# Maximum amount of characters allowed in a message +MAX_MESSAGE_SIZE = 2000 + + +class Reviewer: +    """Schedules, formats, and publishes reviews of helper nominees.""" + +    def __init__(self, name: str, bot: Bot, pool: 'TalentPool'): +        self.bot = bot +        self._pool = pool +        self._review_scheduler = Scheduler(name) + +    def __contains__(self, user_id: int) -> bool: +        """Return True if the user with ID user_id is scheduled for review, False otherwise.""" +        return user_id in self._review_scheduler + +    async def reschedule_reviews(self) -> None: +        """Reschedule all active nominations to be reviewed at the appropriate time.""" +        log.trace("Rescheduling reviews") +        await self.bot.wait_until_guild_available() +        # TODO Once the watch channel is removed, this can be done in a smarter way, e.g create a sync function. +        await self._pool.fetch_user_cache() + +        for user_id, user_data in self._pool.watched_users.items(): +            if not user_data["reviewed"]: +                self.schedule_review(user_id) + +    def schedule_review(self, user_id: int) -> None: +        """Schedules a single user for review.""" +        log.trace(f"Scheduling review of user with ID {user_id}") + +        user_data = self._pool.watched_users[user_id] +        inserted_at = isoparse(user_data['inserted_at']).replace(tzinfo=None) +        review_at = inserted_at + timedelta(days=MAX_DAYS_IN_POOL) + +        # If it's over a day overdue, it's probably an old nomination and shouldn't be automatically reviewed. +        if datetime.utcnow() - review_at < timedelta(days=1): +            self._review_scheduler.schedule_at(review_at, user_id, self.post_review(user_id, update_database=True)) + +    async def post_review(self, user_id: int, update_database: bool) -> None: +        """Format a generic review of a user and post it to the nomination voting channel.""" +        log.trace(f"Posting the review of {user_id}") + +        nomination = self._pool.watched_users[user_id] +        if not nomination: +            log.trace(f"There doesn't appear to be an active nomination for {user_id}") +            return + +        guild = self.bot.get_guild(Guild.id) +        channel = guild.get_channel(Channels.nomination_voting) +        member = guild.get_member(user_id) + +        if update_database: +            await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) + +        if not member: +            await channel.send( +                f"I tried to review the user with ID `{user_id}`, but they don't appear to be on the server 😔" +            ) +            return + +        opening = f"<@&{Roles.moderators}> <@&{Roles.admins}>\n{member.mention} ({member}) for Helper!" + +        current_nominations = "\n\n".join( +            f"**<@{entry['actor']}>:** {entry['reason'] or '*no reason given*'}" for entry in nomination['entries'] +        ) +        current_nominations = f"**Nominated by:**\n{current_nominations}" + +        review_body = await self._construct_review_body(member) + +        seen_emoji = self._random_ducky(guild) +        vote_request = ( +            "*Refer to their nomination and infraction histories for further details*.\n" +            f"*Please react {seen_emoji} if you've seen this post." +            " Then react 👍 for approval, or 👎 for disapproval*." +        ) + +        review = "\n\n".join(part for part in (opening, current_nominations, review_body, vote_request)) + +        message = (await self._bulk_send(channel, review))[-1] +        for reaction in (seen_emoji, "👍", "👎"): +            await message.add_reaction(reaction) + +    async def _construct_review_body(self, member: Member) -> str: +        """Formats the body of the nomination, with details of activity, infractions, and previous nominations.""" +        activity = await self._activity_review(member) +        infractions = await self._infractions_review(member) +        prev_nominations = await self._previous_nominations_review(member) + +        body = f"{activity}\n\n{infractions}" +        if prev_nominations: +            body += f"\n\n{prev_nominations}" +        return body + +    async def _activity_review(self, member: Member) -> str: +        """ +        Format the activity of the nominee. + +        Adds details on how long they've been on the server, their total message count, +        and the channels they're the most active in. +        """ +        log.trace(f"Fetching the metricity data for {member.id}'s review") +        try: +            user_activity = await self.bot.api_client.get(f"bot/users/{member.id}/metricity_review_data") +        except ResponseCodeError as e: +            if e.status == 404: +                log.trace(f"The user {member.id} seems to have no activity logged in Metricity.") +                messages = "no" +                channels = "" +            else: +                log.trace(f"An unexpected error occured while fetching information of user {member.id}.") +                raise +        else: +            log.trace(f"Activity found for {member.id}, formatting review.") +            messages = user_activity["total_messages"] +            # Making this part flexible to the amount of expected and returned channels. +            first_channel = user_activity["top_channel_activity"][0] +            channels = f", with {first_channel[1]} messages in {first_channel[0]}" + +            if len(user_activity["top_channel_activity"]) > 1: +                channels += ", " + ", ".join( +                    f"{count} in {channel}" for channel, count in user_activity["top_channel_activity"][1: -1] +                ) +                last_channel = user_activity["top_channel_activity"][-1] +                channels += f", and {last_channel[1]} in {last_channel[0]}" + +        time_on_server = humanize_delta(relativedelta(datetime.utcnow(), member.joined_at), max_units=2) +        review = ( +            f"{member.name} has been on the server for **{time_on_server}**" +            f" and has **{messages} messages**{channels}." +        ) + +        return review + +    async def _infractions_review(self, member: Member) -> str: +        """ +        Formats the review of the nominee's infractions, if any. + +        The infractions are listed by type and amount, and it is stated how long ago the last one was issued. +        """ +        log.trace(f"Fetching the infraction data for {member.id}'s review") +        infraction_list = await self.bot.api_client.get( +            'bot/infractions/expanded', +            params={'user__id': str(member.id), 'ordering': '-inserted_at'} +        ) + +        log.trace(f"{len(infraction_list)} infractions found for {member.id}, formatting review.") +        if not infraction_list: +            return "They have no infractions." + +        # Count the amount of each type of infraction. +        infr_stats = list(Counter(infr["type"] for infr in infraction_list).items()) + +        # Format into a sentence. +        if len(infr_stats) == 1: +            infr_type, count = infr_stats[0] +            infractions = f"{count} {self._format_infr_name(infr_type, count)}" +        else:  # We already made sure they have infractions. +            infractions = ", ".join( +                f"{count} {self._format_infr_name(infr_type, count)}" +                for infr_type, count in infr_stats[:-1] +            ) +            last_infr, last_count = infr_stats[-1] +            infractions += f", and {last_count} {self._format_infr_name(last_infr, last_count)}" + +        infractions = f"**{infractions}**" + +        # Show when the last one was issued. +        if len(infraction_list) == 1: +            infractions += ", issued " +        else: +            infractions += ", with the last infraction issued " + +        # Infractions were ordered by time since insertion descending. +        infractions += get_time_delta(infraction_list[0]['inserted_at']) + +        return f"They have {infractions}." + +    @staticmethod +    def _format_infr_name(infr_type: str, count: int) -> str: +        """ +        Format the infraction type in a way readable in a sentence. + +        Underscores are replaced with spaces, as well as *attempting* to show the appropriate plural form if necessary. +        This function by no means covers all rules of grammar. +        """ +        formatted = infr_type.replace("_", " ") +        if count > 1: +            if infr_type.endswith(('ch', 'sh')): +                formatted += "e" +            formatted += "s" + +        return formatted + +    async def _previous_nominations_review(self, member: Member) -> Optional[str]: +        """ +        Formats the review of the nominee's previous nominations. + +        The number of previous nominations and unnominations are shown, as well as the reason the last one ended. +        """ +        log.trace(f"Fetching the nomination history data for {member.id}'s review") +        history = await self.bot.api_client.get( +            self._pool.api_endpoint, +            params={ +                "user__id": str(member.id), +                "active": "false", +                "ordering": "-inserted_at" +            } +        ) + +        log.trace(f"{len(history)} previous nominations found for {member.id}, formatting review.") +        if not history: +            return + +        num_entries = sum(len(nomination["entries"]) for nomination in history) + +        nomination_times = f"{num_entries} times" if num_entries > 1 else "once" +        rejection_times = f"{len(history)} times" if len(history) > 1 else "once" +        end_time = time_since(isoparse(history[0]['ended_at']).replace(tzinfo=None), max_units=2) + +        review = ( +            f"They were nominated **{nomination_times}** before" +            f", but their nomination was called off **{rejection_times}**." +            f"\nThe last one ended {end_time} with the reason: {history[0]['end_reason']}" +        ) + +        return review + +    @staticmethod +    def _random_ducky(guild: Guild) -> Union[Emoji, str]: +        """Picks a random ducky emoji to be used to mark the vote as seen. If no duckies found returns 👀.""" +        duckies = [emoji for emoji in guild.emojis if emoji.name.startswith("ducky")] +        if not duckies: +            return "👀" +        return random.choice(duckies) + +    @staticmethod +    async def _bulk_send(channel: TextChannel, text: str) -> List[Message]: +        """ +        Split a text into several if necessary, and post them to the channel. + +        Returns the resulting message objects. +        """ +        messages = textwrap.wrap(text, width=MAX_MESSAGE_SIZE, replace_whitespace=False) +        log.trace(f"The provided string will be sent to the channel {channel.id} as {len(messages)} messages.") + +        results = [] +        for message in messages: +            await asyncio.sleep(1) +            results.append(await channel.send(message)) + +        return results + +    async def mark_reviewed(self, ctx: Context, user_id: int) -> bool: +        """ +        Mark an active nomination as reviewed, updating the database and canceling the review task. + +        Returns True if the user was successfully marked as reviewed, False otherwise. +        """ +        log.trace(f"Updating user {user_id} as reviewed") +        await self._pool.fetch_user_cache() +        if user_id not in self._pool.watched_users: +            log.trace(f"Can't find a nominated user with id {user_id}") +            await ctx.send(f"❌ Can't find a currently nominated user with id `{user_id}`") +            return False + +        nomination = self._pool.watched_users[user_id] +        if nomination["reviewed"]: +            await ctx.send("❌ This nomination was already reviewed, but here's a cookie 🍪") +            return False + +        await self.bot.api_client.patch(f"{self._pool.api_endpoint}/{nomination['id']}", json={"reviewed": True}) +        if user_id in self._review_scheduler: +            self._review_scheduler.cancel(user_id) + +        return True + +    def cancel(self, user_id: int) -> None: +        """ +        Cancels the review of the nominee with ID `user_id`. + +        It's important to note that this applies only until reschedule_reviews is called again. +        To permanently cancel someone's review, either remove them from the pool, or use mark_reviewed. +        """ +        log.trace(f"Canceling the review of user {user_id}.") +        self._review_scheduler.cancel(user_id) + +    def cancel_all(self) -> None: +        """ +        Cancels all reviews. + +        It's important to note that this applies only until reschedule_reviews is called again. +        To permanently cancel someone's review, either remove them from the pool, or use mark_reviewed. +        """ +        log.trace("Canceling all reviews.") +        self._review_scheduler.cancel_all() diff --git a/bot/resources/tags/customhelp.md b/bot/resources/tags/customhelp.md new file mode 100644 index 000000000..6f0b17642 --- /dev/null +++ b/bot/resources/tags/customhelp.md @@ -0,0 +1,3 @@ +**Custom help commands in discord.py** + +To learn more about how to create custom help commands in discord.py by subclassing the help command, please see [this tutorial](https://gist.github.com/InterStella0/b78488fb28cadf279dfd3164b9f0cf96#embed-minimalhelpcommand) by Stella#2000 diff --git a/bot/resources/tags/intents.md b/bot/resources/tags/intents.md new file mode 100644 index 000000000..464caf0ba --- /dev/null +++ b/bot/resources/tags/intents.md @@ -0,0 +1,19 @@ +**Using intents in discord.py** + +Intents are a feature of Discord that tells the gateway exactly which events to send your bot. By default, discord.py has all intents enabled, except for the `Members` and `Presences` intents, which are needed for events such as `on_member` and to get members' statuses. + +To enable one of these intents, you need to first go to the [Discord developer portal](https://discord.com/developers/applications), then to the bot page of your bot's application. Scroll down to the `Privileged Gateway Intents` section, then enable the intents that you need. + +Next, in your bot you need to set the intents you want to connect with in the bot's constructor using the `intents` keyword argument, like this: + +```py +from discord import Intents +from discord.ext import commands + +intents = Intents.default() +intents.members = True + +bot = commands.Bot(command_prefix="!", intents=intents) +``` + +For more info about using intents, see the [discord.py docs on intents](https://discordpy.readthedocs.io/en/latest/intents.html), and for general information about them, see the [Discord developer documentation on intents](https://discord.com/developers/docs/topics/gateway#gateway-intents). diff --git a/bot/utils/services.py b/bot/utils/services.py index 5949c9e48..db9c93d0f 100644 --- a/bot/utils/services.py +++ b/bot/utils/services.py @@ -47,7 +47,14 @@ async def send_to_paste_service(contents: str, *, extension: str = "") -> Option              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 + +            paste_link = URLs.paste_service.format(key=response_json['key']) + extension + +            if extension == '.py': +                return paste_link + +            return paste_link + "?noredirect" +          log.warning(              f"Got unexpected JSON response from paste service: {response_json}\n"              f"trying again ({attempt}/{FAILED_REQUEST_ATTEMPTS})." diff --git a/bot/utils/time.py b/bot/utils/time.py index f862e40f7..466f0adc2 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -85,6 +85,14 @@ def humanize_delta(delta: relativedelta, precision: str = "seconds", max_units:      return humanized +def get_time_delta(time_string: str) -> str: +    """Returns the time in human-readable time delta format.""" +    date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None) +    time_delta = time_since(date_time, precision="minutes", max_units=1) + +    return time_delta + +  def parse_duration_string(duration: str) -> Optional[relativedelta]:      """      Converts a `duration` string to a relativedelta object. diff --git a/config-default.yml b/config-default.yml index 38144c90c..ea0169cd2 100644 --- a/config-default.yml +++ b/config-default.yml @@ -199,6 +199,7 @@ guild:          mod_meta:           &MOD_META       775412552795947058          mod_spam:           &MOD_SPAM       620607373828030464          mod_tools:          &MOD_TOOLS      775413915391098921 +        nomination_voting:                  822853512709931008          organisation:       &ORGANISATION   551789653284356126          staff_lounge:       &STAFF_LOUNGE   464905259261755392 @@ -484,7 +485,7 @@ help_channels:      # Maximum number of channels across all 3 categories      # Note Discord has a hard limit of 50 channels per category, so this shouldn't be > 50 -    max_total_channels: 32 +    max_total_channels: 42      # Prefix for help channel names      name_prefix: 'help-' diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index 5b62463e0..ee9ff650c 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -146,7 +146,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                      name=utils.INFRACTION_AUTHOR_NAME,                      url=utils.RULES_URL,                      icon_url=Icons.token_removed -                ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER), +                ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER),                  "send_result": True              },              { @@ -164,9 +164,11 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                      name=utils.INFRACTION_AUTHOR_NAME,                      url=utils.RULES_URL,                      icon_url=Icons.token_removed -                ), +                ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER),                  "send_result": False              }, +            # Note that this test case asserts that the DM that *would* get sent to the user is formatted +            # correctly, even though that message is deliberately never sent.              {                  "args": (self.user, "note", None, None, Icons.defcon_denied),                  "expected_output": Embed( @@ -182,7 +184,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                      name=utils.INFRACTION_AUTHOR_NAME,                      url=utils.RULES_URL,                      icon_url=Icons.defcon_denied -                ), +                ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER),                  "send_result": False              },              { @@ -200,7 +202,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                      name=utils.INFRACTION_AUTHOR_NAME,                      url=utils.RULES_URL,                      icon_url=Icons.defcon_denied -                ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER), +                ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER),                  "send_result": False              },              { @@ -218,7 +220,7 @@ class ModerationUtilsTests(unittest.IsolatedAsyncioTestCase):                      name=utils.INFRACTION_AUTHOR_NAME,                      url=utils.RULES_URL,                      icon_url=Icons.defcon_denied -                ).set_footer(text=utils.INFRACTION_APPEAL_FOOTER), +                ).set_footer(text=utils.INFRACTION_APPEAL_MODMAIL_FOOTER),                  "send_result": True              }          ] diff --git a/tests/bot/utils/test_services.py b/tests/bot/utils/test_services.py index 1b48f6560..3b71022db 100644 --- a/tests/bot/utils/test_services.py +++ b/tests/bot/utils/test_services.py @@ -30,9 +30,9 @@ class PasteTests(unittest.IsolatedAsyncioTestCase):          """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}.txt?noredirect", "txt"),              (f"https://paste_service.com/{key}.py", "py"), -            (f"https://paste_service.com/{key}", ""), +            (f"https://paste_service.com/{key}?noredirect", ""),          )          response = MagicMock(              json=AsyncMock(return_value={"key": key}) | 
