Skip to content

Refactor: Hybrid Moderation Pipeline with Event-Driven Enforcement #228

@danielhe4rt

Description

@danielhe4rt

Problem Statement

The moderation system was built as a monolithic handler inside MessageReceivedEvent (bot-discord module). This single class orchestrates the entire pipeline inline: content ingestion, classification (rules + AI), case creation, routing, penalty suggestion, and enforcement execution. This creates:

  1. Performance degradation — AI classification (1-3s) blocks the Discord websocket event loop, stalling all other bot events.
  2. Untestable coupling — testing classification requires mocking the entire Discord message context and the full pipeline.
  3. Platform lock-in — adding Twitch, Telegram, or Web moderation would require duplicating the entire 150-line orchestration.
  4. Maintenance burden — all moderation logic lives in a single file that mixes domain rules with platform-specific HTTP calls.
  5. Domain boundary violationsbot-discord imports and instantiates classifiers, rules, advisors, and jobs from the moderation module directly, knowing every internal detail.

Additionally, all Discord REST HTTP calls are made ad-hoc via Laravel's Http facade with copy-pasted headers, lacking type safety, retry logic, and rate limiting.

Solution

Refactor into a hybrid architecture where:

  • The moderation module owns the entire classification and routing pipeline, exposed via a single SubmitForModeration action.
  • Platform modules (bot-discord) only submit content and listen to domain events for enforcement.
  • All Discord HTTP communication is centralized in integration-discord using Saloon typed connectors and requests.
  • A PlatformRegistry provides O(1) lookup of platform adapters by enum.
  • Auto-execution policy is owned by the moderation domain — platforms only execute when told to.

