Files
GMW/services/discord-gateway/ARCHITECTURE.md
T
mytheclipsebotreview 2b6ec59286 refactor(ai): remove semantic embedding cache + Qdrant vector store
Hapus seluruh fitur embedding/Qdrant (tidak dipakai lagi):

- gateway: drop embeddingClient.ts, qdrantClient.ts, archiveEmbedder.ts
  dan tes qdrantEnsure.test.ts; moderationOrchestrator kembali ke
  exact-hash cache -> LLM (tanpa phase-2 semantic lookup); textCacheStore
  kehilangan findSimilarTextModeration / parseQdrantVerdict /
  isSemanticBandAccepted / upsertBareKeyToQdrant; cache-prune hanya
  menyapu Postgres.
- backend: drop embed.ts + qdrant.ts, endpoint messages.semanticSearch
  dan schema/type terkait; kolom embedding dilepas dari schema
  text_analysis_cache.
- frontend: hapus toggle EXACT/SEMANTIC, hook useSemanticSearch,
  API client + tipe SemanticSearchResult.
- config: buang AI_LLM_EMBEDDING_* dan QDRANT_* (env + .env.example).
- docs: ARCHITECTURE.md / AGENTS.md / README.md / diagram arsitektur
  disesuaikan (LLM caller - vision, cache = exact-hash saja).

Verifikasi: tsc 0 (backend, gateway, frontend); bun test 135 pass +
37 pass, 0 fail; biome 0 error.
2026-09-25 01:38:51 +07:00

11 KiB
Raw Blame History

Discord Gateway — Architecture

Pure event-driven microservice (no HTTP server). Captures Discord messages/attachments/reactions/threads/presence, runs LLM-based AI moderation, and publishes everything to Redis pub/sub for the backend to consume. The backend serves the HTTP/WS API to the frontend.

NOTE: this doc is the source of truth for the module layout. The old MODULE_STRUCTURE.md was a stale duplicate and has been removed. README.md only covers how to run the service.

Top-level layout

services/discord-gateway/
├── src/
│   ├── index.ts                     # Entry point → initializeDiscordGateway()
│   ├── app/                         # Process lifecycle
│   │   ├── bootstrap.ts             # Startup order: config → DB → services → metrics → login
│   │   ├── lifecycle.ts             # Everything wired on the Discord 'ready' hook
│   │   ├── process-guards.ts        # SIGINT/SIGTERM + uncaught-error policy
│   │   ├── metrics-collector.ts     # AI pipeline Prometheus gauges
│   │   ├── shutdown.ts              # Graceful shutdown sequence
│   │   └── retention.ts             # Expired-record cleanup scheduler
│   ├── shared/                      # Infrastructure — never imports from modules/
│   │   ├── config/                  # Zod-validated env (index.ts = schema+loader)
│   │   ├── database/                # Drizzle ORM + pg Pool + migrations
│   │   │   ├── init.ts drizzle.ts pool.ts migrate.ts migrateCli.ts
│   │   │   └── schema/              # messages, cache, meta, analytics
│   │   ├── logger/                  # pino wrapper + createChildLogger()
│   │   ├── errors/                  # AppError / ConfigError ... + errorMessage()
│   │   │                            #   + isTransientStreamError()
│   │   ├── utils/                   # retry, pagination
│   │   ├── discord/clientOptions.ts # discord.js-selfbot-v13 client options
│   │   ├── uploader.ts              # Shared attachment upload helper
│   │   ├── redis-channels.ts        # Redis channel + command constants
│   │   └── moderation-types.ts      # Shared AI analysis domain types
│   └── modules/                     # Feature modules, each with an index.ts facade
│       ├── message-capture/         # Discord event listeners + DB store
│       ├── ai-moderation/           # LLM moderation pipeline (see below)
│       ├── attachment-upload/       # Download + (sharp) resize + upload
│       ├── event-broadcaster/       # RedisEventPublisher + EventBroadcaster
│       ├── command-handler/         # Redis-subscribed backend→gateway commands
│       ├── reaction-tracking/ thread-tracking/ user-presence/
│       ├── channel-topic/ guild-member-events/ monitor/
│       └── gateway-metrics/         # Prometheus /metrics endpoint (METRICS_PORT)

