Esposter

Automod Actions

The room word filter does more than block: each filter carries a configurable action — Reject (the default), Warn, or Timeout — turning the shipped filter into a Discord-style AutoMod-lite. Every action still rejects the matching message (Discord blocks and acts) — the action decides what happens to the sender afterwards.

How it works

getMessageCreationRejection (the shared gate in @esposter/db) runs on every message-producing path. It is free of side effects: on a match by a member who cannot manage messages it hands back the matched filter rather than applying it, and the caller that rejects the message is the one that spends the consequence — assertCanCreateMessage in the app, its namesake in the Function worker. Warn and Timeout call executeAutomodAction, which records a moderation-log row attributed to a reserved AutoMod actor id; the app's wrapper additionally emits the same onAdminAction event a manual warn or timeout would, so the targeted client reacts identically. Timeout additionally sets timeoutUntil on the member.

flowchart TD
  CM["createMessage"] --> WF["getMessageCreationRejection"]
  WF -->|no match or moderator| OK["allow"]
  WF -->|match| A{"filter.action"}
  A -->|Reject| ERR["reject the message"]
  A -->|Warn| W["executeAutomodAction — log Warn"]
  A -->|Timeout| T["executeAutomodAction — set timeoutUntil plus log TimeoutUser"]
  W --> EM["emit onAdminAction"]
  T --> EM
  W --> ERR
  T --> ERR
  EM --> C["targeted client shows the moderation notification"]

The moderation-log write is best-effort — a logging failure never turns the block into a server error. The audit log renders the reserved actor as AutoMod instead of resolving a member name.

Data model

roomFiltersInMessage gains action (Postgres enum word_filter_action: Reject default, Warn, Timeout) and timeoutDurationMs (nullable integer). A database CHECK enforces that a Timeout action always carries a positive duration, and the router clears the duration whenever the action is not Timeout so a stale value can never re-arm a timeout.

Procedures

No new procedure — room.filter.upsertRoomFilter (gated ManageRoom) accepts the new action and timeoutDurationMs fields, and readRoomFilter now returns the full filter row so the settings panel can render them. Automod is a caller of the moderation internals, not a new moderation primitive.

Key files

FileRole
packages/db-schema/src/schema/roomFiltersInMessage.tsaction + timeoutDurationMs + enum
packages/db/src/services/message/moderation/getMessageCreationRejection.tsthe shared gate — returns the matched filter, applies nothing
packages/db/src/services/message/moderation/executeAutomodAction.tstimeout + moderation-log core
packages/app/server/services/message/moderation/executeAutomodAction.tsapp wrapper — adds the onAdminAction emit
packages/db/src/services/message/moderation/writeModerationLogEntry.tsshared audit-log writer
packages/db-schema/src/services/message/constants.tsreserved AutoMod actor id (AUTOMOD_USER_ID)
packages/app/app/components/Message/Model/Room/Settings/Type/WordFilter/Index.vueaction + duration settings

Notes

Raid-mode (bulk-join throttling) is intentionally not part of this — see deferred.