Module: Moderate

Defined in:
lib/moderate.rb,
lib/moderate/text.rb,
lib/moderate/event.rb,
lib/moderate/label.rb,
lib/moderate/engine.rb,
lib/moderate/errors.rb,
lib/moderate/macros.rb,
lib/moderate/result.rb,
lib/moderate/version.rb,
lib/moderate/word_list.rb,
lib/moderate/models/flag.rb,
lib/moderate/filters/base.rb,
lib/moderate/models/block.rb,
lib/moderate/configuration.rb,
lib/moderate/models/appeal.rb,
lib/moderate/models/report.rb,
lib/moderate/filters/wordlist.rb,
lib/moderate/jobs/classify_job.rb,
app/helpers/moderate/engine_helper.rb,
lib/moderate/models/concerns/actor.rb,
lib/moderate/services/resolve_flag.rb,
lib/moderate/services/intake_appeal.rb,
lib/moderate/services/intake_notice.rb,
lib/moderate/services/intake_report.rb,
lib/moderate/services/resolve_appeal.rb,
lib/moderate/services/resolve_report.rb,
lib/moderate/models/application_record.rb,
lib/generators/moderate/views_generator.rb,
lib/moderate/models/concerns/reportable.rb,
lib/generators/moderate/install_generator.rb,
app/controllers/moderate/appeals_controller.rb,
app/controllers/moderate/notices_controller.rb,
app/controllers/concerns/moderate/moderation.rb,
app/controllers/moderate/application_controller.rb,
lib/moderate/models/concerns/content_filterable.rb,
app/controllers/moderate/transparency_reports_controller.rb

Overview

The macros lazily mix the gem’s concerns into a host model.

We DON’T ‘require_relative` the concern files here, even though this file is itself require_relative’d by the spine (lib/moderate.rb) at gem-load time. The concerns (Moderate::Actor / Reportable / ContentFilterable) live under ‘lib/moderate/models/concerns/` and are AUTOLOADED by Zeitwerk (the engine `push_dir`s that subtree under the `Moderate` namespace with the `models` and `concerns` dirs collapsed). Requiring them here too would double-manage the same constants and make Zeitwerk raise on its eager-load pass (“already defined”).