Dependency direction is one-way: index.ts → app/ → modules/ → shared/. Code outside a module imports its index.ts facade, never an internal file; deep imports stay valid inside the module itself.

AI moderation pipeline (ai-moderation/)

LLM-only judge — no regex/heuristic classification. One orchestrator call handles a whole batch. Independent text/media lanes (2026-09-24): a conversation batch is split into a text lane (messages with no media) and a media lane (attachments/stickers/embeds) that are dispatched to separate pools, hold SEPARATE per-lane processing locks, and run under SEPARATE LLM concurrency semaphores. The text lane frees its lock and saves+broadcasts the moment text analysis finishes — it never waits on a slow vision/media batch of the same conversation, and vice versa.

  • aiAnalyzer.ts — public API: queueMessageAnalysis, queueConversationAnalysis, getAnalysisQueueStatus, startPendingAIAnalysisWorker. Short-circuits age-restricted and skip-list messages before any LLM work.
  • recovery-worker.ts — periodic sweep for stranded pending messages (re-scheduled per lane) and error/analysis_incomplete messages (individual fallback queue); prunes stale lane locks, per-conversation CB counters and individual in-flight markers.
  • cache-prune.ts — throttled (6h) expired-verdict sweep across Postgres, driven from the recovery interval.
  • batchScheduler.ts — per-conversation per-LANE debounce → processBatch (lane-aware). splitMessagesByLane / laneOfMessage live in analysisLanes.ts (pure, unit-testable).
  • batchProcessor.ts — per-lane batch lock/circuit-breaker, fans failed targets to individual fallback. processBatch releases ITS lane's lock the moment that lane's worker job finishes; the other lane owns its own lock.
  • individualFallbackProcessor.ts — one-message-at-a-time retry path, own CB.
  • conversationState.ts / circuitBreaker.ts — per-conversation PER-LANE state (conversationProcessing holds a lane → startedAt map per key), Piscina textWorkerPool/mediaWorkerPool, getConversationKey.
  • ai-analysis-worker.ts — Piscina entry point (batch (lane) / individual jobs). Runs runModerationAnalysis off the main thread.
  • moderationOrchestrator.ts — exact-hash cache → LLM. Text and media paths run in parallel.
  • textBatchProcessor.ts / mediaBatchProcessor.ts — actual LLM calls (one call per sub-batch, not per message). mediaBatchProcessor routes its moderation LLM call through the MEDIA semaphore.
  • llmClient.ts — central OpenAI-compatible chat client (streaming, retries, thinking-disable injection). TWO concurrency semaphores: AI_LLM_MAX_CONCURRENT (text lane, default 8) and AI_LLM_MEDIA_MAX_CONCURRENT (media lane, default 4) — a vision backlog can never consume text slots. visionAnalyzer.ts / mediaAnalysisClient.ts share the same router/base URL (different model alias for vision).
  • textCacheStore.ts / channelCultureStore.ts / userProfileStore.ts / userProfileStore.ts — caches learned user profile summaries (optional).

Concurrency model

  • Main thread owns TWO per-lane LLM semaphores (2026-09-24): AI_LLM_MAX_CONCURRENT (text, default 8) and AI_LLM_MEDIA_MAX_CONCURRENT (media, default 4) via llmClient.withLlmConcurrency(fn, { lane }).
  • Two Piscina pools run the heavy LLM work off the event loop: a text pool (PISCINA_MAX_THREADS, default 4) and a dedicated media pool (PISCINA_MEDIA_MAX_THREADS, default 2). A batch is routed by lane to the matching pool — this keeps a slow image/vision batch from occupying every thread and blocking unrelated text-only batches behind it. Each worker thread (in either pool) initializes its own pg Pool (min 0, grows to POSTGRES_POOL_MAX). See "Memory & connections" below.

