Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion NHCogs/nhmoderation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
61 changes: 53 additions & 8 deletions NHCogs/nhmoderation/nhmoderation.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@

log = logging.getLogger("red.NHModeration")
AUDIT_BATCH_SIZE = 100
FILTER_CONFIG_EMBED_TITLE = "Message filter"


class NHModeration(commands.Cog):
Expand Down Expand Up @@ -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()
Expand All @@ -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,
*,
Expand Down Expand Up @@ -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")

Expand All @@ -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")

Expand Down Expand Up @@ -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(),
Expand Down
187 changes: 177 additions & 10 deletions tests/test_nhmoderation_cog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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, [])
Expand Down Expand Up @@ -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(),
Expand All @@ -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())
Expand All @@ -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),
)

Expand Down Expand Up @@ -285,13 +440,25 @@ 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(
id=30,
guild=SimpleNamespace(id=10),
channel=SimpleNamespace(id=20),
content="blocked",
embeds=(),
delete=mock.AsyncMock(side_effect=module.discord.NotFound()),
)

Expand Down
Loading