Auto Moderation
Beyond the rule-based filters, AstralChat scores every public chat message with a remote classifier, notifies staff on a hit, and records what it decided into a flagged_messages table so the decisions can later be labelled by hand and used to train an in-house model.
Everything here is best effort and off the main thread: a classifier outage never takes a chat message down with it, and moderation is handed off by MessageStorageService rather than done inline, so message storage is independent of it too.
Pipeline
The classifier is only asked about messages that were actually stored — a message a filter blocked never reaches it, which is exactly why filter hits are recorded separately.
moderation.yml
Every nested section is optional — each accessor falls back to a working default, so an older file keeps loading.
Top level
Key | Type | Default | Description |
|---|---|---|---|
| boolean | enabled | Master switch for the classifier. A missing key counts as enabled; the classifier is only actually called when this is not |
| URI | — | The moderation endpoint. Without it the classifier half of the pipeline is off entirely. |
| String | — | Sent as |
| String | — | Discord webhook URL flagged messages are posted to. |
thresholds
Applied locally, on top of whatever the service decided — tightening moderation is a /chat reload, not a classifier redeploy.
Key | Type | Default | Description |
|---|---|---|---|
| double |
| Score at or above which a message counts as flagged. A value |
| double |
| Score at or above which staff are pinged in game. Same fallback. |
| boolean |
| Also trust the |
|
|
| Per-category overrides of |
A message is flagged when at least one category's score is at or above its threshold, or — with use-service: true — when the service flagged it itself.
notifications
Key | Type | Default | Description |
|---|---|---|---|
| String |
| Chat channel the in-game staff notification is broadcast to. Empty or absent disables it. |
| boolean |
| Also post flagged messages to the Discord |
| boolean |
| Write flagged messages to the server log. |
The console line and the webhook fire for every flagged message; the in-game notification only fires above the stricter alert threshold, and only when the sender is online on this server. It uses message-moderation-notification-format with %player_…%, %reason%, %score% and %message%.
dataset
What gets written to flagged_messages.
Key | Type | Default | Description |
|---|---|---|---|
| boolean |
| Master switch for persistence. |
| double |
| Share of non-flagged messages kept as negative training examples. Clamped to |
| boolean |
| Also record rule-based filter hits — cheap, high-precision labels covering what the model misses. |
| boolean |
| Record private messages too. Off by default: privacy over data volume. |
| int |
| Cap on rows per player per hour, |
Flagged messages are always kept (subject to the quota); clean ones are sampled.
What is recorded
One row per recorded decision in flagged_messages:
Column | Meaning |
|---|---|
| Row id — the one |
| The |
| Who sent it. The name is |
| Channel name, or |
| The raw message, truncated to 512 characters. |
|
|
| Whether it was considered a hit. |
|
|
| The triggered categories or filter names, comma-separated. |
| JSON array of what tripped. |
| JSON object |
| The strongest signal. |
| Which model answered, and its response id. |
| The human verdict — see below. |
flagged and action are deliberately separate: a stored row is not necessarily a hit.
Review labels
/chatmod review writes one of three ground-truth labels:
Label | Meaning |
|---|---|
| The message really does break the rules. |
| False positive — the message is fine. |
| The reviewer could not decide; excluded from the training set. |
The label is the column to train on — the classifier scores are features, not labels.
Exporting the labelled set
Staff commands
See Commands § /chatmod for the full reference — stats, pending, history and review, all behind chat.command.moderation.
Classifier contract
ModerationService.analyze POSTs {"input": ["<message>"]} to url with a five-second connect and request timeout, and expects an OpenAI-moderation-shaped response:
Anything other than HTTP 200 is an error and is logged, not retried. Identical content (lower-cased and stripped) is answered from an in-memory cache of the last 256 responses for 60 seconds — chat is full of repeated lines, and re-scoring them costs a round trip for an answer already known.
All HTTP work, including the blocking Discord webhook calls, runs on a dedicated two-thread pool that is shut down with the plugin.
Turning it off
enabled: false(or nourl) — no classifier calls. Filters, notifications for filter hits, andchat_messagesstorage all keep working.dataset.enabled: false— nothing is written toflagged_messages; staff notifications still fire.notifications.channel: ""— no in-game pings.