This is safe because the macro methods below only REFERENCE the concern constants inside their bodies (‘include Moderate::Actor`), which run when a host model calls `has_reporting_and_blocking`/`has_reportable_content`/`moderates` — long after boot, when the autoloader is fully wired. So Zeitwerk autoloads each concern lazily on first use.

Defined Under Namespace

Modules: Actor, ContentFilterable, EngineHelper, Filters, Generators, Macros, Moderation, Reportable, Services Classes: Appeal, AppealsController, ApplicationController, ApplicationRecord, Block, ClassifyJob, Configuration, ConfigurationError, Engine, Error, Event, Flag, Label, NoticesController, Report, Result, Text, TransparencyReportsController, WordList

Constant Summary collapse

VERSION =
"1.0.0.beta1"

Class Method Summary collapse

Class Method Details

.apply_ban(user:, by:, reason:) ⇒ Object

Apply a ban via the host’s ‘ban_handler` (suspend!, soft-delete, flip a flag, whatever “banned” means in the host’s domain). Keyword-arg signature. No-op by default — the surrounding decision still audits and notifies even if no ban is wired, so the action is never silently dropped (docs/configuration.md).



212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/moderate.rb', line 212

def apply_ban(user:, by:, reason:)
  result = config.ban_handler.call(user: user, by: by, reason: reason)
  payload = {
    user_id: user&.id,
    reason: reason,
    summary: "user #{user&.id || '(unknown)'} banned"
  }.compact

  audit(:user_banned, subject: user, actor: by, recipients: [user].compact, payload: payload)
  notify(:user_banned, subject: user, actor: by, recipients: [user].compact, payload: payload)
  result
end

.audit(event_or_name = nil, **payload) ⇒ Object

Dispatch an auditable moment to the host’s ‘config.audit` hook (no-op by default). Same Event envelope as notify, so a host can point both hooks at the same `case`. Like notify, an audit hook exception is swallowed (turned into a logged warning) so it can never roll back the action it’s recording — audit is observational, never load-bearing.



190
191
192
193
194
195
196
197
# File 'lib/moderate.rb', line 190

def audit(event_or_name = nil, **payload)
  event = event_or_name.is_a?(Event) ? event_or_name : Event.new(name: event_or_name, **payload)
  config.audit.call(event)
  true
rescue => error
  logger&.warn("[moderate] audit hook failed for #{event&.name}: #{error.class}: #{error.message}")
  false
end

.blocked_ids_for(user) ⇒ Object

The single source-of-truth list of user ids “related to” ‘user` via a block edge — i.e. everyone this user has blocked AND everyone who has blocked them (the edge is bidirectional; once either side blocks, neither should see the other). The host enforces blocking everywhere with one query:

Post.where.not(user_id: Moderate.blocked_ids_for(current_user))

Delegates to Moderate::Block (the model that owns the block SQL) so the join logic lives in exactly one place. Returns an empty array for a blank user.



236
237
238
239
240
# File 'lib/moderate.rb', line 236

def blocked_ids_for(user)
  return [] if user.nil?

  Block.related_user_ids(user)
end

.classify(value, policy: nil) ⇒ Object

Classify a value (text or image) and return a Moderate::Result.

Moderate.classify("some sketchy text")           # uses the default adapter
Moderate.classify(value, policy: some_policy)    # uses the policy's adapter

Adapter selection precedence:

1. the adapter named on the passed `policy` (per-field config / `moderates`)
2. the global `config.filter_adapter` default

The adapter contract is the whole point of the gem’s filtering design: ANY object responding to ‘classify(value) → Moderate::Result` is a valid adapter, so the built-in wordlist/image backends and a host’s registered remote classifier are perfectly interchangeable. We tolerate an adapter that returns a plain Hash (a common simpler shape) by funneling it through Result.new, so older/simpler adapters keep working.

Raises:



259
260
261
262
263
264
265
266
267
# File 'lib/moderate.rb', line 259

def classify(value, policy: nil)
  adapter_name = policy&.adapter || config.filter_adapter
  adapter = config.adapter_for(adapter_name)

  raise ConfigurationError, "no filter adapter registered for #{adapter_name.inspect}" if adapter.nil?

  raw = adapter.classify(value)
  coerce_result(raw, source: adapter_name)
end

.configObject Also known as: configuration

The singleton Configuration. Lazily built so merely requiring the gem (before any initializer runs) yields a fully-defaulted, usable config.



52
53
54
# File 'lib/moderate.rb', line 52

def config
  @config ||= Configuration.new
end

.configure {|config| ... } ⇒ Object

The host’s entry point: ‘Moderate.configure do |config| … end`.

We ‘yield` the live config object (so every assignment lands on the singleton) and then run `validate!` ONCE at the end — this is the documented behavior in docs/configuration.md: “The block is validated at the end of `configure`, so a typo’d mode or unknown adapter raises a plain-English ArgumentError immediately instead of failing mysteriously later.” Per-setter checks already fired on assignment; this final pass catches cross-field problems (e.g. a :block filter on an async adapter).

Yields:



70
71
72
73
74
# File 'lib/moderate.rb', line 70

def configure
  yield config if block_given?
  config.validate!
  config
end

.filter_policy_for(record_or_class, field) ⇒ Object

Resolve the FilterPolicy for a given record/class + field, walking the ancestor chain so a policy declared on a base/STI parent applies to subclasses (this is why a marketplace’s ‘Listing` policy covers `Listing::Featured`, etc. — host-agnostically). Falls back to an `:off` policy when nothing is declared, so callers can treat “no policy” and “:off” identically.



