Chat word filter and mutes.
The enforcement side of chat moderation: an admin-managed blocklist checked
against every outgoing message, and mutes that silence a sender globally or
in one lobby/group/party. Both are checked in Gamend.Chat.send_message/2
before the message is persisted, so nothing unmoderated reaches the database
or PubSub.
Reports live in Gamend.Chat.Reports.
Writes go to the database first and are then mirrored into
Gamend.Chat.Moderation.Cache (ETS + PubSub), which is what the per-message
path actually reads.
Summary
Functions
Languages with a bundled word list available to import.
Run content through the blocklist.
Count blocklist entries matching filters.
Count mutes matching filters.
Add a word to the blocklist.
Remove a blocklist entry.
Delete every blocklist entry with the given lang tag. Returns the count.
Fetch one blocklist entry.
Fetch one mute.
Every blocklist entry matching content, as [{word, severity, match_mode}].
Import the bundled list for lang.
Every unexpired mute, for the boot load.
List blocklist entries. Filters: :word, :severity, :lang.
List mutes. Filters: :user_id, :scope, :scope_ref_id, :active (when
true, only unexpired mutes).
Mute user_id.
Whether user_id is currently muted for the given chat (ETS read).
Delete mutes whose expires_at has passed. Returns the number removed.
Remove a mute. Returns {:ok, count} — 0 when the user was not muted.
Update a blocklist entry.
Functions
@spec bundled_languages() :: [String.t()]
Languages with a bundled word list available to import.
The lists are vendored under priv/chat_filter/<lang>.txt; nothing is loaded
until an admin imports it.
Run content through the blocklist.
Returns {:error, :blocked_content} when a block word matches,
{:ok, content, flagged_words} otherwise — content has any mask hits
replaced with ***, and flagged_words is non-empty when a flag word
matched (the caller files a report once the message is persisted).
Masking works on whole whitespace-separated tokens, because a hit is found in the normalized form and its offsets do not map back onto the original text. If a masked message still matches (a multi-word phrase that no single token covers) it is blocked rather than sent through half-masked.
@spec count_filter_words(map()) :: non_neg_integer()
Count blocklist entries matching filters.
@spec count_mutes(map()) :: non_neg_integer()
Count mutes matching filters.
@spec create_filter_word(map()) :: {:ok, Gamend.Chat.FilterWord.t()} | {:error, term()}
Add a word to the blocklist.
Rejected with {:error, :too_many_filter_words} once
max_chat_filter_words is reached.
@spec delete_filter_word(Gamend.Chat.FilterWord.t()) :: {:ok, Gamend.Chat.FilterWord.t()} | {:error, term()}
Remove a blocklist entry.
@spec delete_filter_words_by_lang(String.t()) :: non_neg_integer()
Delete every blocklist entry with the given lang tag. Returns the count.
@spec get_filter_word(Ecto.UUID.t()) :: Gamend.Chat.FilterWord.t() | nil
Fetch one blocklist entry.
@spec get_mute(Ecto.UUID.t()) :: Gamend.Chat.Mute.t() | nil
Fetch one mute.
Every blocklist entry matching content, as [{word, severity, match_mode}].
Exposed for the admin "test a phrase" box so it reports exactly what the runtime path would do.
@spec import_bundled_list(String.t(), String.t()) :: {:ok, non_neg_integer()} | {:error, term()}
Import the bundled list for lang.
Every word goes through the normal changeset, so max_chat_filter_word_len
applies and duplicates are skipped. Returns {:ok, imported_count} or
{:error, :unknown_language | :too_many_filter_words}.
@spec list_active_mutes() :: [Gamend.Chat.Mute.t()]
Every unexpired mute, for the boot load.
@spec list_filter_words( map(), keyword() ) :: [Gamend.Chat.FilterWord.t()]
List blocklist entries. Filters: :word, :severity, :lang.
@spec list_mutes( map(), keyword() ) :: [Gamend.Chat.Mute.t()]
List mutes. Filters: :user_id, :scope, :scope_ref_id, :active (when
true, only unexpired mutes).
@spec mute_user(Ecto.UUID.t(), String.t(), Ecto.UUID.t() | nil, map()) :: {:ok, Gamend.Chat.Mute.t()} | {:error, term()}
Mute user_id.
scope is "global" (every chat, scope_ref_id nil) or one of "lobby",
"group", "party" with the room id as scope_ref_id. attrs may carry
expires_at (nil means permanent), reason and muted_by.
Re-muting an already-muted user replaces the existing mute, so a moderator can extend or shorten one without unmuting first.
@spec muted?(Ecto.UUID.t(), String.t(), Ecto.UUID.t() | nil) :: boolean()
Whether user_id is currently muted for the given chat (ETS read).
@spec purge_expired_mutes() :: non_neg_integer()
Delete mutes whose expires_at has passed. Returns the number removed.
Hygiene only — muted?/3 already ignores expired entries, so a missed sweep
never lets a mute outlive its expiry.
@spec unmute_user(Ecto.UUID.t(), String.t(), Ecto.UUID.t() | nil) :: {:ok, non_neg_integer()}
Remove a mute. Returns {:ok, count} — 0 when the user was not muted.
@spec update_filter_word(Gamend.Chat.FilterWord.t(), map()) :: {:ok, Gamend.Chat.FilterWord.t()} | {:error, term()}
Update a blocklist entry.