Memory & DB connections

MemoryMax=1G (raised from 512M — live RSS sits at ~500 MiB, peak 508 MiB, so 512M left ~2% headroom and risked an OOM-kill restart). Host has 8 GB free.

POSTGRES_POOL_MIN=0 (default). The gateway = main process + up to 4 text Piscina worker threads + up to 2 media Piscina worker threads, each with its own pg Pool. With min:0 the pools stay empty until a query runs and drop idle clients afterward, instead of holding (1 main + 4 text + 2 media) × 2 = 14 permanently-open idle connections against PgBouncer. The pool still grows on demand up to POSTGRES_POOL_MAX.

Event channels (Redis pub/sub)

discord:message:{created,updated,deleted,analyzed}, discord:attachment:{created,uploaded}, discord:analysis:queue_status, discord:reaction:{added,removed}, discord:thread:{created,deleted,updated}, discord:channel_topic:updated, discord:presence:updated, discord:guild_member:{added,removed}. See src/shared/redis-channels.ts for the canonical names.

Initialization flow

bootstrap.ts runs these steps in order (each is a named function):

  1. Validate env (Zod). Refuse to start if AI_ANALYSIS_ENABLED but no key. → assertConfigIsUsable()
  2. Build long-lived services: Discord client, RedisEventPublisher + EventBroadcaster, CommandHandler; install the shutdown handler.
  3. Connect infrastructure → connectDatabase(): AUTO_MIGRATE_ON_STARTUP runs pending Drizzle migrations, then initializeDatabase() (pg Pool, min 0).
  4. registerClientDebugLogging() — only client debug lines carrying signal.
  5. Install process guards (registerProcessGuards).
  6. Register pipeline gauges + start the metrics server (METRICS_PORT, code default 9090, set per deployment).
  7. client.login(token).

On the Discord ready event, lifecycle.ts runs startGatewayLifecycle():

  1. Inject the event broadcaster into message-capture and moderation-actions (before any listener can fire).
  2. Register Discord listeners: message-capture, reaction, thread, presence, channel-topic, guild-member.
  3. Start background work: AI analysis worker + recovery worker, command handler, retention cleanup, weekly digest.

Graceful shutdown

process-guards.ts owns the policy. SIGINT/SIGTERM and non-transient uncaught exceptions/rejections run shutdown.ts; transient stream errors (EPIPE / ECONNRESET / ERR_STREAM_DESTROYED / ERR_STREAM_WRITE_AFTER_END, see isTransientStreamError()) are logged and IGNORED so the bot stays online.

Shutdown order: stop metrics → close event broadcaster (Redis) → close command handler → close DB → destroy client → exit.

Observability

Prometheus scrapes the metrics server at 127.0.0.1:$METRICS_PORT/metrics (bete_* prefix; the code default is 9090 — deployments set it explicitly, this host uses 4018). Collectors run per-scrape and expose: process memory/uptime, and (when AI analysis is on) live pipeline gauges registered by app/metrics-collector.ts — ai_analysis_queued_conversations, ai_analysis_active_batch_requests, ai_analysis_active_text_requests, ai_analysis_active_media_requests, ai_analysis_active_individual_requests, ai_analysis_individual_in_flight, ai_analysis_individual_circuit_breaker_active, ai_analysis_worker_threads_{text,media}, ai_analysis_worker_threads_active_{text,media}.

Key invariants (do not break)

  • LLM is the only judge. Failed LLM → status:"error" + recovery retry. Never reintroduce regex/heuristic content classification.
  • Discord tokens are sanitized (discordTokens.ts: <:emoji:id> → [emoji:name], <@id> → @user, etc.) before content reaches the LLM, so numeric snowflake IDs never trigger false positives.
  • Streaming is mandatory against the router base URL (non-stream waits for the full body and times out). llmClient aggregates SSE chunks.