diff options
| author | 2020-09-19 14:11:43 +0800 | |
|---|---|---|
| committer | 2020-09-19 14:11:43 +0800 | |
| commit | ae1363769455ad93e93f0d1ba3efcc39751ee9fa (patch) | |
| tree | 9d9e6ccbad74a6d6379495f72d27d94c46f26d5a | |
| parent | Add everyone_ping filter. (diff) | |
| parent | Merge pull request #1158 from python-discord/config-update (diff) | |
Merge branch 'master' into bug/1142/fix-everyone-ping
Diffstat (limited to '')
| -rw-r--r-- | bot/cogs/eval.py | 25 | ||||
| -rw-r--r-- | bot/cogs/help_channels.py | 4 | ||||
| -rw-r--r-- | bot/cogs/snekbox.py | 13 | ||||
| -rw-r--r-- | bot/cogs/verification.py | 596 | ||||
| -rw-r--r-- | bot/constants.py | 12 | ||||
| -rw-r--r-- | bot/rules/burst_shared.py | 11 | ||||
| -rw-r--r-- | bot/utils/__init__.py | 19 | ||||
| -rw-r--r-- | bot/utils/helpers.py | 23 | ||||
| -rw-r--r-- | bot/utils/services.py | 54 | ||||
| -rw-r--r-- | config-default.yml | 25 | ||||
| -rw-r--r-- | tests/bot/cogs/test_snekbox.py | 40 | ||||
| -rw-r--r-- | tests/bot/utils/test_services.py | 74 | 
12 files changed, 808 insertions, 88 deletions
| diff --git a/bot/cogs/eval.py b/bot/cogs/eval.py index eb8bfb1cf..23e5998d8 100644 --- a/bot/cogs/eval.py +++ b/bot/cogs/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/cogs/help_channels.py b/bot/cogs/help_channels.py index 541c6f336..0f9cac89e 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/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/cogs/snekbox.py b/bot/cogs/snekbox.py index 63e6d7f31..03bf454ac 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/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: diff --git a/bot/cogs/verification.py b/bot/cogs/verification.py index ae156cf70..9ae92a228 100644 --- a/bot/cogs/verification.py +++ b/bot/cogs/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.cogs.moderation import ModLog -from bot.decorators import in_whitelist, without_role +from bot.decorators import in_whitelist, with_role, without_role  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/constants.py b/bot/constants.py index 70b36984f..e4e0b1732 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -463,6 +463,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. @@ -470,6 +471,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] @@ -580,6 +582,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/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/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/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 cf9ce8798..465f61b5f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -76,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" @@ -134,6 +135,7 @@ style:  guild:      id: 267624335836053506 +    invite: "https://discord.gg/python"      categories:          help_available:                     691405807388196926 @@ -236,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 @@ -485,5 +487,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/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 343e37db9..f22952931 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/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.cogs.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.cogs.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/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) | 
