From 75a8f9a6262fcf188ce54b9fb8130b041c648cd4 Mon Sep 17 00:00:00 2001 From: Eyad Jawad Date: Fri, 31 Jul 2026 21:22:40 +0300 Subject: [PATCH 1/7] Add user nick name moderation, and change user nick name to random name feature --- bot/extensions/name_cog.py | 113 +++++++ bot/services/random_name_service.py | 364 +++++++++++++++++++++ tests/extensions/test_name_cog.py | 134 ++++++++ tests/services/__init__.py | 0 tests/services/test_random_name_service.py | 11 + 5 files changed, 622 insertions(+) create mode 100644 bot/extensions/name_cog.py create mode 100644 bot/services/random_name_service.py create mode 100644 tests/extensions/test_name_cog.py create mode 100644 tests/services/__init__.py create mode 100644 tests/services/test_random_name_service.py diff --git a/bot/extensions/name_cog.py b/bot/extensions/name_cog.py new file mode 100644 index 00000000..8dec4283 --- /dev/null +++ b/bot/extensions/name_cog.py @@ -0,0 +1,113 @@ +import logging + +logger = logging.getLogger(__name__) + +from bot import app +from bot.grace import Grace +from discord import Member, Embed +from discord.ext.commands import Cog, Context, hybrid_command +from bot.services.random_name_service import make_random_name +from bot.helpers.log_helper import notice + + +def slice_name(name: str) -> list[str]: + """Makes all possible slices of a string, excluding ones shorter than 2 chars. + + :param name: The name or string to get sliced. + :type name: str + + :return: The list of all possible slices longer than 2 chars of the name. + :rtype: list[str] + """ + + # There's no inappropriate word of two characters, + # that's why we discard slices of length 2 or 1 + return [ + name[i:j].lower() + for i in range(len(name)) + for j in range(len(name) + 1) + if len(name[i:j]) > 2 + ] + + +class NameCog(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.""" + + BAD_WORDS = set(app.config.get("reddit", "blacklist", "").split(";")) + + def __init__(self, bot: Grace): + self.bot: Grace = bot + + @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 + + name_slices = slice_name(NAME) + + if not self.check_slices_against_bad_words(name_slices): + return + + 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}." + ) + + @hybrid_command( + name="random-name", + description="Changes the name of the user who issued the command to a random name.", + ) + async def give_random_name(self, ctx: Context) -> None: + """Gives the user who invoked the command a new random two words name. + + :param ctx: The context in which the command was invoked. + :type ctx: Context + """ + + old_name = ctx.author.display_name + new_name = make_random_name() + + await ctx.author.edit(nick=new_name) + + logger.info(f"User {old_name} requested a new random name, got {new_name}!") + + name_message: Embed = Embed(title="Name Changed!", color=self.bot.default_color) + name_message.description = ( + f"Your name was changed from {old_name} to {new_name}!" + ) + + await ctx.send(embed=name_message) + + def check_slices_against_bad_words(self, slices: list[str]) -> bool: + """Checks if a name has a bad word in it. + + :param slices: Slices of the name to check. + :type slices: list[str] + + :return: True if the name has a bad word, otherwise false. + :rtype: bool + """ + slices_set = set(slices) + + return not slices_set.isdisjoint(self.BAD_WORDS) + + +async def setup(bot: Grace): + await bot.add_cog(NameCog(bot)) diff --git a/bot/services/random_name_service.py b/bot/services/random_name_service.py new file mode 100644 index 00000000..bbdb128b --- /dev/null +++ b/bot/services/random_name_service.py @@ -0,0 +1,364 @@ +import random + + +def make_random_name() -> str: + """Makes a random two words name. + + :return: The random name generated by the function. + :rtype: str + """ + + # 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", + ] + + name = f"{random.choice(left).capitalize()} {random.choice(right).capitalize()}" + + return name diff --git a/tests/extensions/test_name_cog.py b/tests/extensions/test_name_cog.py new file mode 100644 index 00000000..1785f099 --- /dev/null +++ b/tests/extensions/test_name_cog.py @@ -0,0 +1,134 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from discord import Embed +from bot.extensions.name_cog import * + + +@pytest.fixture +def name_cog(mock_bot): + """Instantiate the NameCog with a mock bot.""" + + mock_bot.get_channel_by_name.return_value = "Mod room" + + return NameCog(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_cog.notice") +@patch("bot.extensions.name_cog.make_random_name") +@patch("bot.extensions.name_cog.slice_name") +async def test_name_on_member_join__with_normal_name__expect_nothing(mock_slice, mock_random, mock_notice, name_cog): + member = AsyncMock() + member.display_name = "Normal Name" + + mock_slice.return_value = ["Name"] + + name_cog.BAD_WORDS = ["Nothing"] + await name_cog.on_member_join(member) + + mock_slice.assert_called_once_with("Normal Name") + 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_cog.notice") +@patch("bot.extensions.name_cog.make_random_name") +@patch("bot.extensions.name_cog.slice_name") +async def test_name_on_member_join__with_bad_name__expect_change_and_message(mock_slice, mock_random, mock_notice, name_cog): + member = AsyncMock() + member.display_name = "Bad Name" + + mock_log = MagicMock() + mock_notice.return_value = mock_log + mock_log.send = AsyncMock() + + mock_slice.return_value = ["Bad"] + mock_random.return_value = "Good Name" + + name_cog.BAD_WORDS = ["Bad"] + + await name_cog.on_member_join(member) + + mock_slice.assert_called_once_with("Bad Name") + + 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") + + +@pytest.mark.asyncio +@patch("bot.extensions.name_cog.make_random_name") +async def test_name_give_random_name__expect_random_name(mock_random, mock_bot, mock_ctx, name_cog): + mock_ctx.author.display_name = "Old name" + mock_ctx.author.edit = AsyncMock() + mock_random.return_value = "New name" + + await name_cog.give_random_name(name_cog, mock_ctx) + + result = Embed(title="Name Changed!", color=mock_bot.default_color) + result.description = "Your name was changed from Old name to New name!" + + mock_random.assert_called_once() + mock_ctx.author.edit.assert_awaited_once_with(nick="New name") + mock_ctx.send.assert_awaited_once_with(embed=result) + + +@pytest.mark.parametrize( + ("slices, bad_word_set, output"), + [ + [["Name"], {"Stuff"}, False], + [["Name"], {"Name"}, True], + [["Name"], {"Stuff", "Name"}, True], + [["Stuff", "Name"], {"Stuff", "Name"}, True], + ] +) +def test_check_slices__expecgtg_matching_output(slices, bad_word_set, output, name_cog): + name_cog.BAD_WORDS = bad_word_set + + assert name_cog.check_slices_against_bad_words(slices) == output + + +@pytest.mark.parametrize( + ("input_name, output_list"), + [ + ["a", []], + ["ab", []], + ["abc", ["abc"]], + ["🐔b1%", ["🐔b1", "🐔b1%", "b1%"]], + ] +) +def test_slice_name__with_normal_input__expect_matching_output(input_name, output_list): + assert slice_name(input_name) == output_list + + +@pytest.mark.parametrize( + ("input_name, output_list"), + [ + ["aBc", ["abc"]], + ["AbC", ["abc"]], + ["ABc", ["abc"]], + ["ABC", ["abc"]], + ["AB1🐔", ["ab1", "ab1🐔", "b1🐔"]], + ] +) +def test_slice_name__with_uppercase_input__expect_case_insensitive_output(input_name, output_list): + assert slice_name(input_name) == output_list 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..51564344 --- /dev/null +++ b/tests/services/test_random_name_service.py @@ -0,0 +1,11 @@ +from unittest.mock import patch + +from bot.services.random_name_service import make_random_name + +@patch("random.choice") +def test_random_name_service__expect_normal_output(mock_random): + mock_random.side_effect = ["good", "grace"] + + assert make_random_name() == "Good Grace" + + assert mock_random.call_count == 2 From fe843fc13fe3ae4acc0787644bce5d874ddece1b Mon Sep 17 00:00:00 2001 From: Eyad Jawad Date: Fri, 31 Jul 2026 21:27:47 +0300 Subject: [PATCH 2/7] Format using black --- bot/extensions/name_cog.py | 5 ++- tests/extensions/test_name_cog.py | 36 ++++++++++++++-------- tests/services/test_random_name_service.py | 1 + 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/bot/extensions/name_cog.py b/bot/extensions/name_cog.py index 8dec4283..e7657420 100644 --- a/bot/extensions/name_cog.py +++ b/bot/extensions/name_cog.py @@ -61,7 +61,10 @@ async def on_member_join(self, member: Member) -> None: 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}.") + 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) diff --git a/tests/extensions/test_name_cog.py b/tests/extensions/test_name_cog.py index 1785f099..9bdfdb40 100644 --- a/tests/extensions/test_name_cog.py +++ b/tests/extensions/test_name_cog.py @@ -23,12 +23,13 @@ def mock_ctx(): return ctx - @pytest.mark.asyncio @patch("bot.extensions.name_cog.notice") @patch("bot.extensions.name_cog.make_random_name") @patch("bot.extensions.name_cog.slice_name") -async def test_name_on_member_join__with_normal_name__expect_nothing(mock_slice, mock_random, mock_notice, name_cog): +async def test_name_on_member_join__with_normal_name__expect_nothing( + mock_slice, mock_random, mock_notice, name_cog +): member = AsyncMock() member.display_name = "Normal Name" @@ -48,7 +49,9 @@ async def test_name_on_member_join__with_normal_name__expect_nothing(mock_slice, @patch("bot.extensions.name_cog.notice") @patch("bot.extensions.name_cog.make_random_name") @patch("bot.extensions.name_cog.slice_name") -async def test_name_on_member_join__with_bad_name__expect_change_and_message(mock_slice, mock_random, mock_notice, name_cog): +async def test_name_on_member_join__with_bad_name__expect_change_and_message( + mock_slice, mock_random, mock_notice, name_cog +): member = AsyncMock() member.display_name = "Bad Name" @@ -68,16 +71,23 @@ async def test_name_on_member_join__with_bad_name__expect_change_and_message(moc 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.") + 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.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") @pytest.mark.asyncio @patch("bot.extensions.name_cog.make_random_name") -async def test_name_give_random_name__expect_random_name(mock_random, mock_bot, mock_ctx, name_cog): +async def test_name_give_random_name__expect_random_name( + mock_random, mock_bot, mock_ctx, name_cog +): mock_ctx.author.display_name = "Old name" mock_ctx.author.edit = AsyncMock() mock_random.return_value = "New name" @@ -99,7 +109,7 @@ async def test_name_give_random_name__expect_random_name(mock_random, mock_bot, [["Name"], {"Name"}, True], [["Name"], {"Stuff", "Name"}, True], [["Stuff", "Name"], {"Stuff", "Name"}, True], - ] + ], ) def test_check_slices__expecgtg_matching_output(slices, bad_word_set, output, name_cog): name_cog.BAD_WORDS = bad_word_set @@ -114,10 +124,10 @@ def test_check_slices__expecgtg_matching_output(slices, bad_word_set, output, na ["ab", []], ["abc", ["abc"]], ["🐔b1%", ["🐔b1", "🐔b1%", "b1%"]], - ] + ], ) def test_slice_name__with_normal_input__expect_matching_output(input_name, output_list): - assert slice_name(input_name) == output_list + assert slice_name(input_name) == output_list @pytest.mark.parametrize( @@ -128,7 +138,9 @@ def test_slice_name__with_normal_input__expect_matching_output(input_name, outpu ["ABc", ["abc"]], ["ABC", ["abc"]], ["AB1🐔", ["ab1", "ab1🐔", "b1🐔"]], - ] + ], ) -def test_slice_name__with_uppercase_input__expect_case_insensitive_output(input_name, output_list): - assert slice_name(input_name) == output_list +def test_slice_name__with_uppercase_input__expect_case_insensitive_output( + input_name, output_list +): + assert slice_name(input_name) == output_list diff --git a/tests/services/test_random_name_service.py b/tests/services/test_random_name_service.py index 51564344..2b4b06e6 100644 --- a/tests/services/test_random_name_service.py +++ b/tests/services/test_random_name_service.py @@ -2,6 +2,7 @@ from bot.services.random_name_service import make_random_name + @patch("random.choice") def test_random_name_service__expect_normal_output(mock_random): mock_random.side_effect = ["good", "grace"] From 59483de56c0fa97ea18aefa153ab0de6c5a99b75 Mon Sep 17 00:00:00 2001 From: Eyad Jawad Date: Fri, 31 Jul 2026 21:50:07 +0300 Subject: [PATCH 3/7] Refactor name cog -> name moderation cog --- .gitignore | 3 ++ .../{name_cog.py => name_moderation_cog.py} | 6 ++- ...ame_cog.py => test_name_moderation_cog.py} | 52 ++++++++++--------- 3 files changed, 34 insertions(+), 27 deletions(-) rename bot/extensions/{name_cog.py => name_moderation_cog.py} (95%) rename tests/extensions/{test_name_cog.py => test_name_moderation_cog.py} (66%) 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_cog.py b/bot/extensions/name_moderation_cog.py similarity index 95% rename from bot/extensions/name_cog.py rename to bot/extensions/name_moderation_cog.py index e7657420..7b73f8e5 100644 --- a/bot/extensions/name_cog.py +++ b/bot/extensions/name_moderation_cog.py @@ -30,7 +30,9 @@ def slice_name(name: str) -> list[str]: ] -class NameCog(Cog, name="Names", description="Checks and changes user's nickname."): +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.""" BAD_WORDS = set(app.config.get("reddit", "blacklist", "").split(";")) @@ -113,4 +115,4 @@ def check_slices_against_bad_words(self, slices: list[str]) -> bool: async def setup(bot: Grace): - await bot.add_cog(NameCog(bot)) + await bot.add_cog(NameModerationCog(bot)) diff --git a/tests/extensions/test_name_cog.py b/tests/extensions/test_name_moderation_cog.py similarity index 66% rename from tests/extensions/test_name_cog.py rename to tests/extensions/test_name_moderation_cog.py index 9bdfdb40..6969d256 100644 --- a/tests/extensions/test_name_cog.py +++ b/tests/extensions/test_name_moderation_cog.py @@ -2,16 +2,16 @@ import pytest from discord import Embed -from bot.extensions.name_cog import * +from bot.extensions.name_moderation_cog import * @pytest.fixture -def name_cog(mock_bot): - """Instantiate the NameCog with a mock bot.""" +def name_moderation_cog(mock_bot): + """Instantiate the NameModerationCog with a mock bot.""" mock_bot.get_channel_by_name.return_value = "Mod room" - return NameCog(mock_bot) + return NameModerationCog(mock_bot) @pytest.fixture @@ -24,19 +24,19 @@ def mock_ctx(): @pytest.mark.asyncio -@patch("bot.extensions.name_cog.notice") -@patch("bot.extensions.name_cog.make_random_name") -@patch("bot.extensions.name_cog.slice_name") -async def test_name_on_member_join__with_normal_name__expect_nothing( - mock_slice, mock_random, mock_notice, name_cog +@patch("bot.extensions.name_moderation_cog.notice") +@patch("bot.extensions.name_moderation_cog.make_random_name") +@patch("bot.extensions.name_moderation_cog.slice_name") +async def test_name_moderation_on_member_join__with_normal_name__expect_nothing( + mock_slice, mock_random, mock_notice, name_moderation_cog ): member = AsyncMock() member.display_name = "Normal Name" mock_slice.return_value = ["Name"] - name_cog.BAD_WORDS = ["Nothing"] - await name_cog.on_member_join(member) + name_moderation_cog.BAD_WORDS = ["Nothing"] + await name_moderation_cog.on_member_join(member) mock_slice.assert_called_once_with("Normal Name") mock_random.assert_not_called() @@ -46,11 +46,11 @@ async def test_name_on_member_join__with_normal_name__expect_nothing( @pytest.mark.asyncio -@patch("bot.extensions.name_cog.notice") -@patch("bot.extensions.name_cog.make_random_name") -@patch("bot.extensions.name_cog.slice_name") -async def test_name_on_member_join__with_bad_name__expect_change_and_message( - mock_slice, mock_random, mock_notice, name_cog +@patch("bot.extensions.name_moderation_cog.notice") +@patch("bot.extensions.name_moderation_cog.make_random_name") +@patch("bot.extensions.name_moderation_cog.slice_name") +async def test_name_moderation_on_member_join__with_bad_name__expect_change_and_message( + mock_slice, mock_random, mock_notice, name_moderation_cog ): member = AsyncMock() member.display_name = "Bad Name" @@ -62,9 +62,9 @@ async def test_name_on_member_join__with_bad_name__expect_change_and_message( mock_slice.return_value = ["Bad"] mock_random.return_value = "Good Name" - name_cog.BAD_WORDS = ["Bad"] + name_moderation_cog.BAD_WORDS = ["Bad"] - await name_cog.on_member_join(member) + await name_moderation_cog.on_member_join(member) mock_slice.assert_called_once_with("Bad Name") @@ -84,15 +84,15 @@ async def test_name_on_member_join__with_bad_name__expect_change_and_message( @pytest.mark.asyncio -@patch("bot.extensions.name_cog.make_random_name") -async def test_name_give_random_name__expect_random_name( - mock_random, mock_bot, mock_ctx, name_cog +@patch("bot.extensions.name_moderation_cog.make_random_name") +async def test_name_moderation_give_random_name__expect_random_name( + mock_random, mock_bot, mock_ctx, name_moderation_cog ): mock_ctx.author.display_name = "Old name" mock_ctx.author.edit = AsyncMock() mock_random.return_value = "New name" - await name_cog.give_random_name(name_cog, mock_ctx) + await name_moderation_cog.give_random_name(name_moderation_cog, mock_ctx) result = Embed(title="Name Changed!", color=mock_bot.default_color) result.description = "Your name was changed from Old name to New name!" @@ -111,10 +111,12 @@ async def test_name_give_random_name__expect_random_name( [["Stuff", "Name"], {"Stuff", "Name"}, True], ], ) -def test_check_slices__expecgtg_matching_output(slices, bad_word_set, output, name_cog): - name_cog.BAD_WORDS = bad_word_set +def test_check_slices__expecgtg_matching_output( + slices, bad_word_set, output, name_moderation_cog +): + name_moderation_cog.BAD_WORDS = bad_word_set - assert name_cog.check_slices_against_bad_words(slices) == output + assert name_moderation_cog.check_slices_against_bad_words(slices) == output @pytest.mark.parametrize( From c801fbd305f9e326cef720ac1f4ef5a036c79d79 Mon Sep 17 00:00:00 2001 From: Eyad Jawad Date: Sat, 1 Aug 2026 13:58:01 +0300 Subject: [PATCH 4/7] Fix it according to the review --- bot/extensions/name_moderation_cog.py | 78 +- bot/services/random_name_service.py | 704 +++++++++---------- config/settings.cfg | 3 + tests/extensions/test_name_moderation_cog.py | 88 +-- tests/services/test_random_name_service.py | 2 +- 5 files changed, 376 insertions(+), 499 deletions(-) diff --git a/bot/extensions/name_moderation_cog.py b/bot/extensions/name_moderation_cog.py index 7b73f8e5..ec3c9af4 100644 --- a/bot/extensions/name_moderation_cog.py +++ b/bot/extensions/name_moderation_cog.py @@ -1,44 +1,19 @@ -import logging - -logger = logging.getLogger(__name__) - from bot import app from bot.grace import Grace -from discord import Member, Embed -from discord.ext.commands import Cog, Context, hybrid_command +from discord import Member +from discord.ext.commands import Cog from bot.services.random_name_service import make_random_name from bot.helpers.log_helper import notice -def slice_name(name: str) -> list[str]: - """Makes all possible slices of a string, excluding ones shorter than 2 chars. - - :param name: The name or string to get sliced. - :type name: str - - :return: The list of all possible slices longer than 2 chars of the name. - :rtype: list[str] - """ - - # There's no inappropriate word of two characters, - # that's why we discard slices of length 2 or 1 - return [ - name[i:j].lower() - for i in range(len(name)) - for j in range(len(name) + 1) - if len(name[i:j]) > 2 - ] - - 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.""" - BAD_WORDS = set(app.config.get("reddit", "blacklist", "").split(";")) - def __init__(self, bot: Grace): self.bot: Grace = bot + self.BAD_WORDS = set(app.config.get("blacklisted", "names", "")) @property def moderation_channel(self): @@ -52,66 +27,39 @@ async def on_member_join(self, member: Member) -> None: :type member: discord.Member """ - NAME = member.display_name - - name_slices = slice_name(NAME) + name = member.display_name - if not self.check_slices_against_bad_words(name_slices): + if not self.contains_bad_word(name): return good_name = make_random_name() await member.edit(nick=good_name) - log = notice("NAME", f"Username of {NAME} was changed.") + 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}.", + 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}." + f"Your name has an inappropriate word in it, thus it was changed from {name} to {good_name}." ) - @hybrid_command( - name="random-name", - description="Changes the name of the user who issued the command to a random name.", - ) - async def give_random_name(self, ctx: Context) -> None: - """Gives the user who invoked the command a new random two words name. - - :param ctx: The context in which the command was invoked. - :type ctx: Context - """ - - old_name = ctx.author.display_name - new_name = make_random_name() - - await ctx.author.edit(nick=new_name) - - logger.info(f"User {old_name} requested a new random name, got {new_name}!") - - name_message: Embed = Embed(title="Name Changed!", color=self.bot.default_color) - name_message.description = ( - f"Your name was changed from {old_name} to {new_name}!" - ) - - await ctx.send(embed=name_message) - - def check_slices_against_bad_words(self, slices: list[str]) -> bool: + def contains_bad_word(self, name: str) -> bool: """Checks if a name has a bad word in it. - :param slices: Slices of the name to check. - :type slices: list[str] + :param name: The name to check. + :type name: str :return: True if the name has a bad word, otherwise false. :rtype: bool """ - slices_set = set(slices) + lowered = name.lower() - return not slices_set.isdisjoint(self.BAD_WORDS) + return any(bad_word in lowered for bad_word in self.BAD_WORDS) async def setup(bot: Grace): diff --git a/bot/services/random_name_service.py b/bot/services/random_name_service.py index bbdb128b..7b70e4dd 100644 --- a/bot/services/random_name_service.py +++ b/bot/services/random_name_service.py @@ -1,5 +1,356 @@ 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. @@ -8,357 +359,6 @@ def make_random_name() -> str: :rtype: str """ - # 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", - ] - - name = f"{random.choice(left).capitalize()} {random.choice(right).capitalize()}" + name = f"{random.choice(_LEFT).capitalize()} {random.choice(_RIGHT).capitalize()}" 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 index 6969d256..1e0cc69b 100644 --- a/tests/extensions/test_name_moderation_cog.py +++ b/tests/extensions/test_name_moderation_cog.py @@ -1,8 +1,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from discord import Embed -from bot.extensions.name_moderation_cog import * +from bot.extensions.name_moderation_cog import NameModerationCog @pytest.fixture @@ -26,19 +25,15 @@ def mock_ctx(): @pytest.mark.asyncio @patch("bot.extensions.name_moderation_cog.notice") @patch("bot.extensions.name_moderation_cog.make_random_name") -@patch("bot.extensions.name_moderation_cog.slice_name") -async def test_name_moderation_on_member_join__with_normal_name__expect_nothing( - mock_slice, mock_random, mock_notice, name_moderation_cog +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" - mock_slice.return_value = ["Name"] - - name_moderation_cog.BAD_WORDS = ["Nothing"] + name_moderation_cog.BAD_WORDS = ["nothing"] await name_moderation_cog.on_member_join(member) - mock_slice.assert_called_once_with("Normal Name") mock_random.assert_not_called() member.edit.assert_not_awaited() member.send.assert_not_awaited() @@ -48,9 +43,8 @@ async def test_name_moderation_on_member_join__with_normal_name__expect_nothing( @pytest.mark.asyncio @patch("bot.extensions.name_moderation_cog.notice") @patch("bot.extensions.name_moderation_cog.make_random_name") -@patch("bot.extensions.name_moderation_cog.slice_name") -async def test_name_moderation_on_member_join__with_bad_name__expect_change_and_message( - mock_slice, mock_random, mock_notice, name_moderation_cog +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" @@ -59,15 +53,12 @@ async def test_name_moderation_on_member_join__with_bad_name__expect_change_and_ mock_notice.return_value = mock_log mock_log.send = AsyncMock() - mock_slice.return_value = ["Bad"] mock_random.return_value = "Good Name" - name_moderation_cog.BAD_WORDS = ["Bad"] + name_moderation_cog.BAD_WORDS = ["bad"] await name_moderation_cog.on_member_join(member) - mock_slice.assert_called_once_with("Bad Name") - mock_random.assert_called_once() member.edit.assert_awaited_once_with(nick="Good Name") @@ -81,68 +72,3 @@ async def test_name_moderation_on_member_join__with_bad_name__expect_change_and_ "User Bad Name joined with an inappropriate name, thus it was changed to Good Name.", ) mock_log.send.assert_awaited_once_with("Mod room") - - -@pytest.mark.asyncio -@patch("bot.extensions.name_moderation_cog.make_random_name") -async def test_name_moderation_give_random_name__expect_random_name( - mock_random, mock_bot, mock_ctx, name_moderation_cog -): - mock_ctx.author.display_name = "Old name" - mock_ctx.author.edit = AsyncMock() - mock_random.return_value = "New name" - - await name_moderation_cog.give_random_name(name_moderation_cog, mock_ctx) - - result = Embed(title="Name Changed!", color=mock_bot.default_color) - result.description = "Your name was changed from Old name to New name!" - - mock_random.assert_called_once() - mock_ctx.author.edit.assert_awaited_once_with(nick="New name") - mock_ctx.send.assert_awaited_once_with(embed=result) - - -@pytest.mark.parametrize( - ("slices, bad_word_set, output"), - [ - [["Name"], {"Stuff"}, False], - [["Name"], {"Name"}, True], - [["Name"], {"Stuff", "Name"}, True], - [["Stuff", "Name"], {"Stuff", "Name"}, True], - ], -) -def test_check_slices__expecgtg_matching_output( - slices, bad_word_set, output, name_moderation_cog -): - name_moderation_cog.BAD_WORDS = bad_word_set - - assert name_moderation_cog.check_slices_against_bad_words(slices) == output - - -@pytest.mark.parametrize( - ("input_name, output_list"), - [ - ["a", []], - ["ab", []], - ["abc", ["abc"]], - ["🐔b1%", ["🐔b1", "🐔b1%", "b1%"]], - ], -) -def test_slice_name__with_normal_input__expect_matching_output(input_name, output_list): - assert slice_name(input_name) == output_list - - -@pytest.mark.parametrize( - ("input_name, output_list"), - [ - ["aBc", ["abc"]], - ["AbC", ["abc"]], - ["ABc", ["abc"]], - ["ABC", ["abc"]], - ["AB1🐔", ["ab1", "ab1🐔", "b1🐔"]], - ], -) -def test_slice_name__with_uppercase_input__expect_case_insensitive_output( - input_name, output_list -): - assert slice_name(input_name) == output_list diff --git a/tests/services/test_random_name_service.py b/tests/services/test_random_name_service.py index 2b4b06e6..383e347b 100644 --- a/tests/services/test_random_name_service.py +++ b/tests/services/test_random_name_service.py @@ -4,7 +4,7 @@ @patch("random.choice") -def test_random_name_service__expect_normal_output(mock_random): +def test_random_name_service__expect_two_capitalized_words(mock_random): mock_random.side_effect = ["good", "grace"] assert make_random_name() == "Good Grace" From f0866489bb89ab6debdb3ab21649f741993b1656 Mon Sep 17 00:00:00 2001 From: Eyad Jawad Date: Sat, 1 Aug 2026 18:31:24 +0300 Subject: [PATCH 5/7] Fix bug sending a message to a user not in the server anymore --- bot/extensions/name_moderation_cog.py | 42 ++++++++++++-------- tests/extensions/test_name_moderation_cog.py | 1 + 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/bot/extensions/name_moderation_cog.py b/bot/extensions/name_moderation_cog.py index ec3c9af4..29c85712 100644 --- a/bot/extensions/name_moderation_cog.py +++ b/bot/extensions/name_moderation_cog.py @@ -1,9 +1,15 @@ -from bot import app -from bot.grace import Grace +import logging + from discord import Member +from discord.errors import Forbidden from discord.ext.commands import Cog -from bot.services.random_name_service import make_random_name + +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( @@ -13,7 +19,7 @@ class NameModerationCog( def __init__(self, bot: Grace): self.bot: Grace = bot - self.BAD_WORDS = set(app.config.get("blacklisted", "names", "")) + self.BAD_WORDS = set(app.config.get("name_moderation", "blacklist", "")) @property def moderation_channel(self): @@ -32,21 +38,25 @@ async def on_member_join(self, member: Member) -> None: if not self.contains_bad_word(name): return - good_name = make_random_name() - await member.edit(nick=good_name) + 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}.", + ) - 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) - 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}." + ) - 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. diff --git a/tests/extensions/test_name_moderation_cog.py b/tests/extensions/test_name_moderation_cog.py index 1e0cc69b..d16224e6 100644 --- a/tests/extensions/test_name_moderation_cog.py +++ b/tests/extensions/test_name_moderation_cog.py @@ -1,6 +1,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest + from bot.extensions.name_moderation_cog import NameModerationCog From 06cf91434b071f875af3aab643a5140a44174b76 Mon Sep 17 00:00:00 2001 From: Eyad Jawad Date: Sat, 1 Aug 2026 21:14:39 +0300 Subject: [PATCH 6/7] Fix it according to the review --- bot/extensions/name_moderation_cog.py | 12 +++++++++--- bot/services/random_name_service.py | 2 +- tests/extensions/test_name_moderation_cog.py | 4 ++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/bot/extensions/name_moderation_cog.py b/bot/extensions/name_moderation_cog.py index 29c85712..68bfdb5b 100644 --- a/bot/extensions/name_moderation_cog.py +++ b/bot/extensions/name_moderation_cog.py @@ -15,11 +15,17 @@ 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.""" + """ + 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", "")) + self.bad_words = set(app.config.get("name_moderation", "blacklist", [])) @property def moderation_channel(self): @@ -69,7 +75,7 @@ def contains_bad_word(self, name: str) -> bool: """ lowered = name.lower() - return any(bad_word in lowered for bad_word in self.BAD_WORDS) + return any(bad_word in lowered for bad_word in self.bad_words) async def setup(bot: Grace): diff --git a/bot/services/random_name_service.py b/bot/services/random_name_service.py index 7b70e4dd..5a45d8a6 100644 --- a/bot/services/random_name_service.py +++ b/bot/services/random_name_service.py @@ -359,6 +359,6 @@ def make_random_name() -> str: :rtype: str """ - name = f"{random.choice(_LEFT).capitalize()} {random.choice(_RIGHT).capitalize()}" + name = f"{random.choice(_LEFT)} {random.choice(_RIGHT)}".title() return name diff --git a/tests/extensions/test_name_moderation_cog.py b/tests/extensions/test_name_moderation_cog.py index d16224e6..01b0d048 100644 --- a/tests/extensions/test_name_moderation_cog.py +++ b/tests/extensions/test_name_moderation_cog.py @@ -32,7 +32,7 @@ async def test_name_moderation_on_member_join__with_none_blacklisted_name__expec member = AsyncMock() member.display_name = "Normal Name" - name_moderation_cog.BAD_WORDS = ["nothing"] + name_moderation_cog.bad_words = ["nothing"] await name_moderation_cog.on_member_join(member) mock_random.assert_not_called() @@ -56,7 +56,7 @@ async def test_name_moderation_on_member_join__with_blacklisted_name__expect_cha mock_random.return_value = "Good Name" - name_moderation_cog.BAD_WORDS = ["bad"] + name_moderation_cog.bad_words = ["bad"] await name_moderation_cog.on_member_join(member) From 27aac6daf4c54ad153babfeeabf0e7aafeab7795 Mon Sep 17 00:00:00 2001 From: Eyad Jawad Date: Sat, 1 Aug 2026 21:15:26 +0300 Subject: [PATCH 7/7] Format using black --- bot/extensions/name_moderation_cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/extensions/name_moderation_cog.py b/bot/extensions/name_moderation_cog.py index 68bfdb5b..e84d7aed 100644 --- a/bot/extensions/name_moderation_cog.py +++ b/bot/extensions/name_moderation_cog.py @@ -16,7 +16,7 @@ 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, + 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: