Gamend.Chat.Moderation (gamend_core v1.0.1192)

Copy Markdown View Source

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).

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

bundled_languages()

@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.

check_content(content)

@spec check_content(String.t()) ::
  {:ok, String.t(), [String.t()]} | {:error, :blocked_content}

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.

count_filter_words(filters \\ %{})

@spec count_filter_words(map()) :: non_neg_integer()

Count blocklist entries matching filters.

count_mutes(filters \\ %{})

@spec count_mutes(map()) :: non_neg_integer()

Count mutes matching filters.

create_filter_word(attrs)

@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.

delete_filter_word(word)

@spec delete_filter_word(Gamend.Chat.FilterWord.t()) ::
  {:ok, Gamend.Chat.FilterWord.t()} | {:error, term()}

Remove a blocklist entry.

delete_filter_words_by_lang(lang)

@spec delete_filter_words_by_lang(String.t()) :: non_neg_integer()

Delete every blocklist entry with the given lang tag. Returns the count.

get_filter_word(id)

@spec get_filter_word(Ecto.UUID.t()) :: Gamend.Chat.FilterWord.t() | nil

Fetch one blocklist entry.

get_mute(id)

@spec get_mute(Ecto.UUID.t()) :: Gamend.Chat.Mute.t() | nil

Fetch one mute.

hits(content)

@spec hits(String.t()) :: [{String.t(), String.t(), String.t()}]

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.

import_bundled_list(lang, severity \\ "block")

@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}.

list_active_mutes()

@spec list_active_mutes() :: [Gamend.Chat.Mute.t()]

Every unexpired mute, for the boot load.

list_filter_words(filters \\ %{}, opts \\ [])

@spec list_filter_words(
  map(),
  keyword()
) :: [Gamend.Chat.FilterWord.t()]

List blocklist entries. Filters: :word, :severity, :lang.

list_mutes(filters \\ %{}, opts \\ [])

@spec list_mutes(
  map(),
  keyword()
) :: [Gamend.Chat.Mute.t()]

List mutes. Filters: :user_id, :scope, :scope_ref_id, :active (when true, only unexpired mutes).

mute_user(user_id, scope, scope_ref_id, attrs \\ %{})

@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.

muted?(user_id, chat_type, chat_ref_id)

@spec muted?(Ecto.UUID.t(), String.t(), Ecto.UUID.t() | nil) :: boolean()

Whether user_id is currently muted for the given chat (ETS read).

purge_expired_mutes()

@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.

unmute_user(user_id, scope, scope_ref_id \\ nil)

@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.

update_filter_word(word, attrs)

@spec update_filter_word(Gamend.Chat.FilterWord.t(), map()) ::
  {:ok, Gamend.Chat.FilterWord.t()} | {:error, term()}

Update a blocklist entry.