diff --git a/.env.example b/.env.example index 3fdd43a..50802c0 100644 --- a/.env.example +++ b/.env.example @@ -49,8 +49,8 @@ BACKLOG_SYNC_BATCH_SIZE=100 # AI Analysis Configuration AI_ANALYSIS_ENABLED=false AI_LLM_API_KEY=your_9router_key_here -AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1 -AI_LLM_MODEL=free +AI_LLM_BASE_URL=https://9router.asepharyana.my.id/v1 +AI_LLM_MODEL=text # Vision model for image/video moderation (falls back to AI_LLM_MODEL if unset) AI_LLM_VISION_MODEL=multimodal # Max concurrent LLM API calls (default: 5) @@ -110,7 +110,7 @@ AUTO_DELETE_FLAGGED_ENABLED=true AUTO_DELETE_FLAGGED_DRY_RUN=true AUTO_DELETE_FLAGGED_DELAY_MS=0 AUTO_DELETE_MIN_CONFIDENCE=0.50 -AUTO_DELETE_ALLOWED_SEVERITIES=critical,high,medium +AUTO_DELETE_ALLOWED_SEVERITIES=critical,high,medium,low AUTO_DELETE_NOTIFY_USER=false # Optional: comma-separated channel/user IDs to exclude # AUTO_DELETE_EXCLUDED_CHANNEL_IDS= @@ -134,10 +134,3 @@ AUTO_MIGRATE_ON_STARTUP=true # Worker Pool Configuration # PISCINA_MAX_THREADS=4 - -# Cache Model Versioning -# Bump this version when the vision/LLM model prompt changes significantly. -# Old cache entries with mismatched versions are automatically ignored, forcing fresh analysis. -# Format: "v" or "v--" -# Example progression: v1 → v2-2026-06-02-terminal-fix → v3-2026-06-15-new-model -# CACHE_MODEL_VERSION=v2-2026-06-02 diff --git a/CLAUDE.md b/CLAUDE.md index dcb1f3f..c7d08f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,513 +4,702 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**Discord Moderation Watcher Bot** — A comprehensive monitoring bot that captures voice, text messages, and images from Discord servers. Records audio from voice channels, captures all text messages (new/edited/deleted) from channels and threads, and uploads attachments to external storage. All data stored in SQLite with real-time dashboard. +**Bete (Discord Moderation Watcher)** — A comprehensive microservice-based Discord monitoring and moderation bot. Captures text messages, images, voice audio, and screenshares from Discord servers. Features AI-powered content moderation with auto-delete, voice recording with real-time streaming, music playback, and a React dashboard. -Built with **Node.js/pnpm** + **discord.js-selfbot-v13** + **@discordjs/voice** + **Express** + **WebSocket**. +Built with **pnpm workspace monorepo** with 3 services and 1 shared library: + +| Package | Path | Description | +|---------|------|-------------| +| `discord-moderation-backend` | `services/backend` | Express HTTP/WS server, REST API, Redis bridge | +| `@bete/discord-gateway` | `services/discord-gateway` | Discord client, voice recording, message capture, AI moderation | +| `@gmw/frontend` | `services/frontend` | React 19 + Vite + Tailwind web dashboard | +| `@bete/shared` | `packages/shared` | Shared types, errors, logger, utilities | + +**Database:** PostgreSQL (Drizzle ORM) — NOT SQLite. + +**Inter-service communication:** Redis pub/sub. ## Architecture ### High-Level Flow -1. **Bot Entry** (`src/index.ts`) — Initializes Discord client, registers event listeners, starts webserver -2. **Message Capture** (`src/moderation/messageCapture.ts`) — Listens to Discord events (messageCreate, messageUpdate, messageDelete) -3. **Message Store** (`src/moderation/messageStore.ts`) — Database operations for messages and attachments -4. **Attachment Uploader** (`src/moderation/attachmentUploader.ts`) — Downloads from Discord, uploads to picser, stores URLs -5. **Voice Controller** (`src/voiceController.ts`) — Manages voice channel connections -6. **Recorder** (`src/recorder.ts`) — Records voice audio to OGG segments -7. **Web Server** (`src/webserver.ts`) — Express + WebSocket for REST API and real-time updates -8. **Dashboard** (`public/dashboard.html`) — Web UI with three tabs (Text, Images, Voice) +``` +Discord + | + v +discord-gateway ---- Redis ---- backend ---- WebSocket ---- frontend + | pub/sub (broadcast) (React) + | | + | | + <------------------+ + (command channel) +``` -### Key Modules +1. **discord-gateway** connects to Discord via `discord.js-selfbot-v13`, captures events (messages, voice, attachments), stores in PostgreSQL, and publishes events to Redis channels (e.g., `discord:message:created`, `discord:voice:pcm`). -**Moderation Subsystem** (`src/moderation/`): -- `types.ts` — TypeScript types for messages, attachments, voice segments -- `messageCapture.ts` — Discord event listeners (messageCreate, messageUpdate, messageDelete) -- `messageStore.ts` — Database CRUD operations (insert, update, query) -- `attachmentUploader.ts` — Picser integration with retry logic and error handling +2. **backend** subscribes to Redis channels, broadcasts events to WebSocket clients, and serves REST API endpoints. -**Database Schema** (SQLite): -- `messages` table — text messages with edit/delete tracking, user metadata, timestamps -- `attachments` table — attachment metadata, Discord URLs, picser URLs, upload status -- Indexes on channel_id, user_id, created_at for fast queries +3. **frontend** connects via WebSocket and HTTP to the backend, provides a dashboard for live monitoring (text, voice, media) and AI moderation oversight. -**Voice Recording** (existing, unchanged): -- `recorder.ts` — Joins voice channel, subscribes to user audio streams -- `recorder/audioStream.ts` — Opus packet subscription -- `recorder/decoder.ts` — Opus decoder with runtime checks -- `recorder/segment.ts` — OGG file rotation (5s segments) +4. **Command flow (reverse):** Frontend -> Backend HTTP/WS -> Redis (`backend:command`) -> discord-gateway (command handler) - for actions like connect voice, play media, moderate message. -**Web Interface**: -- REST API: `/api/messages?channel=&type=text|image` -- WebSocket: real-time events (message_created, message_updated, message_deleted, attachment_uploaded) -- Dashboard: three tabs (Text Messages, Images, Voice) with channel filtering - -### Recording Structure +### Data Flow ``` -recordings/ - ├── / - │ ├── --0.ogg - │ ├── --0.json - │ └── ... +Message Capture: + Discord -> messageCapture.ts -> messageStore.ts (PostgreSQL) + | + +> eventBroadcaster -> Redis -> backend -> WS clients -messages (SQLite): - ├── id, guild_id, channel_id, thread_id - ├── user_id, username, avatar_url - ├── content, edited_content - ├── created_at, edited_at, deleted_at - └── type (text|edited|deleted) +Voice Recording: + Discord -> voiceController.ts -> recorder.ts -> OGG files on disk + | | + +> eventBroadcaster +> decoder.ts -> PCM -> Redis -> WS clients -attachments (SQLite): - ├── id, message_id, guild_id, channel_id, user_id - ├── filename, size, type (MIME) - ├── discord_url, uploaded_url (picser raw_commit) - ├── upload_status (pending|uploaded|failed) - └── created_at, uploaded_at +AI Moderation: + messageStore -> aiAnalyzer.ts -> LLM API -> moderation result + | | + +> eventBroadcaster +> update message in DB ``` +## Service Breakdown + +### backend (`services/backend`) + +Express 5 + Helmet HTTP server with WebSocket (ws) on port 3001 (default). + +**REST API endpoints:** +- `GET /api/health` — Health check with optional `?verbose=true` +- `POST /api/auth/login` — Admin authentication +- `GET /api/config` — App configuration +- `GET /api/messages` — List messages (cursor pagination) +- `GET /api/messages/:channelId` — Messages by channel +- `GET /api/messages/:channelId/attachments` — Attachments by channel +- `GET /api/messages/detail/:id` — Single message +- `POST /api/messages/reanalyze-batch` — Bulk retry AI analysis +- `POST /api/messages/:id/reanalyze` — Retry single message +- `POST /api/messages/:id/moderate` — Dispatch moderation action +- `GET /api/review` — Flagged/warned messages +- `GET /api/analysis/search` — Full-text search with `?q=` +- `POST /api/chat` — Mascot AI chat +- `GET /api/chat/history` — Chat history +- `POST /api/chat/clear` — Clear chat history +- `POST /api/voice/command` — Send voice transmit commands +- `GET /api/status` — Voice connection status +- `POST /api/connect` — Connect to voice channel +- `POST /api/disconnect` — Disconnect from voice +- `GET /api/guilds` — List guilds +- `GET /api/guilds/:guildId/channels` — Text channels +- `GET /api/guilds/:guildId/voice-channels` — Voice channels +- `GET /api/media/status` — Media player status +- `POST /api/media/queue` — Queue media (music/screen) +- `POST /api/media/skip` — Skip current track +- `POST /api/media/stop` — Stop playback +- `POST /api/media/volume` — Set volume +- `GET /api/recordings` — Voice recordings list +- `GET /api/ui-state` — Get persistent UI state +- `POST /api/ui-state` — Save UI state + +**Modules (feature-based, under `src/modules/`):** +- `health/` — Database connectivity check +- `auth/` — Admin password auth +- `messages/` — Message + attachment CRUD, review, reanalyze +- `voice/` — Voice connection, guilds, channels +- `media/` — Music/screenshare player control +- `analysis/` — Full-text search across analyzed messages +- `mascot-chat/` — AI chatbot with server context +- `recordings/` — Voice recording listing +- `ui-state/` — Persistent UI state for dashboard +- `config/` — App config endpoint +- `analytics/` — Analytics (schema defined) + +**WebSocket events (outbound to frontend):** +- `message_created`, `message_updated`, `message_deleted`, `message_analyzed` +- `attachment_created`, `attachment_uploaded` +- `voice_recording_started`, `voice_recording_stopped`, `voice_recording_uploaded` +- `voice_active_user`, `voice_pcm_data` +- `analysis_queue_status` +- `user_state`, `ui_state`, `media_state` +- `heartbeat` (every 30s) + +**WebSocket inbound (from frontend):** +- JSON `{ type: "voice_transmit", buffer: "" }` — forwarded to Redis +- JSON `{ type: "voice_command", command: "..." }` — forwarded to discord-gateway + +### discord-gateway (`services/discord-gateway`) + +The core service that connects to Discord using `discord.js-selfbot-v13`. + +**Modules:** + +- **`message-capture/`** — Listens to `messageCreate`, `messageUpdate`, `messageDelete` events. Stores messages in PostgreSQL. Handles edits, deletes, and backlog sync. + - `messageCapture.ts` — Event listeners + - `messageStore.ts` — Database operations (upsert, update, delete) + - `messageMetadata.ts` — User/channel metadata extraction + - `broadcaster.ts` — Internal event dispatch + - `pagination.ts` — Backlog sync for historical messages + - `analyticsStore.ts` — Per-channel analytics tracking + +- **`voice-recording/`** — Voice channel connection, recording, and real-time PCM streaming. + - `voiceController.ts` — Connection lifecycle (connect/disconnect per guild+channel) + - `recorder.ts` — Manages speaking users, subscribes to audio streams + - `recorder/audioStream.ts` — Opus packet subscription per user + - `recorder/decoder.ts` — Opus to PCM decoding with rotation/cooldown + - `recorder/segment.ts` — OGG file segment rotation (default 5s) + - `recorder/metadata.ts` — User metadata JSON for each segment + - `recorder/sessionRecording.ts` — Session-scoped recording management + - `recorder/uploader.ts` — Upload completed segments + - `player.ts` — Discord player (music/screenshare playback) + - `transmitter.ts` — Browser-to-Discord audio transmission (Redis -> Opus -> Discord) + - `muxer.ts` — Audio muxing logic + - `packetFilter.ts` — Opus packet filtering + - `ffmpegProcess.ts` — FFmpeg-based processing + - `mediaTypes.ts` — Audio/video format definitions + - `teleUpload.ts` — Upload to tele/picser + +- **`attachment-upload/`** — Downloads Discord attachments, uploads to external service. + - `attachmentUploader.ts` — Download + upload with retry + - `imageResizer.ts` — Resize images before upload + - `teleUpload.ts` — Upload to tele/picser API + +- **`ai-moderation/`** — AI-powered content moderation pipeline. + - `aiAnalyzer.ts` — Analysis worker (batch + individual fallback) + - `aiAnalysisWorker.ts` — Piscina worker thread for batch processing + - `llmClient.ts` — Generic LLM API client + - `llmModerationClient.ts` — Moderation-specific LLM client + - `moderationPrompt.ts` — System prompt builder with few-shot + - `autoDeleteManager.ts` — Auto-delete flagged messages + - `conversationContext.ts` — Conversation window builder + - `concurrencyLimiter.ts` — Rate limiter for LLM calls + - `channelCultureStore.ts` — Channel norms/slang context + - `cultureLearner.ts` — Learn channel culture over time + - `userReputationStore.ts` — User trust scores + - `textCacheStore.ts` — Deduplicate repeated text analysis + - `stickerCache.ts` — Upload and cache sticker images + - `stickerPrompt.ts` — Sticker analysis prompt + - `urlFetcher.ts` — Fetch URL content for analysis + - `responseLogger.ts` — Log moderation responses + +- **`event-broadcaster/`** — Redis pub/sub publisher for all events. + - `eventBroadcaster.ts` — `EventBroadcaster` class with typed methods + - `eventTypes.ts` — Channel constants and event interfaces + +- **`command-handler/`** — Listens on `backend:command` Redis channel for backend requests. + - `commandHandler.ts` — Handles voice connect/disconnect, guilds, channels, media, transmit + +**Infrastructure:** +- `src/shared/config/config.ts` — Zod-validated env config (DISCORD_TOKEN, REDIS_URL, AI_LLM_*, etc.) +- `src/shared/database/schema.ts` — Full PostgreSQL schema definition +- `src/shared/database/drizzle.ts` — Drizzle + pg pool initialization +- `src/shared/database/migrate.ts` — Migration runner with advisory locking +- `src/shared/database/voiceRecordingRepo.ts` — Voice recording queries +- `src/shared/discord/clientOptions.ts` — Discord client configuration + +### frontend (`services/frontend`) + +React 19 + Vite 8 + Tailwind CSS 4 + TypeScript dashboard. + +**Tech stack:** +- React 19 with hooks +- Vite 8 (rolldown) for bundling +- Tailwind CSS 4 with PostCSS +- Three.js + React Three Fiber + Drei for 3D visualizations +- GSAP + Framer Motion for animations +- Radix UI primitives (ScrollArea, Slot, Tabs) +- TanStack React Query for data fetching +- Lucide React for icons + +**Feature structure (entity + feature slices):** +- `entities/` — Type exports re-exported from shared API client + - `guild/types.ts` — Guild, Channel + - `message/types.ts` — MessageRecord, PageResult + - `voice/types.ts` — ActiveSpeaker, VoiceStatus + - `media/types.ts` — MediaItem, MediaMode, MediaState + - `ui/types.ts` — UIState, DashboardTab +- `features/` + - `live/` — Voice connection, music player, screenshare, recordings + - Components: ActiveSpeakers, AudioVisualizer, MusicSubPanel, NowPlaying, RecordingsSubPanel, ScreenSubPanel, VoiceConnectionCard + - Hooks: `useVoiceControl`, `useMediaControl` + - `messages/` — Message list with filters + - Hooks: `useMessages` + - `analytics/` — Analytics (hook scaffolded) +- `shared/` + - `api/client.ts` — All HTTP API calls + types + - `ws/socket.ts` — WebSocket singleton with `useDashboardSocket` hook + - `ws/events.ts` — Typed event map + - `hooks/` — useAudioPlayback, useAudioTransmit, useUIState, useMascotChat, useMascotSummary, useFramerStagger, useGsapTransition, useLocalStorage + - `ui/` — Reusable UI components (Badge, Button, Card, Input, Select, Skeleton, Tabs, Toast, ScrollArea) + - `lib/utils.ts` — `cn()` and other utilities + +**WebSocket protocol:** +- Binary: PCM audio data (24kHz mono s16le) +- JSON events: message_*, voice_*, attachment_*, user_state, ui_state, media_state, heartbeat + +### shared (`packages/shared`) + +Shared library used by both backend and discord-gateway. + +**Exports:** +- `@bete/shared` — Everything below +- `@bete/shared/types` — AppConfig, MessageRecord, AttachmentRecord, VoiceSegment, etc. +- `@bete/shared/errors` — AppError, ValidationError, NotFoundError, UnauthorizedError, DatabaseError, ConfigError, DiscordError, TimeoutError, etc. +- `@bete/shared/logger` — Pino-based `createChildLogger(context)` +- `@bete/shared/utils` — Shared utilities + +## Database Schema (PostgreSQL) + +All tables defined in `services/discord-gateway/src/shared/database/schema.ts`. + +### messages +Stores text messages with AI moderation results. +- `id` (text PK), `guild_id`, `channel_id`, `thread_id` +- `user_id`, `username`, `avatar_url` +- `content`, `edited_content`, `type` (text|edited|deleted) +- `created_at`, `edited_at`, `deleted_at` +- `ai_status` (pending|processing|clean|warn|flagged|error) +- `ai_moderation_flags`, `ai_moderation_score`, `ai_analysis`, `ai_categories` +- `ai_severity` (none|low|medium|high|critical), `ai_confidence` +- `ai_recommended_action` (none|monitor|warn|review|delete|escalate) +- `ai_analyzed_at`, `ai_error`, `metadata` +- Indexes: channel, user, created_at, thread, channel+created, thread+created, ai_status+created, guild+ai_status+created, guild+created+deleted, channel+ai_status+created, thread+ai_status+created + +### attachments +Discord attachment metadata with upload tracking. +- `id` (text PK), `message_id` (FK -> messages cascade), `guild_id`, `channel_id` +- `filename`, `size`, `type` (MIME), `discord_url`, `uploaded_url` +- `upload_status` (pending|uploaded|failed), `upload_error` +- `created_at`, `uploaded_at` +- Indexes: channel, message, upload_status, channel+created, thread+created + +### voice_recordings +Voice segment metadata. +- `id` (text PK), `user_id`, `username`, `avatar_url` +- `guild_id`, `channel_id`, `channel_name` +- `filename`, `size_bytes`, `download_url` +- `upload_status` (pending|uploaded|failed), `upload_error` +- `created_at`, `uploaded_at` +- Indexes: user_id, channel_id, created_at + +### ui_state +Persistent dashboard UI state (key-value). +- `key` (text PK), `value` (text), `updated_at` + +### ai_analysis_runs +Tracks AI analysis batch runs. +- `id` (text PK), `conversation_key`, `target_message_ids` (JSON) +- `model`, `request_tokens_estimate`, `response_raw` +- `status` (pending|processing|completed|failed), `error` +- `created_at`, `completed_at` +- Indexes: conversation_key, status, created_at + +### user_reputations +User trust scores for AI context. +- `user_id` (text PK), `guild_id`, `trust_score`, `clean_message_streak` +- `total_infractions`, `last_infraction_at`, `created_at`, `updated_at` +- Indexes: guild_id, trust_score + +### channel_cultures +AI-generated channel norms and slang summaries. +- `channel_id` (text PK), `guild_id`, `culture_summary`, `last_analyzed_at` +- Index: guild_id + +### message_reviews +Manual review tracking for flagged messages. +- `id` (text PK), `message_id`, `guild_id`, `channel_id` +- `reviewer_id`, `status` (pending|approved|rejected|escalated) +- `notes`, `created_at`, `reviewed_at` +- Indexes: message_id, status, created_at, guild+status+created + +### moderation_actions +Action audit log (delete/mute/warn/kick/ban). +- `id` (text PK), `message_id`, `user_id`, `guild_id` +- `action_type` (delete_message|mute_user|warn_user|kick_user|ban_user) +- `reason`, `executed_by`, `status` (pending|executed|failed) +- `error`, `created_at`, `executed_at` +- Indexes: message_id, user_id, status, guild+status+created + +### retention_policies +Data retention rules per guild/channel. +- `id` (text PK), `guild_id`, `channel_id` +- `retention_days`, `apply_to_media`, `apply_to_voice`, `enabled` +- `created_at`, `updated_at` +- Indexes: guild_id, enabled + +### text_analysis_cache +Caches normalized-text moderation results to avoid redundant LLM calls. +- `text` (text PK), `flags` (JSON array), `source` (local|primary_ai|vision_llm) +- `analyzed_at`, `expires_at`, `hit_count` +- Indexes: expires_at, source + +### sticker_cache +Uploaded sticker image URLs for vision analysis. +- `name` (text PK), `image_url`, `mime_type`, `fetched_at` +- Index: fetched_at + +### corrected_moderations +Manual corrections (false positives) for few-shot injection. +- `id` (text PK), `message_id`, `original_flags`, `corrected_flags` +- `correction_notes`, `content_snippet`, `created_at` +- Indexes: created_at, message_id + +### muxer_jobs +Audio post-processing job queue. +- `id` (text PK), `data` (JSON), `status` (pending|processing|completed|failed) +- `attempts`, `maxAttempts`, `created_at`, `updated_at`, `error` +- Indexes: status, created_at + +## Redis Communication + +### discord-gateway publishes (event channels): +| Channel | Event type | When | +|---------|-----------|------| +| `discord:message:created` | `message_created` | New message | +| `discord:message:updated` | `message_updated` | Message edited | +| `discord:message:deleted` | `message_deleted` | Message deleted | +| `discord:message:analyzed` | `message_analyzed` | AI analysis complete | +| `discord:attachment:created` | `attachment_created` | New attachment | +| `discord:attachment:uploaded` | `attachment_uploaded` | Upload complete | +| `discord:voice:started` | `voice_recording_started` | Recording started | +| `discord:voice:stopped` | `voice_recording_stopped` | Recording stopped | +| `discord:voice:uploaded` | `voice_recording_uploaded` | Upload complete | +| `discord:voice:active_user` | `voice_active_user` | Speaker state change | +| `discord:voice:pcm` | `voice_pcm_data` | Live PCM audio chunk | +| `discord:analysis:queue_status` | `analysis_queue_status` | Queue stats | + +### backend publishes (command channel): +| Channel | Command type | Description | +|---------|-------------|-------------| +| `backend:command` | `voice:connect` | Connect to voice | +| `backend:command` | `voice:disconnect` | Disconnect voice | +| `backend:command` | `voice:channels` | List voice channels | +| `backend:command` | `voice:transmit:start/stop` | Audio transmit | +| `backend:command` | `guilds:list` | List guilds | +| `backend:command` | `guilds:text-channels` | List text channels | +| `backend:command` | `media:queue/skip/stop/volume` | Media control | +| `backend:command` | `moderation:action` | Execute moderation action | + +Envelope format: `{ id, type, payload, replyChannel }`. + +Status keys: `voice:status`, `media:status` (set by discord-gateway, read by backend). + ## Development Commands ```bash -# Install dependencies +# Install all dependencies pnpm install -# Development (auto-restart on file changes) -pnpm run dev +# Run each service in development mode (separate terminal each) +pnpm run dev:backend # Backend on port 3001 +pnpm run dev:discord-gateway # Discord client + all features +pnpm run dev:web # Frontend on Vite dev server -# Production -pnpm run start +# Build +pnpm run build:backend +pnpm run build:discord-gateway +pnpm run build:web -# Type checking +# Type checking across all packages pnpm run typecheck -# Linting (Biome) +# Lint (Biome) pnpm run lint -# Format code (Biome) +# Format (Biome) pnpm run format -# Run tests +# Run tests across all packages pnpm run test -# Build TypeScript -pnpm run build +# Database migrations (Drizzle) +pnpm run db:generate # Generate new migration +pnpm run db:migrate # Apply pending migrations +pnpm run db:studio # Open Drizzle Studio + +# Install yt-dlp for media download +pnpm run install:yt-dlp ``` ## Configuration -All config via `.env` (see `.env.example`). Key variables: +Configuration via `.env` (see `.env.example`). Managed by Zod schemas: +- discord-gateway: `services/discord-gateway/src/shared/config/config.ts` +- backend: `services/backend/src/shared/config/index.ts` -**Discord & Monitoring:** -- `DISCORD_TOKEN` — Bot token (required) -- `MONITOR_GUILD_ID` — Target server to monitor (required for moderation) -- `GUILD_ID` — Legacy voice channel guild (optional) -- `VOICE_CHANNEL_ID` — Legacy voice channel ID (optional) - -**Recording:** -- `RECORDINGS_DIR` — Where to save audio files (default: `./recordings`) -- `RECORDING_SEGMENT_MS` — OGG segment duration (default: 5000ms) - -**Decoder:** -- `DECODER_ROTATE_MS` — Opus decoder rotation interval (default: 5000ms) -- `DECODER_COOLDOWN_MS` — Cooldown after decoder error (default: 30000ms) - -**Attachments:** -- `PICSER_UPLOAD_URL` — Picser upload endpoint (default: https://picser.asepharyana.tech/api/upload) -- `ATTACHMENT_UPLOAD_TIMEOUT_MS` — Upload timeout (default: 30000ms) -- `ATTACHMENT_MAX_SIZE_MB` — Max file size (default: 100MB) -- `ATTACHMENT_RETRY_ATTEMPTS` — Retry count (default: 3) - -**Web Server:** -- `WEBSERVER_PORT` — HTTP/WebSocket port (default: 3000) - -**Connection:** -- `VOICE_CONNECTION_TIMEOUT_MS` — Voice join timeout (default: 15000ms) -- `RECONNECT_TIMEOUT_MS` — Reconnect timeout (default: 5000ms) -- `AUDIO_STREAM_SILENCE_DURATION_MS` — Silence threshold (default: 3000ms) - -**Logging:** +### Core (both services) +- `DISCORD_TOKEN` — Discord user token (required) +- `MONITOR_GUILD_ID` — Target guild for text monitoring +- `NODE_ENV` — development|production|test - `LOG_LEVEL` — Pino log level (default: info) - `VERBOSE` — Enable debug logging (default: false) -- `NODE_ENV` — Environment (development|production|test) + +### Database (PostgreSQL) +- `DATABASE_URL` — Connection string (overrides individual params) +- `POSTGRES_HOST`, `POSTGRES_PORT` (5432), `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` +- `POSTGRES_POOL_MIN` (2), `POSTGRES_POOL_MAX` (10) +- `AUTO_MIGRATE_ON_STARTUP` (default: true) + +### Redis +- `REDIS_URL` — Connection string (default: redis://localhost:6379) + +### Voice Recording (discord-gateway) +- `RECORDINGS_DIR` — Audio file output (default: ./recordings) +- `RECORDING_SEGMENT_MS` — OGG segment duration (default: 5000) +- `DECODER_ROTATE_MS` — Opus decoder rotation (default: 5000) +- `DECODER_COOLDOWN_MS` — Decoder error cooldown (default: 30000) +- `AUDIO_STREAM_SILENCE_DURATION_MS` — Silence threshold (default: 3000) +- `VOICE_CONNECTION_TIMEOUT_MS` — Connection timeout (default: 15000) +- `RECONNECT_TIMEOUT_MS` — Reconnect timeout (default: 5000) +- `PACKET_FILTER_MIN_SIZE` — Minimum Opus packet size (default: 8) +- `OPUS_FRAME_SIZE` (960), `AUDIO_SAMPLE_RATE` (48000), `AUDIO_CHANNELS` (2) +- `VOICE_GUILD_ID`, `VOICE_CHANNEL_ID` + +### Attachments +- `TELE_UPLOAD_URL` — Upload endpoint (default: https://upload.asepharyana.my.id/api/upload) +- `ATTACHMENT_UPLOAD_TIMEOUT_MS` (30000), `ATTACHMENT_MAX_SIZE_MB` (100), `ATTACHMENT_RETRY_ATTEMPTS` (3) + +### AI Moderation (discord-gateway) +- `AI_ANALYSIS_ENABLED` — Enable AI analysis (default: false) +- `AI_LLM_API_KEY` — LLM API key (required if enabled) +- `AI_LLM_BASE_URL` — LLM endpoint (default: https://9router.asepharyana.my.id/v1) +- `AI_LLM_MODEL` — Text model (default: text) +- `AI_LLM_VISION_MODEL` — Vision model (optional fallback) +- `AI_LLM_MAX_CONCURRENT` (5), `AI_LLM_TEXT_BATCH_SIZE` (20) +- `AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS` (60000), `AI_LLM_IMAGE_MAX_DIMENSION` (1024) +- `AI_ANALYSIS_DEBOUNCE_MS` (500), `AI_ANALYSIS_MAX_BATCH_SIZE` (200) +- `AI_ANALYSIS_PROCESSING_TIMEOUT_MS` (120000) +- `PISCINA_MAX_THREADS` — Worker pool size (optional) + +### Auto-Delete +- `AUTO_DELETE_FLAGGED_ENABLED` (true), `AUTO_DELETE_FLAGGED_DRY_RUN` (true) +- `AUTO_DELETE_FLAGGED_DELAY_MS` (0), `AUTO_DELETE_MIN_CONFIDENCE` (0.5) +- `AUTO_DELETE_ALLOWED_SEVERITIES`, `AUTO_DELETE_ALLOWED_CATEGORIES` +- `AUTO_DELETE_EXCLUDED_CHANNEL_IDS`, `AUTO_DELETE_EXCLUDED_USER_IDS` +- `AUTO_DELETE_NOTIFY_USER`, `AUTO_DELETE_LOG_CHANNEL_ID` + +### OpenAI Moderation (optional separate endpoint) +- `OPENAI_MODERATION_API_KEY`, `OPENAI_MODERATION_BASE_URL`, `OPENAI_MODERATION_MODEL` + +### Backend +- `WEBSERVER_PORT` (3001), `ADMIN_PASSWORD` (admin123) +- `BACKLOG_SYNC_HOURS` (24), `BACKLOG_SYNC_BATCH_SIZE` (100) + +### Retention +- `RETENTION_MESSAGES_DAYS` (0=off), `RETENTION_ATTACHMENTS_DAYS`, `RETENTION_VOICE_DAYS` +- `RETENTION_CLEANUP_INTERVAL_MS` (86400000), `RETENTION_DRY_RUN` (true) ## Testing -Tests use **Vitest** in `tests/` directory. Run with `pnpm run test`. +Tests use **Vitest**. Currently minimal test coverage. Test directories should be created per service: -**Test Coverage:** -- `tests/moderation/messageStore.test.ts` — Message store CRUD operations -- `tests/moderation/attachmentUploader.test.ts` — Picser response parsing -- `tests/config.test.ts` — Configuration validation -- `tests/decoder.test.ts` — Opus decoder runtime detection +``` +services/backend/tests/ +services/discord-gateway/tests/ +services/frontend/tests/ +``` + +Run tests: `pnpm run test` (runs `vitest run` in each package). ## Code Style - **Formatter**: Biome (2-space indent) -- **Linter**: Biome with custom rules (warn on non-null assertions, noExplicitAny) +- **Linter**: Biome with strict rules - **Language**: TypeScript with strict mode -- **Logging**: Use `createChildLogger(context)` for scoped logs -- **Errors**: Throw custom AppError subclasses with code + statusCode -- **Database**: Use prepared statements, never string interpolation +- **Logging**: Use `createChildLogger(context)` from `@bete/shared/logger` +- **Errors**: Throw custom `AppError` subclasses with `code` + `statusCode` +- **Database**: Use Drizzle ORM or raw parameterized queries (never string interpolation) +- **Imports**: Use `.js` extensions in source files (ESM convention) ## Key Patterns +### Event-Driven Architecture + +All inter-service communication happens through Redis pub/sub. The discord-gateway publishes events on typed channels, the backend subscribes and broadcasts to WebSocket clients. The backend publishes commands on `backend:command` with reply channels for request-response patterns. + ### Message Capture Lifecycle -1. Discord event fires (messageCreate, messageUpdate, messageDelete) -2. Check if guild matches MONITOR_GUILD_ID +1. Discord event fires (`messageCreate`, `messageUpdate`, `messageDelete`) +2. Check guild matches MONITOR_GUILD_ID 3. Extract message metadata (user, channel, content, timestamp) -4. Insert into messages table -5. Broadcast WebSocket event to connected clients -6. If attachments exist: - - Insert into attachments table with status='pending' - - Start async upload to picser (non-blocking) - - On success: update uploaded_url, status='uploaded' - - On failure: store error, status='failed' +4. Upsert into `messages` table in PostgreSQL +5. Publish event to Redis (`discord:message:*`) +6. If attachments exist, insert into `attachments` table with `status='pending'` +7. Start async upload to tele/picser (non-blocking) +8. On success: update `uploaded_url`, `status='uploaded'` +9. On failure: store error, `status='failed'` -### Attachment Upload Flow +### AI Moderation Pipeline -1. Download from Discord URL (with timeout) -2. Validate file size against ATTACHMENT_MAX_SIZE_MB -3. Upload to picser with retry logic (exponential backoff) -4. Parse response, extract raw_commit URL -5. Update database with uploaded_url and status -6. Broadcast attachment_uploaded event +1. Messages with `ai_status='pending'` are picked up by `aiAnalyzer.ts` +2. Batches messages by conversation (thread/channel proximity) +3. Builds context window (recent messages + channel culture + user reputation) +4. Calls LLM via `llmModerationClient.ts` with moderation prompt +5. Updates message with `ai_status`, `ai_moderation_flags`, `ai_severity`, `ai_confidence`, `ai_recommended_action` +6. If `AUTO_DELETE_FLAGGED_ENABLED` and confidence meets threshold, triggers auto-delete +7. Falls back to individual analysis for messages that could not be batched +8. Caches normalized text results in `text_analysis_cache` to avoid repeat calls -### WebSocket Protocol +### Voice Recording Lifecycle -**Inbound (browser → bot):** -- Binary: Raw PCM buffers (24kHz mono s16le) for voice transmission +1. `VoiceController.connect(guildId, channelId)` via Redis command +2. Joins Discord voice channel, sets up audio receiver +3. On user start speaking: create per-user stream, OGG segment manager, Opus decoder +4. Opus packets -> OGG segments on disk + PCM decode for WebSocket broadcast +5. PCM data published to Redis (`discord:voice:pcm`) -> backend -> WS clients +6. On silence (3s timeout): close stream, finalize segment +7. After segment complete: upload to external storage, update database +8. `VoiceController.disconnect()` stops all recording -**Outbound (bot → browser):** -- Binary: 4-byte user ID hash + PCM chunk (voice) -- JSON: `{ type: "user_state", users: [...] }` (active speakers) -- JSON: `{ type: "message_created", data: {...} }` (new text message) -- JSON: `{ type: "message_updated", data: {...} }` (edited message) -- JSON: `{ type: "message_deleted", data: {...} }` (deleted message) -- JSON: `{ type: "attachment_uploaded", data: {...} }` (image uploaded) +### WebSocket Protocol (frontend) + +**Outbound (backend -> frontend):** +- Binary: PCM audio (24kHz mono s16le), prefixed with 4-byte user hash +- JSON events: all typed in `WSEventMap` — `message_*`, `voice_*`, `attachment_*`, `user_state`, `ui_state`, `media_state` + +**Inbound (frontend -> backend):** +- JSON `{ type: "voice_transmit", buffer: "" }` for mic-to-Discord +- JSON `{ type: "voice_command", command: "..." }` for voice control ### Graceful Shutdown -Handles SIGINT/SIGTERM/uncaughtException/unhandledRejection: -1. Stop voice connection -2. Pause player -3. Destroy Discord client -4. Exit process +discord-gateway handles SIGINT/SIGTERM/uncaughtException/unhandledRejection: +1. Close database pool +2. Disconnect voice controller +3. Close event broadcaster (Redis) +4. Close command handler (Redis) +5. Destroy Discord client +6. Exit process -## Dashboard Usage +### Admin Authentication -**Access:** `http://localhost:3000/dashboard.html` +Backend endpoints are protected by `X-Admin-Password` header matching `ADMIN_PASSWORD` env var. The frontend stores the password in `localStorage`. -**Features:** -- Three tabs: Text Messages | Images | Voice -- Channel/thread filter dropdown -- Real-time WebSocket updates -- Polling fallback if WebSocket disconnects -- Message display with metadata (author, timestamp, edits, deletions) -- Image grid with previews and upload status -- Voice segment list (future enhancement) +## Recording Structure -**Keyboard/UI:** -- Click tab to switch content type -- Select channel to filter -- Click image to view full size -- WebSocket status indicator (green = connected) +``` +recordings/ + +-- / + | +-- --0.ogg + | +-- --0.json + | +-- --1.ogg + | +-- ... +``` + +Each segment is 5s (configurable via `RECORDING_SEGMENT_MS`). Metadata JSON includes user info, roles, timestamps, duration. + +## Vendor Packages + +### discord.js-selfbot-v13 (`vendor/discord.js-selfbot-v13`) +Fork of discord.js-selfbot-v13 (git submodule). Provides Discord API access via user account. + +### discord-video-stream (`vendor/discord-video-stream`) +Go Live / video streaming support library. Includes: +- H264 encoding (NVENC, VAAPI, software) +- WebRTC wrapper for Discord voice/video connections +- Stream connection management + +## Dependencies + +**Shared (`@bete/shared`):** +- pino — Structured logging +- zod — Schema validation + +**Backend:** +- express 5 — HTTP server +- ws — WebSocket server +- helmet — Security headers +- @discordjs/voice — Voice state querying (minimal) +- drizzle-orm + pg — PostgreSQL ORM +- ioredis — Redis client +- pino, pino-http — Logging +- prom-client — Prometheus metrics +- axios — HTTP client +- zod — Config validation + +**discord-gateway:** +- discord.js-selfbot-v13 — Discord client (user account) +- @discordjs/voice — Voice connection +- @discordjs/opus — Native Opus codec +- prism-media — Audio encode/decode +- @snazzah/davey — DA-VEY (Discord Audio Video End-to-end encryption) +- ioredis — Redis client +- drizzle-orm + pg — PostgreSQL ORM +- sharp — Image processing +- openai — OpenAI API client +- piscina — Worker threads for AI analysis +- tiktoken — Token counting +- p-retry, p-limit — Async utilities +- lru-cache — In-memory caching +- libsodium-wrappers — Encryption +- node-crc — CRC checksums +- imghash — Image hashing +- ws — WebSocket (internal) +- zod — Config validation + +**Frontend:** +- react 19, react-dom 19 +- @tanstack/react-query — Data fetching +- three, @react-three/fiber, @react-three/drei — 3D +- gsap, framer-motion — Animations +- @radix-ui/* — Accessible UI primitives +- tailwindcss 4, @tailwindcss/postcss — Styling +- lucide-react — Icons +- clsx, tailwind-merge — Class management +- vite 8 — Bundler + +## Notes + +- Bot uses selfbot variant (user account) — check Discord ToS +- Opus decoding requires native `@discordjs/opus` or `opusscript` under Node.js +- OGG segments include metadata JSON for each segment (user info, timestamps, duration) +- WebSocket broadcasts PCM in real-time; browser can transmit audio back to Discord +- Graceful shutdown ensures clean disconnection and resource cleanup +- All database operations use parameterized queries to prevent SQL injection +- Attachment uploads are non-blocking (async) to avoid blocking message capture +- Message capture continues even if AI analysis or attachment upload fails ## Common Tasks ### Add a new config variable - -1. Add to `configSchema` in `src/config.ts` with Zod validation +1. Add to config schema in both `services/backend/src/shared/config/index.ts` and `services/discord-gateway/src/shared/config/config.ts` with Zod validation 2. Add to `.env.example` with description 3. Use via `config.VARIABLE_NAME` ### Add a new REST endpoint - -1. Add route in `src/webserver.ts` (Express) -2. Use database functions from `src/moderation/messageStore.ts` -3. Wrap in try-catch, pass errors to Express error handler +1. Create route handler in `services/backend/src/modules//.routes.ts` +2. Register in `services/backend/src/http/app.ts` +3. Use `asyncHandler` wrapper for error handling 4. Return JSON response ### Add a new WebSocket event - -1. Define broadcast function in `src/webserver.ts` (attach to globalThis) -2. Call from event handler (e.g., messageCapture.ts) -3. Send JSON with `{ type, data, timestamp }` -4. Handle in dashboard JavaScript - -### Debug message capture - -- Set `VERBOSE=true` in `.env` for detailed logging -- Check `/health` endpoint for active users/connections -- Monitor `/metrics` endpoint (Prometheus format) -- Check `recordings//` for voice segments -- Query SQLite directly: `sqlite3 .muxer-queue.db "SELECT * FROM messages LIMIT 10;"` - -### Debug attachment uploads - -- Check `upload_status` in attachments table -- View `upload_error` field for failure reasons -- Monitor logs for "Attachment upload" messages -- Verify picser endpoint is accessible -- Check file size against ATTACHMENT_MAX_SIZE_MB - -## Dependencies - -**Core:** -- **discord.js-selfbot-v13** — Discord client (selfbot variant) -- **@discordjs/voice** — Voice connection management -- **@discordjs/opus** — Native Opus codec (optional, required for web PCM) -- **prism-media** — Audio encoding/decoding (Opus, OGG) - -**Web:** -- **express** — HTTP server -- **ws** — WebSocket server -- **helmet** — Security headers - -**Data:** -- **better-sqlite3** — SQLite database -- **zod** — Config validation - -**Logging & Monitoring:** -- **pino** — Structured logging -- **pino-http** — HTTP request logging -- **prom-client** — Prometheus metrics - -**Utilities:** -- **p-retry** — Retry logic with backoff -- **class-transformer** — Object transformation -- **class-validator** — Data validation - -**Dev:** -- **Biome** — Linting/formatting -- **Vitest** — Testing framework -- **TypeScript** — Type checking - -## Notes - -- Bot uses selfbot variant (user account) rather than standard bot token — check Discord ToS -- Opus decoding requires native `@discordjs/opus` under Node.js -- OGG segments include metadata JSON for each segment (user info, timestamps, duration) -- WebSocket broadcasts PCM in real-time; browser can transmit audio back to Discord -- Graceful shutdown ensures clean disconnection and resource cleanup -- All database operations use prepared statements to prevent SQL injection -- Attachment uploads are non-blocking (async) to avoid blocking message capture -- Message capture continues even if attachment upload fails -- Dashboard uses textContent for XSS prevention (not innerHTML) - -## Future Enhancements - -- Reaction tracking -- Message search/full-text search -- Moderation actions (flag, delete, mute) -- Export/archive functionality -- Retention policies (auto-delete old data) -- Voice segment metadata in dashboard -- User activity analytics -- Audit log export - - -## Architecture - -### High-Level Flow - -1. **Bot Entry** (`src/index.ts`) — Initializes Discord client, sets up graceful shutdown, starts webserver -2. **Voice Controller** (`src/voiceController.ts`) — Manages guild/channel selection and connection lifecycle -3. **Recorder** (`src/recorder.ts`) — Joins voice channel, subscribes to user audio streams, handles Opus decoding and segment rotation -4. **Web Server** (`src/webserver.ts`) — Express + WebSocket server for: - - REST API: guild/channel listing, connect/disconnect - - WebSocket: real-time PCM broadcast to browser, browser-to-Discord audio transmission -5. **Muxer Queue** (`src/muxer-queue.ts`) — SQLite-backed job queue for post-processing audio segments (future use) - -### Key Modules - -- **Recorder subsystem** (`src/recorder/`): - - `audioStream.ts` — Subscribes to Discord audio receiver, emits Opus packets - - `decoder.ts` — Opus decoder with runtime checks, cooldown/rotation logic for web PCM broadcast - - `segment.ts` — Manages OGG file rotation (5s default segments per user) - - `metadata.ts` — Collects user/role info, creates segment metadata JSON - -- **Voice Connection** — Uses `@discordjs/voice` receiver to subscribe to speaking users; each user gets their own stream -- **Audio Pipeline**: - - Discord → Opus packets → PacketFilter → OGG segments (disk) + OpusDecoder → PCM (web broadcast) - - Browser → 24kHz mono PCM → upsample to 48kHz stereo → Opus encoder → OGG → Discord player - -- **Metrics** (`src/metrics.ts`) — Prometheus metrics for audio levels, recordings, connections, WebSocket clients -- **Logging** (`src/logger.ts`) — Pino logger with pretty-print in dev, JSON in prod -- **Config** (`src/config.ts`) — Zod-validated environment variables with sensible defaults -- **Error Handling** (`src/errors.ts`) — Custom error classes (AppError, ConfigError, AudioError, VoiceConnectionError, ValidationError) - -### Recording Structure - -``` -recordings/ - ├── / - │ ├── --0.ogg - │ ├── --0.json - │ ├── --1.ogg - │ ├── --1.json - │ └── ... -``` - -Each segment is 5s (configurable). Metadata JSON includes user info, roles, timestamps, duration. - -### Database - -- **Muxer Queue** (`.muxer-queue.db`) — SQLite with WAL mode, tracks pending/processing/completed/failed jobs for audio post-processing - -## Development Commands - -```bash -# Install dependencies -pnpm install - -# Development (auto-restart on file changes) -pnpm run dev - -# Production -pnpm run start - -# Type checking -pnpm run typecheck - -# Linting (Biome) -pnpm run lint - -# Format code (Biome) -pnpm run format - -# Run tests -pnpm run test - -# Build TypeScript -pnpm run build -``` - -## Configuration - -All config via `.env` (see `.env.example`). Key variables: - -- `DISCORD_TOKEN` — Bot token (required) -- `RECORDINGS_DIR` — Where to save audio files (default: `./recordings`) -- `RECORDING_SEGMENT_MS` — OGG segment duration (default: 5000ms) -- `DECODER_ROTATE_MS` — Opus decoder rotation interval (default: 5000ms) -- `DECODER_COOLDOWN_MS` — Cooldown after decoder error (default: 30000ms) -- `WEBSERVER_PORT` — HTTP/WebSocket port (default: 3000) -- `VOICE_CONNECTION_TIMEOUT_MS` — Voice join timeout (default: 15000ms) -- `AUDIO_STREAM_SILENCE_DURATION_MS` — Silence threshold before ending stream (default: 3000ms) -- `LOG_LEVEL` — Pino log level (default: info) -- `VERBOSE` — Enable debug logging (default: false) - -## Testing - -Tests use **Vitest** in `tests/` directory. Run with `pnpm run test`. - -Example: `tests/decoder.test.ts` tests Opus decoder runtime detection and native opus availability. - -## Code Style - -- **Formatter**: Biome (2-space indent) -- **Linter**: Biome with custom rules (warn on non-null assertions, noExplicitAny) -- **Language**: TypeScript with strict mode -- **Logging**: Use `createChildLogger(context)` for scoped logs -- **Errors**: Throw custom AppError subclasses with code + statusCode - -## Key Patterns - -### Voice Connection Lifecycle - -1. `VoiceController.connect(guildId, channelId)` → calls `startRecording()` -2. `startRecording()` joins channel, sets up receiver, subscribes to speaking users -3. On user speak: create stream, segment manager, decoder; pipe to OGG + web broadcast -4. On silence (3s): close stream, save metadata JSON -5. `VoiceController.disconnect()` → calls `stopRecording()` → destroys connection - -### Audio Decoding (Web Broadcast) - -- OpusDecoder wraps prism decoder with error recovery -- Rotates decoder every 5s to prevent memory leaks -- Cools down for 30s after error before retrying -- Downsamples 48kHz stereo → 24kHz mono for web transmission - -### WebSocket Protocol - -- **Inbound** (browser → bot): Raw PCM buffers (24kHz mono s16le) -- **Outbound** (bot → browser): - - Binary: 4-byte user ID hash + PCM chunk - - JSON: `{ type: "user_state", users: [...] }` on connect/user activity change - -### Graceful Shutdown - -Handles SIGINT/SIGTERM/uncaughtException/unhandledRejection: -1. Stop voice connection -2. Pause player -3. Destroy Discord client -4. Exit process - -## Future Expansion (Text/Image Monitoring) - -Current scope: voice only. Planned additions: -- Text channel message capture -- Image/attachment logging -- Per-channel/per-user filtering -- Moderation action triggers - -These will likely require: -- Additional event listeners in recorder -- Extended metadata schema -- New storage/indexing strategy -- Webhook/alert system - -## Common Tasks - -### Add a new config variable - -1. Add to `configSchema` in `src/config.ts` with Zod validation -2. Add to `.env.example` -3. Use via `config.VARIABLE_NAME` - -### Add a new REST endpoint - -1. Add route in `src/webserver.ts` (Express) -2. Use `VoiceController` methods or create new ones -3. Wrap in try-catch, pass errors to Express error handler - -### Add metrics - -1. Define gauge/counter/histogram in `src/metrics.ts` -2. Update in relevant code paths -3. Metrics exposed at `/metrics` endpoint (Prometheus format) - -### Debug audio issues - -- Set `VERBOSE=true` in `.env` for detailed logging -- Check `/health` endpoint for active users/connections -- Monitor audio levels via `/metrics` (audio_level_db gauge) -- Check segment files in `recordings//` directory - -## Dependencies - -- **discord.js-selfbot-v13** — Discord client (selfbot variant for user account access) -- **@discordjs/voice** — Voice connection management -- **@discordjs/opus** — Native Opus codec (optional, required for web PCM decode) -- **prism-media** — Audio encoding/decoding (Opus, OGG) -- **express** — HTTP server -- **ws** — WebSocket server -- **better-sqlite3** — SQLite database (muxer queue) -- **pino** — Structured logging -- **prom-client** — Prometheus metrics -- **zod** — Config validation -- **Biome** — Linting/formatting -- **Vitest** — Testing framework - -## Notes - -- Bot uses selfbot variant (user account) rather than standard bot token — check Discord ToS -- Opus decoding requires native `@discordjs/opus` under Node.js -- OGG segments include metadata JSON for each segment (user info, timestamps, duration) -- WebSocket broadcasts PCM in real-time; browser can transmit audio back to Discord -- Graceful shutdown ensures clean disconnection and resource cleanup +1. Add to `eventTypes.ts` in discord-gateway +2. Add publish method to `EventBroadcaster` in discord-gateway +3. Add subscription + broadcast mapping in `services/backend/src/ws/redis-bridge.ts` +4. Add event type to `WSEventMap` in frontend `events.ts` +5. Add handler to `WsHandlers` in frontend `socket.ts` + +### Add a new database table +1. Add table definition in `services/discord-gateway/src/shared/database/schema.ts` +2. Generate migration: `pnpm run db:generate` +3. Check migration file in `drizzle/migrations/` +4. Apply: `pnpm run db:migrate` + +### Add a new Redis command +1. Add handler case in `commandHandler.ts` switch statement +2. Add publish call on backend side (see `voice.service.ts` or `media.service.ts`) +3. Update frontend API client if needed + +### Debug AI moderation +- Set `AI_ANALYSIS_ENABLED=true` and `VERBOSE=true` +- Check `ai_status`, `ai_error` fields in messages table +- Monitor `/api/analysis/search?q=` for analysis results +- Check `ai_analysis_runs` table for batch run status +- Adjust `AI_ANALYSIS_*` tuning variables + +### Debug voice recording +- Set `VERBOSE=true` +- Check `/api/status` for active connection +- Monitor segment files in `recordings//` +- Check `voice_recordings` table for upload status ## CodeGraph Usage (Required) diff --git a/packages/shared/package.json b/packages/shared/package.json index 03dc020..a992161 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -7,7 +7,7 @@ "types": "dist/index.d.ts", "exports": { ".": "./dist/index.js", - "./types": "./dist/types/index.js", + "./config": "./dist/config/index.js", "./errors": "./dist/errors/index.js", "./logger": "./dist/logger/index.js", "./utils": "./dist/utils/index.js" diff --git a/packages/shared/src/config/index.ts b/packages/shared/src/config/index.ts new file mode 100644 index 0000000..adfae9c --- /dev/null +++ b/packages/shared/src/config/index.ts @@ -0,0 +1,240 @@ +/** + * Unified configuration schema shared by all services. + * + * This is the single source of truth for all environment variables. + * Individual services re-export from here; they do NOT define their own schemas. + */ + +import { ConfigError } from "../errors/index.js"; +import { z } from "zod"; + +export const configSchema = z + .object({ + // ── Discord ────────────────────────────────────────────────────────── + DISCORD_TOKEN: z + .string() + .min(1, "DISCORD_TOKEN is required") + .transform((value) => value.replace(/^("|')|(?:("|'))$/g, "")), + MONITOR_GUILD_ID: z.string().min(1).optional(), + + // ── Legacy voice ───────────────────────────────────────────────────── + GUILD_ID: z.string().min(1).optional(), + VOICE_GUILD_ID: z.string().min(1).optional(), + VOICE_CHANNEL_ID: z.string().min(1).optional(), + + // ── Text capture legacy ────────────────────────────────────────────── + TEXT_GUILD_ID: z.string().min(1).optional(), + TEXT_CHANNEL_ID: z.string().min(1).optional(), + + // ── Recording ──────────────────────────────────────────────────────── + RECORDINGS_DIR: z.string().default("./recordings"), + RECORDING_SEGMENT_MS: z.coerce.number().positive().default(5000), + + // ── Decoder ────────────────────────────────────────────────────────── + DECODER_ROTATE_MS: z.coerce.number().positive().default(5000), + DECODER_COOLDOWN_MS: z.coerce.number().positive().default(30000), + + // ── Audio ──────────────────────────────────────────────────────────── + AUDIO_STREAM_SILENCE_DURATION_MS: z.coerce.number().positive().default(3000), + PACKET_FILTER_MIN_SIZE: z.coerce.number().positive().default(8), + OPUS_FRAME_SIZE: z.coerce.number().positive().default(960), + AUDIO_SAMPLE_RATE: z.coerce.number().positive().default(48000), + AUDIO_CHANNELS: z.coerce.number().positive().default(2), + AVATAR_SIZE: z.coerce.number().positive().default(64), + + // ── Server ─────────────────────────────────────────────────────────── + WEBSERVER_PORT: z.coerce.number().positive().default(3000), + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), + LOG_LEVEL: z + .enum(["error", "warn", "info", "http", "verbose", "debug", "silly"]) + .default("info"), + VERBOSE: z + .string() + .optional() + .transform((v) => v === "true") + .default(false), + ADMIN_PASSWORD: z.string().default("admin123"), + + // ── Database (PostgreSQL) ──────────────────────────────────────────── + DATABASE_URL: z.string().optional(), + POSTGRES_HOST: z.string().default("localhost"), + POSTGRES_PORT: z.coerce.number().int().positive().default(5432), + POSTGRES_USER: z.string().optional(), + POSTGRES_PASSWORD: z.string().optional(), + POSTGRES_DB: z.string().optional(), + POSTGRES_POOL_MIN: z.coerce.number().int().positive().default(2), + POSTGRES_POOL_MAX: z.coerce.number().int().positive().default(10), + + // ── Redis ──────────────────────────────────────────────────────────── + REDIS_URL: z.string().default("redis://localhost:6379"), + + // ── Connection ─────────────────────────────────────────────────────── + VOICE_CONNECTION_TIMEOUT_MS: z.coerce.number().positive().default(15000), + RECONNECT_TIMEOUT_MS: z.coerce.number().positive().default(5000), + + // ── Attachments ───────────────────────────────────────────────────── + TELE_UPLOAD_URL: z + .string() + .url() + .default("https://upload.asepharyana.my.id/api/upload"), + ATTACHMENT_UPLOAD_TIMEOUT_MS: z.coerce.number().positive().default(30000), + ATTACHMENT_MAX_SIZE_MB: z.coerce.number().positive().default(100), + ATTACHMENT_RETRY_ATTEMPTS: z.coerce.number().positive().default(3), + BACKLOG_SYNC_HOURS: z.coerce.number().positive().default(24), + BACKLOG_SYNC_BATCH_SIZE: z.coerce + .number() + .int() + .positive() + .max(100) + .default(100), + + // ── AI Analysis ───────────────────────────────────────────────────── + AI_ANALYSIS_ENABLED: z + .string() + .optional() + .transform((v) => v === "true") + .default(false), + AI_LLM_API_KEY: z.string().optional(), + AI_LLM_BASE_URL: z + .string() + .url() + .default("https://9router.asepharyana.my.id/v1"), + AI_LLM_MODEL: z.string().default("text"), + AI_LLM_VISION_MODEL: z.string().optional(), + AI_LLM_MAX_CONCURRENT: z.coerce.number().int().positive().default(5), + AI_LLM_IMAGE_MAX_DIMENSION: z.coerce.number().int().positive().default(1024), + AI_LLM_TEXT_BATCH_SIZE: z.coerce.number().int().positive().default(20), + AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS: z.coerce + .number() + .int() + .positive() + .default(60000), + + // ── AI Analysis Timing ────────────────────────────────────────────── + AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500), + AI_ANALYSIS_RECOVERY_INTERVAL_MS: z.coerce.number().positive().default(15000), + AI_ANALYSIS_ERROR_COOLDOWN_MS: z.coerce.number().positive().default(30000), + + // ── AI Analysis Batch ─────────────────────────────────────────────── + AI_ANALYSIS_MAX_BATCH_SIZE: z.coerce.number().int().positive().default(200), + AI_ANALYSIS_MAX_CONTEXT_TOKENS: z.coerce.number().positive().default(8000), + AI_ANALYSIS_MAX_TARGET_TOKENS: z.coerce.number().positive().default(4000), + AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT: z.coerce.number().int().positive().default(20), + AI_ANALYSIS_PROCESSING_TIMEOUT_MS: z.coerce + .number() + .positive() + .default(120000), + AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT: z.coerce + .number() + .int() + .positive() + .default(50), + AI_ANALYSIS_INDIVIDUAL_CB_THRESHOLD: z.coerce + .number() + .int() + .positive() + .default(50), + PISCINA_MAX_THREADS: z.coerce.number().int().positive().optional(), + + // ── OpenAI Moderation ─────────────────────────────────────────────── + OPENAI_MODERATION_API_KEY: z.string().optional(), + OPENAI_MODERATION_BASE_URL: z + .string() + .url() + .default("https://api.openai.com/v1"), + OPENAI_MODERATION_MODEL: z.string().default("omni-moderation-latest"), + + // ── Auto Delete ───────────────────────────────────────────────────── + AUTO_DELETE_FLAGGED_ENABLED: z + .string() + .optional() + .transform((v) => v === "true") + .default(true), + AUTO_DELETE_FLAGGED_DRY_RUN: z + .string() + .optional() + .transform((v) => v === "true") + .default(false), + AUTO_DELETE_FLAGGED_DELAY_MS: z.coerce.number().min(0).default(0), + AUTO_DELETE_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.5), + AUTO_DELETE_ALLOWED_SEVERITIES: z.string().default("critical,high,medium,low"), + AUTO_DELETE_ALLOWED_CATEGORIES: z.string().default(""), + AUTO_DELETE_EXCLUDED_CHANNEL_IDS: z.string().default(""), + AUTO_DELETE_EXCLUDED_USER_IDS: z.string().default(""), + AUTO_DELETE_NOTIFY_USER: z + .string() + .optional() + .transform((v) => v === "true") + .default(false), + AUTO_DELETE_LOG_CHANNEL_ID: z.string().default(""), + + // ── Retention ─────────────────────────────────────────────────────── + RETENTION_MESSAGES_DAYS: z.coerce.number().int().min(0).default(0), + RETENTION_ATTACHMENTS_DAYS: z.coerce.number().int().min(0).default(0), + RETENTION_VOICE_DAYS: z.coerce.number().int().min(0).default(0), + RETENTION_CLEANUP_INTERVAL_MS: z.coerce + .number() + .positive() + .default(24 * 60 * 60 * 1000), + RETENTION_DRY_RUN: z + .string() + .optional() + .transform((v) => v === "true") + .default(true), + AUTO_MIGRATE_ON_STARTUP: z + .string() + .optional() + .transform((v) => v === "true") + .default(true), + }) + .superRefine((value, ctx) => { + if (!value.AI_ANALYSIS_ENABLED) { + // skip: AI analysis not enabled + } else if (!value.AI_LLM_API_KEY) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["AI_LLM_API_KEY"], + message: "AI_LLM_API_KEY is required when AI_ANALYSIS_ENABLED=true", + }); + } + + // Validate database configuration + if (!value.DATABASE_URL && !value.POSTGRES_HOST) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["DATABASE_URL"], + message: "Either DATABASE_URL or POSTGRES_HOST must be provided", + }); + } + }); + +export type AppConfig = z.infer & { + EFFECTIVE_TEXT_GUILD_ID?: string; + EFFECTIVE_VOICE_GUILD_ID?: string; +}; + +export function loadConfig( + env: NodeJS.ProcessEnv = process.env, +): AppConfig { + try { + const parsed = configSchema.parse(env); + return { + ...parsed, + EFFECTIVE_TEXT_GUILD_ID: parsed.MONITOR_GUILD_ID, + EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID ?? parsed.GUILD_ID, + }; + } catch (error) { + if (error instanceof z.ZodError) { + const messages = error.issues + .map((e) => `${e.path.join(".")}: ${e.message}`) + .join("\n"); + throw new ConfigError(`Configuration validation failed:\n${messages}`); + } + throw error; + } +} + +/** Singleton config loaded from process.env at import time. */ +export const config = loadConfig(); diff --git a/packages/shared/src/errors/index.ts b/packages/shared/src/errors/index.ts index bb01bfb..0ddb7ee 100644 --- a/packages/shared/src/errors/index.ts +++ b/packages/shared/src/errors/index.ts @@ -33,30 +33,6 @@ export class UnauthorizedError extends AppError { } } -export class ForbiddenError extends AppError { - constructor(message = "Forbidden") { - super(message, "FORBIDDEN", 403); - this.name = "ForbiddenError"; - } -} - -export class ConflictError extends AppError { - constructor(message: string) { - super(message, "CONFLICT", 409); - this.name = "ConflictError"; - } -} - -export class InternalServerError extends AppError { - constructor( - message = "Internal server error", - details?: Record, - ) { - super(message, "INTERNAL_SERVER_ERROR", 500, details); - this.name = "InternalServerError"; - } -} - export class DatabaseError extends AppError { constructor(message: string, details?: Record) { super(message, "DATABASE_ERROR", 500, details); @@ -70,17 +46,3 @@ export class ConfigError extends AppError { this.name = "ConfigError"; } } - -export class DiscordError extends AppError { - constructor(message: string, details?: Record) { - super(message, "DISCORD_ERROR", 500, details); - this.name = "DiscordError"; - } -} - -export class TimeoutError extends AppError { - constructor(operation: string) { - super(`${operation} timed out`, "TIMEOUT", 504); - this.name = "TimeoutError"; - } -} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 20bd00b..813871a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,5 @@ export * from "./errors/index.js"; export * from "./logger/index.js"; -export * from "./types/index.js"; export * from "./utils/index.js"; +export * from "./moderation-types.js"; +export * from "./config/index.js"; diff --git a/packages/shared/src/moderation-types.ts b/packages/shared/src/moderation-types.ts new file mode 100644 index 0000000..75cd324 --- /dev/null +++ b/packages/shared/src/moderation-types.ts @@ -0,0 +1,220 @@ +// Shared moderation types for all services +// Source of truth — snake_case + number (matching PostgreSQL schema) + +export type AIStatus = + | "pending" + | "processing" + | "clean" + | "warn" + | "flagged" + | "error"; +export type AISeverity = "none" | "low" | "medium" | "high" | "critical"; +export type AIRecommendedAction = + | "none" + | "monitor" + | "warn" + | "review" + | "delete" + | "escalate"; + +export interface BroadcasterClient { + messageCreated: (data: unknown) => void; + messageUpdated: (data: unknown) => void; + messageDeleted: (data: unknown) => void; + messageAnalyzed: (data: unknown) => void; + attachmentCreated: (data: unknown) => void; + attachmentUploaded: (data: unknown) => void; + voiceRecordingStarted: (data: unknown) => void; + voiceRecordingStopped: (data: unknown) => void; + voiceRecordingUploaded: (data: unknown) => void; + analysisQueueStatus: (data: unknown) => void; +} + +export type ModerationBroadcaster = BroadcasterClient; + +export interface RoleMetadata { + id: string; + name: string; + position: number; +} + +export interface UserMetadata { + userId: string; + username: string; + tag: string; + displayName: string; + avatarUrl: string; + bot: boolean; + roles: RoleMetadata[]; + highestRole: RoleMetadata | null; + joinedTimestamp: number | null; +} + +export interface MessageRecord { + id: string; + guild_id: string; + channel_id: string; + thread_id: string | null; + user_id: string; + username: string; + avatar_url: string | null; + content: string; + edited_content: string | null; + created_at: number; + edited_at: number | null; + deleted_at: number | null; + type: "text" | "edited" | "deleted"; + metadata: string | null; + ai_status?: AIStatus | null; + ai_moderation_flags?: string | null; + ai_moderation_score?: number | null; + ai_analysis?: string | null; + ai_categories?: string | null; + ai_severity?: AISeverity | null; + ai_confidence?: number | null; + ai_recommended_action?: AIRecommendedAction | null; + ai_analyzed_at?: number | null; + ai_error?: string | null; +} + +export interface AttachmentRecord { + id: string; + message_id: string; + guild_id: string; + channel_id: string; + thread_id: string | null; + user_id: string; + filename: string; + size: number; + type: string; + discord_url: string; + uploaded_url: string | null; + upload_status: "pending" | "uploaded" | "failed"; + upload_error: string | null; + created_at: number; + uploaded_at: number | null; +} + +export interface VoiceSegmentRecord { + id: string; + user_id: string; + session_id: string; + guild_id: string; + channel_id: string; + filename: string; + duration_ms: number; + created_at: number; +} + +export interface DashboardMessage { + id: string; + channel_id: string; + user_id: string; + username: string; + avatar_url: string | null; + content: string; + created_at: number; + type: "text" | "image" | "voice"; +} + +export interface MessageQuery { + guildId?: string; + channelId?: string; + threadId?: string; + status?: AIStatus[]; + userId?: string; + q?: string; + cursor?: string; + limit: number; +} + +export interface PageResult { + data: T[]; + nextCursor: string | null; +} + +export interface AnalysisResult { + messageId: string; + status: Exclude; + flags: string[]; + score: number; + analysis: string; + categories?: string[]; + severity?: AISeverity; + confidence?: number; + recommendedAction?: AIRecommendedAction; + policyVersion?: string; + evidence?: string[]; +} + +export interface VoiceRecordingUploadData { + id: string; + user_id: string; + username: string; + avatar_url: string | null; + guild_id: string | null; + channel_id: string | null; + channel_name: string | null; + filename: string; + size_bytes: number; + download_url: string; + upload_status: string; + created_at: number; + uploaded_at: number; +} + +export interface AnalysisQueueStatus { + queuedConversations: number; + activeRequests: number; + activeIndividualRequests: number; + individualInFlightCount: number; + individualCircuitBreakerActive: boolean; + lastError: string | null; +} + +export type ReviewStatus = "pending" | "approved" | "rejected" | "escalated"; + +export interface MessageReview { + id: string; + message_id: string; + guild_id: string; + channel_id: string; + reviewer_id: string | null; + status: ReviewStatus; + notes: string | null; + created_at: number; + reviewed_at: number | null; +} + +export type ModerationActionType = + | "delete_message" + | "mute_user" + | "warn_user" + | "kick_user" + | "ban_user"; + +export interface ModerationAction { + id: string; + message_id: string | null; + user_id: string | null; + guild_id: string; + action_type: ModerationActionType; + reason: string | null; + executed_by: string | null; + status: "pending" | "executed" | "failed"; + error: string | null; + created_at: number; + executed_at: number | null; +} + +export interface RetentionPolicy { + id: string; + guild_id: string; + channel_id: string | null; + retention_days: number; + apply_to_media: boolean; + apply_to_voice: boolean; + enabled: boolean; + created_at: number; + updated_at: number; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts deleted file mode 100644 index 12974bf..0000000 --- a/packages/shared/src/types/index.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Shared types for all services -export interface AppConfig { - NODE_ENV: "development" | "production" | "test"; - LOG_LEVEL: string; - VERBOSE: boolean; -} - -export interface DatabaseConfig { - DATABASE_URL: string; - AUTO_MIGRATE_ON_STARTUP: boolean; -} - -export interface DiscordConfig { - DISCORD_TOKEN: string; - MONITOR_GUILD_ID: string; -} - -export interface AIConfig { - AI_LLM_API_KEY: string; -} - -export interface RedisConfig { - REDIS_URL: string; -} - -export interface WebServerConfig { - WEBSERVER_PORT: number; - ADMIN_PASSWORD: string; -} - -export interface MessageRecord { - id: string; - guildId: string; - channelId: string; - userId: string; - username: string; - content: string; - createdAt: Date; - editedAt?: Date; - deletedAt?: Date; -} - -export interface AttachmentRecord { - id: string; - messageId: string; - filename: string; - size: number; - mimeType: string; - discordUrl: string; - uploadedUrl?: string; - uploadStatus: "pending" | "uploaded" | "failed"; - createdAt: Date; -} - -export interface VoiceSegment { - userId: string; - sessionStart: number; - segmentIndex: number; - duration: number; - filePath: string; - createdAt: Date; -} - -export interface AnalyticsData { - totalMessages: number; - totalAttachments: number; - totalVoiceSegments: number; - activeUsers: number; - lastUpdated: Date; -} diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index c5d233a..505255b 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -4,82 +4,24 @@ export function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -export function formatBytes(bytes: number): string { - if (bytes === 0) return "0 Bytes"; - const k = 1024; - const sizes = ["Bytes", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]; -} - -export function generateId(): string { - return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; -} - -export function isValidUrl(url: string): boolean { - try { - new URL(url); - return true; - } catch { - return false; - } -} - -export function sanitizeString(str: string): string { - return str.replace(/[<>]/g, "").trim(); -} - -export interface PaginationParams { - page: number; - limit: number; -} - -export interface PaginatedResponse { - data: T[]; - total: number; - page: number; - limit: number; - pages: number; -} - -export function calculatePagination( - total: number, - page: number, - limit: number, -): PaginatedResponse { - return { - data: [], - total, - page, - limit, - pages: Math.ceil(total / limit), - }; -} - -export function getOffset(page: number, limit: number): number { - return (page - 1) * limit; -} - // --------------------------------------------------------------------------- -// Retry with exponential backoff (port of discord-gateway retry utility) +// Retry with exponential backoff // --------------------------------------------------------------------------- -export interface RetryOptions { - /** Number of retry attempts (default: 3) */ - retries?: number; - /** Initial delay in ms (default: 1000) */ - minTimeout?: number; - /** Maximum delay in ms (default: 30000) */ - maxTimeout?: number; - /** Multiplication factor for each retry (default: 2) */ - factor?: number; - /** Optional AbortSignal to cancel retries */ - signal?: AbortSignal; -} - export async function retryWithBackoff( fn: () => Promise, - options: RetryOptions = {}, + options: { + /** Number of retry attempts (default: 3) */ + retries?: number; + /** Initial delay in ms (default: 1000) */ + minTimeout?: number; + /** Maximum delay in ms (default: 30000) */ + maxTimeout?: number; + /** Multiplication factor for each retry (default: 2) */ + factor?: number; + /** Optional AbortSignal to cancel retries */ + signal?: AbortSignal; + } = {}, ): Promise { const { retries = 3, @@ -131,114 +73,3 @@ export async function retryWithBackoff( } throw lastError!; } - -// --------------------------------------------------------------------------- -// Generic in-memory TTL cache with LRU-style pruning -// --------------------------------------------------------------------------- - -interface CacheEntry { - value: V; - expiresAt: number; -} - -export interface TtlCacheOptions { - /** Default TTL in ms for entries (default: 60000) */ - defaultTtlMs?: number; - /** Maximum entries before pruning (default: 500) */ - maxEntries?: number; - /** Called when an entry is evicted */ - onEvict?: (key: K, value: unknown) => void; -} - -export class TtlCache { - private store = new Map>(); - private readonly defaultTtlMs: number; - private readonly maxEntries: number; - private readonly onEvict?: (key: K, value: V) => void; - - constructor(options: TtlCacheOptions = {}) { - this.defaultTtlMs = options.defaultTtlMs ?? 60_000; - this.maxEntries = options.maxEntries ?? 500; - this.onEvict = options.onEvict; - } - - /** - * Get a value by key. Returns undefined if missing or expired. - */ - get(key: K): V | undefined { - const entry = this.store.get(key); - if (!entry) return undefined; - if (Date.now() > entry.expiresAt) { - this.store.delete(key); - return undefined; - } - return entry.value; - } - - /** - * Set a value with optional custom TTL. Prunes oldest entries if at capacity. - */ - set(key: K, value: V, ttlMs?: number): void { - if (this.store.size >= this.maxEntries) { - this.prune(); - } - this.store.set(key, { - value, - expiresAt: Date.now() + (ttlMs ?? this.defaultTtlMs), - }); - } - - /** - * Check if a key exists and is not expired (without removing it). - */ - has(key: K): boolean { - return this.get(key) !== undefined; - } - - /** - * Remove a specific entry. - */ - delete(key: K): boolean { - return this.store.delete(key); - } - - /** - * Remove all expired entries. - */ - prune(): void { - const now = Date.now(); - const toDelete: K[] = []; - for (const [key, entry] of this.store) { - if (now > entry.expiresAt) { - toDelete.push(key); - } - } - for (const key of toDelete) { - const entry = this.store.get(key); - this.store.delete(key); - if (entry && this.onEvict) this.onEvict(key, entry.value); - } - // If still over limit after TTL pruning, drop oldest entries - if (this.store.size > this.maxEntries) { - const keysToDelete = Array.from(this.store.keys()).slice( - 0, - this.store.size - this.maxEntries, - ); - for (const key of keysToDelete) { - const entry = this.store.get(key); - this.store.delete(key); - if (entry && this.onEvict) this.onEvict(key, entry.value); - } - } - } - - /** Current number of entries (including possibly expired ones). */ - get size(): number { - return this.store.size; - } - - /** Remove all entries. */ - clear(): void { - this.store.clear(); - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d26159e..92b6206 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,9 +76,6 @@ importers: pino: specifier: ^9.6.0 version: 9.14.0 - pino-http: - specifier: ^10.3.0 - version: 10.5.0 prom-client: specifier: ^15.1.3 version: 15.1.3 @@ -4268,9 +4265,6 @@ packages: pino-abstract-transport@3.0.0: resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} - pino-http@10.5.0: - resolution: {integrity: sha512-hD91XjgaKkSsdn8P7LaebrNzhGTdB086W3pyPihX0EzGPjq5uBJBXo4N5guqNaK6mUjg9aubMF7wDViYek9dRA==} - pino-pretty@13.1.3: resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} hasBin: true @@ -8948,13 +8942,6 @@ snapshots: dependencies: split2: 4.2.0 - pino-http@10.5.0: - dependencies: - get-caller-file: 2.0.5 - pino: 9.14.0 - pino-std-serializers: 7.1.0 - process-warning: 5.0.0 - pino-pretty@13.1.3: dependencies: colorette: 2.0.20 diff --git a/services/backend/package.json b/services/backend/package.json index 49e00a5..96e1540 100644 --- a/services/backend/package.json +++ b/services/backend/package.json @@ -24,7 +24,6 @@ "ioredis": "^5.11.0", "pg": "^8.21.0", "pino": "^9.6.0", - "pino-http": "^10.3.0", "prom-client": "^15.1.3", "ws": "^8.20.1", "zod": "^4.4.3" diff --git a/services/backend/src/shared/config/index.ts b/services/backend/src/shared/config/index.ts index 3b99da8..a64571e 100644 --- a/services/backend/src/shared/config/index.ts +++ b/services/backend/src/shared/config/index.ts @@ -1,117 +1,5 @@ import "dotenv/config"; -import { ConfigError } from "@bete/shared/errors"; -import { z } from "zod"; +import { config as sharedConfig } from "@bete/shared/config"; -const configSchema = z - .object({ - // Server - WEBSERVER_PORT: z.coerce.number().positive().default(3001), - NODE_ENV: z - .enum(["development", "production", "test"]) - .default("development"), - LOG_LEVEL: z - .enum(["error", "warn", "info", "http", "verbose", "debug", "silly"]) - .default("info"), - VERBOSE: z - .string() - .optional() - .transform((v) => v === "true") - .default(false), - - // Database - DATABASE_URL: z.string().url().optional(), - DATABASE_HOST: z.string().default("localhost"), - DATABASE_PORT: z.coerce.number().default(5432), - DATABASE_NAME: z.string().default("discord_moderation"), - DATABASE_USER: z.string().default("postgres"), - DATABASE_PASSWORD: z.string().optional(), - - // Redis (optional, for pub/sub) - REDIS_URL: z.string().url().optional(), - REDIS_HOST: z.string().default("localhost"), - REDIS_PORT: z.coerce.number().default(6379), - - // Discord - MONITOR_GUILD_ID: z.string().min(1).optional(), - - // Admin - ADMIN_PASSWORD: z.string().optional(), - - // Analytics - BACKLOG_SYNC_HOURS: z.coerce.number().positive().default(24), - BACKLOG_SYNC_BATCH_SIZE: z.coerce - .number() - .int() - .positive() - .max(100) - .default(100), - - // AI Moderation - AI_ANALYSIS_ENABLED: z - .string() - .optional() - .transform((v) => v === "true") - .default(false), - OPENAI_MODERATION_API_KEY: z.string().optional(), - OPENAI_MODERATION_BASE_URL: z - .string() - .url() - .default("https://api.openai.com/v1"), - OPENAI_MODERATION_MODEL: z.string().default("omni-moderation-latest"), - AI_LLM_API_KEY: z.string().optional(), - AI_LLM_BASE_URL: z - .string() - .url() - .default("https://9router.asepharyana.my.id/v1"), - AI_LLM_MODEL: z.string().default("text"), - AI_LLM_VISION_MODEL: z.string().optional(), - AI_LLM_MAX_CONCURRENT: z.coerce.number().int().positive().default(5), - AI_LLM_IMAGE_MAX_DIMENSION: z.coerce - .number() - .int() - .positive() - .default(1024), - AI_LLM_TEXT_BATCH_SIZE: z.coerce.number().int().positive().default(20), - AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS: z.coerce - .number() - .int() - .positive() - .default(60000), - - // Attachments - ATTACHMENT_UPLOAD_TIMEOUT_MS: z.coerce.number().positive().default(30000), - ATTACHMENT_MAX_SIZE_MB: z.coerce.number().positive().default(100), - ATTACHMENT_RETRY_ATTEMPTS: z.coerce.number().positive().default(3), - TELE_UPLOAD_URL: z - .string() - .url() - .default("https://upload.asepharyana.my.id/api/upload"), - }) - .superRefine((value, ctx) => { - if (!value.DATABASE_URL && !value.DATABASE_HOST) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["DATABASE_URL"], - message: "Either DATABASE_URL or DATABASE_HOST must be provided", - }); - } - }); - -export function loadConfig( - env: NodeJS.ProcessEnv = process.env, -): z.infer { - try { - return configSchema.parse(env); - } catch (error) { - if (error instanceof z.ZodError) { - const messages = error.issues - .map((e) => `${e.path.join(".")}: ${e.message}`) - .join("\n"); - throw new ConfigError(`Configuration validation failed:\n${messages}`); - } - throw error; - } -} - -export const config = loadConfig(); +export const config = sharedConfig; export type Config = typeof config; diff --git a/services/backend/src/shared/database/index.ts b/services/backend/src/shared/database/index.ts index 30bc08e..6cbd391 100644 --- a/services/backend/src/shared/database/index.ts +++ b/services/backend/src/shared/database/index.ts @@ -16,7 +16,7 @@ export async function initializeDatabase() { const databaseUrl = config.DATABASE_URL || - `postgresql://${config.DATABASE_USER}${config.DATABASE_PASSWORD ? `:${config.DATABASE_PASSWORD}` : ""}@${config.DATABASE_HOST}:${config.DATABASE_PORT}/${config.DATABASE_NAME}`; + `postgresql://${config.POSTGRES_USER}${config.POSTGRES_PASSWORD ? `:${config.POSTGRES_PASSWORD}` : ""}@${config.POSTGRES_HOST}:${config.POSTGRES_PORT}/${config.POSTGRES_DB}`; pool = new Pool({ connectionString: databaseUrl, diff --git a/services/backend/src/shared/middlewares/index.ts b/services/backend/src/shared/middlewares/index.ts index 77a710c..c0ac9a0 100644 --- a/services/backend/src/shared/middlewares/index.ts +++ b/services/backend/src/shared/middlewares/index.ts @@ -60,7 +60,7 @@ export function requireParam( name: string, ): string { if (typeof value !== "string" || value.length === 0) { - throw new Error(`Missing ${kind}: ${name}`); + throw new ValidationError(`Missing ${kind}: ${name}`); } return value; } diff --git a/services/backend/src/shared/redis/index.ts b/services/backend/src/shared/redis/index.ts index 251c0fe..c4c4b9e 100644 --- a/services/backend/src/shared/redis/index.ts +++ b/services/backend/src/shared/redis/index.ts @@ -31,18 +31,11 @@ let publisherClient: Redis | null = null; let subscriberClient: Redis | null = null; function ensureRedisConfig(): boolean { - return !!(config.REDIS_URL || config.REDIS_HOST); + return !!(config.REDIS_URL); } function createClient(): Redis { - if (config.REDIS_URL) { - return new Redis(config.REDIS_URL, { keyPrefix: "" }); - } - return new Redis({ - host: config.REDIS_HOST, - port: config.REDIS_PORT, - keyPrefix: "", - }); + return new Redis(config.REDIS_URL, { keyPrefix: "" }); } // --------------------------------------------------------------------------- diff --git a/services/backend/src/ws/broadcast.ts b/services/backend/src/ws/broadcast.ts index d132ac7..963759f 100644 --- a/services/backend/src/ws/broadcast.ts +++ b/services/backend/src/ws/broadcast.ts @@ -1,7 +1,7 @@ /** - * Global broadcast functions for WebSocket events. + * Broadcast functions for WebSocket events. * - * These are assigned by ws/server.ts when the WebSocket server initializes. + * These are injected by ws/server.ts when the WebSocket server initializes. * Other modules call them to push real-time events to connected frontend clients. * * Usage: @@ -13,38 +13,48 @@ type BroadcastFn = (data: unknown) => void; type BroadcastRawFn = (type: string, data: unknown) => void; type BroadcastBinaryFn = (data: Buffer) => void; -declare global { - // biome-ignore lint/suspicious/noAssignInExpressions: intentional global broadcast registry - var __broadcastFns: - | { - messageCreated: BroadcastFn; - messageUpdated: BroadcastFn; - messageDeleted: BroadcastFn; - attachmentUploaded: BroadcastFn; - raw: BroadcastRawFn; - binary: BroadcastBinaryFn; - } - | undefined; +export interface BroadcastFunctions { + messageCreated: BroadcastFn; + messageUpdated: BroadcastFn; + messageDeleted: BroadcastFn; + attachmentUploaded: BroadcastFn; + raw: BroadcastRawFn; + binary: BroadcastBinaryFn; } const noop: BroadcastFn = () => {}; const noopRaw: BroadcastRawFn = () => {}; const noopBinary: BroadcastBinaryFn = () => {}; +let _fns: BroadcastFunctions | null = null; + +/** + * Inject broadcast functions from the WebSocket server initializer. + * Must be called once during server startup before any broadcast is used. + */ +export function setBroadcastFunctions(fns: BroadcastFunctions): void { + _fns = fns; +} + +/** Clear injected functions (used during cleanup). */ +export function clearBroadcastFunctions(): void { + _fns = null; +} + export const broadcastMessageCreated: BroadcastFn = (data) => - (globalThis.__broadcastFns?.messageCreated ?? noop)(data); + (_fns?.messageCreated ?? noop)(data); export const broadcastMessageUpdated: BroadcastFn = (data) => - (globalThis.__broadcastFns?.messageUpdated ?? noop)(data); + (_fns?.messageUpdated ?? noop)(data); export const broadcastMessageDeleted: BroadcastFn = (data) => - (globalThis.__broadcastFns?.messageDeleted ?? noop)(data); + (_fns?.messageDeleted ?? noop)(data); export const broadcastAttachmentUploaded: BroadcastFn = (data) => - (globalThis.__broadcastFns?.attachmentUploaded ?? noop)(data); + (_fns?.attachmentUploaded ?? noop)(data); export const broadcastRaw: BroadcastRawFn = (type, data) => - (globalThis.__broadcastFns?.raw ?? noopRaw)(type, data); + (_fns?.raw ?? noopRaw)(type, data); export const broadcastBinary: BroadcastBinaryFn = (data) => - (globalThis.__broadcastFns?.binary ?? noopBinary)(data); + (_fns?.binary ?? noopBinary)(data); diff --git a/services/backend/src/ws/redis-bridge.ts b/services/backend/src/ws/redis-bridge.ts index 4cce247..75c18dd 100644 --- a/services/backend/src/ws/redis-bridge.ts +++ b/services/backend/src/ws/redis-bridge.ts @@ -32,14 +32,7 @@ const SUBSCRIPTIONS: ChannelMapping[] = [ let subscriber: Redis | null = null; function createSubscriber(): Redis { - if (config.REDIS_URL) { - return new Redis(config.REDIS_URL, { keyPrefix: "" }); - } - return new Redis({ - host: config.REDIS_HOST, - port: config.REDIS_PORT, - keyPrefix: "", - }); + return new Redis(config.REDIS_URL, { keyPrefix: "" }); } /** @@ -93,7 +86,7 @@ function handleSubscriptionMessage(channel: string, message: string): void { } export async function startRedisBridge(): Promise { - if (!config.REDIS_URL && !config.REDIS_HOST) { + if (!config.REDIS_URL) { logger.info("Redis not configured, skipping Redis bridge"); return; } diff --git a/services/backend/src/ws/server.ts b/services/backend/src/ws/server.ts index 56cf81d..920201a 100644 --- a/services/backend/src/ws/server.ts +++ b/services/backend/src/ws/server.ts @@ -1,6 +1,7 @@ import type { Server } from "node:http"; import { createChildLogger } from "@bete/shared/logger"; import { WebSocket, WebSocketServer } from "ws"; +import { setBroadcastFunctions } from "./broadcast.js"; const logger = createChildLogger("ws.server"); @@ -149,7 +150,7 @@ export function createWebSocketServer(server: Server): WebSocketServer { // Don't let the interval keep the process alive after wss closes heartbeatInterval.unref(); - // Expose broadcast functions on globalThis + // Defines broadcast functions and injects them via setBroadcastFunctions function broadcast(event: Omit) { const payload = JSON.stringify({ ...event, @@ -166,7 +167,7 @@ export function createWebSocketServer(server: Server): WebSocketServer { } } - function broadcastRaw(data: Buffer) { + function broadcastBinary(data: Buffer) { for (const client of clients) { if (client.readyState === WebSocket.OPEN) { try { @@ -178,7 +179,7 @@ export function createWebSocketServer(server: Server): WebSocketServer { } } - globalThis.__broadcastFns = { + setBroadcastFunctions({ messageCreated: (data: unknown) => broadcast({ type: "message_created", data }), messageUpdated: (data: unknown) => @@ -188,13 +189,12 @@ export function createWebSocketServer(server: Server): WebSocketServer { attachmentUploaded: (data: unknown) => broadcast({ type: "attachment_uploaded", data }), raw: (type: string, data: unknown) => broadcast({ type, data }), - binary: broadcastRaw, - }; + binary: broadcastBinary, + }); // Cleanup on close wss.on("close", () => { clearInterval(heartbeatInterval); - globalThis.__broadcastFns = undefined; }); logger.info({ path: "/ws" }, "WebSocket server created"); diff --git a/services/discord-gateway/src/mock-crc.ts b/services/discord-gateway/src/mock-crc.ts deleted file mode 100644 index 4513f60..0000000 --- a/services/discord-gateway/src/mock-crc.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Mock CRC for discord.js compatibility -export {}; - -declare global { - var crc32: ((data: Buffer) => number) | undefined; -} - -if (!globalThis.crc32) { - globalThis.crc32 = (data: Buffer) => { - let crc = 0 ^ -1; - for (let i = 0; i < data.length; i++) { - crc = (crc >>> 8) ^ ((crc ^ data[i]) & 0xff); - } - return (crc ^ -1) >>> 0; - }; -} diff --git a/services/discord-gateway/src/modules/ai-moderation/stickerCache.ts b/services/discord-gateway/src/modules/ai-moderation/stickerCache.ts index e15c5c5..abd3180 100644 --- a/services/discord-gateway/src/modules/ai-moderation/stickerCache.ts +++ b/services/discord-gateway/src/modules/ai-moderation/stickerCache.ts @@ -1,7 +1,7 @@ import { createChildLogger } from "@bete/shared/logger"; import { config } from "../../shared/config/config.js"; import { executeAll, executeGet } from "../../shared/database/drizzle.js"; -import { uploadToTele } from "../attachment-upload/teleUpload.js"; +import { uploadToTele } from "../voice-recording/teleUpload.js"; const logger = createChildLogger("sticker-cache"); diff --git a/services/discord-gateway/src/modules/attachment-upload/attachmentUploader.ts b/services/discord-gateway/src/modules/attachment-upload/attachmentUploader.ts index 4e36c8a..a5edf5c 100644 --- a/services/discord-gateway/src/modules/attachment-upload/attachmentUploader.ts +++ b/services/discord-gateway/src/modules/attachment-upload/attachmentUploader.ts @@ -5,7 +5,7 @@ import { updateAttachmentAsUploaded, updateAttachmentDiscordUrl, } from "../message-capture/messageStore.js"; -import { uploadToTele } from "./teleUpload.js"; +import { uploadToTele } from "../voice-recording/teleUpload.js"; const logger = createChildLogger("attachment-uploader"); diff --git a/services/discord-gateway/src/modules/attachment-upload/teleUpload.ts b/services/discord-gateway/src/modules/attachment-upload/teleUpload.ts deleted file mode 100644 index 633ad50..0000000 --- a/services/discord-gateway/src/modules/attachment-upload/teleUpload.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { retryWithBackoff } from "@bete/shared/utils"; - -export interface TeleUploadResponse { - download_url: string; - public_id?: string; - file_name?: string; - size_bytes?: number; -} - -export interface TeleUploadResult { - url: string; - publicId?: string; - filename?: string; - sizeBytes?: number; -} - -export function parseTeleUploadResponse( - response: TeleUploadResponse, -): TeleUploadResult { - if (!response.download_url) { - throw new Error("Missing download_url in response"); - } - - return { - url: response.download_url, - publicId: response.public_id, - filename: response.file_name, - sizeBytes: response.size_bytes, - }; -} - -export async function uploadToTele(input: { - buffer: Buffer; - filename: string; - contentType: string; - uploadUrl: string; - timeoutMs?: number; - retries: number; -}): Promise { - const { buffer, filename, contentType, uploadUrl, timeoutMs, retries } = - input; - - const response = await retryWithBackoff( - async () => { - const fileBlob = new Blob([new Uint8Array(buffer)], { - type: contentType, - }); - const formData = new FormData(); - formData.append("file", fileBlob, filename); - formData.append("fileName", filename); - - const res = await fetch(uploadUrl, { - method: "POST", - headers: { - accept: "application/json", - }, - body: formData, - ...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}), - }); - - if (!res.ok) { - throw new Error(`Upload failed: Status ${res.status}`); - } - - return (await res.json()) as TeleUploadResponse; - }, - { - retries, - minTimeout: 0, - maxTimeout: 0, - }, - ); - - return parseTeleUploadResponse(response); -} diff --git a/services/discord-gateway/src/modules/message-capture/types.ts b/services/discord-gateway/src/modules/message-capture/types.ts index 8922911..fa00ab6 100644 --- a/services/discord-gateway/src/modules/message-capture/types.ts +++ b/services/discord-gateway/src/modules/message-capture/types.ts @@ -1,55 +1,45 @@ import type fs from "node:fs"; import type prism from "prism-media"; +import type { + AIStatus, + AISeverity, + AIRecommendedAction, + BroadcasterClient, + ModerationBroadcaster, + RoleMetadata, + UserMetadata, + MessageRecord, + AttachmentRecord, + VoiceRecordingUploadData, + AnalysisQueueStatus, +} from "@bete/shared"; -export type AIStatus = - | "pending" - | "processing" - | "clean" - | "warn" - | "flagged" - | "error"; -export type AISeverity = "none" | "low" | "medium" | "high" | "critical"; -export type AIRecommendedAction = - | "none" - | "monitor" - | "warn" - | "review" - | "delete" - | "escalate"; - -export interface BroadcasterClient { - messageCreated: (data: unknown) => void; - messageUpdated: (data: unknown) => void; - messageDeleted: (data: unknown) => void; - messageAnalyzed: (data: unknown) => void; - attachmentCreated: (data: unknown) => void; - attachmentUploaded: (data: unknown) => void; - voiceRecordingStarted: (data: unknown) => void; - voiceRecordingStopped: (data: unknown) => void; - voiceRecordingUploaded: (data: unknown) => void; - analysisQueueStatus: (data: unknown) => void; -} - -export type ModerationBroadcaster = BroadcasterClient; - -export interface RoleMetadata { - id: string; - name: string; - position: number; -} - -export interface UserMetadata { - userId: string; - username: string; - tag: string; - displayName: string; - avatarUrl: string; - bot: boolean; - roles: RoleMetadata[]; - highestRole: RoleMetadata | null; - joinedTimestamp: number | null; -} +// Re-export all shared types for backward compatibility +export type { + AIStatus, + AISeverity, + AIRecommendedAction, + BroadcasterClient, + ModerationBroadcaster, + RoleMetadata, + UserMetadata, + MessageRecord, + AttachmentRecord, + VoiceSegmentRecord, + DashboardMessage, + MessageQuery, + PageResult, + AnalysisResult, + VoiceRecordingUploadData, + AnalysisQueueStatus, + MessageReview, + ModerationAction, + RetentionPolicy, + ReviewStatus, + ModerationActionType, +} from "@bete/shared"; +// Types that are LOCAL ONLY (not in shared) — keep here export interface SegmentState { index: number; startTime: number; @@ -80,119 +70,7 @@ export interface PcmBroadcaster { ) => void; } -export interface MessageRecord { - id: string; - guild_id: string; - channel_id: string; - thread_id: string | null; - user_id: string; - username: string; - avatar_url: string | null; - content: string; - edited_content: string | null; - created_at: number; - edited_at: number | null; - deleted_at: number | null; - type: "text" | "edited" | "deleted"; - metadata: string | null; - ai_status?: AIStatus | null; - ai_moderation_flags?: string | null; - ai_moderation_score?: number | null; - ai_analysis?: string | null; - ai_categories?: string | null; - ai_severity?: AISeverity | null; - ai_confidence?: number | null; - ai_recommended_action?: AIRecommendedAction | null; - ai_analyzed_at?: number | null; - ai_error?: string | null; -} - -export interface AttachmentRecord { - id: string; - message_id: string; - guild_id: string; - channel_id: string; - thread_id: string | null; - user_id: string; - filename: string; - size: number; - type: string; - discord_url: string; - uploaded_url: string | null; - upload_status: "pending" | "uploaded" | "failed"; - upload_error: string | null; - created_at: number; - uploaded_at: number | null; -} - -export interface VoiceSegmentRecord { - id: string; - user_id: string; - session_id: string; - guild_id: string; - channel_id: string; - filename: string; - duration_ms: number; - created_at: number; -} - -export interface DashboardMessage { - id: string; - channel_id: string; - user_id: string; - username: string; - avatar_url: string | null; - content: string; - created_at: number; - type: "text" | "image" | "voice"; -} - -export interface MessageQuery { - guildId?: string; - channelId?: string; - threadId?: string; - status?: AIStatus[]; - userId?: string; - q?: string; - cursor?: string; - limit: number; -} - -export interface PageResult { - data: T[]; - nextCursor: string | null; -} - -export interface AnalysisResult { - messageId: string; - status: Exclude; - flags: string[]; - score: number; - analysis: string; - categories?: string[]; - severity?: AISeverity; - confidence?: number; - recommendedAction?: AIRecommendedAction; - policyVersion?: string; - evidence?: string[]; -} - -export interface VoiceRecordingUploadData { - id: string; - user_id: string; - username: string; - avatar_url: string | null; - guild_id: string | null; - channel_id: string | null; - channel_name: string | null; - filename: string; - size_bytes: number; - download_url: string; - upload_status: string; - created_at: number; - uploaded_at: number; -} - +// Local-only types (not shared across services) export type ModerationWsEvent = | { type: "ui_state"; state: unknown } | { type: "user_state"; users: unknown[] } @@ -204,59 +82,3 @@ export type ModerationWsEvent = | { type: "analysis_queue_status"; data: AnalysisQueueStatus } | { type: "media_state"; state: unknown } | { type: "voice_recording_uploaded"; data: VoiceRecordingUploadData }; - -export interface AnalysisQueueStatus { - queuedConversations: number; - activeRequests: number; - activeIndividualRequests: number; - individualInFlightCount: number; - individualCircuitBreakerActive: boolean; - lastError: string | null; -} - -export type ReviewStatus = "pending" | "approved" | "rejected" | "escalated"; - -export interface MessageReview { - id: string; - message_id: string; - guild_id: string; - channel_id: string; - reviewer_id: string | null; - status: ReviewStatus; - notes: string | null; - created_at: number; - reviewed_at: number | null; -} - -export type ModerationActionType = - | "delete_message" - | "mute_user" - | "warn_user" - | "kick_user" - | "ban_user"; - -export interface ModerationAction { - id: string; - message_id: string | null; - user_id: string | null; - guild_id: string; - action_type: ModerationActionType; - reason: string | null; - executed_by: string | null; - status: "pending" | "executed" | "failed"; - error: string | null; - created_at: number; - executed_at: number | null; -} - -export interface RetentionPolicy { - id: string; - guild_id: string; - channel_id: string | null; - retention_days: number; - apply_to_media: boolean; - apply_to_voice: boolean; - enabled: boolean; - created_at: number; - updated_at: number; -} diff --git a/services/discord-gateway/src/modules/voice-recording/muxer.ts b/services/discord-gateway/src/modules/voice-recording/muxer.ts deleted file mode 100644 index 7c67819..0000000 --- a/services/discord-gateway/src/modules/voice-recording/muxer.ts +++ /dev/null @@ -1 +0,0 @@ -export { buildMuxFfmpegArgs, runFfmpeg } from "./ffmpegProcess.js"; diff --git a/services/discord-gateway/src/shared/config/config.ts b/services/discord-gateway/src/shared/config/config.ts index 5542f79..92a3d00 100644 --- a/services/discord-gateway/src/shared/config/config.ts +++ b/services/discord-gateway/src/shared/config/config.ts @@ -1,236 +1,20 @@ import "dotenv/config"; -import { ConfigError } from "@bete/shared/errors"; -import { z } from "zod"; +import type { AppConfig as SharedAppConfig } from "@bete/shared/config"; +import { config as sharedConfig, loadConfig as sharedLoadConfig } from "@bete/shared/config"; -const configSchema = z - .object({ - DISCORD_TOKEN: z - .string() - .min(1, "DISCORD_TOKEN is required") - .transform((value) => value.replace(/^("|')|(?:("|'))$/g, "")), - VOICE_CHANNEL_ID: z.string().min(1).optional(), - GUILD_ID: z.string().min(1).optional(), - TEXT_GUILD_ID: z.string().min(1).optional(), - TEXT_CHANNEL_ID: z.string().min(1).optional(), - VOICE_GUILD_ID: z.string().min(1).optional(), - VERBOSE: z - .string() - .optional() - .transform((v) => v === "true") - .default(false), - RECORDINGS_DIR: z.string().default("./recordings"), - RECORDING_SEGMENT_MS: z.coerce.number().positive().default(5000), - DECODER_ROTATE_MS: z.coerce.number().positive().default(5000), - DECODER_COOLDOWN_MS: z.coerce.number().positive().default(30000), - WEBSERVER_PORT: z.coerce.number().positive().default(3000), - VOICE_CONNECTION_TIMEOUT_MS: z.coerce.number().positive().default(15000), - RECONNECT_TIMEOUT_MS: z.coerce.number().positive().default(5000), - AUDIO_STREAM_SILENCE_DURATION_MS: z.coerce - .number() - .positive() - .default(3000), - PACKET_FILTER_MIN_SIZE: z.coerce.number().positive().default(8), - OPUS_FRAME_SIZE: z.coerce.number().positive().default(960), - AUDIO_SAMPLE_RATE: z.coerce.number().positive().default(48000), - AUDIO_CHANNELS: z.coerce.number().positive().default(2), - AVATAR_SIZE: z.coerce.number().positive().default(64), - LOG_LEVEL: z - .enum(["error", "warn", "info", "http", "verbose", "debug", "silly"]) - .default("info"), - NODE_ENV: z - .enum(["development", "production", "test"]) - .default("development"), - MONITOR_GUILD_ID: z.string().min(1).optional(), - TELE_UPLOAD_URL: z - .string() - .url() - .default("https://upload.asepharyana.my.id/api/upload"), - ATTACHMENT_UPLOAD_TIMEOUT_MS: z.coerce.number().positive().default(30000), - ATTACHMENT_MAX_SIZE_MB: z.coerce.number().positive().default(100), - ATTACHMENT_RETRY_ATTEMPTS: z.coerce.number().positive().default(3), - BACKLOG_SYNC_HOURS: z.coerce.number().positive().default(24), - BACKLOG_SYNC_BATCH_SIZE: z.coerce - .number() - .int() - .positive() - .max(100) - .default(100), - AI_ANALYSIS_ENABLED: z - .string() - .optional() - .transform((v) => v === "true") - .default(false), - OPENAI_MODERATION_API_KEY: z.string().optional(), - OPENAI_MODERATION_BASE_URL: z - .string() - .url() - .default("https://api.openai.com/v1"), - OPENAI_MODERATION_MODEL: z.string().default("omni-moderation-latest"), - AI_LLM_API_KEY: z.string().optional(), - AI_LLM_BASE_URL: z - .string() - .url() - .default("https://9router.asepharyana.my.id/v1"), - /** Model used for text-only moderation (messages, badword analysis). */ - AI_LLM_MODEL: z.string().default("text"), - /** Model used for image/video moderation (vision-capable model). */ - AI_LLM_VISION_MODEL: z.string().optional(), - /** Max concurrent LLM API calls (default: 5). */ - AI_LLM_MAX_CONCURRENT: z.coerce.number().int().positive().default(5), - /** Maximum image dimension in pixels before resize for vision API (default: 1024). */ - AI_LLM_IMAGE_MAX_DIMENSION: z.coerce - .number() - .int() - .positive() - .default(1024), - /** Maximum messages per text-only moderation batch (default: 20). */ - AI_LLM_TEXT_BATCH_SIZE: z.coerce.number().int().positive().default(20), - /** Timeout in ms for individual media analysis calls (default: 60000). */ - AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS: z.coerce - .number() - .int() - .positive() - .default(60000), - AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500), - AI_ANALYSIS_RECOVERY_INTERVAL_MS: z.coerce - .number() - .positive() - .default(15000), - AI_ANALYSIS_ERROR_COOLDOWN_MS: z.coerce.number().positive().default(30000), - /** Max messages fetched per conversation batch (token budget is the real constraint). */ - AI_ANALYSIS_MAX_BATCH_SIZE: z.coerce.number().int().positive().default(200), - AI_ANALYSIS_MAX_CONTEXT_TOKENS: z.coerce.number().positive().default(8000), - /** Token budget for target messages specifically (separate from context window). */ - AI_ANALYSIS_MAX_TARGET_TOKENS: z.coerce.number().positive().default(4000), - AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT: z.coerce - .number() - .int() - .positive() - .default(20), - /** - * How long a conversation is considered locked while being processed. - * Must exceed (LLM timeout × max retries) + network overhead. - * LLM client timeout=30s, retries=3 → minimum safe value ≈ 100s. - */ - AI_ANALYSIS_PROCESSING_TIMEOUT_MS: z.coerce - .number() - .positive() - .default(120000), - /** Max concurrent individual-fallback jobs admitted by the main event loop. */ - AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT: z.coerce - .number() - .int() - .positive() - .default(50), - /** - * How many consecutive individual-fallback errors trigger the individual - * circuit breaker (separate from the batch circuit breaker). - */ - AI_ANALYSIS_INDIVIDUAL_CB_THRESHOLD: z.coerce - .number() - .int() - .positive() - .default(50), - /** Max Piscina worker threads for batch AI analysis (default: os.availableParallelism). */ - PISCINA_MAX_THREADS: z.coerce.number().int().positive().optional(), - // AI moderation uses the Primary LLM (AI_LLM_*) endpoint only. - // No NVIDIA or Groq fallback. - AUTO_DELETE_FLAGGED_ENABLED: z - .string() - .optional() - .transform((v) => v === "true") - .default(true), - AUTO_DELETE_FLAGGED_DELAY_MS: z.coerce.number().min(0).default(0), - AUTO_DELETE_FLAGGED_DRY_RUN: z - .string() - .optional() - .transform((v) => v === "true") - .default(false), - AUTO_DELETE_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.5), - AUTO_DELETE_ALLOWED_SEVERITIES: z - .string() - .default("critical,high,medium,low"), - AUTO_DELETE_ALLOWED_CATEGORIES: z.string().default(""), - AUTO_DELETE_EXCLUDED_CHANNEL_IDS: z.string().default(""), - AUTO_DELETE_EXCLUDED_USER_IDS: z.string().default(""), - AUTO_DELETE_NOTIFY_USER: z - .string() - .optional() - .transform((v) => v === "true") - .default(false), - AUTO_DELETE_LOG_CHANNEL_ID: z.string().default(""), - RETENTION_MESSAGES_DAYS: z.coerce.number().int().min(0).default(0), - RETENTION_ATTACHMENTS_DAYS: z.coerce.number().int().min(0).default(0), - RETENTION_VOICE_DAYS: z.coerce.number().int().min(0).default(0), - RETENTION_CLEANUP_INTERVAL_MS: z.coerce - .number() - .positive() - .default(24 * 60 * 60 * 1000), - RETENTION_DRY_RUN: z - .string() - .optional() - .transform((v) => v === "true") - .default(true), - AUTO_MIGRATE_ON_STARTUP: z - .string() - .optional() - .transform((v) => v === "true") - .default(true), - DATABASE_URL: z.string().optional(), - POSTGRES_HOST: z.string().default("localhost"), - POSTGRES_PORT: z.coerce.number().int().positive().default(5432), - POSTGRES_USER: z.string().optional(), - POSTGRES_PASSWORD: z.string().optional(), - POSTGRES_DB: z.string().optional(), - POSTGRES_POOL_MIN: z.coerce.number().int().positive().default(2), - POSTGRES_POOL_MAX: z.coerce.number().int().positive().default(10), - ADMIN_PASSWORD: z.string().default("admin123"), - REDIS_URL: z.string().min(1).default("redis://localhost:6379"), - }) - .superRefine((value, ctx) => { - if (!value.AI_ANALYSIS_ENABLED) { - // Continue to database validation - } else if (!value.AI_LLM_API_KEY) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["AI_LLM_API_KEY"], - message: "AI_LLM_API_KEY is required when AI_ANALYSIS_ENABLED=true", - }); - } - - // Validate PostgreSQL configuration - if (!value.DATABASE_URL && !value.POSTGRES_HOST) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["DATABASE_URL"], - message: "Either DATABASE_URL or POSTGRES_HOST must be provided", - }); - } - }); - -export type AppConfig = z.infer & { +// Re-export the unified config with EFFECTIVE_* fields added +export type AppConfig = SharedAppConfig & { EFFECTIVE_TEXT_GUILD_ID?: string; EFFECTIVE_VOICE_GUILD_ID?: string; }; export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { - try { - const parsed = configSchema.parse(env); - return { - ...parsed, - // AI text capture and analytics are pinned to the monitor guild. - EFFECTIVE_TEXT_GUILD_ID: parsed.MONITOR_GUILD_ID, - EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID ?? parsed.GUILD_ID, - }; - } catch (error) { - if (error instanceof z.ZodError) { - const messages = error.issues - .map((e) => `${e.path.join(".")}: ${e.message}`) - .join("\n"); - throw new ConfigError(`Configuration validation failed:\n${messages}`); - } - throw error; - } + const parsed = sharedLoadConfig(env); + return { + ...parsed, + EFFECTIVE_TEXT_GUILD_ID: parsed.MONITOR_GUILD_ID, + EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID ?? parsed.GUILD_ID, + }; } export const config = loadConfig(); diff --git a/test-llm.js b/test-llm.js deleted file mode 100644 index 17b213e..0000000 --- a/test-llm.js +++ /dev/null @@ -1,60 +0,0 @@ -const baseURL = process.env.AI_LLM_BASE_URL; -const apiKey = process.env.AI_LLM_API_KEY; - -async function main() { - console.log("🚀 Initializing test script..."); - console.log("baseURL:", baseURL); - console.log("apiKey:", apiKey ? "Set (Hidden)" : "Not Set"); - - const model = "cf/@cf/google/gemma-4-26b-a4b-it"; - const startMs = Date.now(); - - try { - console.log(`\n📡 Mengirim request ke model: ${model} ...`); - - // Kita gunakan timeout manual menggunakan AbortController untuk mensimulasikan - // timeout LLM klien di batas waktu tinggi - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 120_000); // 120 detik - - const response = await fetch(`${baseURL}/chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) - }, - body: JSON.stringify({ - model: model, - messages: [ - { role: "system", content: "You are a helpful assistant." }, - { role: "user", content: "Jelaskan secara singkat cara kerja timeout API." } - ] - }), - signal: controller.signal - }); - - clearTimeout(timeoutId); - - const endMs = Date.now(); - console.log(`\n✅ Respons diterima dalam ${endMs - startMs}ms`); - console.log("Status HTTP:", response.status); - - const text = await response.text(); - try { - const data = JSON.parse(text); - console.dir(data, { depth: null }); - console.log("\n📝 Content:"); - console.log(data.choices?.[0]?.message?.content); - } catch { - console.log("\n📝 Raw Text Response:"); - console.log(text); - } - - } catch (error) { - const endMs = Date.now(); - console.log(`\n❌ Request gagal setelah ${endMs - startMs}ms`); - console.error("Pesan Error:", error.message); - } -} - -main().catch(console.error);