diff options
| -rw-r--r-- | bot/cogs/filtering.py | 61 | ||||
| -rw-r--r-- | bot/cogs/site.py | 2 | ||||
| -rw-r--r-- | bot/utils/redis_cache.py | 11 | ||||
| -rw-r--r-- | tests/bot/utils/test_redis_cache.py | 10 | 
4 files changed, 62 insertions, 22 deletions
| diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 1d9fddb12..4ebc831e1 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -1,6 +1,8 @@ +import asyncio  import logging  import re -from typing import Optional, Union +from datetime import datetime, timedelta +from typing import List, Optional, Union  import discord.errors  from dateutil.relativedelta import relativedelta @@ -14,6 +16,7 @@ from bot.constants import (      Channels, Colours,      Filter, Icons, URLs  ) +from bot.utils.redis_cache import RedisCache  log = logging.getLogger(__name__) @@ -40,6 +43,8 @@ TOKEN_WATCHLIST_PATTERNS = [  ]  WATCHLIST_PATTERNS = WORD_WATCHLIST_PATTERNS + TOKEN_WATCHLIST_PATTERNS +DAYS_BETWEEN_ALERTS = 3 +  def expand_spoilers(text: str) -> str:      """Return a string containing all interpretations of a spoilered message.""" @@ -52,8 +57,12 @@ def expand_spoilers(text: str) -> str:  class Filtering(Cog):      """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 +    name_alerts = RedisCache() +      def __init__(self, bot: Bot):          self.bot = bot +        self.name_lock = asyncio.Lock()          staff_mistake_str = "If you believe this was a mistake, please let staff know!"          self.filters = { @@ -112,6 +121,7 @@ class Filtering(Cog):      async def on_message(self, msg: Message) -> None:          """Invoke message filter for new messages."""          await self._filter_message(msg) +        await self.check_bad_words_in_name(msg.author)      @Cog.listener()      async def on_message_edit(self, before: Message, after: Message) -> None: @@ -126,6 +136,55 @@ class Filtering(Cog):              delta = relativedelta(after.edited_at, before.edited_at).microseconds          await self._filter_message(after, delta) +    @staticmethod +    def get_name_matches(name: str) -> List[re.Match]: +        """Check bad words from passed string (name). Return list of matches.""" +        matches = [] +        for pattern in WATCHLIST_PATTERNS: +            if match := pattern.search(name): +                matches.append(match) +        return matches + +    async def check_send_alert(self, member: Member) -> bool: +        """When there is less than 3 days after last alert, return `False`, otherwise `True`.""" +        if last_alert := await self.name_alerts.get(member.id): +            last_alert = datetime.utcfromtimestamp(last_alert) +            if datetime.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert: +                log.trace(f"Last alert was too recent for {member}'s nickname.") +                return False + +        return True + +    async def check_bad_words_in_name(self, member: Member) -> None: +        """Send a mod alert every 3 days if a username still matches a watchlist pattern.""" +        # Use lock to avoid race conditions +        async with self.name_lock: +            # Check whether the users display name contains any words in our blacklist +            matches = self.get_name_matches(member.display_name) + +            if not matches or not await self.check_send_alert(member): +                return + +            log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).") + +            log_string = ( +                f"**User:** {member.mention} (`{member.id}`)\n" +                f"**Display Name:** {member.display_name}\n" +                f"**Bad Matches:** {', '.join(match.group() for match in matches)}" +            ) + +            await self.mod_log.send_log_message( +                icon_url=Icons.token_removed, +                colour=Colours.soft_red, +                title="Username filtering alert", +                text=log_string, +                channel_id=Channels.mod_alerts, +                thumbnail=member.avatar_url +            ) + +            # Update time when alert sent +            await self.name_alerts.set(member.id, datetime.utcnow().timestamp()) +      async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None:          """Filter the input message to see if it violates any of our rules, and then respond accordingly."""          # Should we filter this message? diff --git a/bot/cogs/site.py b/bot/cogs/site.py index e61cd5003..ac29daa1d 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -33,7 +33,7 @@ class Site(Cog):          embed.colour = Colour.blurple()          embed.description = (              f"[Our official website]({url}) is an open-source community project " -            "created with Python and Flask. It contains information about the server " +            "created with Python and Django. It contains information about the server "              "itself, lets you sign up for upcoming events, has its own wiki, contains "              "a list of valuable learning resources, and much more."          ) diff --git a/bot/utils/redis_cache.py b/bot/utils/redis_cache.py index de80cee84..354e987b9 100644 --- a/bot/utils/redis_cache.py +++ b/bot/utils/redis_cache.py @@ -100,16 +100,7 @@ class RedisCache:      def _set_namespace(self, namespace: str) -> None:          """Try to set the namespace, but do not permit collisions.""" -        # We need a unique namespace, to prevent collisions. This loop -        # will try appending underscores to the end of the namespace until -        # it finds one that is unique. -        # -        # For example, if `john` and `john_`  are both taken, the namespace will -        # be `john__` at the end of this loop. -        while namespace in self._namespaces: -            namespace += "_" - -        log.trace(f"RedisCache setting namespace to {self._namespace}") +        log.trace(f"RedisCache setting namespace to {namespace}")          self._namespaces.append(namespace)          self._namespace = namespace diff --git a/tests/bot/utils/test_redis_cache.py b/tests/bot/utils/test_redis_cache.py index 8c1a40640..e5d6e4078 100644 --- a/tests/bot/utils/test_redis_cache.py +++ b/tests/bot/utils/test_redis_cache.py @@ -44,16 +44,6 @@ class RedisCacheTests(unittest.IsolatedAsyncioTestCase):          with self.assertRaises(RuntimeError):              await bad_cache.set("test", "me_up_deadman") -    def test_namespace_collision(self): -        """Test that we prevent colliding namespaces.""" -        bob_cache_1 = RedisCache() -        bob_cache_1._set_namespace("BobRoss") -        self.assertEqual(bob_cache_1._namespace, "BobRoss") - -        bob_cache_2 = RedisCache() -        bob_cache_2._set_namespace("BobRoss") -        self.assertEqual(bob_cache_2._namespace, "BobRoss_") -      async def test_set_get_item(self):          """Test that users can set and get items from the RedisDict."""          test_cases = ( | 
