Image messages previously blocked the whole analysis pipeline:
- conversationProcessing was a single lock per conversation; processBatch
awaited BOTH text and media worker jobs before releasing it, so a fast
text verdict sat unused until the slow vision/media batch finished
- one global LLM semaphore (AI_LLM_MAX_CONCURRENT) was shared by text and
media, so a vision backlog could starve text inference
- recovery worker gated on conversationProcessing.size
Now the queue is split into independent text/media lanes:
- conversationProcessing maps key -> Partial<Record<lane, startedAt>>;
each lane holds its own lock and frees it the moment ITS worker job
resolves (ownership-guarded clear prevents stale timers clearing newer
slots)
- two LLM semaphores: AI_LLM_MAX_CONCURRENT (text, default 8) and
AI_LLM_MEDIA_MAX_CONCURRENT (media, default 4) via
withLlmConcurrency(fn, { lane })
- batchScheduler schedules per conversation+lane (timer keys
'<key>::<lane>'); splitMessagesByLane/laneOfMessage moved to pure
analysisLanes.ts (unit-testable without Piscina)
- ai-analysis-worker batch jobs carry a lane field; per-lane active
request gauges (active_text_requests / active_media_requests)
- added tests/analysisLaneLock.test.ts (7 tests: independent lane locks,
preserving other-lane lock, clear-all, ownership guard, lane split)
Docs: ARCHITECTURE.md + AGENTS.md concurrency model updated.
typecheck/lint/test(138)/build all green.
6.0 KiB
6.0 KiB
Discord Gateway — Agent Guide
Read
../../AGENTS.mdfirst. This file adds gateway-specific conventions.
Event-driven microservice: captures Discord events, runs AI moderation, publishes to Redis.
Quick reference
pnpm typecheck # tsc --noEmit
pnpm lint # biome check --diagnostic-level=error .
pnpm build # tsc
pnpm test # vitest run
pnpm format # biome format --write .
Architecture (Event-driven)
src/
├── index.ts # Entry point → initializeDiscordGateway()
├── app/
│ ├── bootstrap.ts # Wires client, DB, Redis, workers, schedulers
│ ├── shutdown.ts # Graceful shutdown
│ └── retention.ts # Expired-record cleanup
├── shared/
│ ├── config/index.ts # Zod-validated env (SINGLE source of truth)
│ ├── database/ # Drizzle ORM + pg Pool + migrations
│ ├── logger/index.ts # pino + createChildLogger()
│ ├── errors/index.ts # AppError hierarchy
│ ├── utils/ # retry, pagination
│ ├── discord/clientOptions.ts # discord.js-selfbot-v13 options
│ ├── uploader.ts # Attachment upload helper
│ ├── redis-channels.ts # Redis channel-name constants
│ └── moderation-types.ts # Shared AI analysis types
├── modules/
│ ├── ai-moderation/ # LLM moderation pipeline (largest module)
│ ├── message-capture/ # Discord event listeners + DB store
│ ├── attachment-upload/ # Download + sharp resize + upload
│ ├── event-broadcaster/ # Redis pub/sub publisher
│ ├── command-handler/ # Backend→gateway Redis commands
│ ├── reaction-tracking/ # Reaction events
│ ├── thread-tracking/ # Thread events
│ ├── user-presence/ # Presence/status events
│ ├── channel-topic/ # Channel topic events
│ ├── guild-member-events/ # Member join/leave
│ └── gateway-metrics/ # Prometheus /metrics (port 4016)
└── tests/ # Vitest suites
Key invariants (DO NOT BREAK)
- LLM is the only judge. Failed LLM →
status:"error"+ recovery retry. Never reintroduce regex/heuristic content classification. - Discord tokens sanitized before reaching LLM (
discordTokens.ts). - Semantic cache is batched — one embed call + one Qdrant batch search.
- Streaming is mandatory against the omniroute base URL.
AI moderation pipeline
aiAnalyzer.ts → batchScheduler.ts → batchProcessor.ts → individualFallbackProcessor.ts
↓ ↓ ↓ ↓
moderationOrchestrator.ts → (hash cache → Qdrant → LLM)
↓ ↓ ↓
textBatchProcessor.ts mediaBatchProcessor.ts llmClient.ts
embeddingClient.ts
qdrantClient.ts
- Entry:
aiAnalyzer.ts(queueMessageAnalysis,startPendingAIAnalysisWorker) - Concurrency: two per-lane LLM semaphores (2026-09-24) — text
(
AI_LLM_MAX_CONCURRENT, default 8) and media/vision (AI_LLM_MEDIA_MAX_CONCURRENT, default 4); a media backlog can never consume text slots - Piscina: text pool (4 threads) + media pool (2 threads)
- Locks are per conversation per lane (
conversationProcessingmaps key → lane → startedAt): the text lane of a conversation never waits on that conversation's media lane (this was the "image blocks the queue" bug) - Each worker thread has its own pg Pool (min 0, grows to
POSTGRES_POOL_MAX)
Module: message-capture
messageCapture.ts— Discord event listeners (messageCreate/Update/Delete)messageStore.ts— DB operationsmessageMetadata.ts— metadata extractionmessagesDb.ts/messagesCrud.ts— DB schema operationsarchiveEmbedder.ts— Qdrant embedding (respect age-restricted guard)retentionDb.ts/reviewsDb.ts/attachmentsDb.ts— auxiliary tables
Redis channels (outbound to backend)
See src/shared/redis-channels.ts for canonical names. Examples:
discord:message:created, discord:moderation:action, discord:attachment:uploaded
Config (env vars)
All validated via Zod in shared/config/index.ts. Critical:
DISCORD_TOKEN— selfbot tokenMONITOR_GUILD_ID— primary guildDATABASE_URL— PostgreSQLREDIS_URL— pub/sub to backendAI_ANALYSIS_ENABLED— master toggle for AI moderationAI_LLM_BASE_URL/AI_LLM_API_KEY— LLM routerPISCINA_MAX_THREADS/PISCINA_MEDIA_MAX_THREADS— worker pool sizing
Concurrency model
- Main thread: event loop + LLM semaphore
- Text Piscina pool:
PISCINA_MAX_THREADS(default 4) - Media Piscina pool:
PISCINA_MEDIA_MAX_THREADS(default 2) - Batch routed to media pool if ANY message has attachment/sticker/embed
- Each worker thread initializes own pg Pool (min 0, grows to
POSTGRES_POOL_MAX) - MemoryMax: 1G (service systemd limit)
Testing
- Vitest. Test files:
tests/<name>.test.ts - Run:
pnpm test - Key test areas: batch operations, cache guards, image/video handling, context enrichment
- Unit tests for pure helpers (classifier, normalize, parse), integration for pipeline stages
Common pitfalls
- Piscina pool isolation: worker threads are NOT the main thread. Cannot share state via module-level variables. Use DB or Redis for cross-thread state.
- Cache eviction: LRU caches (user metadata, term glossary) evict at max size. Don't assume cache hit after eviction.
- Circuit breaker: per-conversation CB opens after repeated failures.
Check
circuitBreaker.tsstate when debugging "missing analysis". - Gateway≠Backend schema: both have
redis-channels.tsandmoderation-types.ts. Keep them in sync manually.