Repository: python-discord/bot

Issue:
Detecting bad words in nicknames
## Description
Since the management decided that we don't care about detecting names which are hard to mention
I changed this issue to solve something different - Bad word detection in our members nicknames.

This system will contain some checks that will be triggered on the event `on_message`, the display_name of the message author will be checked, and if it contains bad words, will be reported or logged.

The checks should be fast and simple to prevent long and heavy calculations that are triggered on each message, but we can expand them to fit our needs.


### Solution
**This system should be implemented as part of the Filtering cog, especially considering the overlap between this and the regex filter (formerly word and token filter).**

We will be using REGEX (Can use the same wordlists we have in the regex filters.) to detect bad words in the members `display_name` (If they have a nice nickname and an awful username, we don't care.)

This should be detected in the event `on_message`, but we want a long cooldown so we don't get notified that often - maybe 3 days.


There is no need to ping moderators about this when this filter triggers. This will be a pingless alert.

The correct channel to deliver this to is #mod-alerts (ID: 473092532147060736)



Discussion:
Think a good idea would be to check if the user has any Unicode, to begin with, *before* checking how many ASCII characters they have. I would just require a simple check if their entire name is ASCII why bother counting it. Just reduces the number of times calling a loop, altho minor.
The check for any Unicode by itself requires a loop over the string, so in that light might as well reduce it to a single loop of counting how many mentionable `alnum` in my opinion.

The deal breaker will be how we can make it not spammy, so it will trigger once per user per day perhaps.
wouldn't just `<name>.isascii()` work for skipping over that loop? if the username is `MyUsername#6621` it will be true, so we could just do `if not <name>.isascii()` then check using the loop
Okay, so I've discussed this with the admins and moderators, and we have several comments. @F4zii Please use these comments to reformulate the original issue text and change what this issue is going to solve.

1. We don't care about detecting names which are hard to mention. It's overkill, todays current system works fine. Let's not do that.
2. However, we might want to detect nicknames that have bad words in them. You can use the same wordlists we have in the regex filters.
3. On that note, this should be implemented as part of the Filtering cog, especially considering the overlap between this and the regex filter (formerly word and token filter).
4. This should be detected on_message, but we want a long cooldown so we don't get notified that often - maybe 3 days.
5. There is no need to ping moderators about this when this filter triggers. This will be a pingless alert.
6. The correct channel to deliver this to is #mod-alerts (ID: 473092532147060736)
7. Only check the users display_name, not their username. If they have a nice nickname and an awful username, we don't care.

Please pivot this issue to incorporate all of these directions, and then you have my approval.


About the cooldown itself @lemonsaurus
the check cooldown will be per user ? or just the cooldown of being notified?
per user, yeah. We don't want to be notified of the same user every time they send a message, right? that would just be spam.
Could use just some simple dictionary with a timestamp to keep it quick and just check if timestamp diffrence is past a certain point

Something like that. Honestly, cooldowns are a solved problem in the bot, so just do it however we're doing it in other cogs. Ideally in a DRY way.
Maybe a warning over dm or in channel could be issued to the user with the inappropriate nickname as well?
> Maybe a warning over dm or in channel could be issued to the user with the inappropriate nickname as well?

This sounds like automoderation, which we don't want to do. Our bad word filter has false positives, and I don't want it DMing users when that happens. It's better to let our moderators make these kinds of decisions.
I think this should listen for `on_member_update` event, not `on_message` because `on_message` will be called too much. `on_member_update` should check is `display_name` changed and when it is, do check.

Resolving patch:
diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py
--- 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 @@
     Channels, Colours,
     Filter, Icons, URLs
 )
+from bot.utils.redis_cache import RedisCache
 
 log = logging.getLogger(__name__)
 
@@ -40,6 +43,8 @@
 ]
 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 @@ def mod_log(self) -> ModLog:
     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 @@ async def on_message_edit(self, before: Message, after: Message) -> None:
             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/utils/redis_cache.py b/bot/utils/redis_cache.py
--- a/bot/utils/redis_cache.py
+++ b/bot/utils/redis_cache.py
@@ -100,16 +100,7 @@ def __init__(self) -> None:
 
     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
 


Test patch:
diff --git a/tests/bot/utils/test_redis_cache.py b/tests/bot/utils/test_redis_cache.py
--- a/tests/bot/utils/test_redis_cache.py
+++ b/tests/bot/utils/test_redis_cache.py
@@ -44,16 +44,6 @@ async def test_class_attribute_required(self):
         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 = (