diff options
| author | 2020-06-20 01:39:56 +0200 | |
|---|---|---|
| committer | 2020-06-20 01:39:56 +0200 | |
| commit | 28919b6cbe0e9e037cfa1cd2ac7b18dc66d3edc2 (patch) | |
| tree | 1d94ccc3583a73565965043f3d29898575d85465 | |
| parent | Write unit test for `sub_clyde` (diff) | |
| parent | Merge pull request #1015 from python-discord/kwzrd/pipenv-html-script (diff) | |
Merge branch 'master' into bug/mod/bot-2a/webhook-clyde
Diffstat (limited to '')
| -rw-r--r-- | Pipfile | 1 | ||||
| -rw-r--r-- | bot/cogs/filtering.py | 85 | ||||
| -rw-r--r-- | bot/cogs/help_channels.py | 124 | ||||
| -rw-r--r-- | bot/cogs/moderation/infractions.py | 53 | ||||
| -rw-r--r-- | bot/cogs/moderation/scheduler.py | 7 | ||||
| -rw-r--r-- | bot/cogs/token_remover.py | 17 | ||||
| -rw-r--r-- | bot/cogs/webhook_remover.py | 14 | ||||
| -rw-r--r-- | bot/constants.py | 1 | ||||
| -rw-r--r-- | bot/resources/tags/customcooldown.md | 20 | ||||
| -rw-r--r-- | bot/utils/redis_cache.py | 23 | ||||
| -rw-r--r-- | config-default.yml | 4 | ||||
| -rw-r--r-- | tests/bot/cogs/test_token_remover.py | 32 | ||||
| -rw-r--r-- | tests/bot/utils/test_redis_cache.py | 4 | 
13 files changed, 285 insertions, 100 deletions
| @@ -50,4 +50,5 @@ precommit = "pre-commit install"  build = "docker build -t pythondiscord/bot:latest -f Dockerfile ."  push = "docker push pythondiscord/bot:latest"  test = "coverage run -m unittest" +html = "coverage html"  report = "coverage report" diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 4ebc831e1..76ea68660 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -2,11 +2,12 @@ import asyncio  import logging  import re  from datetime import datetime, timedelta -from typing import List, Optional, Union +from typing import List, Mapping, Optional, Union +import dateutil  import discord.errors  from dateutil.relativedelta import relativedelta -from discord import Colour, Member, Message, TextChannel +from discord import Colour, HTTPException, Member, Message, NotFound, TextChannel  from discord.ext.commands import Cog  from discord.utils import escape_markdown @@ -17,6 +18,8 @@ from bot.constants import (      Filter, Icons, URLs  )  from bot.utils.redis_cache import RedisCache +from bot.utils.scheduling import Scheduler +from bot.utils.time import wait_until  log = logging.getLogger(__name__) @@ -54,7 +57,10 @@ def expand_spoilers(text: str) -> str:      ) -class Filtering(Cog): +OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) + + +class Filtering(Cog, Scheduler):      """Filtering out invites, blacklisting domains, and warning us of certain regular expressions."""      # Redis cache mapping a user ID to the last timestamp a bad nickname alert was sent @@ -62,6 +68,8 @@ class Filtering(Cog):      def __init__(self, bot: Bot):          self.bot = bot +        super().__init__() +          self.name_lock = asyncio.Lock()          staff_mistake_str = "If you believe this was a mistake, please let staff know!" @@ -75,7 +83,8 @@ class Filtering(Cog):                  "notification_msg": (                      "Your post has been removed for abusing Unicode character rendering (aka Zalgo text). "                      f"{staff_mistake_str}" -                ) +                ), +                "schedule_deletion": False              },              "filter_invites": {                  "enabled": Filter.filter_invites, @@ -86,7 +95,8 @@ class Filtering(Cog):                  "notification_msg": (                      f"Per Rule 6, your invite link has been removed. {staff_mistake_str}\n\n"                      r"Our server rules can be found here: <https://pythondiscord.com/pages/rules>" -                ) +                ), +                "schedule_deletion": False              },              "filter_domains": {                  "enabled": Filter.filter_domains, @@ -96,22 +106,27 @@ class Filtering(Cog):                  "user_notification": Filter.notify_user_domains,                  "notification_msg": (                      f"Your URL has been removed because it matched a blacklisted domain. {staff_mistake_str}" -                ) +                ), +                "schedule_deletion": False              },              "watch_regex": {                  "enabled": Filter.watch_regex,                  "function": self._has_watch_regex_match,                  "type": "watchlist",                  "content_only": True, +                "schedule_deletion": True              },              "watch_rich_embeds": {                  "enabled": Filter.watch_rich_embeds,                  "function": self._has_rich_embed,                  "type": "watchlist",                  "content_only": False, -            }, +                "schedule_deletion": False +            }          } +        self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) +      @property      def mod_log(self) -> ModLog:          """Get currently loaded ModLog cog instance.""" @@ -242,6 +257,20 @@ class Filtering(Cog):                              if _filter["user_notification"]:                                  await self.notify_member(msg.author, _filter["notification_msg"], msg.channel) +                        # If the message is classed as offensive, we store it in the site db and +                        # it will be deleted it after one week. +                        if _filter["schedule_deletion"] and not is_private: +                            delete_date = (msg.created_at + OFFENSIVE_MSG_DELETE_TIME).isoformat() +                            data = { +                                'id': msg.id, +                                'channel_id': msg.channel.id, +                                'delete_date': delete_date +                            } + +                            await self.bot.api_client.post('bot/offensive-messages', json=data) +                            self.schedule_task(msg.id, data) +                            log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") +                          if is_private:                              channel_str = "via DM"                          else: @@ -359,7 +388,7 @@ class Filtering(Cog):          Attempts to catch some of common ways to try to cheat the system.          """ -        # Remove backslashes to prevent escape character aroundfuckery like +        # Remove backslashes to prevent escape character around fuckery like          # discord\.gg/gdudes-pony-farm          text = text.replace("\\", "") @@ -428,6 +457,46 @@ class Filtering(Cog):          except discord.errors.Forbidden:              await channel.send(f"{filtered_member.mention} {reason}") +    async def _scheduled_task(self, msg: dict) -> None: +        """Delete an offensive message once its deletion date is reached.""" +        delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) + +        await wait_until(delete_at) +        await self.delete_offensive_msg(msg) + +    async def reschedule_offensive_msg_deletion(self) -> None: +        """Get all the pending message deletion from the API and reschedule them.""" +        await self.bot.wait_until_ready() +        response = await self.bot.api_client.get('bot/offensive-messages',) + +        now = datetime.utcnow() + +        for msg in response: +            delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) + +            if delete_at < now: +                await self.delete_offensive_msg(msg) +            else: +                self.schedule_task(msg['id'], msg) + +    async def delete_offensive_msg(self, msg: Mapping[str, str]) -> None: +        """Delete an offensive message, and then delete it from the db.""" +        try: +            channel = self.bot.get_channel(msg['channel_id']) +            if channel: +                msg_obj = await channel.fetch_message(msg['id']) +                await msg_obj.delete() +        except NotFound: +            log.info( +                f"Tried to delete message {msg['id']}, but the message can't be found " +                f"(it has been probably already deleted)." +            ) +        except HTTPException as e: +            log.warning(f"Failed to delete message {msg['id']}: status {e.status}") + +        await self.bot.api_client.delete(f'bot/offensive-messages/{msg["id"]}') +        log.info(f"Deleted the offensive message with id {msg['id']}.") +  def setup(bot: Bot) -> None:      """Load the Filtering cog.""" diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 6ff285c37..187adfe51 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -5,8 +5,7 @@ import logging  import random  import typing as t  from collections import deque -from contextlib import suppress -from datetime import datetime +from datetime import datetime, timedelta, timezone  from pathlib import Path  import discord @@ -15,6 +14,7 @@ from discord.ext import commands  from bot import constants  from bot.bot import Bot +from bot.utils import RedisCache  from bot.utils.checks import with_role_check  from bot.utils.scheduling import Scheduler @@ -99,13 +99,24 @@ class HelpChannels(Scheduler, commands.Cog):      Help channels are named after the chemical elements in `bot/resources/elements.json`.      """ +    # This cache tracks which channels are claimed by which members. +    # RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] +    help_channel_claimants = RedisCache() + +    # This cache maps a help channel to whether it has had any +    # activity other than the original claimant. True being no other +    # activity and False being other activity. +    # RedisCache[discord.TextChannel.id, bool] +    unanswered = RedisCache() + +    # This dictionary maps a help channel to the time it was claimed +    # RedisCache[discord.TextChannel.id, UtcPosixTimestamp] +    claim_times = RedisCache() +      def __init__(self, bot: Bot):          super().__init__()          self.bot = bot -        self.help_channel_claimants: ( -            t.Dict[discord.TextChannel, t.Union[discord.Member, discord.User]] -        ) = {}          # Categories          self.available_category: discord.CategoryChannel = None @@ -125,16 +136,6 @@ class HelpChannels(Scheduler, commands.Cog):          self.on_message_lock = asyncio.Lock()          self.init_task = self.bot.loop.create_task(self.init_cog()) -        # Stats - -        # This dictionary maps a help channel to the time it was claimed -        self.claim_times: t.Dict[int, datetime] = {} - -        # This dictionary maps a help channel to whether it has had any -        # activity other than the original claimant. True being no other -        # activity and False being other activity. -        self.unanswered: t.Dict[int, bool] = {} -      def cog_unload(self) -> None:          """Cancel the init task and scheduled tasks when the cog unloads."""          log.trace("Cog unload: cancelling the init_cog task") @@ -197,7 +198,7 @@ class HelpChannels(Scheduler, commands.Cog):      async def dormant_check(self, ctx: commands.Context) -> bool:          """Return True if the user is the help channel claimant or passes the role check.""" -        if self.help_channel_claimants.get(ctx.channel) == ctx.author: +        if await self.help_channel_claimants.get(ctx.channel.id) == ctx.author.id:              log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.")              self.bot.stats.incr("help.dormant_invoke.claimant")              return True @@ -222,10 +223,11 @@ class HelpChannels(Scheduler, commands.Cog):          log.trace("close command invoked; checking if the channel is in-use.")          if ctx.channel.category == self.in_use_category:              if await self.dormant_check(ctx): -                with suppress(KeyError): -                    del self.help_channel_claimants[ctx.channel] +                # Remove the claimant and the cooldown role +                await self.help_channel_claimants.delete(ctx.channel.id)                  await self.remove_cooldown_role(ctx.author) +                  # Ignore missing task when cooldown has passed but the channel still isn't dormant.                  self.cancel_task(ctx.author.id, ignore_missing=True) @@ -284,6 +286,15 @@ class HelpChannels(Scheduler, commands.Cog):              if channel.category_id == category.id and not self.is_excluded_channel(channel):                  yield channel +    async def get_in_use_time(self, channel_id: int) -> t.Optional[timedelta]: +        """Return the duration `channel_id` has been in use. Return None if it's not in use.""" +        log.trace(f"Calculating in use time for channel {channel_id}.") + +        claimed_timestamp = await self.claim_times.get(channel_id) +        if claimed_timestamp: +            claimed = datetime.utcfromtimestamp(claimed_timestamp) +            return datetime.utcnow() - claimed +      @staticmethod      def get_names() -> t.List[str]:          """ @@ -386,7 +397,7 @@ class HelpChannels(Scheduler, commands.Cog):          log.trace("Initialising the cog.")          await self.init_categories() -        await self.reset_send_permissions() +        await self.check_cooldowns()          self.channel_queue = self.create_channel_queue()          self.name_queue = self.create_name_queue() @@ -546,19 +557,17 @@ class HelpChannels(Scheduler, commands.Cog):          self.bot.stats.incr(f"help.dormant_calls.{caller}") -        if channel.id in self.claim_times: -            claimed = self.claim_times[channel.id] -            in_use_time = datetime.now() - claimed +        in_use_time = await self.get_in_use_time(channel.id) +        if in_use_time:              self.bot.stats.timing("help.in_use_time", in_use_time) -        if channel.id in self.unanswered: -            if self.unanswered[channel.id]: -                self.bot.stats.incr("help.sessions.unanswered") -            else: -                self.bot.stats.incr("help.sessions.answered") +        unanswered = await self.unanswered.get(channel.id) +        if unanswered: +            self.bot.stats.incr("help.sessions.unanswered") +        elif unanswered is not None: +            self.bot.stats.incr("help.sessions.answered")          log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") -          log.trace(f"Sending dormant message for #{channel} ({channel.id}).")          embed = discord.Embed(description=DORMANT_MSG)          await channel.send(embed=embed) @@ -637,17 +646,17 @@ class HelpChannels(Scheduler, commands.Cog):          if self.is_in_category(channel, constants.Categories.help_in_use):              log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") -            # Check if there is an entry in unanswered (does not persist across restarts) -            if channel.id in self.unanswered: -                claimant = self.help_channel_claimants.get(channel) -                if not claimant: -                    # The mapping for this channel was lost, we can't do anything. +            # Check if there is an entry in unanswered +            if await self.unanswered.contains(channel.id): +                claimant_id = await self.help_channel_claimants.get(channel.id) +                if not claimant_id: +                    # The mapping for this channel doesn't exist, we can't do anything.                      return                  # Check the message did not come from the claimant -                if claimant.id != message.author.id: +                if claimant_id != message.author.id:                      # Mark the channel as answered -                    self.unanswered[channel.id] = False +                    await self.unanswered.set(channel.id, False)      @commands.Cog.listener()      async def on_message(self, message: discord.Message) -> None: @@ -680,12 +689,15 @@ class HelpChannels(Scheduler, commands.Cog):              await self.move_to_in_use(channel)              await self.revoke_send_permissions(message.author)              # Add user with channel for dormant check. -            self.help_channel_claimants[channel] = message.author +            await self.help_channel_claimants.set(channel.id, message.author.id)              self.bot.stats.incr("help.claimed") -            self.claim_times[channel.id] = datetime.now() -            self.unanswered[channel.id] = True +            # Must use a timezone-aware datetime to ensure a correct POSIX timestamp. +            timestamp = datetime.now(timezone.utc).timestamp() +            await self.claim_times.set(channel.id, timestamp) + +            await self.unanswered.set(channel.id, True)              log.trace(f"Releasing on_message lock for {message.id}.") @@ -720,15 +732,28 @@ class HelpChannels(Scheduler, commands.Cog):          msg = await self.get_last_message(channel)          return self.match_bot_embed(msg, AVAILABLE_MSG) -    async def reset_send_permissions(self) -> None: -        """Reset send permissions in the Available category for claimants.""" -        log.trace("Resetting send permissions in the Available category.") +    async def check_cooldowns(self) -> None: +        """Remove expired cooldowns and re-schedule active ones.""" +        log.trace("Checking all cooldowns to remove or re-schedule them.")          guild = self.bot.get_guild(constants.Guild.id) +        cooldown = constants.HelpChannels.claim_minutes * 60 + +        for channel_id, member_id in await self.help_channel_claimants.items(): +            member = guild.get_member(member_id) +            if not member: +                continue  # Member probably left the guild. + +            in_use_time = await self.get_in_use_time(channel_id) -        # TODO: replace with a persistent cache cause checking every member is quite slow -        for member in guild.members: -            if self.is_claimant(member): +            if not in_use_time or in_use_time.seconds > cooldown: +                # Remove the role if no claim time could be retrieved or if the cooldown expired. +                # Since the channel is in the claimants cache, it is definitely strange for a time +                # to not exist. However, it isn't a reason to keep the user stuck with a cooldown.                  await self.remove_cooldown_role(member) +            else: +                # The member is still on a cooldown; re-schedule it for the remaining time. +                remaining = cooldown - in_use_time.seconds +                await self.schedule_cooldown_expiration(member, remaining)      async def add_cooldown_role(self, member: discord.Member) -> None:          """Add the help cooldown role to `member`.""" @@ -781,11 +806,14 @@ class HelpChannels(Scheduler, commands.Cog):          # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner).          self.cancel_task(member.id, ignore_missing=True) -        timeout = constants.HelpChannels.claim_minutes * 60 -        callback = self.remove_cooldown_role(member) +        await self.schedule_cooldown_expiration(member, constants.HelpChannels.claim_minutes * 60) -        log.trace(f"Scheduling {member}'s ({member.id}) send message permissions to be reinstated.") -        self.schedule_task(member.id, TaskData(timeout, callback)) +    async def schedule_cooldown_expiration(self, member: discord.Member, seconds: int) -> None: +        """Schedule the cooldown role for `member` to be removed after a duration of `seconds`.""" +        log.trace(f"Scheduling removal of {member}'s ({member.id}) cooldown.") + +        callback = self.remove_cooldown_role(member) +        self.schedule_task(member.id, TaskData(seconds, callback))      async def send_available_message(self, channel: discord.TextChannel) -> None:          """Send the available message by editing a dormant message or sending a new message.""" diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 5bfaad796..3b28526b2 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -53,7 +53,7 @@ class Infractions(InfractionScheduler, commands.Cog):      # region: Permanent infractions      @command() -    async def warn(self, ctx: Context, user: Member, *, reason: str = None) -> None: +    async def warn(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None:          """Warn a user for the given reason."""          infraction = await utils.post_infraction(ctx, user, "warning", reason, active=False)          if infraction is None: @@ -62,12 +62,12 @@ class Infractions(InfractionScheduler, commands.Cog):          await self.apply_infraction(ctx, infraction, user)      @command() -    async def kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: +    async def kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None:          """Kick a user for the given reason."""          await self.apply_kick(ctx, user, reason, active=False)      @command() -    async def ban(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None: +    async def ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:          """Permanently ban a user for the given reason and stop watching them with Big Brother."""          await self.apply_ban(ctx, user, reason) @@ -75,7 +75,7 @@ class Infractions(InfractionScheduler, commands.Cog):      # region: Temporary infractions      @command(aliases=["mute"]) -    async def tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: str = None) -> None: +    async def tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: t.Optional[str] = None) -> None:          """          Temporarily mute a user for the given reason and duration. @@ -94,7 +94,14 @@ class Infractions(InfractionScheduler, commands.Cog):          await self.apply_mute(ctx, user, reason, expires_at=duration)      @command() -    async def tempban(self, ctx: Context, user: FetchedMember, duration: Expiry, *, reason: str = None) -> None: +    async def tempban( +        self, +        ctx: Context, +        user: FetchedMember, +        duration: Expiry, +        *, +        reason: t.Optional[str] = None +    ) -> None:          """          Temporarily ban a user for the given reason and duration. @@ -116,7 +123,7 @@ class Infractions(InfractionScheduler, commands.Cog):      # region: Permanent shadow infractions      @command(hidden=True) -    async def note(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None: +    async def note(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:          """Create a private note for a user with the given reason without notifying the user."""          infraction = await utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False)          if infraction is None: @@ -125,12 +132,12 @@ class Infractions(InfractionScheduler, commands.Cog):          await self.apply_infraction(ctx, infraction, user)      @command(hidden=True, aliases=['shadowkick', 'skick']) -    async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: +    async def shadow_kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None:          """Kick a user for the given reason without notifying the user."""          await self.apply_kick(ctx, user, reason, hidden=True, active=False)      @command(hidden=True, aliases=['shadowban', 'sban']) -    async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None: +    async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None:          """Permanently ban a user for the given reason without notifying the user."""          await self.apply_ban(ctx, user, reason, hidden=True) @@ -138,7 +145,13 @@ class Infractions(InfractionScheduler, commands.Cog):      # region: Temporary shadow infractions      @command(hidden=True, aliases=["shadowtempmute, stempmute", "shadowmute", "smute"]) -    async def shadow_tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: str = None) -> None: +    async def shadow_tempmute( +        self, ctx: Context, +        user: Member, +        duration: Expiry, +        *, +        reason: t.Optional[str] = None +    ) -> None:          """          Temporarily mute a user for the given reason and duration without notifying the user. @@ -163,7 +176,7 @@ class Infractions(InfractionScheduler, commands.Cog):          user: FetchedMember,          duration: Expiry,          *, -        reason: str = None +        reason: t.Optional[str] = None      ) -> None:          """          Temporarily ban a user for the given reason and duration without notifying the user. @@ -198,7 +211,7 @@ class Infractions(InfractionScheduler, commands.Cog):      # endregion      # region: Base apply functions -    async def apply_mute(self, ctx: Context, user: Member, reason: str, **kwargs) -> None: +    async def apply_mute(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None:          """Apply a mute infraction with kwargs passed to `post_infraction`."""          if await utils.get_active_infraction(ctx, user, "mute"):              return @@ -218,7 +231,7 @@ class Infractions(InfractionScheduler, commands.Cog):          await self.apply_infraction(ctx, infraction, user, action())      @respect_role_hierarchy() -    async def apply_kick(self, ctx: Context, user: Member, reason: str, **kwargs) -> None: +    async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None:          """Apply a kick infraction with kwargs passed to `post_infraction`."""          infraction = await utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs)          if infraction is None: @@ -226,11 +239,14 @@ class Infractions(InfractionScheduler, commands.Cog):          self.mod_log.ignore(Event.member_remove, user.id) -        action = user.kick(reason=textwrap.shorten(reason, width=512, placeholder="...")) +        if reason: +            reason = textwrap.shorten(reason, width=512, placeholder="...") + +        action = user.kick(reason=reason)          await self.apply_infraction(ctx, infraction, user, action)      @respect_role_hierarchy() -    async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: str, **kwargs) -> None: +    async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None:          """          Apply a ban infraction with kwargs passed to `post_infraction`. @@ -259,9 +275,10 @@ class Infractions(InfractionScheduler, commands.Cog):          self.mod_log.ignore(Event.member_remove, user.id) -        truncated_reason = textwrap.shorten(reason, width=512, placeholder="...") +        if reason: +            reason = textwrap.shorten(reason, width=512, placeholder="...") -        action = ctx.guild.ban(user, reason=truncated_reason, delete_message_days=0) +        action = ctx.guild.ban(user, reason=reason, delete_message_days=0)          await self.apply_infraction(ctx, infraction, user, action)          if infraction.get('expires_at') is not None: @@ -281,7 +298,7 @@ class Infractions(InfractionScheduler, commands.Cog):      # endregion      # region: Base pardon functions -    async def pardon_mute(self, user_id: int, guild: discord.Guild, reason: str) -> t.Dict[str, str]: +    async def pardon_mute(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]:          """Remove a user's muted role, DM them a notification, and return a log dict."""          user = guild.get_member(user_id)          log_text = {} @@ -307,7 +324,7 @@ class Infractions(InfractionScheduler, commands.Cog):          return log_text -    async def pardon_ban(self, user_id: int, guild: discord.Guild, reason: str) -> t.Dict[str, str]: +    async def pardon_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]:          """Remove a user's ban on the Discord guild and return a log dict."""          user = discord.Object(user_id)          log_text = {} diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index b03d89537..d75a72ddb 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -127,18 +127,17 @@ class InfractionScheduler(Scheduler):                      dm_result = ":incoming_envelope: "                      dm_log_text = "\nDM: Sent" +        end_msg = ""          if infraction["actor"] == self.bot.user.id:              log.trace(                  f"Infraction #{id_} actor is bot; including the reason in the confirmation message."              ) - -            end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" +            if reason: +                end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})"          elif ctx.channel.id not in STAFF_CHANNELS:              log.trace(                  f"Infraction #{id_} context is not in a staff channel; omitting infraction count."              ) - -            end_msg = ""          else:              log.trace(f"Fetching total infraction count for {user}.") diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index d55e079e9..ef979f222 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -4,7 +4,7 @@ import logging  import re  import typing as t -from discord import Colour, Message +from discord import Colour, Message, NotFound  from discord.ext.commands import Cog  from bot import utils @@ -63,6 +63,10 @@ class TokenRemover(Cog):          See: https://discordapp.com/developers/docs/reference#snowflakes          """ +        # Ignore DMs; can't delete messages in there anyway. +        if not msg.guild or msg.author.bot: +            return +          found_token = self.find_token_in_message(msg)          if found_token:              await self.take_action(msg, found_token) @@ -79,7 +83,13 @@ class TokenRemover(Cog):      async def take_action(self, msg: Message, found_token: Token) -> None:          """Remove the `msg` containing the `found_token` and send a mod log message."""          self.mod_log.ignore(Event.message_delete, msg.id) -        await msg.delete() + +        try: +            await msg.delete() +        except NotFound: +            log.debug(f"Failed to remove token in message {msg.id}: message already deleted.") +            return +          await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention))          log_message = self.format_log_message(msg, found_token) @@ -112,9 +122,6 @@ class TokenRemover(Cog):      @classmethod      def find_token_in_message(cls, msg: Message) -> t.Optional[Token]:          """Return a seemingly valid token found in `msg` or `None` if no token is found.""" -        if msg.author.bot: -            return -          # Use finditer rather than search to guard against method calls prematurely returning the          # token check (e.g. `message.channel.send` also matches our token pattern)          for match in TOKEN_RE.finditer(msg.content): diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 1b5c3f821..543869215 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -1,7 +1,7 @@  import logging  import re -from discord import Colour, Message +from discord import Colour, Message, NotFound  from discord.ext.commands import Cog  from bot.bot import Bot @@ -35,7 +35,13 @@ class WebhookRemover(Cog):          """Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`."""          # Don't log this, due internal delete, not by user. Will make different entry.          self.mod_log.ignore(Event.message_delete, msg.id) -        await msg.delete() + +        try: +            await msg.delete() +        except NotFound: +            log.debug(f"Failed to remove webhook in message {msg.id}: message already deleted.") +            return +          await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention))          message = ( @@ -59,6 +65,10 @@ class WebhookRemover(Cog):      @Cog.listener()      async def on_message(self, msg: Message) -> None:          """Check if a Discord webhook URL is in `message`.""" +        # Ignore DMs; can't delete messages in there anyway. +        if not msg.guild or msg.author.bot: +            return +          matches = WEBHOOK_URL_RE.search(msg.content)          if matches:              await self.delete_and_respond(msg, matches[1] + "xxx") diff --git a/bot/constants.py b/bot/constants.py index 470221369..a1b392c82 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -226,6 +226,7 @@ class Filter(metaclass=YAMLGetter):      notify_user_domains: bool      ping_everyone: bool +    offensive_msg_delete_days: int      guild_invite_whitelist: List[int]      domain_blacklist: List[str]      word_watchlist: List[str] diff --git a/bot/resources/tags/customcooldown.md b/bot/resources/tags/customcooldown.md new file mode 100644 index 000000000..ac7e70aee --- /dev/null +++ b/bot/resources/tags/customcooldown.md @@ -0,0 +1,20 @@ +**Cooldowns in discord.py** + +Cooldowns can be used in discord.py to rate-limit. In this example, we're using it in an on_message. + +```python +from discord.ext import commands + +message_cooldown = commands.CooldownMapping.from_cooldown(1.0, 60.0, commands.BucketType.user) + +async def on_message(message): +    bucket = message_cooldown.get_bucket(message) +    retry_after = bucket.update_rate_limit() +    if retry_after: +        await message.channel.send(f"Slow down! Try again in {retry_after} seconds.") +    else: +        await message.channel.send("Not ratelimited!") +``` + +`from_cooldown` takes the amount of `update_rate_limit()`s needed to trigger the cooldown, the time in which the cooldown is triggered, and a [`BucketType`](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.discord.ext.commands.BucketType). diff --git a/bot/utils/redis_cache.py b/bot/utils/redis_cache.py index 354e987b9..58cfe1df5 100644 --- a/bot/utils/redis_cache.py +++ b/bot/utils/redis_cache.py @@ -11,7 +11,7 @@ log = logging.getLogger(__name__)  # Type aliases  RedisKeyType = Union[str, int] -RedisValueType = Union[str, int, float] +RedisValueType = Union[str, int, float, bool]  RedisKeyOrValue = Union[RedisKeyType, RedisValueType]  # Prefix tuples @@ -20,6 +20,7 @@ _VALUE_PREFIXES = (      ("f|", float),      ("i|", int),      ("s|", str), +    ("b|", bool),  )  _KEY_PREFIXES = (      ("i|", int), @@ -47,8 +48,8 @@ class RedisCache:      behaves, and should be familiar to Python users. The biggest difference is that      all the public methods in this class are coroutines, and must be awaited. -    Because of limitations in Redis, this cache will only accept strings, integers and -    floats both for keys and values. +    Because of limitations in Redis, this cache will only accept strings and integers for keys, +    and strings, integers, floats and booleans for values.      Please note that this class MUST be created as a class attribute, and that that class      must also contain an attribute with an instance of our Bot. See `__get__` and `__set_name__` @@ -108,8 +109,15 @@ class RedisCache:      def _to_typestring(key_or_value: RedisKeyOrValue, prefixes: _PrefixTuple) -> str:          """Turn a valid Redis type into a typestring."""          for prefix, _type in prefixes: -            if isinstance(key_or_value, _type): +            # Convert bools into integers before storing them. +            if type(key_or_value) is bool: +                bool_int = int(key_or_value) +                return f"{prefix}{bool_int}" + +            # isinstance is a bad idea here, because isintance(False, int) == True. +            if type(key_or_value) is _type:                  return f"{prefix}{key_or_value}" +          raise TypeError(f"RedisCache._to_typestring only supports the following: {prefixes}.")      @staticmethod @@ -122,6 +130,13 @@ class RedisCache:          # Now we convert our unicode string back into the type it originally was.          for prefix, _type in prefixes:              if key_or_value.startswith(prefix): + +                # For booleans, we need special handling because bool("False") is True. +                if prefix == "b|": +                    value = key_or_value[len(prefix):] +                    return bool(int(value)) + +                # Otherwise we can just convert normally.                  return _type(key_or_value[len(prefix):])          raise TypeError(f"RedisCache._from_typestring only supports the following: {prefixes}.") diff --git a/config-default.yml b/config-default.yml index aff5fb2e1..64c4e715b 100644 --- a/config-default.yml +++ b/config-default.yml @@ -269,7 +269,8 @@ filter:      notify_user_domains:     false      # Filter configuration -    ping_everyone: true  # Ping @everyone when we send a mod-alert? +    ping_everyone:             true  # Ping @everyone when we send a mod-alert? +    offensive_msg_delete_days: 7     # How many days before deleting an offensive message?      guild_invite_whitelist:          - 280033776820813825  # Functional Programming @@ -331,6 +332,7 @@ filter:          - ssteam.site          - steamwalletgift.com          - discord.gift +        - lmgtfy.com      word_watchlist:          - goo+ks* diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index a10124d2d..3349caa73 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -3,7 +3,7 @@ from re import Match  from unittest import mock  from unittest.mock import MagicMock -from discord import Colour +from discord import Colour, NotFound  from bot import constants  from bot.cogs import token_remover @@ -121,15 +121,16 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):          find_token_in_message.assert_called_once_with(self.msg)          take_action.assert_not_awaited() -    @autospec("bot.cogs.token_remover", "TOKEN_RE") -    def test_find_token_ignores_bot_messages(self, token_re): -        """The token finder should ignore messages authored by bots.""" -        self.msg.author.bot = True +    @autospec(TokenRemover, "find_token_in_message") +    async def test_on_message_ignores_dms_bots(self, find_token_in_message): +        """Shouldn't parse a message if it is a DM or authored by a bot.""" +        cog = TokenRemover(self.bot) +        dm_msg = MockMessage(guild=None) +        bot_msg = MockMessage(author=MagicMock(bot=True)) -        return_value = TokenRemover.find_token_in_message(self.msg) - -        self.assertIsNone(return_value) -        token_re.finditer.assert_not_called() +        for msg in (dm_msg, bot_msg): +            await cog.on_message(msg) +            find_token_in_message.assert_not_called()      @autospec("bot.cogs.token_remover", "TOKEN_RE")      def test_find_token_no_matches(self, token_re): @@ -281,6 +282,19 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase):              channel_id=constants.Channels.mod_alerts          ) +    @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) +    async def test_take_action_delete_failure(self, mod_log_property): +        """Shouldn't send any messages if the token message can't be deleted.""" +        cog = TokenRemover(self.bot) +        mod_log_property.return_value = mock.create_autospec(ModLog, spec_set=True, instance=True) +        self.msg.delete.side_effect = NotFound(MagicMock(), MagicMock()) + +        token = mock.create_autospec(Token, spec_set=True, instance=True) +        await cog.take_action(self.msg, token) + +        self.msg.delete.assert_called_once_with() +        self.msg.channel.send.assert_not_awaited() +  class TokenRemoverExtensionTests(unittest.TestCase):      """Tests for the token_remover extension.""" diff --git a/tests/bot/utils/test_redis_cache.py b/tests/bot/utils/test_redis_cache.py index e5d6e4078..a2f0fe55d 100644 --- a/tests/bot/utils/test_redis_cache.py +++ b/tests/bot/utils/test_redis_cache.py @@ -49,7 +49,9 @@ class RedisCacheTests(unittest.IsolatedAsyncioTestCase):          test_cases = (              ('favorite_fruit', 'melon'),              ('favorite_number', 86), -            ('favorite_fraction', 86.54) +            ('favorite_fraction', 86.54), +            ('favorite_boolean', False), +            ('other_boolean', True),          )          # Test that we can get and set different types. | 
