feat(core): implement data retention, metrics, and enhanced media handling

This commit introduces several significant improvements across the backend and gateway services:

- **Data Retention**: Added an automated cleanup scheduler in `discord-gateway` to prune expired messages, attachments, and voice recordings based on configurable retention policies.
- **Observability**: Integrated `prom-client` in the `backend` service to expose Prometheus metrics via `/api/metrics` and added default Node.js runtime metrics.
- **Media Handling**: Enhanced `MediaHandler` in `discord-gateway` to support media URL resolution and improved playback status tracking.
- **API & Config**: Expanded the configuration endpoint to expose more system settings and reorganized `.env.example` for better readability.
- **Refactoring & Cleanup**:
    - Removed unused `better-sqlite3` dependency.
    - Refactored voice channel routing.
    - Improved error handling and testing coverage with comprehensive unit tests for shared utilities and error classes.
- **Documentation**: Added `MEMORY.md` for project context.
This commit is contained in:
MythEclipse
2026-06-10 20:56:16 +07:00
parent f04b0f0b42
commit 2557a07916
18 changed files with 1537 additions and 249 deletions
+101 -117
View File
@@ -1,136 +1,120 @@
# Discord Bot Configuration
DISCORD_TOKEN=your_bot_token_here
MONITOR_GUILD_ID=your_guild_id_here
TEXT_GUILD_ID=optional_text_guild_id
TEXT_CHANNEL_ID=optional_text_channel_id
# =============================================================================
# Recording Configuration
RECORDINGS_DIR=./recordings
RECORDING_SEGMENT_MS=5000
VERBOSE=false
# === Discord ===
DISCORD_TOKEN=your_bot_token_here # REQUIRED
MONITOR_GUILD_ID=your_guild_id_here # Target guild for text monitoring
TEXT_GUILD_ID=optional_text_guild_id # Override text capture guild (falls back to MONITOR_GUILD_ID)
TEXT_CHANNEL_ID=optional_text_channel_id # Restrict text capture to a single channel
# Decoder Configuration
DECODER_ROTATE_MS=5000
DECODER_COOLDOWN_MS=30000
# === Voice Channels ===
# VOICE_GUILD_ID= # Guild for voice connection (optional)
# VOICE_CHANNEL_ID= # Channel for voice connection (optional)
# Audio Configuration
AUDIO_STREAM_SILENCE_DURATION_MS=3000
PACKET_FILTER_MIN_SIZE=8
OPUS_FRAME_SIZE=960
AUDIO_SAMPLE_RATE=48000
AUDIO_CHANNELS=2
AVATAR_SIZE=64
# === Recording ===
RECORDINGS_DIR=./recordings # Audio file output directory (default: ./recordings)
RECORDING_SEGMENT_MS=5000 # OGG segment duration in ms (default: 5000)
# Webserver Configuration
WEBSERVER_PORT=3000
# === Decoder ===
DECODER_ROTATE_MS=5000 # Opus decoder rotation interval in ms (default: 5000)
DECODER_COOLDOWN_MS=30000 # Decoder error cooldown in ms (default: 30000)
# Connection Configuration
VOICE_CONNECTION_TIMEOUT_MS=15000
RECONNECT_TIMEOUT_MS=5000
# === Audio ===
AUDIO_STREAM_SILENCE_DURATION_MS=3000 # Silence threshold in ms before stopping stream (default: 3000)
PACKET_FILTER_MIN_SIZE=8 # Minimum Opus packet size in bytes (default: 8)
OPUS_FRAME_SIZE=960 # Opus frame size in samples (default: 960)
AUDIO_SAMPLE_RATE=48000 # Audio sample rate in Hz (default: 48000)
AUDIO_CHANNELS=2 # Number of audio channels (default: 2)
AVATAR_SIZE=64 # User avatar size in pixels (default: 64)
# Voice Recording Selection
# VOICE_GUILD_ID falls back to legacy GUILD_ID when omitted.
GUILD_ID=legacy_voice_guild_id
VOICE_GUILD_ID=voice_guild_id
VOICE_CHANNEL_ID=voice_channel_id
# === Webserver ===
WEBSERVER_PORT=3001 # Backend HTTP/WS server port (default: 3001)
# Logging Configuration
LOG_LEVEL=info
NODE_ENV=development
# === Connection ===
VOICE_CONNECTION_TIMEOUT_MS=15000 # Voice connection timeout in ms (default: 15000)
RECONNECT_TIMEOUT_MS=5000 # Reconnect timeout in ms (default: 5000)
# Moderation Configuration
TELE_UPLOAD_URL=https://upload.asepharyana.my.id/api/upload
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000
ATTACHMENT_MAX_SIZE_MB=100
ATTACHMENT_RETRY_ATTEMPTS=3
BACKLOG_SYNC_HOURS=24
BACKLOG_SYNC_BATCH_SIZE=100
# === Logging ===
LOG_LEVEL=info # Pino log level: error|warn|info|http|verbose|debug|silly (default: info)
NODE_ENV=development # Environment: development|production|test (default: development)
VERBOSE=false # Enable verbose/debug logging (default: false)
# AI Analysis Configuration
AI_ANALYSIS_ENABLED=false
AI_LLM_API_KEY=your_9router_key_here
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)
AI_LLM_MAX_CONCURRENT=5
# Maximum image dimension in pixels before resize for vision API (default: 1024)
AI_LLM_IMAGE_MAX_DIMENSION=1024
# Maximum messages per text-only moderation batch (default: 20)
AI_LLM_TEXT_BATCH_SIZE=20
# Timeout in ms for individual media analysis calls (default: 60000)
AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS=60000
# === Admin ===
ADMIN_PASSWORD=admin123 # Backend admin password for API auth (default: admin123)
# AI Moderation Analysis Tuning (advanced)
AI_ANALYSIS_DEBOUNCE_MS=500
AI_ANALYSIS_RECOVERY_INTERVAL_MS=15000
AI_ANALYSIS_ERROR_COOLDOWN_MS=30000
# Max messages fetched per conversation batch (default: 200)
AI_ANALYSIS_MAX_BATCH_SIZE=200
AI_ANALYSIS_MAX_CONTEXT_TOKENS=8000
# Token budget for target messages specifically (default: 4000)
AI_ANALYSIS_MAX_TARGET_TOKENS=4000
AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT=20
# How long a conversation is locked while being processed (default: 120000ms)
AI_ANALYSIS_PROCESSING_TIMEOUT_MS=120000
# Max concurrent individual-fallback jobs (default: 50)
AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT=50
# Consecutive errors before individual circuit breaker trips (default: 50)
AI_ANALYSIS_INDIVIDUAL_CB_THRESHOLD=50
# OpenAI Moderation (optional separate provider)
# OPENAI_MODERATION_API_KEY=your_key_here
# OPENAI_MODERATION_BASE_URL=https://api.openai.com/v1
# OPENAI_MODERATION_MODEL=omni-moderation-latest
# Admin
ADMIN_PASSWORD=admin123
# Database Configuration (PostgreSQL)
# Option 1: Use DATABASE_URL for connection string
# === Database (PostgreSQL) ===
# Option 1: Connection string (overrides individual params)
# DATABASE_URL=postgresql://user:password@localhost:5432/discord_bot
# Option 2: Use individual connection parameters
# POSTGRES_HOST=localhost
# POSTGRES_PORT=5432
# POSTGRES_USER=postgres
# POSTGRES_PASSWORD=your_password_here
# POSTGRES_DB=discord_bot
# Option 2: Individual connection parameters
POSTGRES_HOST=localhost # PostgreSQL host (default: localhost)
POSTGRES_PORT=5432 # PostgreSQL port (default: 5432)
POSTGRES_USER=postgres # PostgreSQL user (optional if DATABASE_URL provided)
POSTGRES_PASSWORD=your_password_here # PostgreSQL password (optional if DATABASE_URL provided)
POSTGRES_DB=discord_bot # PostgreSQL database name (optional if DATABASE_URL provided)
POSTGRES_POOL_MIN=2 # Minimum pool connections (default: 2)
POSTGRES_POOL_MAX=10 # Maximum pool connections (default: 10)
# Redis Configuration (queue + persistent KV store)
# REDIS_URL=redis://localhost:6379
# === Redis ===
REDIS_URL=redis://localhost:6379 # Redis connection string (default: redis://localhost:6379)
# PostgreSQL Connection Pool Configuration
# POSTGRES_POOL_MIN=2
# POSTGRES_POOL_MAX=10
# === Attachments ===
TELE_UPLOAD_URL=https://upload.asepharyana.my.id/api/upload # Attachment upload endpoint (default)
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000 # Upload timeout in ms (default: 30000)
ATTACHMENT_MAX_SIZE_MB=100 # Max attachment size in MB (default: 100)
ATTACHMENT_RETRY_ATTEMPTS=3 # Upload retry count (default: 3)
BACKLOG_SYNC_HOURS=24 # Backlog sync lookback window in hours (default: 24)
BACKLOG_SYNC_BATCH_SIZE=100 # Messages per backlog batch, max 100 (default: 100)
# Auto-Delete Configuration
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,low
AUTO_DELETE_NOTIFY_USER=false
# Optional: comma-separated channel/user IDs to exclude
# AUTO_DELETE_EXCLUDED_CHANNEL_IDS=
# AUTO_DELETE_EXCLUDED_USER_IDS=
# Optional: comma-separated category filter (empty = all categories)
# AUTO_DELETE_ALLOWED_CATEGORIES=
# Optional: log channel ID for auto-delete actions
# AUTO_DELETE_LOG_CHANNEL_ID=
# === AI Analysis ===
AI_ANALYSIS_ENABLED=false # Enable AI content moderation (default: false)
# AI_LLM_API_KEY= # REQUIRED if AI_ANALYSIS_ENABLED=true. LLM API key
AI_LLM_BASE_URL=https://9router.asepharyana.my.id/v1 # LLM API base URL (default)
AI_LLM_MODEL=text # LLM text model name (default: text)
# AI_LLM_VISION_MODEL= # Vision model for image analysis (falls back to AI_LLM_MODEL)
AI_LLM_MAX_CONCURRENT=5 # Max concurrent LLM API calls (default: 5)
AI_LLM_IMAGE_MAX_DIMENSION=1024 # Max image dimension in pixels before resize (default: 1024)
AI_LLM_TEXT_BATCH_SIZE=20 # Max messages per text-only moderation batch (default: 20)
AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS=60000 # Timeout in ms for media analysis calls (default: 60000)
# Retention Configuration (0 = disabled)
RETENTION_MESSAGES_DAYS=0
RETENTION_ATTACHMENTS_DAYS=0
RETENTION_VOICE_DAYS=0
# Cleanup interval in ms (default: 24h)
RETENTION_CLEANUP_INTERVAL_MS=86400000
RETENTION_DRY_RUN=true
# === AI Analysis Tuning ===
AI_ANALYSIS_DEBOUNCE_MS=500 # Debounce window for batching messages in ms (default: 500)
AI_ANALYSIS_RECOVERY_INTERVAL_MS=15000 # Recovery interval after errors in ms (default: 15000)
AI_ANALYSIS_ERROR_COOLDOWN_MS=30000 # Cooldown period after consecutive errors in ms (default: 30000)
AI_ANALYSIS_MAX_BATCH_SIZE=200 # Max messages fetched per conversation batch (default: 200)
AI_ANALYSIS_MAX_CONTEXT_TOKENS=8000 # Token budget for context window (default: 8000)
AI_ANALYSIS_MAX_TARGET_TOKENS=4000 # Token budget for target messages (default: 4000)
AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT=20 # Max messages in context window (default: 20)
AI_ANALYSIS_PROCESSING_TIMEOUT_MS=120000 # Conversation lock timeout in ms (default: 120000)
AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT=50 # Max concurrent individual-fallback jobs (default: 50)
AI_ANALYSIS_INDIVIDUAL_CB_THRESHOLD=50 # Consecutive errors before circuit breaker trips (default: 50)
# Database Migration Configuration
# Safe default: run migrations on startup before the app accepts traffic.
AUTO_MIGRATE_ON_STARTUP=true
# === OpenAI Moderation (optional separate provider) ===
# OPENAI_MODERATION_API_KEY= # OpenAI API key for moderation endpoint
# OPENAI_MODERATION_BASE_URL=https://api.openai.com/v1 # OpenAI moderation base URL (default)
# OPENAI_MODERATION_MODEL=omni-moderation-latest # OpenAI moderation model (default)
# Worker Pool Configuration
# PISCINA_MAX_THREADS=4
# === Auto-Delete ===
AUTO_DELETE_FLAGGED_ENABLED=true # Enable auto-deletion of flagged messages (default: true)
AUTO_DELETE_FLAGGED_DRY_RUN=true # Dry-run mode: log but do not delete (default: false)
AUTO_DELETE_FLAGGED_DELAY_MS=0 # Delay before auto-delete in ms (default: 0)
AUTO_DELETE_MIN_CONFIDENCE=0.5 # Minimum AI confidence threshold 0-1 (default: 0.5)
AUTO_DELETE_ALLOWED_SEVERITIES=critical,high,medium,low # Comma-separated severities (default)
AUTO_DELETE_ALLOWED_CATEGORIES= # Comma-separated category filter (empty = all)
AUTO_DELETE_EXCLUDED_CHANNEL_IDS= # Comma-separated channel IDs to exclude
AUTO_DELETE_EXCLUDED_USER_IDS= # Comma-separated user IDs to exclude
AUTO_DELETE_NOTIFY_USER=false # Notify user when their message is auto-deleted (default: false)
AUTO_DELETE_LOG_CHANNEL_ID= # Channel ID to log auto-delete actions
# === Retention (0 = disabled) ===
RETENTION_MESSAGES_DAYS=0 # Message retention in days (default: 0 = off)
RETENTION_ATTACHMENTS_DAYS=0 # Attachment retention in days (default: 0 = off)
RETENTION_VOICE_DAYS=0 # Voice recording retention in days (default: 0 = off)
RETENTION_CLEANUP_INTERVAL_MS=86400000 # Cleanup interval in ms (default: 24h)
RETENTION_DRY_RUN=true # Dry-run: log but do not delete (default: true)
# === Migration ===
AUTO_MIGRATE_ON_STARTUP=true # Run database migrations on startup (default: true)
# === Worker Pool ===
# PISCINA_MAX_THREADS=4 # Worker thread pool size (optional, defaults to CPU cores)
+92
View File
@@ -0,0 +1,92 @@
# Durable Memory Wiki
Consolidated knowledge and long-term facts.
## Core Learnings
- **Topic:** session
**Context:** Failure observation: session
*Promoted on:* 2026-06-10T03:02:46.068Z
- **Topic:** biome check diagnosticlevelerror
**Context:** Failure observation: Exit code 1
$ biome check --diagnostic-level=error .
src/modules/voice-recording/recorder/segment.ts:1:1 assist/source/organizeImports FIXABLE ━━━━━━━━━━
× Sort these imports
*Promoted on:* 2026-06-10T03:02:46.068Z
- **Topic:** typecheck scope workspace
**Context:** Failure observation: Exit code 2
$ pnpm -r run typecheck
Scope: 6 of 7 workspace projects
packages/shared typecheck$ tsc --noEmit
packages/shared typecheck: Done
services/backend typecheck$ tsc --noEmit
services/discord-g
*Promoted on:* 2026-06-10T03:02:46.068Z
- **Topic:** eisdir illegal operation
**Context:** Failure observation: EISDIR: illegal operation on a directory, read '/mnt/code/bete/packages/shared/src/types/'
*Promoted on:* 2026-06-10T03:02:46.068Z
- **Topic:** exist current working
**Context:** Failure observation: File does not exist. Note: your current working directory is /mnt/code/bete.
*Promoted on:* 2026-06-10T03:02:46.068Z
- **Topic:** error 32603 pattern
**Context:** Failure observation: MCP error -32603: pattern must be a non-empty string.
*Promoted on:* 2026-06-10T03:02:46.068Z
- **Topic:** errpnpmnoscript missing script
**Context:** Failure observation: Exit code 2
[ERR_PNPM_NO_SCRIPT] Missing script: build:shared
Command "build:shared" not found. Did you mean "pnpm run build:backend"?
$ tsc
$ pnpm --filter './services/backend' run build
$ tsc
$ pnp
*Promoted on:* 2026-06-10T03:02:46.068Z
- **Topic:** projects matched filters
**Context:** Failure observation: Exit code 1
No projects matched the filters "vendor/*" in "/mnt/code/bete"
Scope: 6 of 7 workspace projects
vendor/discord.js-selfbot-v13 test$ npm run lint && npm run test:typescript && npm run docs:
*Promoted on:* 2026-06-10T03:02:46.068Z
- **Topic:** scope workspace projects
**Context:** Failure observation: Exit code 1
$ pnpm -r run test
Scope: 6 of 7 workspace projects
vendor/discord.js-selfbot-v13 test$ npm run lint && npm run test:typescript && npm run docs:test
vendor/discord.js-selfbot-v13 test: > d
*Promoted on:* 2026-06-10T03:02:46.068Z
- **Topic:** error 32603 include
**Context:** Failure observation: MCP error -32603: include must be an array of strings.
*Promoted on:* 2026-06-10T03:02:46.068Z
- **Topic:** nodeinternalmodulescjsloader1522 throw error
**Context:** Failure observation: Exit code 1
node:internal/modules/cjs/loader:1522
throw err;
^
Error: Cannot find module 'ioredis'
Require stack:
- /mnt/code/bete/[eval]
at Module._resolveFilename (node:internal/modules/cjs
*Promoted on:* 2026-06-10T03:02:46.068Z
- **Topic:** biome check diagnosticlevelerror
**Context:** Failure observation: Exit code 1
$ biome check --diagnostic-level=error src/
src/features/messages/index.tsx:4:1 assist/source/organizeImports FIXABLE ━━━━━━━━━━━━━━━━━━━━━━
*Promoted on:* 2026-06-10T09:16:35.182Z
- **Topic:** eval1 matches found
**Context:** Failure observation: Exit code 1
(eval):1: no matches found: tsconfig*.json
*Promoted on:* 2026-06-10T09:49:12.792Z
-2
View File
@@ -22,8 +22,6 @@
},
"devDependencies": {
"@biomejs/biome": "latest",
"@types/better-sqlite3": "^7.6.13",
"better-sqlite3": "^11.9.1",
"drizzle-kit": "^0.31.10",
"tsx": "^4.22.2",
"typescript": "^5.9.3"
+2
View File
@@ -11,6 +11,8 @@
"./database/schema": "./dist/database/schema.js",
"./errors": "./dist/errors/index.js",
"./logger": "./dist/logger/index.js",
"./moderation-types": "./dist/moderation-types.js",
"./redis-channels": "./dist/redis-channels.js",
"./utils": "./dist/utils/index.js"
},
"scripts": {
+4 -39
View File
@@ -15,12 +15,6 @@ importers:
'@biomejs/biome':
specifier: latest
version: 2.4.16
'@types/better-sqlite3':
specifier: ^7.6.13
version: 7.6.13
better-sqlite3:
specifier: ^11.9.1
version: 11.10.0
drizzle-kit:
specifier: ^0.31.10
version: 0.31.10
@@ -35,7 +29,7 @@ importers:
dependencies:
drizzle-orm:
specifier: ^0.45.2
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.21.0)
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)
pino:
specifier: ^9.0.0
version: 9.14.0
@@ -69,7 +63,7 @@ importers:
version: 17.4.2
drizzle-orm:
specifier: ^0.45.2
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.21.0)
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)
express:
specifier: ^5.2.1
version: 5.2.1
@@ -145,7 +139,7 @@ importers:
version: 17.4.2
drizzle-orm:
specifier: ^0.45.2
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.21.0)
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)
imghash:
specifier: ^1.1.4
version: 1.1.4
@@ -2192,9 +2186,6 @@ packages:
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
'@types/better-sqlite3@7.6.13':
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
@@ -2502,15 +2493,9 @@ packages:
before-after-hook@2.2.3:
resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==}
better-sqlite3@11.10.0:
resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==}
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
bintrees@1.0.2:
resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==}
@@ -3212,9 +3197,6 @@ packages:
resolution: {integrity: sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw==}
engines: {node: '>=6'}
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@@ -6903,10 +6885,6 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/better-sqlite3@7.6.13':
dependencies:
'@types/node': 25.9.0
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
@@ -7213,19 +7191,10 @@ snapshots:
before-after-hook@2.2.3: {}
better-sqlite3@11.10.0:
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.3
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
bindings@1.5.0:
dependencies:
file-uri-to-path: 1.0.0
bintrees@1.0.2: {}
bl@4.1.0:
@@ -7592,12 +7561,10 @@ snapshots:
esbuild: 0.25.12
tsx: 4.22.1
drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.21.0):
drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0):
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/better-sqlite3': 7.6.13
'@types/pg': 8.20.0
better-sqlite3: 11.10.0
pg: 8.21.0
dunder-proto@1.0.1:
@@ -7962,8 +7929,6 @@ snapshots:
file-type@10.11.0: {}
file-uri-to-path@1.0.0: {}
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
-2
View File
@@ -9,7 +9,6 @@ packages:
allowBuilds:
'@discordjs/opus': true
'@lng2004/node-datachannel': true
better-sqlite3: true
esbuild: true
node-av: true
node-crc: true
@@ -28,7 +27,6 @@ minimumReleaseAgeExclude:
onlyBuiltDependencies:
- '@discordjs/opus'
- '@lng2004/node-datachannel'
- better-sqlite3
- esbuild
- node-av
- sharp
@@ -9,6 +9,18 @@ export function createConfigRouter(): Router {
router.get("/config", (_req, res) => {
res.json({
monitorGuildId: config.MONITOR_GUILD_ID || null,
webserverPort: config.WEBSERVER_PORT,
nodeEnv: config.NODE_ENV,
backlogSyncHours: config.BACKLOG_SYNC_HOURS,
backlogSyncBatchSize: config.BACKLOG_SYNC_BATCH_SIZE,
retentionMessagesDays: config.RETENTION_MESSAGES_DAYS,
retentionAttachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
retentionVoiceDays: config.RETENTION_VOICE_DAYS,
autoDeleteFlaggedEnabled: config.AUTO_DELETE_FLAGGED_ENABLED,
aiAnalysisEnabled: config.AI_ANALYSIS_ENABLED,
voiceGuildId: config.VOICE_GUILD_ID || null,
voiceChannelId: config.VOICE_CHANNEL_ID || null,
logLevel: config.LOG_LEVEL,
});
});
@@ -1,4 +1,5 @@
import type { Request, Response } from "express";
import { register } from "prom-client";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { healthService } from "./health.service.js";
@@ -10,3 +11,10 @@ export const handleHealthCheck = asyncHandler(
res.status(status).json(result);
},
);
export const handleMetrics = asyncHandler(
async (_req: Request, res: Response) => {
res.set("Content-Type", register.contentType);
res.end(await register.metrics());
},
);
@@ -1,6 +1,11 @@
import { collectDefaultMetrics, register } from "prom-client";
import type { Router } from "express";
import express from "express";
import { handleHealthCheck } from "./health.controller.js";
import { handleHealthCheck, handleMetrics } from "./health.controller.js";
// Initialize default Node.js runtime metrics (event loop lag, memory, GC, etc.)
// Called once at module load, not per-request.
collectDefaultMetrics();
export function createHealthRouter(): Router {
const router = express.Router();
@@ -8,5 +13,8 @@ export function createHealthRouter(): Router {
// GET /api/health
router.get("/health", handleHealthCheck);
// GET /api/metrics — Prometheus scrape endpoint
router.get("/metrics", handleMetrics);
return router;
}
@@ -2,7 +2,11 @@ import { createChildLogger } from "@bete/shared/logger";
import type { Request, Response, Router } from "express";
import express from "express";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { getGuilds, getTextChannels } from "./voice.service.js";
import {
getGuilds,
getTextChannels,
getVoiceChannels,
} from "./voice.service.js";
const logger = createChildLogger("guilds.routes");
@@ -32,5 +36,18 @@ export function createGuildsRouter(): Router {
}),
);
// GET /api/guilds/:guildId/voice-channels
router.get(
"/:guildId/voice-channels",
asyncHandler(async (req: Request, res: Response) => {
const guildId = Array.isArray(req.params.guildId)
? req.params.guildId[0]
: req.params.guildId;
logger.debug({ guildId }, "Fetching voice channels");
const channels = await getVoiceChannels(guildId);
res.json(channels);
}),
);
return router;
}
@@ -3,7 +3,6 @@ import express from "express";
import {
handleConnectVoice,
handleDisconnectVoice,
handleGetVoiceChannels,
handleGetVoiceStatus,
handleVoiceCommand,
} from "./voice.controller.js";
@@ -20,9 +19,6 @@ export function createVoiceRouter(): Router {
// POST /api/disconnect
router.post("/disconnect", handleDisconnectVoice);
// GET /api/guilds/:guildId/voice-channels
router.get("/guilds/:guildId/voice-channels", handleGetVoiceChannels);
// POST /api/voice/command — send arbitrary voice command (transmit start/stop)
router.post("/voice/command", handleVoiceCommand);
+265 -4
View File
@@ -1,7 +1,268 @@
import { describe, expect, it } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
describe("backend", () => {
it("should load without errors", () => {
expect(true).toBe(true);
// ─── Shared Error Classes ────────────────────────────────────────────────────
import {
AppError,
NotFoundError,
ValidationError,
UnauthorizedError,
DatabaseError,
ConfigError,
} from "@bete/shared/errors";
// ─── Shared utilities ─────────────────────────────────────────────────────────
import { delay, retryWithBackoff, encodeCursor, decodeCursor, pageResult } from "@bete/shared/utils";
// ─── Backend middleware ──────────────────────────────────────────────────────
import { asyncHandler, requireParam } from "../src/shared/middlewares/index.js";
// ═══════════════════════════════════════════════════════════════════════════════
// 1. AppError / Error Hierarchy Tests
// ═══════════════════════════════════════════════════════════════════════════════
describe("AppError subclasses", () => {
it("AppError stores message, code, statusCode, and details", () => {
const err = new AppError("custom", "CUSTOM", 418, { reason: "teapot" });
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe("custom");
expect(err.code).toBe("CUSTOM");
expect(err.statusCode).toBe(418);
expect(err.details).toEqual({ reason: "teapot" });
expect(err.name).toBe("AppError");
});
it("AppError defaults statusCode to 500", () => {
const err = new AppError("msg", "X");
expect(err.statusCode).toBe(500);
});
it("NotFoundError has 404 status and formatted message", () => {
const err = new NotFoundError("User");
expect(err).toBeInstanceOf(AppError);
expect(err.statusCode).toBe(404);
expect(err.code).toBe("NOT_FOUND");
expect(err.message).toBe("User not found");
expect(err.name).toBe("NotFoundError");
});
it("NotFoundError appends id when provided", () => {
const err = new NotFoundError("Message", "abc-123");
expect(err.message).toBe("Message not found: abc-123");
});
it("ValidationError has 400 status and forwards details", () => {
const details = { field: "email" };
const err = new ValidationError("Invalid input", details);
expect(err).toBeInstanceOf(AppError);
expect(err.statusCode).toBe(400);
expect(err.code).toBe("VALIDATION_ERROR");
expect(err.details).toBe(details);
expect(err.name).toBe("ValidationError");
});
it("UnauthorizedError has 401 status and default message", () => {
const err = new UnauthorizedError();
expect(err.statusCode).toBe(401);
expect(err.code).toBe("UNAUTHORIZED");
expect(err.message).toBe("Unauthorized");
});
it("UnauthorizedError accepts custom message", () => {
const err = new UnauthorizedError("Access denied");
expect(err.message).toBe("Access denied");
});
it("DatabaseError has 500 status and forwards details", () => {
const err = new DatabaseError("DB down", { cause: "timeout" });
expect(err.statusCode).toBe(500);
expect(err.code).toBe("DATABASE_ERROR");
expect(err.details).toEqual({ cause: "timeout" });
});
it("ConfigError has 500 status", () => {
const err = new ConfigError("Bad config");
expect(err.statusCode).toBe(500);
expect(err.code).toBe("CONFIG_ERROR");
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// 2. Utility Function Tests
// ═══════════════════════════════════════════════════════════════════════════════
describe("delay", () => {
afterEach(() => {
vi.useRealTimers();
});
it("resolves after the given time", async () => {
vi.useFakeTimers();
const promise = delay(500);
vi.advanceTimersByTime(500);
await expect(promise).resolves.toBeUndefined();
});
it("rejects are not triggered on non-matching timer", async () => {
vi.useFakeTimers();
const promise = delay(1000);
// Advance only part way — the timer should NOT fire yet
vi.advanceTimersByTime(500);
// The timer is still pending; the promise has not resolved yet
// We advance the rest
vi.advanceTimersByTime(500);
await expect(promise).resolves.toBeUndefined();
});
});
describe("retryWithBackoff", () => {
afterEach(() => {
vi.useRealTimers();
});
it("returns the result on first success without retrying", async () => {
const fn = vi.fn().mockResolvedValue("ok");
await expect(retryWithBackoff(fn)).resolves.toBe("ok");
expect(fn).toHaveBeenCalledTimes(1);
});
it("re-throws after exhausting all retries", async () => {
const fn = vi.fn().mockRejectedValue(new Error("persistent"));
await expect(
retryWithBackoff(fn, { retries: 1, minTimeout: 1, maxTimeout: 5 }),
).rejects.toThrow("persistent");
// initial call + 1 retry
expect(fn.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it("throws AbortError immediately when signal is already aborted", async () => {
const ac = new AbortController();
ac.abort();
const fn = vi.fn().mockResolvedValue("ok");
await expect(
retryWithBackoff(fn, { retries: 3, signal: ac.signal }),
).rejects.toThrow("Aborted");
expect(fn).not.toHaveBeenCalled();
});
it("respects abort signal during retry", async () => {
vi.useFakeTimers();
const ac = new AbortController();
const fn = vi.fn().mockRejectedValue(new Error("fail"));
const promise = retryWithBackoff(fn, {
retries: 5,
minTimeout: 100,
signal: ac.signal,
});
// Schedule abort after first failure + backoff starts
setTimeout(() => ac.abort(), 150);
vi.advanceTimersByTime(200);
await vi.waitFor(async () => {
await expect(promise).rejects.toThrow("Aborted");
});
});
});
describe("pagination utilities", () => {
it("encodeCursor produces a base64 string", () => {
const result = encodeCursor({ created_at: 1000, id: "msg-1" });
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});
it("encodeCursor round-trips through decodeCursor", () => {
const data = { created_at: 1234567890, id: "abc-def" };
const cursor = encodeCursor(data);
expect(decodeCursor(cursor)).toEqual(data);
});
it("decodeCursor returns null for undefined / empty", () => {
expect(decodeCursor()).toBeNull();
expect(decodeCursor("")).toBeNull();
});
it("decodeCursor returns null for malformed input", () => {
// Completely invalid base64
expect(decodeCursor("!!!not-valid!!!")).toBeNull();
// Valid base64 but not JSON
const notJson = Buffer.from("not-json").toString("base64");
expect(decodeCursor(notJson)).toBeNull();
// Valid JSON but wrong shape (missing created_at / id)
const wrongShape = Buffer.from(JSON.stringify({ foo: "bar" })).toString("base64");
expect(decodeCursor(wrongShape)).toBeNull();
});
it("pageResult truncates and sets nextCursor when rows exceed limit", () => {
const rows = [
{ id: "a", created_at: 100 },
{ id: "b", created_at: 200 },
{ id: "c", created_at: 300 },
];
const { data, nextCursor } = pageResult(rows, 2);
expect(data).toHaveLength(2);
expect(data[0].id).toBe("a");
expect(nextCursor).toBeTruthy();
});
it("pageResult returns null nextCursor when fewer rows than limit", () => {
const rows = [{ id: "a", created_at: 100 }];
const { data, nextCursor } = pageResult(rows, 2);
expect(data).toHaveLength(1);
expect(nextCursor).toBeNull();
});
it("pageResult returns empty data for empty input", () => {
const { data, nextCursor } = pageResult([], 10);
expect(data).toEqual([]);
expect(nextCursor).toBeNull();
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// 3. Middleware Tests
// ═══════════════════════════════════════════════════════════════════════════════
describe("asyncHandler", () => {
it("passes thrown errors to next()", async () => {
const error = new Error("handler-error");
const wrapped = asyncHandler(async () => {
throw error;
});
const next = vi.fn();
wrapped({} as any, {} as any, next);
// .catch(next) is a microtask — flush the queue
await Promise.resolve();
await Promise.resolve();
expect(next).toHaveBeenCalledWith(error);
});
it("does not call next when handler resolves successfully", async () => {
const wrapped = asyncHandler(async (_req: any, _res: any, _next: any) => {
// no-op
});
const next = vi.fn();
wrapped({} as any, {} as any, next);
await Promise.resolve();
await Promise.resolve();
expect(next).not.toHaveBeenCalled();
});
});
describe("requireParam", () => {
it("returns the value for a non-empty string", () => {
expect(requireParam("hello", "param", "name")).toBe("hello");
});
it("throws ValidationError for undefined", () => {
expect(() => requireParam(undefined, "query", "q")).toThrow(ValidationError);
});
it("throws ValidationError for empty string", () => {
expect(() => requireParam("", "param", "id")).toThrow(ValidationError);
});
it("throws with a descriptive message", () => {
expect(() => requireParam(null, "header", "X-Token")).toThrow("Missing header: X-Token");
});
});
@@ -1,6 +1,8 @@
import { ConfigError, DatabaseError } from "@bete/shared/errors";
import { createChildLogger } from "@bete/shared/logger";
import { Client } from "discord.js-selfbot-v13";
import { inArray, lt } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { startPendingAIAnalysisWorker } from "../modules/ai-moderation/aiAnalyzer.js";
import { CommandHandler } from "../modules/command-handler/commandHandler.js";
import {
@@ -11,19 +13,174 @@ import {
registerMessageCapture,
setEventBroadcaster as setMessageCaptureEventBroadcaster,
} from "../modules/message-capture/messageCapture.js";
import { getExpiredMessages } from "../modules/message-capture/messageStore.js";
import { setEventBroadcaster as setRecorderEventBroadcaster } from "../modules/voice-recording/recorder.js";
import { VoiceController } from "../modules/voice-recording/voiceController.js";
import { config } from "../shared/config/config.js";
import {
closeDatabase,
getDatabase,
initializeDatabase,
} from "../shared/database/drizzle.js";
import { runMigrations } from "../shared/database/migrate.js";
import type * as schema from "../shared/database/schema.js";
import {
attachmentsTable,
messagesTable,
voiceRecordingsTable,
} from "../shared/database/schema.js";
import { createDiscordClientOptions } from "../shared/discord/clientOptions.js";
import { createGracefulShutdown } from "./shutdown.js";
const logger = createChildLogger("discord-gateway");
// ─── Retention Cleanup ─────────────────────────────────────────────────────
function startRetentionCleanup(): void {
const intervalMs = config.RETENTION_CLEANUP_INTERVAL_MS;
const dryRun = config.RETENTION_DRY_RUN;
logger.info(
{
intervalMs,
dryRun,
messagesDays: config.RETENTION_MESSAGES_DAYS,
attachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
voiceDays: config.RETENTION_VOICE_DAYS,
},
"Starting retention cleanup scheduler",
);
async function runCleanupTick(): Promise<void> {
const db = getDatabase() as unknown as NodePgDatabase<typeof schema>;
// ── Expired messages ────────────────────────────────────────────────
if (config.RETENTION_MESSAGES_DAYS > 0) {
try {
const expiredMessages = await getExpiredMessages(
config.RETENTION_MESSAGES_DAYS,
);
if (expiredMessages.length > 0) {
const ids = expiredMessages.map((m: { id: string }) => m.id);
logger.info(
{ count: ids.length, dryRun },
"Expired messages found for cleanup",
);
if (!dryRun) {
await db
.delete(messagesTable)
.where(inArray(messagesTable.id, ids));
logger.info({ count: ids.length }, "Expired messages deleted");
}
}
} catch (error) {
logger.error(
{
error: error instanceof Error ? error.message : String(error),
},
"Failed to clean up expired messages",
);
}
}
// ── Expired attachments ─────────────────────────────────────────────
if (config.RETENTION_ATTACHMENTS_DAYS > 0) {
try {
const cutoff =
Date.now() - config.RETENTION_ATTACHMENTS_DAYS * 24 * 60 * 60 * 1000;
const expiredAttachments = await db
.select({ id: attachmentsTable.id })
.from(attachmentsTable)
.where(lt(attachmentsTable.created_at, cutoff))
.limit(1000);
if (expiredAttachments.length > 0) {
const ids = expiredAttachments.map((a: { id: string }) => a.id);
logger.info(
{ count: ids.length, dryRun },
"Expired attachments found for cleanup",
);
if (!dryRun) {
await db
.delete(attachmentsTable)
.where(inArray(attachmentsTable.id, ids));
logger.info({ count: ids.length }, "Expired attachments deleted");
}
}
} catch (error) {
logger.error(
{
error: error instanceof Error ? error.message : String(error),
},
"Failed to clean up expired attachments",
);
}
}
// ── Expired voice recordings ────────────────────────────────────────
if (config.RETENTION_VOICE_DAYS > 0) {
try {
const cutoff =
Date.now() - config.RETENTION_VOICE_DAYS * 24 * 60 * 60 * 1000;
const expiredRecordings = await db
.select({ id: voiceRecordingsTable.id })
.from(voiceRecordingsTable)
.where(lt(voiceRecordingsTable.created_at, cutoff))
.limit(1000);
if (expiredRecordings.length > 0) {
const ids = expiredRecordings.map((r: { id: string }) => r.id);
logger.info(
{ count: ids.length, dryRun },
"Expired voice recordings found for cleanup",
);
if (!dryRun) {
await db
.delete(voiceRecordingsTable)
.where(inArray(voiceRecordingsTable.id, ids));
logger.info(
{ count: ids.length },
"Expired voice recordings deleted",
);
}
}
} catch (error) {
logger.error(
{
error: error instanceof Error ? error.message : String(error),
},
"Failed to clean up expired voice recordings",
);
}
}
}
// Run immediately on start, then schedule
runCleanupTick().catch((error) => {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Initial retention cleanup tick failed",
);
});
setInterval(() => {
runCleanupTick().catch((error) => {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Retention cleanup tick failed",
);
});
}, intervalMs);
}
// ─── Bootstrap ─────────────────────────────────────────────────────────────
export async function initializeDiscordGateway() {
if (config.AI_ANALYSIS_ENABLED && !config.AI_LLM_API_KEY) {
throw new ConfigError(
@@ -98,6 +255,9 @@ export async function initializeDiscordGateway() {
// Start command handler after Discord is ready
commandHandler.start(client, voiceController);
logger.info("Command handler started");
// Start retention cleanup scheduler
startRetentionCleanup();
});
client.on("error", (err) => {
@@ -1,15 +1,23 @@
import { type CommandMessage, type CommandReply } from "@bete/shared";
import { createChildLogger } from "@bete/shared/logger";
import { StreamType } from "@discordjs/voice";
import { resolveMediaUrl } from "../voice-recording/mediaSource.js";
import { discordPlayer } from "../voice-recording/player.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface CurrentTrack {
title: string;
url: string;
duration?: number;
}
export interface MediaStatusPayload {
playing: boolean;
musicVolume: number;
current: unknown;
current: CurrentTrack | null;
queue: unknown[];
}
@@ -19,29 +27,84 @@ export interface MediaStatusPayload {
export class MediaHandler {
private logger = createChildLogger("media-handler");
private currentTrack: CurrentTrack | null = null;
getCurrentMediaStatus(): MediaStatusPayload {
return {
playing: discordPlayer.getStatus() === "playing",
musicVolume: discordPlayer.getMusicVolume(),
current: null,
current: this.currentTrack,
queue: [],
};
}
async handleMediaQueue(cmd: CommandMessage): Promise<CommandReply<unknown>> {
this.logger.info(
"media:queue received — media queueing is handled externally",
);
return {
id: cmd.id,
success: true,
data: this.getCurrentMediaStatus(),
};
const url = String(cmd.payload.url ?? "").trim();
if (!url) {
this.logger.warn("media:queue received without a URL");
return {
id: cmd.id,
success: false,
data: null,
error: "url is required",
};
}
if (!discordPlayer.isConnected()) {
this.logger.warn(
"media:queue attempted without an active voice connection",
);
return {
id: cmd.id,
success: false,
data: null,
error: "Not connected to a voice channel. Connect to voice first.",
};
}
try {
this.logger.info({ url }, "Resolving media URL");
const resolution = await resolveMediaUrl(url);
this.currentTrack = {
title: resolution.title ?? url,
url,
duration: resolution.duration,
};
discordPlayer.playStream(resolution.stream, "music", {
inputType: StreamType.Arbitrary,
inlineVolume: true,
volume: discordPlayer.getMusicVolume(),
});
this.logger.info(
{ url, title: resolution.title },
"Media queued and playback started",
);
return {
id: cmd.id,
success: true,
data: this.getCurrentMediaStatus(),
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error({ error: message, url }, "Failed to queue media");
return {
id: cmd.id,
success: false,
data: null,
error: message,
};
}
}
async handleMediaSkip(cmd: CommandMessage): Promise<CommandReply<unknown>> {
discordPlayer.stop("music");
this.currentTrack = null;
return {
id: cmd.id,
success: true,
@@ -51,6 +114,7 @@ export class MediaHandler {
async handleMediaStop(cmd: CommandMessage): Promise<CommandReply<unknown>> {
discordPlayer.stop("music");
this.currentTrack = null;
return {
id: cmd.id,
success: true,
@@ -111,67 +111,6 @@ export class VoiceHandler {
}
}
async handleGuildsList(cmd: CommandMessage): Promise<CommandReply<unknown>> {
if (!this.client) {
return {
id: cmd.id,
success: false,
data: null,
error: "Gateway not initialized",
};
}
try {
const guilds = this.client.guilds.cache
.map((guild) => ({ id: guild.id, name: guild.name }))
.sort((a, b) => a.name.localeCompare(b.name));
return { id: cmd.id, success: true, data: guilds };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { id: cmd.id, success: false, data: null, error: msg };
}
}
async handleWatchableChannels(
cmd: CommandMessage,
): Promise<CommandReply<unknown>> {
if (!this.client) {
return {
id: cmd.id,
success: false,
data: null,
error: "Gateway not initialized",
};
}
const guildId = String(cmd.payload.guildId ?? "");
if (!guildId) {
return {
id: cmd.id,
success: false,
data: null,
error: "guildId is required",
};
}
try {
const guild = await this.client.guilds.fetch(guildId);
const channels = await guild.channels.fetch();
const textChannels = channels
.filter((c) => c?.type === "GUILD_TEXT")
.map((c) => ({
id: c.id,
name: c.name,
type: c.type,
}));
return { id: cmd.id, success: true, data: textChannels };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { id: cmd.id, success: false, data: null, error: msg };
}
}
async handleVoiceTransmitStart(
cmd: CommandMessage,
): Promise<CommandReply<unknown>> {
@@ -0,0 +1,374 @@
import { type ChildProcess, spawn } from "node:child_process";
import { PassThrough, Readable } from "node:stream";
import { createChildLogger } from "@bete/shared/logger";
import { StreamType } from "@discordjs/voice";
const logger = createChildLogger("media-source");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface MediaInfo {
title: string;
duration: number;
uploader?: string;
thumbnail?: string;
}
export interface MediaSourceResolution {
stream: Readable;
type: StreamType;
title?: string;
duration?: number;
info: MediaInfo;
}
export interface ResolveOptions {
/** Timeout in milliseconds for the yt-dlp process. */
timeout?: number;
/**
* yt-dlp format string override (e.g. "bestaudio[ext=m4a]").
* Defaults to "bestaudio".
*/
quality?: string;
}
// ---------------------------------------------------------------------------
// Internal state
// ---------------------------------------------------------------------------
/** Tracks all spawned yt-dlp child processes for shutdown cleanup. */
const activeProcesses: Set<ChildProcess> = new Set();
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function parseSeconds(value: string): number {
const n = Number.parseFloat(value);
return Number.isFinite(n) ? n : 0;
}
/**
* Read the first two newline-delimited lines from a Readable stdout stream.
*
* yt-dlp with `--print before_dl:title --print before_dl:duration` outputs:
* line 1: video title
* line 2: duration in seconds (float)
* rest: raw binary audio data
*
* Returns the parsed header and a new Readable that contains all remaining
* data (the audio stream).
*/
function readFirstTwoLines(stdout: Readable): Promise<{
title: string;
duration: number;
remaining: Readable;
}> {
return new Promise((resolve, reject) => {
const passThrough = new PassThrough();
let buffer = Buffer.alloc(0);
let title = "";
let stage: "title" | "duration" | "done" = "title";
function cleanup() {
stdout.removeListener("data", onData);
stdout.removeListener("error", onError);
stdout.removeListener("end", onEnd);
}
function onData(chunk: Buffer) {
if (stage === "done") return;
buffer = Buffer.concat([buffer, chunk]);
processBuffer();
}
function processBuffer() {
while (buffer.length > 0 && stage !== "done") {
const nl = buffer.indexOf(0x0a); // '\n' byte
if (nl === -1) break; // Need more data
const line = buffer.subarray(0, nl).toString("utf8").trim();
buffer = buffer.subarray(nl + 1);
if (stage === "title") {
title = line;
stage = "duration";
} else if (stage === "duration") {
const duration = parseSeconds(line);
stage = "done";
cleanup();
// Write any buffered data that follows the second newline
if (buffer.length > 0) {
passThrough.write(buffer);
}
// Pipe the remainder of stdout into the pass-through
stdout.pipe(passThrough);
resolve({ title, duration, remaining: passThrough });
return;
}
}
}
function onError(err: Error) {
if (stage !== "done") {
cleanup();
reject(err);
}
}
function onEnd() {
if (stage !== "done") {
cleanup();
reject(
new Error(
`yt-dlp stdout ended before metadata could be read. ` +
`Stage: ${stage}, partial title: "${title}"`,
),
);
}
}
stdout.on("data", onData);
stdout.on("error", onError);
stdout.on("end", onEnd);
});
}
function buildNotInstalledError(): Error {
return new Error(
"yt-dlp is not installed or not found in PATH. " +
'Run "pnpm run install:yt-dlp" to install it.',
);
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Resolve a media URL (YouTube, Spotify, etc.) to a playable audio stream.
*
* Spawns `yt-dlp`, extracts the title and duration from the first two stdout
* lines, then pipes the remaining raw audio data into a Readable stream.
*
* The returned stream uses `StreamType.Arbitrary` — suitable for
* `DiscordPlayer.playStream()` with `inputType: StreamType.Arbitrary`.
*
* @throws If yt-dlp is not installed or the process exits with a non-zero code
* before the metadata headers have been parsed.
*/
export function resolveMediaUrl(
url: string,
options?: ResolveOptions,
): Promise<MediaSourceResolution> {
return new Promise<MediaSourceResolution>((resolve, reject) => {
const format = options?.quality ?? "bestaudio";
const args = [
"-f",
format,
"--audio-format",
"best",
"-o",
"-",
"--print",
"before_dl:title",
"--print",
"before_dl:duration",
url,
];
logger.info({ url }, "Spawning yt-dlp for media resolution");
const proc = spawn("yt-dlp", args, {
stdio: ["pipe", "pipe", "pipe"],
});
activeProcesses.add(proc);
let stderrBuf = "";
let resolved = false;
// -- helpers -----------------------------------------------------------
const failOnce = (err: Error) => {
if (resolved) return;
resolved = true;
activeProcesses.delete(proc);
reject(err);
};
// -- spawn error (ENOENT etc.) ----------------------------------------
proc.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") {
failOnce(buildNotInstalledError());
} else {
failOnce(new Error(`yt-dlp failed to start: ${err.message}`));
}
});
// -- stderr (capture for diagnostics) ----------------------------------
if (proc.stderr) {
proc.stderr.on("data", (chunk: Buffer) => {
stderrBuf += chunk.toString("utf8");
});
}
// -- stdout: parse header, then stream audio ---------------------------
readFirstTwoLines(proc.stdout)
.then(({ title, duration, remaining }) => {
if (resolved) return;
resolved = true;
activeProcesses.delete(proc);
const info: MediaInfo = { title, duration };
resolve({
stream: remaining,
type: StreamType.Arbitrary,
title,
duration,
info,
});
})
.catch((err: Error) => {
failOnce(err);
});
// -- process exit (non-zero means failure) -----------------------------
proc.on("close", (code, signal) => {
activeProcesses.delete(proc);
if (resolved) return;
if (code !== null && code !== 0) {
const detail = stderrBuf.trim() ? `: ${stderrBuf.trim()}` : "";
failOnce(new Error(`yt-dlp exited with code ${code}${detail}`));
} else if (signal) {
failOnce(new Error(`yt-dlp was killed by signal ${signal}`));
}
});
// -- optional timeout --------------------------------------------------
if (options?.timeout && options.timeout > 0) {
const timer = setTimeout(() => {
if (resolved) return;
logger.warn({ url, timeout: options.timeout }, "yt-dlp timed out");
proc.kill("SIGTERM");
failOnce(new Error(`yt-dlp timed out after ${options.timeout}ms`));
}, options.timeout);
proc.once("close", () => clearTimeout(timer));
}
});
}
/**
* Extract metadata (title, duration, uploader, thumbnail) from a media URL
* without downloading the audio stream.
*
* Uses `yt-dlp --dump-json` and parses the JSON output.
*
* @throws If yt-dlp is not installed or the process exits with a non-zero
* code.
*/
export async function extractMediaInfo(url: string): Promise<MediaInfo> {
return new Promise<MediaInfo>((resolve, reject) => {
const args = ["--dump-json", "--no-warnings", url];
logger.debug({ url }, "Spawning yt-dlp for metadata extraction");
const proc = spawn("yt-dlp", args, {
stdio: ["pipe", "pipe", "pipe"],
});
activeProcesses.add(proc);
let stdoutBuf = "";
let stderrBuf = "";
if (proc.stdout) {
proc.stdout.on("data", (chunk: Buffer) => {
stdoutBuf += chunk.toString("utf8");
});
}
if (proc.stderr) {
proc.stderr.on("data", (chunk: Buffer) => {
stderrBuf += chunk.toString("utf8");
});
}
proc.on("error", (err: NodeJS.ErrnoException) => {
activeProcesses.delete(proc);
if (err.code === "ENOENT") {
reject(buildNotInstalledError());
} else {
reject(new Error(`yt-dlp failed to start: ${err.message}`));
}
});
proc.on("close", (code) => {
activeProcesses.delete(proc);
if (code !== 0) {
const detail = stderrBuf.trim() ? `: ${stderrBuf.trim()}` : "";
reject(
new Error(
`yt-dlp metadata extraction exited with code ${code}${detail}`,
),
);
return;
}
try {
const raw = JSON.parse(stdoutBuf.trim()) as Record<string, unknown>;
resolve({
title: String(raw.title ?? url),
duration: typeof raw.duration === "number" ? raw.duration : 0,
uploader: String(raw.uploader ?? raw.channel ?? "") || undefined,
thumbnail: String(raw.thumbnail ?? "") || undefined,
});
} catch (parseErr) {
reject(
new Error(
`Failed to parse yt-dlp JSON output: ${(parseErr as Error).message}`,
),
);
}
});
});
}
/**
* Kill all active yt-dlp child processes.
*
* Call during graceful shutdown to ensure no orphan processes remain.
*/
export function cleanup(): void {
if (activeProcesses.size === 0) return;
logger.info(
{ count: activeProcesses.size },
"Killing active yt-dlp processes",
);
for (const proc of activeProcesses) {
try {
proc.kill("SIGTERM");
} catch {
// Process may already be dead — ignore
}
}
activeProcesses.clear();
}
@@ -1,7 +1,417 @@
import { describe, expect, it } from "vitest";
import { describe, it, expect, vi, afterEach } from "vitest";
describe("discord-gateway", () => {
it("should load without errors", () => {
expect(true).toBe(true);
// ═══════════════════════════════════════════════════════════════════════════════
// 1. AppError Hierarchy
// ═══════════════════════════════════════════════════════════════════════════════
import {
AppError,
NotFoundError,
ValidationError,
UnauthorizedError,
DatabaseError,
ConfigError,
} from "@bete/shared/errors";
describe("AppError subclasses", () => {
it("AppError carries code, statusCode, and details", () => {
const err = new AppError("err", "X", 500, { info: "test" });
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe("X");
expect(err.statusCode).toBe(500);
expect(err.details).toEqual({ info: "test" });
});
it("NotFoundError sets 404 status", () => {
expect(new NotFoundError("R").statusCode).toBe(404);
expect(new NotFoundError("R").code).toBe("NOT_FOUND");
});
it("ValidationError sets 400 status", () => {
expect(new ValidationError("V").statusCode).toBe(400);
expect(new ValidationError("V").code).toBe("VALIDATION_ERROR");
});
it("UnauthorizedError sets 401 status", () => {
expect(new UnauthorizedError().statusCode).toBe(401);
});
it("DatabaseError sets 500 status", () => {
expect(new DatabaseError("X").statusCode).toBe(500);
});
it("ConfigError sets 500 status", () => {
expect(new ConfigError("X").statusCode).toBe(500);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// 2. Shared Utilities
// ═══════════════════════════════════════════════════════════════════════════════
import { delay, retryWithBackoff, encodeCursor, decodeCursor, pageResult } from "@bete/shared/utils";
describe("delay", () => {
afterEach(() => { vi.useRealTimers(); });
it("resolves after specified time with fake timers", async () => {
vi.useFakeTimers();
const p = delay(250);
vi.advanceTimersByTime(250);
await expect(p).resolves.toBeUndefined();
});
});
describe("retryWithBackoff", () => {
afterEach(() => { vi.useRealTimers(); });
it("resolves on first attempt", async () => {
const fn = vi.fn().mockResolvedValue(42);
await expect(retryWithBackoff(fn)).resolves.toBe(42);
expect(fn).toHaveBeenCalledTimes(1);
});
it("throws after retries are exhausted", async () => {
const fn = vi.fn().mockRejectedValue(new Error("fail"));
await expect(
retryWithBackoff(fn, { retries: 1, minTimeout: 1, maxTimeout: 5 }),
).rejects.toThrow("fail");
expect(fn.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it("rejects immediately when already aborted", async () => {
const ac = new AbortController();
ac.abort();
await expect(
retryWithBackoff(() => Promise.resolve("ok"), { signal: ac.signal }),
).rejects.toThrow("Aborted");
});
});
describe("pagination utils", () => {
it("encode/decode round-trips correctly", () => {
const data = { created_at: 999, id: "id-1" };
expect(decodeCursor(encodeCursor(data))).toEqual(data);
});
it("decodeCursor rejects invalid input with null", () => {
expect(decodeCursor()).toBeNull();
expect(decodeCursor("")).toBeNull();
expect(decodeCursor("!!!")).toBeNull();
});
it("pageResult handles hasMore and no-more cases", () => {
const r1 = pageResult([{ id: "a", created_at: 1 }], 5);
expect(r1.nextCursor).toBeNull();
const r2 = pageResult(
[{ id: "a", created_at: 1 }, { id: "b", created_at: 2 }],
1,
);
expect(r2.data).toHaveLength(1);
expect(r2.nextCursor).toBeTruthy();
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// 3. Redis Channel Constants
// ═══════════════════════════════════════════════════════════════════════════════
import {
DISCORD_MESSAGE_CREATED,
DISCORD_MESSAGE_UPDATED,
DISCORD_MESSAGE_DELETED,
DISCORD_MESSAGE_ANALYZED,
DISCORD_ATTACHMENT_CREATED,
DISCORD_VOICE_STARTED,
DISCORD_VOICE_PCM,
DISCORD_ANALYSIS_QUEUE_STATUS,
BACKEND_COMMAND,
VOICE_STATUS_KEY,
MEDIA_STATUS_KEY,
COMMAND_VOICE_CONNECT,
COMMAND_VOICE_DISCONNECT,
COMMAND_GUILDS_LIST,
COMMAND_MEDIA_QUEUE,
COMMAND_MEDIA_SKIP,
COMMAND_MEDIA_STOP,
COMMAND_MEDIA_VOLUME,
COMMAND_MODERATION_ACTION,
} from "@bete/shared/redis-channels";
describe("Redis channel constants", () => {
it("define event channel names", () => {
expect(DISCORD_MESSAGE_CREATED).toBe("discord:message:created");
expect(DISCORD_MESSAGE_UPDATED).toBe("discord:message:updated");
expect(DISCORD_MESSAGE_DELETED).toBe("discord:message:deleted");
expect(DISCORD_MESSAGE_ANALYZED).toBe("discord:message:analyzed");
expect(DISCORD_ATTACHMENT_CREATED).toBe("discord:attachment:created");
expect(DISCORD_VOICE_STARTED).toBe("discord:voice:started");
expect(DISCORD_VOICE_PCM).toBe("discord:voice:pcm");
expect(DISCORD_ANALYSIS_QUEUE_STATUS).toBe("discord:analysis:queue_status");
});
it("define command channel and status keys", () => {
expect(BACKEND_COMMAND).toBe("backend:command");
expect(VOICE_STATUS_KEY).toBe("voice:status");
expect(MEDIA_STATUS_KEY).toBe("media:status");
});
it("define command type constants", () => {
expect(COMMAND_VOICE_CONNECT).toBe("voice:connect");
expect(COMMAND_VOICE_DISCONNECT).toBe("voice:disconnect");
expect(COMMAND_GUILDS_LIST).toBe("guilds:list");
expect(COMMAND_MEDIA_QUEUE).toBe("media:queue");
expect(COMMAND_MEDIA_SKIP).toBe("media:skip");
expect(COMMAND_MEDIA_STOP).toBe("media:stop");
expect(COMMAND_MEDIA_VOLUME).toBe("media:volume");
expect(COMMAND_MODERATION_ACTION).toBe("moderation:action");
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// 4. Config Validation
// ═══════════════════════════════════════════════════════════════════════════════
vi.hoisted(() => {
process.env.DISCORD_TOKEN = "test-discord-token-for-tests";
process.env.DATABASE_URL = "postgres://test:test@localhost:5432/test";
});
import { loadConfig, configSchema } from "@bete/shared/config";
describe("Config validation", () => {
it("loadConfig succeeds with minimal valid env", () => {
const cfg = loadConfig({
DISCORD_TOKEN: "abc",
DATABASE_URL: "postgres://localhost/db",
});
expect(cfg.DISCORD_TOKEN).toBe("abc");
// Defaults
expect(cfg.RECORDINGS_DIR).toBe("./recordings");
expect(cfg.RECORDING_SEGMENT_MS).toBe(5000);
expect(cfg.NODE_ENV).toBe("development");
expect(cfg.WEBSERVER_PORT).toBe(3001);
expect(cfg.OPUS_FRAME_SIZE).toBe(960);
expect(cfg.AUDIO_SAMPLE_RATE).toBe(48000);
expect(cfg.LOG_LEVEL).toBe("info");
});
it("loadConfig throws ConfigError when DISCORD_TOKEN is missing", () => {
expect(() => loadConfig({})).toThrow(ConfigError);
});
it("schema parses boolean-string transforms correctly", () => {
const result = configSchema.parse({
DISCORD_TOKEN: "tok",
DATABASE_URL: "pg://localhost/db",
VERBOSE: "true",
AI_ANALYSIS_ENABLED: "true",
AI_LLM_API_KEY: "sk-test",
});
expect(result.AI_ANALYSIS_ENABLED).toBe(true);
});
it("schema provides sensible default for NODE_ENV", () => {
const result = configSchema.parse({
DISCORD_TOKEN: "tok",
DATABASE_URL: "pg://localhost/db",
});
expect(result.NODE_ENV).toBe("development");
});
it("gateway loadConfig adds EFFECTIVE_TEXT_GUILD_ID from MONITOR_GUILD_ID", async () => {
const { loadConfig: gwLoadConfig } = await import("../src/shared/config/config.js");
const cfg = gwLoadConfig({
DISCORD_TOKEN: "tok",
DATABASE_URL: "pg://localhost/db",
MONITOR_GUILD_ID: "guild-1",
});
expect(cfg.EFFECTIVE_TEXT_GUILD_ID).toBe("guild-1");
});
it("gateway loadConfig prefers TEXT_GUILD_ID over MONITOR_GUILD_ID", async () => {
const { loadConfig: gwLoadConfig } = await import("../src/shared/config/config.js");
const cfg = gwLoadConfig({
DISCORD_TOKEN: "tok",
DATABASE_URL: "pg://localhost/db",
TEXT_GUILD_ID: "text-guild",
MONITOR_GUILD_ID: "monitor-guild",
});
expect(cfg.EFFECTIVE_TEXT_GUILD_ID).toBe("text-guild");
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// 5. Pure function modules
// ═══════════════════════════════════════════════════════════════════════════════
import { sniffImageMimeType } from "../src/modules/ai-moderation/imageMimeSniffer.js";
describe("sniffImageMimeType", () => {
function buf(...bytes: number[]): Buffer {
const b = Buffer.alloc(12);
for (let i = 0; i < bytes.length; i++) b[i] = bytes[i];
return b;
}
it("detects JPEG", () => {
expect(sniffImageMimeType(buf(0xff, 0xd8, 0xff))).toBe("image/jpeg");
});
it("detects PNG", () => {
expect(
sniffImageMimeType(buf(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)),
).toBe("image/png");
});
it("detects GIF", () => {
expect(sniffImageMimeType(buf(0x47, 0x49, 0x46, 0x38))).toBe("image/gif");
});
it("detects WebP", () => {
expect(
sniffImageMimeType(buf(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50)),
).toBe("image/webp");
});
it("detects AVIF", () => {
// ftyp box with avif brand at bytes 8-11
expect(
sniffImageMimeType(buf(0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66)),
).toBe("image/avif");
});
it("detects HEIC", () => {
// ftyp box with heic brand at bytes 8-11
expect(
sniffImageMimeType(buf(0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63)),
).toBe("image/heic");
});
it("returns null for short buffer (< 12 bytes)", () => {
expect(sniffImageMimeType(Buffer.alloc(3))).toBeNull();
});
it("returns null for unrecognised data", () => {
expect(sniffImageMimeType(Buffer.alloc(12))).toBeNull();
});
});
import {
clampScore,
deriveSeverity,
deriveRecommendedAction,
hasDeferralAnalysis,
} from "../src/modules/ai-moderation/severityDeriver.js";
describe("severityDeriver", () => {
describe("clampScore", () => {
it("clamps values between 0 and 1", () => {
expect(clampScore(-0.5)).toBe(0);
expect(clampScore(0.5)).toBe(0.5);
expect(clampScore(1.5)).toBe(1);
});
it("handles undefined and NaN with fallback", () => {
expect(clampScore(undefined)).toBe(0);
expect(clampScore(NaN)).toBe(0);
});
it("allows custom fallback", () => {
expect(clampScore(undefined, 0.5)).toBe(0.5);
});
});
describe("deriveSeverity", () => {
it("returns none for clean status", () => {
expect(deriveSeverity("clean", 0)).toBe("none");
});
it("returns low for warn with low score", () => {
expect(deriveSeverity("warn", 0.5)).toBe("low");
});
it("returns medium for warn with score >= 0.65", () => {
expect(deriveSeverity("warn", 0.65)).toBe("medium");
});
it("returns critical for flagged with score >= 0.9", () => {
expect(deriveSeverity("flagged", 0.9)).toBe("critical");
});
it("returns high for flagged with score >= 0.75", () => {
expect(deriveSeverity("flagged", 0.75)).toBe("high");
});
it("returns medium for flagged with score < 0.75", () => {
expect(deriveSeverity("flagged", 0.5)).toBe("medium");
});
});
describe("deriveRecommendedAction", () => {
it("returns none for clean status", () => {
expect(deriveRecommendedAction("clean", "none")).toBe("none");
});
it("returns review for warn with medium severity", () => {
expect(deriveRecommendedAction("warn", "medium")).toBe("review");
});
it("returns warn for warn with low severity", () => {
expect(deriveRecommendedAction("warn", "low")).toBe("warn");
});
});
describe("hasDeferralAnalysis", () => {
it("detects Indonesian deferral: kurang konteks", () => {
expect(hasDeferralAnalysis("kurang konteks untuk menilai")).toBe(true);
});
it("detects English deferral: insufficient context", () => {
expect(hasDeferralAnalysis("insufficient context to moderate")).toBe(true);
});
it("detects cannot determine pattern", () => {
expect(hasDeferralAnalysis("cannot determine")).toBe(true);
});
it("returns false for non-deferral text", () => {
expect(hasDeferralAnalysis("This message is perfectly clean")).toBe(false);
});
it("returns false for exception pattern (decisive verdict)", () => {
expect(
hasDeferralAnalysis("tidak bisa menentukan karena tidak ada pelanggaran"),
).toBe(false);
});
});
});
import { extractJson } from "../src/modules/ai-moderation/jsonExtractor.js";
describe("extractJson", () => {
it("extracts from plain JSON string", () => {
expect(extractJson('{"a":1}')).toEqual({ a: 1 });
});
it("extracts from JSON inside markdown code block", () => {
const input = '```json\n{"key": "value"}\n```';
expect(extractJson(input)).toEqual({ key: "value" });
});
it("extracts from JSON inside unlabeled code block", () => {
const input = '```\n{"nested": {"x": 42}}\n```';
expect(extractJson(input)).toEqual({ nested: { x: 42 } });
});
it("extracts JSON from surrounding text", () => {
const input = 'Here is the result: {"status": "ok"} end.';
expect(extractJson(input)).toEqual({ status: "ok" });
});
it("throws an error when no JSON is found", () => {
expect(() => extractJson("this has no json at all")).toThrow(
"No JSON object found",
);
});
it("throws on empty string", () => {
expect(() => extractJson("")).toThrow("No JSON object found");
});
});
@@ -1,7 +1,7 @@
import { motion } from "framer-motion";
import { Filter, RotateCw, Search, X } from "lucide-react";
import { useMemo, useState } from "react";
import type { MessageRecord } from "../../shared/api/client";
import { type MessageRecord, request } from "../../shared/api/client";
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
import {
Badge,
@@ -58,9 +58,9 @@ export function MessagesPanel({
setIsSearching(true);
try {
const params = new URLSearchParams({ q: searchQuery, limit: "50" });
const response = await fetch(`/api/analysis/search?${params}`);
if (!response.ok) throw new Error("Search failed");
const data = await response.json();
const data = await request<{ results: MessageRecord[] }>(
`/api/analysis/search?${params}`,
);
setSearchResults(data.results || []);
setShowSearch(true);
} catch {