274
275
276
277
278
279
280
281
282
283
284
285
286
287
# File 'lib/moderate.rb', line 274

def filter_policy_for(record_or_class, field)
  klass = record_or_class.is_a?(Class) ? record_or_class : record_or_class.class
  field_s = field.to_s

  policy = klass.ancestors.filter_map do |ancestor|
    next unless ancestor.respond_to?(:name) && ancestor.name

    config.filters[[ancestor.name, field_s]]
  end.first

  policy || Configuration::FilterPolicy.new(
    class_name: klass.name, field: field_s, adapter: config.filter_adapter, mode: :off
  )
end

.localeObject

The locale for copy the gem generates itself, falling back to the app’s default. Read lazily so a host setting I18n.default_locale after our boot still wins.



299
300
301
# File 'lib/moderate.rb', line 299

def locale
  config.locale || (defined?(I18n) ? I18n.default_locale : :en)
end

.notify(event_or_name, **payload) ⇒ Object

Dispatch a notifiable moment to the host’s ‘config.notify` hook.

Accepts either a ready-made Moderate::Event or an event NAME plus payload —the gem’s services mostly call ‘Moderate.notify(:report_received, subject:, recipients:, …)`, but a pre-built Event is accepted too. We always hand the hook a Moderate::Event so the host’s single ‘case event.name` works uniformly.

RETURNS a “delivered” boolean. This exists specifically for legal-email gating: DSA Art. 16(4) requires a confirmation of receipt for a notice, and the notice flow needs to know whether the confirmation actually went out so it can fall back (e.g. show an on-screen receipt) if the host hasn’t wired a mailer. “Delivered” means the hook ran without raising and returned a truthy value — the default no-op hook returns nil ⇒ false, which correctly signals “nothing was sent.”

We never let a host hook’s exception bubble into a moderation action (a slow or broken mailer must not roll back a decision). On error we audit the failure and return false.



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/moderate.rb', line 162

def notify(event_or_name, **payload)
  event = event_or_name.is_a?(Event) ? event_or_name : Event.new(name: event_or_name, **payload)

  begin
    result = config.notify.call(event)
    # A lambda may legitimately return a delivery handle, a job, true, etc.
    # Anything truthy counts as delivered; nil/false counts as not-delivered.
    result ? true : false
  rescue => error
    audit(
      name: :notify_failed,
      subject: event.subject,
      payload: {
        event: event.name,
        error_class: error.class.name,
        error_message: error.message,
        summary: "notify hook failed for #{event.name}: #{error.class}"
      }
    )
    false
  end
end

.register_adapter(name, adapter) ⇒ Object

Register a filter adapter at runtime (the facade twin of ‘config.register_adapter`, so a host can call either `Moderate.register_adapter(…)` or `config.register_adapter(…)`).



292
293
294
# File 'lib/moderate.rb', line 292

def register_adapter(name, adapter)
  config.register_adapter(name, adapter)
end

.register_reportable(klass) ⇒ Object

Auto-discovered set of classes that declared themselves reportable (via the ‘has_reportable_content` macro or `include Moderate::Reportable`). The Reportable concern calls `Moderate.register_reportable(self)` on inclusion, so there’s NO manual registry to maintain — exactly what the README promises (“Reportable classes are auto-discovered from the ‘has_reportable_content` macro — no manual registry.”).

Stored as a Set of STRING class names (not Class objects) so we never pin a class in memory across a Zeitwerk reload in development; we constantize on read.



123
124
125
126
127
128
129
# File 'lib/moderate.rb', line 123

def register_reportable(klass)
  name = klass.is_a?(Class) ? klass.name : klass.to_s
  return if name.nil? || name.empty?

  reportable_registry << name
  name
end

.reportable_classesObject

The reportable classes, constantized on demand. We rescue a NameError per entry so a class that was registered then removed (a dev-time edit) doesn’t break the whole list.



