diff --git a/NHCogs/nhmoderation/README.md b/NHCogs/nhmoderation/README.md index ef26ee5..8ded097 100644 --- a/NHCogs/nhmoderation/README.md +++ b/NHCogs/nhmoderation/README.md @@ -1,6 +1,6 @@ # NHModeration -NHModeration stores ban-related moderation evidence in SQLite and renders BanChart from local history. Chart rendering never reads the Discord API. +NHModeration stores ban-related moderation evidence in SQLite, renders BanChart from local history, and deletes messages containing configured phrases. Chart rendering and message filtering never read the Discord API. Version 1 does not replace ban, unban, mute, warn, or softban commands. @@ -12,7 +12,7 @@ Version 1 does not replace ban, unban, mute, warn, or softban commands. [p]load NHModeration ``` -The bot needs View Audit Log and Ban Members for migration and synchronization. Administrative `nhmod` output must be used in a channel hidden from `@everyone`. BanChart may be posted in public channels. +The bot needs View Audit Log and Ban Members for migration and synchronization. It needs Manage Messages to delete filtered messages. Message Content intent must be enabled for phrase matching. Administrative `nhmod` output must be used in a channel hidden from `@everyone`. BanChart may be posted in public channels. ## Initial migration @@ -49,6 +49,10 @@ Names are resolved only from the Discord cache. The command does not call `fetch |---|---| | `[p]nhmod` | Show the NHModeration command overview | | `[p]nhmod status` | Show private migration, historical coverage, synchronization, and schedule health | +| `[p]nhmod filter` | Show filter commands and the configured phrases | +| `[p]nhmod filter add ` | Add a phrase to the message filter | +| `[p]nhmod filter remove ` | Remove a phrase from the message filter | +| `[p]nhmod filter list` | List the configured phrases | | `[p]nhmod migrate` | Show migration commands | | `[p]nhmod migrate plan` | Check cached permissions and local readiness without importing history | | `[p]nhmod migrate run` | Start or resume the initial import | @@ -57,6 +61,12 @@ Names are resolved only from the Discord cache. The command does not call `fetch 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. + +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. + ## Synchronization Gateway and Red ModLog events are stored immediately. A low-cost catch-up runs after startup when migration is complete. @@ -73,6 +83,6 @@ Expected input and permission errors return a short useful response. Public outp ## Stored data and deletion -NHModeration stores immutable source observations and rebuildable canonical actions. Stored fields may include guild, target, technical executor, credited moderator, and channel IDs, action type, timestamps, reasons, expiry, source identity, migration identity, attribution, synchronization cursors, and operational failures. +NHModeration stores immutable source observations and rebuildable canonical actions. Stored fields may include guild, target, technical executor, credited moderator, and channel IDs, action type, timestamps, reasons, expiry, source identity, migration identity, attribution, synchronization cursors, and operational failures. Configured message filter phrases are stored per guild in Red Config. Red user-data deletion anonymizes matching identities and reasons, then rebuilds affected actions. Guild removal deletes the guild's history, synchronization state, migration state, failures, and configuration. diff --git a/NHCogs/nhmoderation/info.json b/NHCogs/nhmoderation/info.json index 03c2468..a360046 100644 --- a/NHCogs/nhmoderation/info.json +++ b/NHCogs/nhmoderation/info.json @@ -4,8 +4,8 @@ ], "name": "NHModeration", "install_msg": "Thank you for installing NHModeration. Run `[p]nhmod` in a private moderator channel to get started.", - "short": "Store moderation history and render BanChart without live API reads.", - "description": "Import and retain moderation evidence in SQLite, reconcile missed ban events, and render a local BanChart by credited moderator.", + "short": "Store moderation history, render BanChart, and filter configured phrases.", + "description": "Import and retain moderation evidence in SQLite, reconcile missed ban events, render a local BanChart, and delete messages containing configured phrases.", "tags": [ "moderation", "statistics", diff --git a/NHCogs/nhmoderation/nhmoderation.py b/NHCogs/nhmoderation/nhmoderation.py index d510684..6edb02c 100644 --- a/NHCogs/nhmoderation/nhmoderation.py +++ b/NHCogs/nhmoderation/nhmoderation.py @@ -10,8 +10,14 @@ from redbot.core import Config, commands, modlog from redbot.core.bot import Red from redbot.core.data_manager import cog_data_path +from redbot.core.utils.chat_formatting import pagify -from ..command_overview import channel_is_private, send_group_overview +from ..command_overview import ( + MAX_FIELD_VALUE_LENGTH, + channel_is_private, + overview_embeds, + send_group_overview, +) from ..operational_errors import OperationalErrorReporter, OperationalFailure from ..ranked_donut_chart import render_ranked_donut_chart from .command_inputs import parse_banchart_arguments @@ -41,7 +47,11 @@ def __init__(self, bot: Red) -> None: identifier=self.CONFIG_IDENTIFIER, force_registration=True, ) - self.config.register_guild(error_channel=None, error_maintainer_id=None) + self.config.register_guild( + error_channel=None, + error_maintainer_id=None, + message_filter_phrases=[], + ) database_path = cog_data_path(self) / "moderation.sqlite" self.history = NHModerationHistory(database_path) self._operational_errors = OperationalErrorReporter( @@ -52,10 +62,17 @@ def __init__(self, bot: Red) -> None: self._startup_task: asyncio.Task[None] | None = None self._gateway_catchup_task: asyncio.Task[None] | None = None self._sync_tasks: dict[int, asyncio.Task[Any]] = {} + self._message_filter_phrases: dict[int, tuple[str, ...]] = {} + self._message_filter_lock = asyncio.Lock() async def cog_load(self) -> None: await self.history.initialize() await self._operational_errors.initialize() + guild_configs = await self.config.all_guilds() + self._message_filter_phrases = { + int(guild_id): tuple(settings.get("message_filter_phrases", ())) + for guild_id, settings in guild_configs.items() + } self._synchronizer = ModerationSynchronizer( self.history, bot_user_id=lambda: getattr(getattr(self.bot, "user", None), "id", 0), @@ -98,7 +115,37 @@ async def red_delete_data_for_user(self, *, requester: str, user_id: int) -> Non async def on_guild_remove(self, guild: discord.Guild) -> None: await self.history.delete_guild_data(guild.id) await self._operational_errors.delete_guild(guild.id) - await self.config.guild(guild).clear() + async with self._message_filter_lock: + self._message_filter_phrases.pop(guild.id, None) + await self.config.guild(guild).clear() + + @commands.Cog.listener() + async def on_message(self, message: discord.Message) -> None: + if message.guild is None or not message.content: + 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): + return + try: + await message.delete() + except discord.NotFound: + pass + except (discord.Forbidden, discord.HTTPException) as error: + await self.report_operational_error( + guild_id=message.guild.id, + action="delete filtered message", + error=error, + channel_id=message.channel.id, + message_id=message.id, + ) + return + await self._mark_operational_recovered( + message.guild, + "delete filtered message", + ) async def report_operational_error( self, @@ -547,6 +594,96 @@ async def nhmod_status(self, ctx: commands.Context) -> None: await ctx.send(embed=embed, allowed_mentions=discord.AllowedMentions.none()) await self._mark_operational_recovered(ctx.guild, "nhmod status") + @nhmod.group(name="filter", invoke_without_command=True) + async def nhmod_filter(self, ctx: commands.Context) -> None: + """Manage phrases that cause matching messages to be deleted.""" + self._require_private_channel(ctx) + await send_group_overview( + ctx, + lambda: self._send_filter_phrases(ctx), + ) + await self._mark_operational_recovered(ctx.guild, "nhmod filter") + + @nhmod_filter.command(name="add") + async def nhmod_filter_add(self, ctx: commands.Context, *, phrase: str) -> None: + """Add a phrase to the message filter.""" + self._require_private_channel(ctx) + normalized = phrase.strip().casefold() + if not normalized: + raise commands.UserFeedbackCheckFailure("Phrase cannot be empty") + async with self._message_filter_lock: + phrases = list(self._message_filter_phrases.get(ctx.guild.id, ())) + if normalized in phrases: + raise commands.UserFeedbackCheckFailure("That phrase is already configured") + phrases.append(normalized) + await self.config.guild(ctx.guild).set_raw( + "message_filter_phrases", + value=phrases, + ) + self._message_filter_phrases[ctx.guild.id] = tuple(phrases) + await ctx.send( + f"Phrase added: `{normalized}`", + allowed_mentions=discord.AllowedMentions.none(), + ) + await self._mark_operational_recovered(ctx.guild, "nhmod filter add") + + @nhmod_filter.command(name="remove") + async def nhmod_filter_remove(self, ctx: commands.Context, *, phrase: str) -> None: + """Remove a phrase from the message filter.""" + self._require_private_channel(ctx) + normalized = phrase.strip().casefold() + if not normalized: + raise commands.UserFeedbackCheckFailure("Phrase cannot be empty") + async with self._message_filter_lock: + phrases = list(self._message_filter_phrases.get(ctx.guild.id, ())) + if normalized not in phrases: + raise commands.UserFeedbackCheckFailure("Phrase is not configured") + phrases.remove(normalized) + await self.config.guild(ctx.guild).set_raw( + "message_filter_phrases", + value=phrases, + ) + self._message_filter_phrases[ctx.guild.id] = tuple(phrases) + await ctx.send( + f"Phrase removed: `{normalized}`", + allowed_mentions=discord.AllowedMentions.none(), + ) + await self._mark_operational_recovered(ctx.guild, "nhmod filter remove") + + @nhmod_filter.command(name="list") + async def nhmod_filter_list(self, ctx: commands.Context) -> None: + """List phrases in the message filter.""" + self._require_private_channel(ctx) + await self._send_filter_phrases(ctx) + await self._mark_operational_recovered(ctx.guild, "nhmod filter list") + + async def _send_filter_phrases(self, ctx: commands.Context) -> None: + phrases = self._message_filter_phrases.get(ctx.guild.id, ()) + if not phrases: + description = "No message filter phrases are configured" + fields = [] + else: + description = "Messages containing any configured phrase are deleted" + content = "\n".join(f"{index}. {phrase}" for index, phrase in enumerate(phrases, 1)) + fields = [ + ( + "Configured phrases" if index == 0 else "Configured phrases continued", + page, + ) + for index, page in enumerate( + pagify( + content, + page_length=MAX_FIELD_VALUE_LENGTH, + delims=["\n"], + ) + ) + ] + for embed in overview_embeds("Message filter", description, fields): + await ctx.send( + embed=embed, + allowed_mentions=discord.AllowedMentions.none(), + ) + @nhmod.group(name="migrate", invoke_without_command=True) async def nhmod_migrate(self, ctx: commands.Context) -> None: """Plan or run the initial moderation history import.""" diff --git a/tests/test_nhmoderation_cog.py b/tests/test_nhmoderation_cog.py index 762641c..e1eb295 100644 --- a/tests/test_nhmoderation_cog.py +++ b/tests/test_nhmoderation_cog.py @@ -54,6 +54,10 @@ def test_registered_command_tree_uses_accepted_names(self): "nhmod migrate", "nhmod migrate plan", "nhmod migrate run", + "nhmod filter", + "nhmod filter add", + "nhmod filter remove", + "nhmod filter list", "nhmod sync", "nhmod repair", }, @@ -61,6 +65,7 @@ def test_registered_command_tree_uses_accepted_names(self): async def test_maintenance_commands_inherit_manage_messages_permission(self): with loaded_nhmoderation() as module: + def context(*, red_mod=False, manage_messages=False): return SimpleNamespace( is_red_mod=red_mod, @@ -77,6 +82,9 @@ def context(*, red_mod=False, manage_messages=False): manage_messages = context(manage_messages=True) unauthorized = context() for command_name in ( + "nhmod_filter_add", + "nhmod_filter_remove", + "nhmod_filter_list", "nhmod_migrate_run", "nhmod_sync", "nhmod_repair", @@ -118,6 +126,209 @@ async def test_bare_nhmod_groups_use_runtime_overviews(self): [mock.call(ctx), mock.call(ctx)], ) + async def test_filter_commands_normalize_persist_list_and_remove_phrases(self): + with loaded_nhmoderation() as module: + subject = module.NHModeration(SimpleNamespace()) + subject._require_private_channel = mock.Mock() + subject._mark_operational_recovered = mock.AsyncMock() + guild = SimpleNamespace(id=10) + ctx = SimpleNamespace(guild=guild, send=mock.AsyncMock()) + + 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"]) + self.assertEqual(subject._message_filter_phrases[10], ("mixed case phrase",)) + self.assertFalse(ctx.send.await_args.kwargs["allowed_mentions"].everyone) + + with self.assertRaisesRegex(Exception, "already configured"): + await module.NHModeration.nhmod_filter_add.callback( + subject, + ctx, + phrase="MIXED CASE PHRASE", + ) + + ctx.send.reset_mock() + embed = object() + with mock.patch.object( + module, + "overview_embeds", + return_value=[embed], + ) as renderer: + await module.NHModeration.nhmod_filter_list.callback(subject, ctx) + self.assertIn( + "mixed case phrase", + renderer.call_args.args[2][0][1], + ) + self.assertEqual(ctx.send.await_args.args, ()) + 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", + ) + + stored = await subject.config.guild(guild).get_raw("message_filter_phrases") + self.assertEqual(stored, []) + self.assertEqual(subject._message_filter_phrases[10], ()) + + with self.assertRaisesRegex(Exception, "cannot be empty"): + await module.NHModeration.nhmod_filter_add.callback( + subject, + ctx, + phrase=" ", + ) + + async def test_bare_filter_group_shows_overview_and_current_configuration(self): + with loaded_nhmoderation() as module: + subject = module.NHModeration(SimpleNamespace()) + subject._message_filter_phrases = {10: ("blocked phrase",)} + subject._require_private_channel = mock.Mock() + subject._mark_operational_recovered = mock.AsyncMock() + ctx = SimpleNamespace( + guild=SimpleNamespace(id=10), + send=mock.AsyncMock(), + ) + + async def render_overview(_ctx, config_sender): + await config_sender() + + embed = object() + with ( + mock.patch.object( + module, + "send_group_overview", + new=mock.AsyncMock(side_effect=render_overview), + ) as overview, + mock.patch.object( + module, + "overview_embeds", + return_value=[embed], + ) as renderer, + ): + await module.NHModeration.nhmod_filter.callback(subject, ctx) + + overview.assert_awaited_once() + self.assertIn("blocked phrase", renderer.call_args.args[2][0][1]) + self.assertEqual(ctx.send.await_args.args, ()) + self.assertIs(ctx.send.await_args.kwargs["embed"], embed) + self.assertFalse(ctx.send.await_args.kwargs["allowed_mentions"].everyone) + subject._require_private_channel.assert_called_once_with(ctx) + + async def test_message_filter_deletes_case_insensitive_substring_matches(self): + with loaded_nhmoderation() as module: + subject = module.NHModeration(SimpleNamespace()) + subject._message_filter_phrases = {10: ("blocked phrase",)} + 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="prefixBLOCKED PHRASEsuffix", + author=SimpleNamespace(bot=True), + webhook_id=40, + delete=mock.AsyncMock(), + ) + + await module.NHModeration.on_message(subject, message) + + message.delete.assert_awaited_once_with() + subject.report_operational_error.assert_not_awaited() + subject._mark_operational_recovered.assert_awaited_once_with( + message.guild, + "delete filtered message", + ) + + async def test_message_filter_reports_discord_delete_failures(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() + error = module.discord.Forbidden() + message = SimpleNamespace( + id=30, + guild=SimpleNamespace(id=10), + channel=SimpleNamespace(id=20), + content="blocked", + delete=mock.AsyncMock(side_effect=error), + ) + + await module.NHModeration.on_message(subject, message) + + subject.report_operational_error.assert_awaited_once_with( + guild_id=10, + action="delete filtered message", + error=error, + channel_id=20, + message_id=30, + ) + + async def test_message_filter_ignores_dms_nonmatches_and_already_gone_messages(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() + dm = SimpleNamespace( + guild=None, + content="blocked", + delete=mock.AsyncMock(), + ) + nonmatch = SimpleNamespace( + guild=SimpleNamespace(id=10), + content="allowed", + delete=mock.AsyncMock(), + ) + already_gone = SimpleNamespace( + id=30, + guild=SimpleNamespace(id=10), + channel=SimpleNamespace(id=20), + content="blocked", + delete=mock.AsyncMock(side_effect=module.discord.NotFound()), + ) + + await module.NHModeration.on_message(subject, dm) + await module.NHModeration.on_message(subject, nonmatch) + await module.NHModeration.on_message(subject, already_gone) + + dm.delete.assert_not_awaited() + nonmatch.delete.assert_not_awaited() + already_gone.delete.assert_awaited_once_with() + subject.report_operational_error.assert_not_awaited() + subject._mark_operational_recovered.assert_awaited_once_with( + already_gone.guild, + "delete filtered message", + ) + + async def test_message_filter_cache_is_restored_during_cog_load(self): + with loaded_nhmoderation() as module: + subject = module.NHModeration(SimpleNamespace()) + await subject.config.guild_from_id(10).set_raw( + "message_filter_phrases", + value=["first phrase", "second phrase"], + ) + subject.history.initialize = mock.AsyncMock() + subject._operational_errors.initialize = mock.AsyncMock() + subject._weekly_scheduler = mock.AsyncMock() + subject._startup_catchup = mock.AsyncMock() + + await module.NHModeration.cog_load(subject) + try: + self.assertEqual( + subject._message_filter_phrases[10], + ("first phrase", "second phrase"), + ) + finally: + await module.NHModeration.cog_unload(subject) + async def test_banchart_reads_history_and_sends_shared_chart(self): with loaded_nhmoderation() as module: subject = object.__new__(module.NHModeration)