User Stories

  1. As a bot operator, I want the Discord websocket event loop to remain responsive during moderation checks, so that other bot features (commands, greetings, voice) are not delayed.
  2. As a developer, I want to test the moderation pipeline without needing a Discord connection, so that CI runs fast and reliably.
  3. As a developer, I want to add a new platform (Twitch, Telegram) by implementing a single contract and registering a listener, so that multi-platform moderation is achievable in hours not days.
  4. As a moderator, I want rule-based violations (keyword matches) to be caught instantly (<5ms) without waiting for AI, so that obvious spam is removed immediately.
  5. As a moderator, I want AI-based classification to run asynchronously with retry logic, so that OpenAI outages don't crash the bot or lose flagged content.
  6. As a moderator, I want auto-execution to only happen for deterministic rule matches (never AI-only), so that false positives from AI always go to human review.
  7. As an admin, I want immunity from automated punitive actions, so that misconfigured rules cannot accidentally ban staff.
  8. As a developer, I want all Discord REST API calls to go through typed Saloon request classes, so that endpoints are discoverable, testable with MockClient, and have consistent timeouts/retry.
  9. As a developer, I want the ModerationContentDTO to be lightweight (no Eloquent models), so that it serializes cleanly for queue jobs without stale state.
  10. As a developer, I want identity resolution to use the existing FindExternalIdentity action from the identity module, so that caching and resolution logic is not duplicated.
  11. As a developer, I want a PlatformRegistry with enum-based lookup, so that adapter resolution is O(1) and lazy (only instantiates what's needed).
  12. As a developer, I want the moderation module to never import from bot-discord or integration-discord, so that domain logic remains platform-agnostic.
  13. As a developer, I want embed formatting for Discord notifications to be in a dedicated builder class, so that it's testable without HTTP mocks.
  14. As a developer, I want the DiscordRoleResolver in integration-discord to encapsulate role-based protection checks, so that the adapter is thin and the resolution is independently testable.
  15. As a developer, I want classification and routing in a single job with routing extracted as a composable Action, so that there's no orphan state between steps but each concern is still unit-testable.

Implementation Decisions

Architecture Pattern

  • Hybrid pipeline (C2): synchronous rule pre-screening + async AI classification via queue jobs.
  • Event-driven enforcement: moderation emits CaseReadyForEnforcement only when auto-execution policy passes; platform listeners handle the how.

Module Responsibilities

moderation (domain core, platform-agnostic):

  • SubmitForModeration — single entry point Action. Runs RuleBasedClassifier inline. Dispatches ClassifyAndRoute (rule match) or ScreenContent (no match, AI needed).
  • ClassifyAndRoute — async job. Classifies with AI, then delegates to RouteCaseAction. Emits CaseQueued and conditionally CaseReadyForEnforcement.
  • ScreenContent — async job. Calls AI, if flagged creates case + routes directly (self-contained, no second AI call).
  • RouteCaseAction — Action. Calculates priority, consults HistoryBasedPenaltyAdvisor, suggests action.
  • PlatformRegistry — singleton, maps Platform enum → adapter instance. Registered via ServiceProviders.
  • Auto-execution policy: classifier_version === 'rules' && suggested_action !== null.

integration-discord (transport layer):

  • Transport/DiscordConnector — Saloon connector with Bot token auth, 5s connect / 10s request timeout.
  • Transport/DiscordOAuthConnector — Saloon connector for OAuth flows.
  • Transport/Requests/ — typed request classes per Discord API endpoint (Members, Bans, Messages, Channels, OAuth).
  • Transport/DiscordRoleResolver — uses GetMember request, resolves protection tier (admin/mod/none) from configured role IDs.
  • OAuth/DiscordOAuthClient — refactored to use DiscordOAuthConnector + typed requests instead of Http:: facade.

bot-discord (runtime + platform glue):

  • MessageReceivedEvent — thin: resolves tenant, tracks activity, calls SubmitForModeration. No classification logic.
  • DiscordModerationAdapter — implements ModerationPlatformContract. Uses DiscordConnector + DiscordRoleResolver. Delegates HTTP to integration-discord.
  • Listeners/AutoExecuteAction — listens CaseReadyForEnforcement, creates ModerationAction, dispatches ExecuteAction.
  • Listeners/NotifyModerationChannel — listens CaseQueued, uses ModerationEmbedBuilder + CreateMessage request.
  • Moderation/ModerationEmbedBuilder — formats case data into Discord embed arrays.

Identity Resolution

  • FindExternalIdentity (identity module) gains optional ?string $tenantId parameter for non-HTTP contexts (bot/queue).
  • ModerationContentDTO carries only authorExternalId (string). SubmitForModeration resolves to author_id via FindExternalIdentity when creating the case.

DTO Changes

  • ModerationContentDTO: remove ?User $author property. Keep authorExternalId, tenantId, and platform data only. Fully serializable.

Domain Events

Event Emitter Consumers
CaseCreated SubmitForModeration / ScreenContent Audit log
CaseQueued ClassifyAndRoute / ScreenContent NotifyModerationChannel (bot-discord)
CaseReadyForEnforcement ClassifyAndRoute / ScreenContent AutoExecuteAction (bot-discord)
CaseResolved ExecuteAction Audit log
ActionExecuted ExecuteAction Audit log

Dependency Rules (enforced)

  • moderation → imports from identity only (via FindExternalIdentity)
  • integration-discord → imports from identity only (OAuth DTOs)
  • bot-discord → imports from moderation (events, contracts, models) + integration-discord (Transport)

Testing Decisions

What makes a good test

  • Test external behavior through public interfaces, not internal implementation details.
  • Use factories and Saloon's MockClient instead of Http::fake() for transport tests.
  • One assertion concept per test (a test name should describe the behavior, not the mechanics).

Modules to test

moderation:

  • SubmitForModeration — unit test: rule match creates case + dispatches job; no match dispatches ScreenContent; below-threshold returns null.
  • ClassifyAndRoute ��� feature test: classifies, updates case, emits events. Test auto-execution policy (rules → event emitted; AI → no event).
  • ScreenContent — feature test: AI flags → case created + routed; AI clears → no case.
  • RouteCaseAction �� unit test: priority calculation, penalty escalation, suggested action.
  • PlatformRegistry — unit test: register, resolve, resolve-unknown-throws.

integration-discord:

  • DiscordConnector — unit test: correct base URL, auth header format Bot <token>.
  • Saloon requests — unit test per request: correct endpoint, method, payload shape.
  • DiscordRoleResolver — unit test with MockClient: admin tier, mod tier, no tier, API failure.
  • DiscordOAuthClient — feature test: token exchange, user retrieval (via MockClient).

bot-discord:

  • DiscordModerationAdapter — feature test with MockClient: execute mute/kick/ban/warn, protection checks, failure scenarios. (Existing tests migrated from Http::fake to Saloon MockClient.)
  • AutoExecuteAction listener — feature test: creates action + dispatches job when event received; ignores non-Discord platforms.
  • NotifyModerationChannel listener — feature test: sends embed via connector; handles missing config gracefully.
  • ModerationEmbedBuilder — unit test: correct embed structure, field values, truncation.

Prior art

  • app-modules/moderation/tests/Feature/PipelineTest.php — existing pipeline integration tests (ingest → classify → route).
  • app-modules/bot-discord/tests/Feature/Moderation/DiscordModerationAdapterTest.php — existing adapter tests with Http::fake (to be migrated to MockClient).
  • app-modules/moderation/tests/Unit/PenaltyAdvisorTest.php — unit test pattern for domain logic.

Out of Scope

  • Admin panel (Filament) changes — no UI modifications in this refactor.
  • New platform implementations (Twitch, Telegram) — this refactor enables them but doesn't implement them.
  • AI model changes — keeping OpenAI Moderation API as-is; no new classifiers.
  • Database migrations — no schema changes; existing tables support the new architecture.
  • ETL migration to Saloon — the integration-discord/ETL/ directory stays on Http:: for now; it's legacy import code with different concerns.
  • Laracord internal changes — websocket runtime stays as-is.
  • Appeal workflow changes — not affected by this refactor.
  • Config structure changesconfig/moderation.php stays the same.

Further Notes

  • ADR documented: app-modules/moderation/docs/adr/0001-hybrid-pipeline-with-event-driven-enforcement.md
  • Domain glossary: app-modules/moderation/CONTEXT.md, app-modules/bot-discord/CONTEXT.md, app-modules/integration-discord/CONTEXT.md
  • Context map: CONTEXT-MAP.md at repo root defines dependency rules between modules.
  • Saloon dependency: saloonphp/saloon needs to be added to composer.json before transport implementation begins.
  • Suggested implementation order: (1) Add Saloon + Transport layer, (2) Refactor DiscordOAuthClient to validate Transport works, (3) Create PlatformRegistry + SubmitForModeration, (4) Create ScreenContent + refactor ClassifyAndRoute, (5) Refactor MessageReceivedEvent to be thin, (6) Create listeners (AutoExecuteAction, refactor NotifyModerationChannel), (7) Migrate adapter tests to MockClient.

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentFully specified, ready for an AFK agent

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions