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
16 changes: 13 additions & 3 deletions NHCogs/nhmoderation/README.md
Original file line number Diff line number Diff line change
@@ -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.

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

Expand Down Expand Up @@ -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 <phrase>` | Add a phrase to the message filter |
| `[p]nhmod filter remove <phrase>` | 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 |
Expand All @@ -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.
Expand All @@ -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.
4 changes: 2 additions & 2 deletions NHCogs/nhmoderation/info.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
143 changes: 140 additions & 3 deletions NHCogs/nhmoderation/nhmoderation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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."""
Expand Down
Loading
Loading