From 86e4abb17ad4d887490ae0359817ec4aa1077b85 Mon Sep 17 00:00:00 2001 From: Pxx500 Date: Sun, 30 Aug 2026 17:14:12 +0200 Subject: [PATCH] filter phrases in message embeds --- NHCogs/nhmoderation/README.md | 4 +- NHCogs/nhmoderation/nhmoderation.py | 61 +++++++-- tests/test_nhmoderation_cog.py | 187 ++++++++++++++++++++++++++-- 3 files changed, 233 insertions(+), 19 deletions(-) diff --git a/NHCogs/nhmoderation/README.md b/NHCogs/nhmoderation/README.md index 8ded097..9a3c861 100644 --- a/NHCogs/nhmoderation/README.md +++ b/NHCogs/nhmoderation/README.md @@ -63,7 +63,9 @@ The `nhmod` root, all maintenance commands, and BanChart require Manage Messages ## Message phrase filter -The filter applies to guild message content. Matching is a case-insensitive plain substring check. It also matches a phrase inside a larger word. The first match deletes the whole message without posting a public response. Messages from moderators, bots, and webhooks use the same rules. +The filter applies to guild message content and text inside embeds. It checks embed titles, descriptions, field names, field values, author names, and footer text. Matching is a case-insensitive plain substring check. It also matches a phrase inside a larger word. The first match deletes the whole message without posting a public response. Messages from moderators, bots, and webhooks use the same rules. + +New messages and cached message edits are checked. This covers embeds that Discord adds or updates after the original message without fetching the message from the Discord API. Phrases are configured per guild and normalized before storage. The listener reads a memory cache that is restored when the cog loads and updated by the filter commands. Deletion is not recorded as a moderation action and does not affect BanChart. diff --git a/NHCogs/nhmoderation/nhmoderation.py b/NHCogs/nhmoderation/nhmoderation.py index 6edb02c..fb33acb 100644 --- a/NHCogs/nhmoderation/nhmoderation.py +++ b/NHCogs/nhmoderation/nhmoderation.py @@ -33,6 +33,7 @@ log = logging.getLogger("red.NHModeration") AUDIT_BATCH_SIZE = 100 +FILTER_CONFIG_EMBED_TITLE = "Message filter" class NHModeration(commands.Cog): @@ -121,13 +122,29 @@ async def on_guild_remove(self, guild: discord.Guild) -> None: @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: - if message.guild is None or not message.content: + await self._filter_message(message) + + @commands.Cog.listener() + async def on_message_edit( + self, + before: discord.Message, + after: discord.Message, + ) -> None: + del before + await self._filter_message(after) + + async def _filter_message(self, message: discord.Message) -> None: + if message.guild is None: + return + bot_user_id = getattr(getattr(self.bot, "user", None), "id", None) + if bot_user_id is not None and message.author.id == bot_user_id and any( + embed.title == FILTER_CONFIG_EMBED_TITLE for embed in message.embeds + ): return phrases = self._message_filter_phrases.get(message.guild.id, ()) if not phrases: return - content = message.content.casefold() - if not any(phrase in content for phrase in phrases): + if not self._message_matches_phrases(message, phrases): return try: await message.delete() @@ -147,6 +164,24 @@ async def on_message(self, message: discord.Message) -> None: "delete filtered message", ) + @staticmethod + def _message_matches_phrases( + message: discord.Message, + phrases: tuple[str, ...], + ) -> bool: + texts = [message.content] + for embed in message.embeds: + texts.extend((embed.title, embed.description)) + texts.extend(field.name for field in embed.fields) + texts.extend(field.value for field in embed.fields) + texts.extend((embed.author.name, embed.footer.text)) + return any( + phrase in text.casefold() + for text in texts + if text + for phrase in phrases + ) + async def report_operational_error( self, *, @@ -621,9 +656,10 @@ async def nhmod_filter_add(self, ctx: commands.Context, *, phrase: str) -> None: value=phrases, ) self._message_filter_phrases[ctx.guild.id] = tuple(phrases) - await ctx.send( + await self._send_filter_output( + ctx, f"Phrase added: `{normalized}`", - allowed_mentions=discord.AllowedMentions.none(), + [], ) await self._mark_operational_recovered(ctx.guild, "nhmod filter add") @@ -644,9 +680,10 @@ async def nhmod_filter_remove(self, ctx: commands.Context, *, phrase: str) -> No value=phrases, ) self._message_filter_phrases[ctx.guild.id] = tuple(phrases) - await ctx.send( + await self._send_filter_output( + ctx, f"Phrase removed: `{normalized}`", - allowed_mentions=discord.AllowedMentions.none(), + [], ) await self._mark_operational_recovered(ctx.guild, "nhmod filter remove") @@ -678,7 +715,15 @@ async def _send_filter_phrases(self, ctx: commands.Context) -> None: ) ) ] - for embed in overview_embeds("Message filter", description, fields): + await self._send_filter_output(ctx, description, fields) + + @staticmethod + async def _send_filter_output( + ctx: commands.Context, + description: str, + fields: list[tuple[str, str]], + ) -> None: + for embed in overview_embeds(FILTER_CONFIG_EMBED_TITLE, description, fields): await ctx.send( embed=embed, allowed_mentions=discord.AllowedMentions.none(), diff --git a/tests/test_nhmoderation_cog.py b/tests/test_nhmoderation_cog.py index e1eb295..06780bf 100644 --- a/tests/test_nhmoderation_cog.py +++ b/tests/test_nhmoderation_cog.py @@ -134,11 +134,16 @@ async def test_filter_commands_normalize_persist_list_and_remove_phrases(self): guild = SimpleNamespace(id=10) ctx = SimpleNamespace(guild=guild, send=mock.AsyncMock()) - await module.NHModeration.nhmod_filter_add.callback( - subject, - ctx, - phrase=" Mixed CASE Phrase ", - ) + with mock.patch.object( + module, + "overview_embeds", + return_value=[object()], + ): + await module.NHModeration.nhmod_filter_add.callback( + subject, + ctx, + phrase=" Mixed CASE Phrase ", + ) stored = await subject.config.guild(guild).get_raw("message_filter_phrases") self.assertEqual(stored, ["mixed case phrase"]) @@ -168,11 +173,16 @@ async def test_filter_commands_normalize_persist_list_and_remove_phrases(self): self.assertIs(ctx.send.await_args.kwargs["embed"], embed) self.assertFalse(ctx.send.await_args.kwargs["allowed_mentions"].everyone) - await module.NHModeration.nhmod_filter_remove.callback( - subject, - ctx, - phrase="MIXED CASE PHRASE", - ) + with mock.patch.object( + module, + "overview_embeds", + return_value=[object()], + ): + await module.NHModeration.nhmod_filter_remove.callback( + subject, + ctx, + phrase="MIXED CASE PHRASE", + ) stored = await subject.config.guild(guild).get_raw("message_filter_phrases") self.assertEqual(stored, []) @@ -232,6 +242,7 @@ async def test_message_filter_deletes_case_insensitive_substring_matches(self): guild=SimpleNamespace(id=10), channel=SimpleNamespace(id=20), content="prefixBLOCKED PHRASEsuffix", + embeds=(), author=SimpleNamespace(bot=True), webhook_id=40, delete=mock.AsyncMock(), @@ -246,6 +257,149 @@ async def test_message_filter_deletes_case_insensitive_substring_matches(self): "delete filtered message", ) + async def test_message_filter_checks_every_supported_embed_text_part(self): + with loaded_nhmoderation() as module: + subject = module.NHModeration(SimpleNamespace(user=SimpleNamespace(id=50))) + subject._message_filter_phrases = {10: ("blocked",)} + subject.report_operational_error = mock.AsyncMock() + subject._mark_operational_recovered = mock.AsyncMock() + empty_embed = { + "title": None, + "description": None, + "fields": (), + "author": SimpleNamespace(name=None), + "footer": SimpleNamespace(text=None), + } + cases = { + "title": {"title": "prefix BLOCKED suffix"}, + "description": {"description": "prefix BLOCKED suffix"}, + "field name": { + "fields": (SimpleNamespace(name="BLOCKED", value="allowed"),) + }, + "field value": { + "fields": (SimpleNamespace(name="allowed", value="BLOCKED"),) + }, + "author name": {"author": SimpleNamespace(name="BLOCKED")}, + "footer text": {"footer": SimpleNamespace(text="BLOCKED")}, + } + + for label, overrides in cases.items(): + with self.subTest(part=label): + embed = SimpleNamespace(**(empty_embed | overrides)) + message = SimpleNamespace( + id=30, + guild=SimpleNamespace(id=10), + channel=SimpleNamespace(id=20), + content="", + embeds=(embed,), + author=SimpleNamespace(id=60, bot=True), + webhook_id=40, + delete=mock.AsyncMock(), + ) + + await module.NHModeration.on_message(subject, message) + + message.delete.assert_awaited_once_with() + + async def test_message_filter_checks_embeds_added_by_message_edit(self): + with loaded_nhmoderation() as module: + subject = module.NHModeration(SimpleNamespace()) + subject._message_filter_phrases = {10: ("blocked",)} + subject.report_operational_error = mock.AsyncMock() + subject._mark_operational_recovered = mock.AsyncMock() + before = SimpleNamespace(content="allowed", embeds=()) + after = SimpleNamespace( + id=30, + guild=SimpleNamespace(id=10), + channel=SimpleNamespace(id=20), + content="allowed", + embeds=( + SimpleNamespace( + title=None, + description="BLOCKED", + fields=(), + author=SimpleNamespace(name=None), + footer=SimpleNamespace(text=None), + ), + ), + delete=mock.AsyncMock(), + ) + + await module.NHModeration.on_message_edit(subject, before, after) + + after.delete.assert_awaited_once_with() + + async def test_message_filter_preserves_its_own_configuration_embed(self): + with loaded_nhmoderation() as module: + subject = module.NHModeration(SimpleNamespace(user=SimpleNamespace(id=50))) + subject._message_filter_phrases = {10: ("blocked",)} + subject.report_operational_error = mock.AsyncMock() + subject._mark_operational_recovered = mock.AsyncMock() + message = SimpleNamespace( + id=30, + guild=SimpleNamespace(id=10), + channel=SimpleNamespace(id=20), + content="", + author=SimpleNamespace(id=50), + embeds=( + SimpleNamespace( + title="Message filter", + description="1. blocked", + fields=(), + author=SimpleNamespace(name=None), + footer=SimpleNamespace(text=None), + ), + ), + delete=mock.AsyncMock(), + ) + + await module.NHModeration.on_message(subject, message) + + message.delete.assert_not_awaited() + + async def test_filter_command_confirmation_is_preserved_by_message_filter(self): + with loaded_nhmoderation() as module: + subject = module.NHModeration(SimpleNamespace(user=SimpleNamespace(id=50))) + subject._require_private_channel = mock.Mock() + subject._mark_operational_recovered = mock.AsyncMock() + guild = SimpleNamespace(id=10) + ctx = SimpleNamespace(guild=guild, send=mock.AsyncMock()) + def render_confirmation(title, description, fields): + return [ + SimpleNamespace( + title=title, + description=description, + fields=fields, + author=SimpleNamespace(name=None), + footer=SimpleNamespace(text=None), + ) + ] + + with mock.patch.object( + module, + "overview_embeds", + side_effect=render_confirmation, + ): + await module.NHModeration.nhmod_filter_add.callback( + subject, + ctx, + phrase="blocked", + ) + + confirmation_embed = ctx.send.await_args.kwargs["embed"] + sent_message = SimpleNamespace( + id=30, + guild=guild, + channel=SimpleNamespace(id=20), + content="", + author=SimpleNamespace(id=50), + embeds=(confirmation_embed,), + delete=mock.AsyncMock(), + ) + await module.NHModeration.on_message(subject, sent_message) + + sent_message.delete.assert_not_awaited() + async def test_message_filter_reports_discord_delete_failures(self): with loaded_nhmoderation() as module: subject = module.NHModeration(SimpleNamespace()) @@ -258,6 +412,7 @@ async def test_message_filter_reports_discord_delete_failures(self): guild=SimpleNamespace(id=10), channel=SimpleNamespace(id=20), content="blocked", + embeds=(), delete=mock.AsyncMock(side_effect=error), ) @@ -285,6 +440,17 @@ async def test_message_filter_ignores_dms_nonmatches_and_already_gone_messages(s nonmatch = SimpleNamespace( guild=SimpleNamespace(id=10), content="allowed", + embeds=( + SimpleNamespace( + title="allowed", + description="still allowed", + fields=( + SimpleNamespace(name="allowed", value="also allowed"), + ), + author=SimpleNamespace(name="allowed"), + footer=SimpleNamespace(text="allowed"), + ), + ), delete=mock.AsyncMock(), ) already_gone = SimpleNamespace( @@ -292,6 +458,7 @@ async def test_message_filter_ignores_dms_nonmatches_and_already_gone_messages(s guild=SimpleNamespace(id=10), channel=SimpleNamespace(id=20), content="blocked", + embeds=(), delete=mock.AsyncMock(side_effect=module.discord.NotFound()), )