134
135
136
137
138
139
140
# File 'lib/moderate.rb', line 134

def reportable_classes
  reportable_registry.filter_map do |name|
    name.constantize
  rescue NameError
    nil
  end
end

.reset!Object

Reset to a pristine, fully-defaulted Configuration. The primary consumer is the test suite (‘Moderate.reset!` between cases); it’s documented as part of the public API. We also drop the cached user-class constant so a test that swaps ‘config.user_class` doesn’t see a stale lazily-memoized class.

IMPORTANT: we do NOT clear the reportable REGISTRY here. Reportable classes are discovered once, at MODEL LOAD time (the ‘has_reportable_content` macro / `include Moderate::Reportable` runs `Moderate.register_reportable(self)` on inclusion). In a booted app (and the eager-loaded test suite) the models load exactly once, so wiping the registry on every `reset!` would leave `Moderate.reportable_classes` permanently empty after the first reset — the macros would never re-run to repopulate it. The registry is a load-time FACT, not configuration, so it correctly survives a config reset.



89
90
91
92
93
# File 'lib/moderate.rb', line 89

def reset!
  @config = Configuration.new
  @user_class = nil
  self
end

.run_on_block(blocker:, blocked:, at:) ⇒ Object

Run the optional ‘on_block` side-effect hook (cancel a pending invite, leave a shared room, …). Keyword-arg signature per docs/configuration.md. `at:` is the block row’s creation time so hosts can apply time-aware teardown without reaching back into the database. Kept as a facade method so Moderate::Block has one call site and doesn’t reach into config internals. No-op by default.



204
205
206
# File 'lib/moderate.rb', line 204

def run_on_block(blocker:, blocked:, at:)
  config.on_block.call(blocker: blocker, blocked: blocked, at: at)
end

.transparency(from: nil, to: nil) ⇒ Object

DSA Art. 24 transparency aggregation for a period — the numbers a host publishes (notices received by intake/ground, actions taken, automated-means usage, appeal outcomes, median handling times). This is the queryable building block: the public ‘/transparency` page (off by default — see `config.transparency_report_enabled`) renders this, and a host that keeps the page off can still call this to publish its own report in its own format.



309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
# File 'lib/moderate.rb', line 309

def transparency(from: nil, to: nil)
  to ||= Time.respond_to?(:current) ? Time.current : Time.now
  from ||= to - (365 * 24 * 60 * 60)
  reports = Moderate::Report.where(created_at: from..to)
  appeals = Moderate::Appeal.where(created_at: from..to)
  flags = Moderate::Flag.where(created_at: from..to)

  {
    period: { from: from, to: to },
    notices_by_intake: reports.group(:intake_kind).count,
    dsa_notices_by_legal_reason: reports.where(intake_kind: "dsa").group(:legal_reason).count,
    actions_by_basis: reports.where.not(resolved_at: nil).group(:resolution_basis).count,
    automated_flags_by_source: flags.group(:source).count,
    appeals_by_status: appeals.group(:status).count,
    median_notice_action_seconds: transparency_median(reports.where.not(resolved_at: nil).pluck(:created_at, :resolved_at)),
    median_appeal_action_seconds: transparency_median(appeals.where.not(resolved_at: nil).pluck(:created_at, :resolved_at))
  }
end

.user_classObject

The actor model (who reports/blocks/gets reported/gets banned), resolved by constantizing ‘config.user_class` LAZILY on first use. Lazy on purpose: the initializer that sets `config.user_class = “User”` runs before the User model is necessarily loaded, so we must not constantize at configure time.

Memoized, but cleared by ‘reset!` and re-derived if the configured name changes (guards against a stale constant in long-lived processes/tests).



104
105
106
107
108
109
110
111
# File 'lib/moderate.rb', line 104

def user_class
  name = config.user_class
  if @user_class.nil? || @user_class_name != name
    @user_class = name.constantize
    @user_class_name = name
  end
  @user_class
end