diff --git a/.gitignore b/.gitignore index e7368a51..646df245 100755 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,10 @@ build/ env/ lib64 .mypy_cache/ +.ruff_cache/ .pytest_cache tmp .coverage +.python-version .venv +.vscode \ No newline at end of file diff --git a/bot/extensions/name_moderation_cog.py b/bot/extensions/name_moderation_cog.py new file mode 100644 index 00000000..e84d7aed --- /dev/null +++ b/bot/extensions/name_moderation_cog.py @@ -0,0 +1,82 @@ +import logging + +from discord import Member +from discord.errors import Forbidden +from discord.ext.commands import Cog + +from bot import app +from bot.grace import Grace +from bot.helpers.log_helper import notice +from bot.services.random_name_service import make_random_name + +logger = logging.getLogger(__name__) + + +class NameModerationCog( + Cog, name="Names", description="Checks and changes user's nickname." +): + """ + A cog that checks when a member joins if they have a bad word in their name, + and changes their name in case they do. + + Configure in the .env file like this: + BLACKLISTED_NAMES = bad_word1, bad_word2, bad_word3,... + """ + + def __init__(self, bot: Grace): + self.bot: Grace = bot + self.bad_words = set(app.config.get("name_moderation", "blacklist", [])) + + @property + def moderation_channel(self): + return self.bot.get_channel_by_name("moderation_logs") + + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + """Chnage a user's nickname if it contains a bad word. + + :param member: The member to check their username or display name. + :type member: discord.Member + """ + + name = member.display_name + + if not self.contains_bad_word(name): + return + + try: + good_name = make_random_name() + await member.edit(nick=good_name) + + log = notice("NAME", f"Username of {name} was changed.") + log.add_field( + "Reason: ", + f"User {name} joined with an inappropriate name, thus it was changed to {good_name}.", + ) + + if self.moderation_channel: + await log.send(self.moderation_channel) + + await member.send( + f"Your name has an inappropriate word in it, thus it was changed from {name} to {good_name}." + ) + + except Forbidden: + logger.info("User left before we could send them a message.") + + def contains_bad_word(self, name: str) -> bool: + """Checks if a name has a bad word in it. + + :param name: The name to check. + :type name: str + + :return: True if the name has a bad word, otherwise false. + :rtype: bool + """ + lowered = name.lower() + + return any(bad_word in lowered for bad_word in self.bad_words) + + +async def setup(bot: Grace): + await bot.add_cog(NameModerationCog(bot)) diff --git a/bot/services/random_name_service.py b/bot/services/random_name_service.py new file mode 100644 index 00000000..5a45d8a6 --- /dev/null +++ b/bot/services/random_name_service.py @@ -0,0 +1,364 @@ +import random + +# source : https://github.com/moby/moby/blob/master/internal/namesgenerator/names-generator.go +_LEFT = [ + "admiring", + "adoring", + "affectionate", + "agitated", + "amazing", + "angry", + "awesome", + "beautiful", + "blissful", + "bold", + "boring", + "brave", + "busy", + "charming", + "clever", + "compassionate", + "competent", + "condescending", + "confident", + "cool", + "cranky", + "crazy", + "dazzling", + "determined", + "distracted", + "dreamy", + "eager", + "ecstatic", + "elastic", + "elated", + "elegant", + "eloquent", + "epic", + "exciting", + "fervent", + "festive", + "flamboyant", + "focused", + "friendly", + "frosty", + "funny", + "gallant", + "gifted", + "goofy", + "gracious", + "great", + "happy", + "hardcore", + "heuristic", + "hopeful", + "hungry", + "infallible", + "inspiring", + "intelligent", + "interesting", + "jolly", + "jovial", + "keen", + "kind", + "laughing", + "loving", + "lucid", + "magical", + "modest", + "musing", + "mystifying", + "naughty", + "nervous", + "nice", + "nifty", + "nostalgic", + "objective", + "optimistic", + "peaceful", + "pedantic", + "pensive", + "practical", + "priceless", + "quirky", + "quizzical", + "recursing", + "relaxed", + "reverent", + "romantic", + "sad", + "serene", + "sharp", + "silly", + "sleepy", + "stoic", + "strange", + "stupefied", + "suspicious", + "sweet", + "tender", + "thirsty", + "trusting", + "unruffled", + "upbeat", + "vibrant", + "vigilant", + "vigorous", + "wizardly", + "wonderful", + "xenodochial", + "youthful", + "zealous", + "zen", +] + +_RIGHT = [ + "agnesi", + "albattani", + "allen", + "almeida", + "antonelli", + "archimedes", + "ardinghelli", + "aryabhata", + "austin", + "babbage", + "banach", + "banzai", + "bardeen", + "bartik", + "bassi", + "beaver", + "bell", + "benz", + "bhabha", + "bhaskara", + "black", + "blackburn", + "blackwell", + "bohr", + "booth", + "borg", + "bose", + "bouman", + "boyd", + "brahmagupta", + "brattain", + "brown", + "buck", + "burnell", + "cannon", + "carson", + "cartwright", + "carver", + "cerf", + "chandrasekhar", + "chaplygin", + "chatelet", + "chatterjee", + "chaum", + "chebyshev", + "clarke", + "cohen", + "colden", + "cori", + "cray", + "curie", + "curran", + "darwin", + "davinci", + "dewdney", + "dhawan", + "diffie", + "dijkstra", + "dirac", + "driscoll", + "dubinsky", + "easley", + "edison", + "einstein", + "elbakyan", + "elgamal", + "elion", + "ellis", + "engelbart", + "euclid", + "euler", + "faraday", + "feistel", + "fermat", + "fermi", + "feynman", + "franklin", + "gagarin", + "galileo", + "galois", + "ganguly", + "gates", + "gauss", + "germain", + "goldberg", + "goldstine", + "goldwasser", + "golick", + "goodall", + "gould", + "greider", + "grothendieck", + "haibt", + "hamilton", + "haslett", + "hawking", + "heisenberg", + "hellman", + "hermann", + "herschel", + "hertz", + "heyrovsky", + "hodgkin", + "hofstadter", + "hoover", + "hopper", + "hugle", + "hypatia", + "ishizaka", + "jackson", + "jang", + "jemison", + "jennings", + "jepsen", + "johnson", + "joliot", + "jones", + "kalam", + "kapitsa", + "kare", + "keldysh", + "keller", + "kepler", + "khayyam", + "khorana", + "kilby", + "kirch", + "knuth", + "kowalevski", + "lalande", + "lamarr", + "lamport", + "leakey", + "leavitt", + "lederberg", + "lehmann", + "lewin", + "lichterman", + "liskov", + "lovelace", + "lumiere", + "mahavira", + "margulis", + "matsumoto", + "maxwell", + "mayer", + "mccarthy", + "mcclintock", + "mclaren", + "mclean", + "mcnulty", + "meitner", + "mendel", + "mendeleev", + "meninsky", + "merkle", + "mestorf", + "mirzakhani", + "montalcini", + "moore", + "morse", + "moser", + "murdock", + "napier", + "nash", + "neumann", + "newton", + "nightingale", + "nobel", + "noether", + "northcutt", + "noyce", + "panini", + "pare", + "pascal", + "pasteur", + "payne", + "perlman", + "pike", + "poincare", + "poitras", + "proskuriakova", + "ptolemy", + "raman", + "ramanujan", + "rhodes", + "ride", + "ritchie", + "robinson", + "roentgen", + "rosalind", + "rubin", + "saha", + "sammet", + "sanderson", + "satoshi", + "shamir", + "shannon", + "shaw", + "shirley", + "shockley", + "shtern", + "sinoussi", + "snyder", + "solomon", + "spence", + "stonebraker", + "sutherland", + "swanson", + "swartz", + "swirles", + "taussig", + "tesla", + "tharp", + "thompson", + "torvalds", + "tu", + "turing", + "varahamihira", + "vaughan", + "villani", + "visvesvaraya", + "volhard", + "wescoff", + "wilbur", + "wiles", + "williams", + "williamson", + "wilson", + "wing", + "wozniak", + "wright", + "wu", + "yalow", + "yonath", + "zhukovsky", +] + + +def make_random_name() -> str: + """Makes a random two words name. + + :return: The random name generated by the function. + :rtype: str + """ + + name = f"{random.choice(_LEFT)} {random.choice(_RIGHT)}".title() + + return name diff --git a/config/settings.cfg b/config/settings.cfg index c9c89b5e..4724db7c 100644 --- a/config/settings.cfg +++ b/config/settings.cfg @@ -29,3 +29,6 @@ minimum_account_age = 30 [reddit] blacklist = ${REDDIT_BLACKLIST} + +[name_moderation] +blacklist = ${BLACKLISTED_NAMES} \ No newline at end of file diff --git a/tests/extensions/test_name_moderation_cog.py b/tests/extensions/test_name_moderation_cog.py new file mode 100644 index 00000000..01b0d048 --- /dev/null +++ b/tests/extensions/test_name_moderation_cog.py @@ -0,0 +1,75 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from bot.extensions.name_moderation_cog import NameModerationCog + + +@pytest.fixture +def name_moderation_cog(mock_bot): + """Instantiate the NameModerationCog with a mock bot.""" + + mock_bot.get_channel_by_name.return_value = "Mod room" + + return NameModerationCog(mock_bot) + + +@pytest.fixture +def mock_ctx(): + """Create a mock command context.""" + ctx = MagicMock() + ctx.defer = AsyncMock() + ctx.send = AsyncMock() + return ctx + + +@pytest.mark.asyncio +@patch("bot.extensions.name_moderation_cog.notice") +@patch("bot.extensions.name_moderation_cog.make_random_name") +async def test_name_moderation_on_member_join__with_none_blacklisted_name__expect_nothing( + mock_random, mock_notice, name_moderation_cog +): + member = AsyncMock() + member.display_name = "Normal Name" + + name_moderation_cog.bad_words = ["nothing"] + await name_moderation_cog.on_member_join(member) + + mock_random.assert_not_called() + member.edit.assert_not_awaited() + member.send.assert_not_awaited() + mock_notice.assert_not_called() + + +@pytest.mark.asyncio +@patch("bot.extensions.name_moderation_cog.notice") +@patch("bot.extensions.name_moderation_cog.make_random_name") +async def test_name_moderation_on_member_join__with_blacklisted_name__expect_change_and_message( + mock_random, mock_notice, name_moderation_cog +): + member = AsyncMock() + member.display_name = "Bad Name" + + mock_log = MagicMock() + mock_notice.return_value = mock_log + mock_log.send = AsyncMock() + + mock_random.return_value = "Good Name" + + name_moderation_cog.bad_words = ["bad"] + + await name_moderation_cog.on_member_join(member) + + mock_random.assert_called_once() + + member.edit.assert_awaited_once_with(nick="Good Name") + member.send.assert_awaited_once_with( + "Your name has an inappropriate word in it, thus it was changed from Bad Name to Good Name." + ) + + mock_notice.assert_called_once_with("NAME", "Username of Bad Name was changed.") + mock_log.add_field.assert_called_once_with( + "Reason: ", + "User Bad Name joined with an inappropriate name, thus it was changed to Good Name.", + ) + mock_log.send.assert_awaited_once_with("Mod room") diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/services/test_random_name_service.py b/tests/services/test_random_name_service.py new file mode 100644 index 00000000..383e347b --- /dev/null +++ b/tests/services/test_random_name_service.py @@ -0,0 +1,12 @@ +from unittest.mock import patch + +from bot.services.random_name_service import make_random_name + + +@patch("random.choice") +def test_random_name_service__expect_two_capitalized_words(mock_random): + mock_random.side_effect = ["good", "grace"] + + assert make_random_name() == "Good Grace" + + assert mock_random.call_count == 2