Author SHA1 Message Date
asepharyana c8473b0610 build(nix): fix binding link path — LDC_LIB is full .so path
binding.gyp appended '/libdatachannel.so.0.24.0' to LDC_LIB; nixpkgs output
layout is <out>/lib/libdatachannel.so.0.24.1. Make LDC_LIB the complete
library path (env or default) and drop the append.
2026-08-11 18:54:12 +07:00
asepharyana 3deca91ffe build(nix): use nixpkgs libdatachannel (no cmake/fetchFromGitHub)
libdatachannel-src fetchFromGitHub + manual cmake build fails: GitHub tarball
does not include git submodules (deps/plog, libjuice, libsrtp, usrsctp) →
CMake 'source directory does not contain CMakeLists.txt'.

Switch to pkgs.libdatachannel (0.24.1): nixpkgs builds submodules + ships
lib/dev outputs. In the Nix sandbox everything is consistent (store glibc),
so the GLIBC_ABI_GNU2_TLS issue that blocks host-local use of 0.24.1 does
not apply to the Nix build. binding.gyp defaults stay on local 0.24.0 for
dev; Nix sets LDC_INCLUDE/LDC_LIB to the store paths.
2026-08-11 18:45:51 +07:00
asepharyana edec2edf82 build(nix): binding — NAPI_INCLUDE from pnpm store (depth 3), gyp env fallback 2026-08-11 18:35:49 +07:00
asepharyana 17013fe1e5 build(nix): gateway binding — gyp env-var paths, correct cwd, tolerant install
- binding.gyp: resolve libdatachannel include/.so via LDC_INCLUDE/LDC_LIB env
  (node -e expression) instead of hardcoded /tmp/ldc-build paths
- flake buildPhase: run node-gyp from native/libdatachannel-min root (was
  build/ subdir → 'binding.gyp not found'); export LDC_INCLUDE (fetchFromGitHub
  source) + LDC_LIB (cmake build dir)
- flake installPhase: tolerate missing binding (screen share disabled, gateway
  still starts); copy .so real files via -rL
2026-08-11 18:32:09 +07:00
asepharyana 3acb03391a build(nix): gateway flake — build libdatachannel-min binding, drop datachannel/node-av/zeromq
- Replace the per-package rebuild loop (node-datachannel cmake-js, zeromq)
  with: opus build + libdatachannel-min N-API binding build (fetchFromGitHub
  libdatachannel v0.24.0 — pinned because nixpkgs 0.24.1 is glibc-incompatible
  with this host; sha256 1jk53qs…).
- Removes ~760MB of node-datachannel build/cleanup cruft from the build
  phase; node_modules now 423MB (was 1.5GB).
2026-08-11 17:53:47 +07:00
asepharyana 9109d3c898 perf(golive): drop @dank074/discord-video-stream — node_modules 1.5GB → 423MB
Remove the last heavy GoLive dependency now that src/goLive/ replaces it:
- @dank074/discord-video-stream (pulled in @lng2004/node-datachannel
  771MB, node-av 118MB + @seydx/node-av-linux-x64 167MB, zeromq 21MB,
  fluent-ffmpeg 13MB — ~1.09GB total)
- onlyBuiltDependencies: drop node-av/zeromq/@lng2004 (keep opus/esbuild/sharp)
- pnpm.lock regenerated; orphan .pnpm dirs removed locally
- @discordjs/opus prebuild: rebuilt binary copied into
  prebuild/node-v127-napi-v3-linux-x64-glibc-2.39/ (node-pre-gyp find path)

Verified: tsc 0 errors, vitest 8/8, biome clean, opus encode OK.
Fresh CI install now ~423MB instead of ~1.5GB.
2026-08-11 17:49:33 +07:00
asepharyana 9139e225f4 perf(golive): ffmpeg-spawn demuxer (no node-av) + E2E pipeline tests
Phase 2 — replace the 114MB node-av binary with a plain ffmpeg spawn:

Demuxer.ts: spool stream input to temp file → probe via ffmpeg stderr
(ffmpeg-headless ships NO ffprobe — parse 'Stream #0:0: Video: h264...
640x360, 30 fps' from -loglevel info) → ffmpeg -c copy -f h264 pipe:1
→ NAL-split frames. Falls back to h264 defaults when probe fails.

prepareStream.ts: resolve ffmpeg from FFMPEG_PATH env → Nix store
ffmpeg-headless (hash-prefixed entry!) → PATH; split encoder option
strings ('-forced-idr 1' → two argv) — fluent-ffmpeg used to split
automatically, spawn does not.

E2E tests (tsx, need LD_LIBRARY_PATH=/tmp/ldc-build):
- golive-demux-e2e.ts: real H264 file → 33 NAL frames + dims from probe
- golive-pipeline-e2e.ts: prepareStream → demux → 82 frames
- golive-videostream-e2e.ts: local peer pair → demux → VideoStream →
  native setPacketizer/sendFrame/addTimestamp → 33 frames sent connected

Pitfalls captured: setPacketizer before negotiation breaks createOffer
('No DataChannel or Track to negotiate'); track methods are read-only
(no monkeypatching); both peers must declare audio+video tracks or
answer hangs; state() returns 'closed' after close() — snapshot first.
2026-08-11 17:38:06 +07:00
asepharyana 9ae230d047 perf(golive/spike): binding addTrack + TS port of @dank074 media stack
Phase 1 spike: replace @dank074/discord-video-stream + node-datachannel +
node-av (1.3GB) with minimal libdatachannel N-API binding + native RTP
packetizers (H264 FU-A, RTCP SR/NACK, pacer) + pure-TS GoLive stack.

Binding v0.4: addTrack (m=audio/video SDP), TrackWrap w/ setPacketizer +
sendFrame (raw RTP to transport) + addTimestamp — verified by two-peer
handshake emitting SDP with audio(opus 120)+video(H264 101) and 8-frame
RTP roundtrip.

TS layer (src/goLive/, 21 files): CodecPayloadType, VoiceOpCodes,
GatewayOpCodes, utils, BaseMediaConnection (voice WS + DAVE + heartbeat),
VoiceConnection, StreamConnection, Streamer, WebRtcWrapper (SDP mungling,
DAVE encrypt, packetizer chain), BaseMediaStream (pacing/sync), VideoStream,
AudioStream, Demuxer (ffmpeg-spawn NUT/AnnexB, no node-av 114M binary),
Encoders, prepareStream/playStream.

Integration: screenShareController.ts now imports from ../../goLive/index.js —
prepareStream(prepared, ...) + playStream(prepared, streamer, {...}).

Tests: tests/goLive-port.test.ts (8/8 pass). tsc --noEmit clean. biome clean.
2026-08-11 16:16:52 +07:00
asepharyana a1a6d8b418 spike: expose libdatachannel media packetizer chain via Track
Track.setPacketizer(kind, ssrc, pt, clockRate, ...) builds the same
media-handler chain node-datachannel does for @dank074:
  RtpPacketizer (Opus | H264 | H265 | AV1) → RtcpSrReporter →
  RtcpNackResponder → PacingHandler(25Mbps, 1ms) for video
Track.sendFrame(encodedFrame) packetizes into RTP; addTimestamp(delta)
advances the RTP timestamp (node-datachannel contract).

Verified test-packetizer.js: two peers connected over tracks, real opus
frames + AnnexB H264 (SPS/PPS/IDR) flow through the chain without crash.
This removes the need for a JS RTP packetizer entirely — libdatachannel
0.24 has the full media stack built in.
2026-08-11 14:57:04 +07:00
asepharyana 4f06c30c05 spike: add addTrack + TrackWrap to libdatachannel-min binding
Expose rtc::Track with send(binary) for raw RTP — verified:
- SDP from addTrack(audio)+addTrack(video) has m=audio (opus 120)
  and m=video (H264 101 + H265/VP8/VP9/AV1 + RTX)
- libdatachannel Track::send() sends RAW RTP/RTCP when no media
  handler is set (verified in src/track.cpp impl::Track::outgoing) —
  so RTP packetization can live in pure JS, keeping the binding minimal

Also fix: Track class was missing from InitAll exports (crash on
TrackWrap::NewInstance — null FunctionReference).
2026-08-11 14:51:56 +07:00
asepharyana 2203dd5771 spike: minimal N-API libdatachannel binding — WebRTC handshake proven
Phase 0 of GoLive rewrite (drop @dank074/node-datachannel 771MB):
minimal N-API binding exposing PeerConnection/DataChannel/ICE/SDP,
built against libdatachannel 0.24.0 (from node-datachannel _deps source).

Verified: offer/answer/ICE/DataChannel roundtrip between two local
peers (test-handshake.js). Key findings:
- callbacks must be registered in ctor BEFORE createDataChannel
- SDP with candidates comes from localDescription() at gathering Complete
- answer auto-generates on setRemoteDescription(offer); do NOT call
  setLocalDescription() after or role=actpass breaks the peer
2026-08-11 14:23:31 +07:00
asepharyana a53d7b71da fix(voice): screen share GoLive died instantly — neutralize node-av custom ffmpeg filters
prepareStream (from @dank074/discord-video-stream) unconditionally appends
audio filters 'volume@internal_lib' + 'azmq' that exist ONLY in its custom
node-av jellyfin-ffmpeg build. The Nix deployment runs plain ffmpeg-headless
on PATH, so fluent-ffmpeg died instantly with 'Filter not found' (exit 8),
the NUT output stream stayed empty, and playStream's node-av demux failed
with 'Failed to open input from Readable stream: Invalid data found when
processing input' — every screen share failed ~100ms after start.

Fix: pass customFfmpegFlags ['-filter:a','anull'] — ffmpeg applies the LAST
-filter:a for a stream, so the trailing no-op filter overrides the custom
chain (verified: command ends with '-filter:a anull', transcode runs, node-av
demux finds video+audio). Realtime volume control was already removed from
GMW (a690e5b), so dropping the filters is lossless.

Verified end-to-end with the failing URL (youtu.be/fONoh7Pc6VU, AV1+Opus
DASH): getDirectScreenInput → NUT merge → patched prepareStream → node-av
demux finds H264 video + Opus audio streams.
2026-08-11 10:56:49 +07:00
asepharyana c18431bdbf fix(ai-moderation): never cache vision outputs that claim 'no image seen'
Root cause (3rd layer after 50371bd + 4f4c435): a vision model run
(2026-08-10) returned 'Maaf, saya tidak melihat gambar apapun yang terlampir...'
and that text was cached as a VALID vision_llm result (image + phash keys,
24h/7d TTL). Every subsequent analysis of the same image (same hash/phash)
hit the poisoned cache, so image analysis looked broken forever even though
9router responded fine — the moderation LLM wrote 'lampiran yang gagal
terbaca' from a cache hit.

Also: mimo via 9router streams reasoning in delta.reasoning +
delta.reasoning_details[].text (content:"") — extractChunkText only read
delta.reasoning_content, so those runs aggregated empty → 'Vision API null
response' (observed 08:54/09:07/09:38).

Fixes:
- llmClient.extractChunkText: fall back to delta.reasoning and
  reasoning_details[].text (mimo), on top of reasoning_content (gemma).
- visionAnalyzer: isNoImageSeenText() detects 'no image' style outputs;
  such results are NEVER cached, and poisoned entries are purged when hit
  (LRU/DB/phash) so re-analysis actually re-runs vision.
- Tests: reasoning/reasoning_details extraction + isNoImageSeenText
  (Indonesian + English, no false positives on real descriptions).
2026-08-11 09:55:43 +07:00
asepharyana 4f4c43555f fix(ai-moderation): attachment-upload race dropped images before vision
Root cause (2nd layer after 50371bd): the analysis worker could pick up an
image message while its attachment upload was still in flight
(upload_status='pending'). downloadAndExtractFrame then fell back to the
Discord CDN URL (cdn.discordapp.com), which often 404s for old/purged links,
and 'if (!res.ok) return' silently dropped the image — no log, no vision
call, empty image map, and the LLM produced a text-only verdict like
'lampiran yang gagal terbaca oleh sistem'.

Fixes:
- ai-analysis-worker: skip targets whose attachment upload is still pending
  (both batch + individual paths) — they stay ai_status='pending' and the
  next 15s cycle analyzes them after the upload lands.
- mediaDownloader.downloadAndExtractFrame: try uploaded_url first, then
  discord_url as fallback; log non-OK responses (status + host) instead of
  silently returning; log when all candidate URLs fail.
2026-08-11 09:44:34 +07:00
asepharyana 50371bd2d1 fix(ai-moderation): read delta.reasoning_content in stream aggregation — image vision never returned text
Root cause: 9router combo 'multimodal' routes to cloudflare-ai/@cf/google/
gemma-4-26b-a4b-it which streams ALL output in delta.reasoning_content
(content:"") and finishes with 'length' at max_tokens. llmClient only read
delta.content, so llmVision returned empty → every image moderation fell back
to text-only analysis ('Meskipun analisis gambar gagal' in every ai_analysis).

Fix: extractChunkText() prefers delta.content then falls back to
delta.reasoning_content (also handles message/text/response fields), with
unit tests for the exact 9router chunk shape. Verified live against a real
DB image: oc/mimo-v2.5-free (new first model in the multimodal combo) returns
a proper description in delta.content.
2026-08-11 08:06:28 +07:00
asepharyana 0792ff4dc0 perf(nix): prune devDependencies from shipped node_modules
gateway output 1.4G -> 424M (-70%), backend 198M -> 60M (-70%).

- pruneProd: delete every .pnpm dir not in 'pnpm list --prod' graph
  (biome/typescript/esbuild/drizzle-kit/vitest/tsx ~150MB+) then drop
  dangling symlinks (top-level, scoped dirs, hoist, .bin) so stdenv
  noBrokenSymlinks fixup passes.
- NOT using 'pnpm install --prod': it collapses the public-hoist dir
  (.pnpm/node_modules) that peer resolution relies on for
  @lng2004/node-datachannel + @seydx/node-av-linux-x64 (voice breaks).
- node-datachannel: strip build/_deps (cmake FetchContent ~380MB) +
  nested node_modules (nw-gyp/typescript/puppeteer ~380MB) after
  compile; runtime needs only build/Release/node_datachannel.node.
- verified: native binaries (datachannel/opus/zeromq) intact, all 27
  runtime modules resolve, dev tools 0.
2026-08-10 22:17:38 +07:00
asepharyana eb89bb79ed ci(deploy): fix attic push fallbacks - VPS-hop sudo, direct push retry, ssh URL
- VPS-hop attic push now runs via sudo so attic reads root's config
  (~/.config/attic) which has the imrnes-ts server (Tailscale). Without
  it the push ran as the CI user whose config only has pub ->
  'Server imrnes-ts does not exist', silently skipping the cache upload.
- Direct push retried 3x (attic push is idempotent): a transient 502
  (e.g. atticd restart mid-push, Traefik blip) no longer aborts the
  whole closure upload before falling back to VPS-hop.
- Restore $VPS_USER in the 3 ssh:// nix copy fallbacks (was committed
  as masked '***' -> nix copy would ssh as user '***' and fail).
2026-08-10 21:18:25 +07:00
asepharyana 7d6c741bb2 ci(deploy): force narinfo write with --ignore-upstream-cache-filter; Traefik readTimeout=0 on imrnes 2026-08-10 20:18:56 +07:00
asepharyana 4cb4904517 ci(deploy): fix attic fast path - public cache, extra-substituters, self-hosted client bootstrap
Root causes found by reproducing the 2026-08-10 run:
- attic 'gmw' cache was created private -> every narinfo/nix-cache-info
  read returned 401, so the VPS could never actually substitute from
  attic ('Substituted from Attic cache' was a false positive when the
  store path happened to be already present locally).
- VPS nix.conf used extra-trusted-substituters, which Determinate Nix
  never merges for nix-store CLI clients; extra-substituters (all
  users, no trust gate) fixes substitution (verified end-to-end:
  delete path -> nix-store --realise pulls from attic over HTTPS).
- runner bootstrap of the attic client depended on nix copy --from
  ssh:// (fragile, failed on runner); now the prebuilt attic client
  closure lives in the attic cache itself and the runner pulls it over
  HTTPS via extra-substituters configured in the Install Nix step.

Also surfaces bootstrap stderr on fallback for future debugging.
2026-08-10 18:29:00 +07:00
asepharyana 4ee295bd29 ci(deploy): push to attic directly from runner, skip slow SSH closure copy
The old Push-to-Attic step SSH-copied the full closure (~794MB gateway) to
the VPS on every new store path before attic push — at ~500KB/s that took
25+ minutes (observed 40min+ in-flight run). The runner can now push
straight to the public attic endpoint (https://attic.asepharyana.my.id,
token auth validated) after pulling the prebuilt attic client closure
(52MB) from the VPS via nix copy --from. Falls back to the VPS-hop flow
whenever the direct path fails.
2026-08-10 17:28:28 +07:00
asepharyana 65c9c2cd9e feat(ai-moderation): enrich analysis context with recency, repetition, user history and channel topic
- <message> targets now carry time (ISO), repetitions (N identical short texts = spam signal), bot and edited flags; escape id/user XML
- rich <user_reputation>: total_infractions, clean_streak, last_offense_days_ago, repeat_offender (7-day window)
- <user_history> with last flagged messages for repeat offenders (wires dead getUserRecentInfractions)
- <user_profile as_of> staleness signal; <location_context topic> from captured channel topic
- prompt framing + output instructions teach the LLM to use the new signals without treating history as proof
- tests: contextEnrichment.test.ts (13) + topic cases in conversationContext.test.ts
2026-08-10 17:15:33 +07:00
asepharyana 0a5254bf20 feat(ai-moderation): enhance context handling with structured XML blocks and user profiles 2026-08-10 16:46:55 +07:00
asepharyana 4a51f3055c ci(deploy): push builds to attic binary cache (attic.asepharyana.my.id) 2026-08-10 15:27:59 +07:00
asepharyana 185d81f0e0 feat(ai-moderation): reset offensive nickname instead of deleting message
When the ONLY violation is offensive_username (message content clean):
- Message is NOT deleted (nickname-only violation bypasses auto-delete)
- Member's server nickname is reset to default username via
  setNickname(null) (Discord shows the global username again)
- Action 'reset_nickname' logged to moderation_actions; cooldown
  10min per guild:user (LRU) so repeated messages by same member
  don't hammer the Discord PATCH
- Config: AUTO_NICKNAME_RESET_ENABLED / AUTO_NICKNAME_RESET_COOLDOWN_MS
2026-08-10 11:48:27 +07:00
asepharyana ecbb538c9f feat(ai-moderation): use per-server nickname (displayName) in analysis payload
- resolveDisplayName(): member.displayName from captured metadata,
  falls back to global username
- Applied to context lines, target message blocks, and media message
  blocks — LLM sees the name the channel actually sees (nickname can
  carry moderation signal itself)
2026-08-10 11:36:37 +07:00
asepharyana 4049ab4201 feat(ai-moderation): rich context + link media vision analysis
- Conversation context recency gates (GAP_MS/MAX_AGE_MS): drop stale
  messages before silence gaps; cold_start anchor + flow descriptor
  tells LLM whether conversation is ongoing or restarted
- [location] block: channel name, thread name, nsfw/age flags from
  captured metadata (thread names instead of bare IDs)
- Link media -> multimodal: text-batch URL fetches that resolve to
  images now run vision analysis (bounded 15s) and switch prompt to
  mixed mode; <web_content> gains og:title for page context
- pnpm-workspace.yaml: approve sharp build script (unblocks install)
2026-08-10 11:26:26 +07:00
asepharyana 5d094829c4 fix(ui): fit select popup to content and wrap long items
A single long guild name was clipped (whitespace-nowrap + narrow min-width),
so the dropdown rendered as a tiny 144px box with truncated text. Size the
popup to fit-content up to the available width and let option text wrap.
2026-08-07 19:29:36 +07:00
asepharyana abbd78f42b fix(ui): theme popover/input tokens and open select below trigger
Select dropdowns (voice tab, guild selector) rendered with a transparent
background because --color-popover/--color-input were undefined, and the
popup overlapped the trigger due to alignItemWithTrigger. Define the missing
theme tokens (popover, popover-foreground, input, secondary) and default the
select popup to open below the trigger.
2026-08-07 18:18:14 +07:00
asepharyana 4f9d4a5c7d refactor: remove unused UI components and replace GlassCard with Card in voice components
- Deleted Item, Kbd, Marker, Message, NativeSelect, Questionnaire, Spinner components.
- Replaced GlassCard with Card in VoiceActivityTimeline, VoiceConnectionCard, ListenControl, MicControl, and SpeakerWaveform components.
- Introduced AppSidebar and ThemeToggle components for improved navigation and theme management.
2026-08-07 17:33:12 +07:00
asepharyana 2c995b41d7 feat: add new UI components including RadioGroup, Resizable, Sidebar, Spinner, Table, Toast, and ToggleGroup
- Implemented RadioGroup and RadioGroupItem for radio button functionality.
- Created ResizablePanelGroup, ResizablePanel, and ResizableHandle for resizable panels.
- Developed Sidebar component with context for state management and various subcomponents (SidebarTrigger, SidebarMenu, etc.).
- Added Spinner component for loading indicators.
- Introduced Table component with TableHeader, TableBody, TableFooter, and related subcomponents for structured data display.
- Built Toast component for notifications with customizable actions and icons.
- Implemented ToggleGroup and ToggleGroupItem for toggle button functionality with context support.
2026-08-07 16:11:33 +07:00
asepharyana 18dd6a56ba feat(media): loop mode + high-quality OggOpus music playback
- Loop: toggle via POST /api/media/loop → COMMAND_MEDIA_LOOP; gateway
  replays finished music track on natural end (queue untouched); status
  payload exposes loop flag; FE tombol Loop di music-player + mini-player.
- Kualitas suara: music playback sekarang di-transcode sekali via ffmpeg ke
  OggOpus 48kHz stereo 192kbps dengan volume di-bake ke encode — menghindari
  double lossy encode (inlineVolume) yang bikin suara buram. Screen share
  tetap pakai jalur lama.
- Backend: MediaState.loop, setLoop service, route + schema validation.
2026-08-07 14:53:37 +07:00
asepharyana a690e5b63e refactor(media): remove volume control from FE & BE
Volume sudah di-set default 0.3 di gateway (suara kecil saat play), dan
user bisa naikin sendiri di Discord (command media:volume) — jadi kontrol
volume lewat dashboard tak perlu. Hapus:
- BE: POST /api/media/volume route, mediaVolumeSchema, setVolume service
- FE: useMediaVolume hook, mediaApi.volume, slider volume di music-player
  + mini-player, field volume/setVolume di MediaPlayerProvider
Pertahankan COMMAND_MEDIA_VOLUME di gateway (masih dipakai command DC)
dan mic volume (terpisah, tetap di voice page).
2026-08-07 14:27:33 +07:00
asepharyana 62ffb676f9 feat(media): default music volume 30% instead of 100%
Volume play music terlalu besar buat user — default sekarang 0.3 (30%)
di semua layer: player gateway (musicVolume=0.3), backend state/schema
(default 0.3), dan UI slider (fallback 0.3). User tetap bisa naikin
manual via slider volume di dashboard.
2026-08-07 13:41:12 +07:00
asepharyana 4797aca20f ci(deploy): poll service readiness instead of fixed 3s sleep
is-active after sleep 3 false-fails when the unit is still activating
(e.g. Next standalone boot >3s) — exit 3 flagged the deploy red even though
the service came up fine. Poll is-active up to 30s and only fail if it never
reaches 'active'.
2026-08-07 11:22:29 +07:00
asepharyana 42b8afd412 fix(flake): purge dangling pnpm symlinks in standalone tree
noBrokenSymlinks fails frontend build: .next/standalone/node_modules/.pnpm/
node_modules/semver -> missing target. The standalone server never resolves
pnpm's hoisted .pnpm dir (it bundles its own node_modules) — delete broken
symlinks before install so stdenv check passes.
2026-08-07 10:57:18 +07:00
asepharyana 7575a701bd style(backend): biome format — organize imports + spacing (live-speaker/redis-bridge/voice.service) 2026-08-07 10:49:16 +07:00
asepharyana 9f91155944 ci(deploy): build+deploy frontend package (SSR standalone server)
Sebelumnya frontend hanya static export yang di-serve nginx di dalam package
proxy. Sekarang frontend = runtime mandiri (Next.js standalone :4017) yang
nginx proxikan ('/' -> Next server, '/api' + '/ws' -> backend :4001). Tambah
'frontend' ke matrix deploy agar dideploy + memulai unit gmw-frontend.
2026-08-07 10:46:02 +07:00
asepharyana f20889868d feat(frontend): rebuild as SSR with server-authoritative shared state
Rombak total alur data frontend: dari static-export CSR (tiap browser
fetch sendiri + akumulasi state voice per-tab) jadi server-side rendering.

Frontend (Next.js):
- next.config: output export -> standalone; halaman jadi server components
- server data layer baru src/lib/api/server.ts (GMW_BACKEND_URL, no window)
- dashboard/media/messages/moderation/recordings/voice page -> RSC yang
  fetch backend di render-time, seed ke client view (SWR fallbackData)
- hook-hook utama terima initialData -> first paint data server, revalidate
  SWR setelahnya, tanpa spinner-blank-load
- messages: guild/channel/tab/selected dibaca dari URL di server, page awal
  di-fetch server-side

Shared realtime state (voice) server-authoritative:
- backend src/modules/voice/live-speaker.ts: agregat voice_active_user dari
  gateway jadi snapshot authoritatif (single source of truth semua browser)
- GET /api/voice/status kini include activeSpeakers
- WS initial states kirim voice_state snapshot saat connect (late join
  langsung dapat state yang sama, bukan daftar kosong)
- useSpeakers seed dari server snapshot + voice_state full-replace +
  voice_active_user delta upsert

Deploy:
- flake.nix: frontend package build SSR standalone (server.js wrapper,
  GMW_FRONTEND_PORT=4017); proxy nginx template proxy / -> Next server,
  /api + /ws tetap ke backend :4001
2026-08-07 10:44:03 +07:00
asepharyana aa440eda69 fix(gateway): music/screen playback heads — read yt-dlp headers from stderr, drop no-simulate
Music playback produced no audio: with `-o -` yt-dlp streams media on
stdout and emits its `--print` title/duration headers on stderr, but
resolveMediaUrl read them from stdout — stripping two binary 'lines' off
the WebM container and corrupting the stream (player 'playing' but silent).
Now headers are read from stderr and the stdout media stream is returned
untouched.

Screenshare was failing with EACCES: getDirectScreenInput used --no-simulate,
making yt-dlp write .f*.part files into the read-only Nix store CWD. Dropped
it — simulate mode still returns requested_formats[].url in the JSON.

Adds tests/mediaResolve.test.ts (stderr-header + untouched-stream regression).
2026-08-06 21:26:50 +07:00
asepharyana f251e69f51 fix(backend): force-exit failsafe so shutdown never hangs
shutdown() awaited httpServer.close(), which waits for ALL open
connections. A lingering WS/keep-alive socket left the process zombie
forever after an uncaughtException (e.g. pg 'Connection terminated
unexpectedly' to imrnes) — no exit, so systemd Restart=always could
never revive it; /api/guilds returned 502 until manual restart.

Add 10s force-exit timer in shutdown(); clear it on clean completion.
2026-08-05 23:58:30 +07:00
asepharyana 9a2fa999bf fix(voice): proper shadcn select dropdowns + top reactors leaderboard
- ui/select: trigger default w-full h-9 (was w-fit h-8 — selects rendered
  tiny/misaligned); callers keep size override via className
- VoiceConnectionCard: labeled full-width h-10 selects (Server/Guild +
  Voice Channel), guild icon + name in options, channel type icon +
  'no akses' tag, empty states, htmlFor/id a11y wiring
- GuildSelector sidebar + messages channel filter bumped to match
- Backend GET /api/dashboard/reactors: top users by net reactions given
  (adds-removes) + messages_reacted + emojis_used
- Reactions tab: second 'Top reaktor' leaderboard panel
2026-08-05 11:29:56 +07:00
asepharyana f999be4fa0 feat(dashboard): add moderation log page + message edit history
- Backend moderation module: GET /api/moderation/stats (per-status + failed rate)
  + GET /api/moderation/actions (filter by status/actionType, cursor paging),
  joins messages for target username + content
- Message GET /api/messages/detail/:id now returns edit_count + edit_history
  (old_content snapshots from message_edits, newest first)
- FE: new /moderation page — summary cards (total/executed/failed/pending +
  failed-rate), status+type filter chips, timeline rows with action icon,
  target user, reason, status badge, timestamps, error text
- FE: message detail shows 'Riwayat edit' panel with previous versions
2026-08-05 11:11:10 +07:00
asepharyana a309570d29 feat(dashboard): expose trust reputation + reactions leaderboard
- GET /api/dashboard/reactions: top reacted messages (net add-remove),
  joined with message content, channel name, top 3 emoji breakdown
- Users tab: colored trust tier badge (Trusted/Netral/At Risk/Kritis)
  in list rows + detail panel (was plain number badge)
- Dashboard: new Reactions sub-tab with leaderboard
  (rank, emoji cluster, message, author, channel, count)
2026-08-05 10:20:12 +07:00
asepharyana 2f51f94610 fix(moderation): remove manual reanalyze triggers — auto-recovery only
Manual per-message and batch reanalyze buttons/endpoints let anyone
re-queue arbitrary messages for LLM analysis, burning AI credits on
spam. Removed:
- FE: Reanalyze buttons in message list, search panel, and messages page
- FE: useReanalyze/useReanalyzeBatch hooks + messagesApi methods
- BE: POST /api/messages/:id/reanalyze and /reanalyze-batch endpoints
- BE: markForReanalysis/reanalyzeErrorBatch service+repository methods

Recovery of failed messages is fully automatic: the discord-gateway
startPendingAIAnalysisWorker retries 'pending' (batch path) and
'error/analysis_incomplete' (individual path) messages on
AI_ANALYSIS_RECOVERY_INTERVAL_MS.
2026-08-04 15:37:56 +07:00
asepharyana 88484f12a9 ci: add Nix GC cleanup job on VPS after deploy 2026-08-04 13:57:44 +07:00
asepharyana a2542493cd fix(voice): screen share now carries audio — merge DASH video+audio into single NUT input
getDirectVideoUrl used yt-dlp --get-url with bestvideo+bestaudio, which
prints the video-only and audio-only URLs on SEPARATE lines. Only the
first (video-only) line was used, so ffmpeg had no audio track and the
GoLive stream had no sound.

Replace with getDirectScreenInput which:
- uses --dump-single-json to fetch both fresh URLs in ONE yt-dlp run
  (signature URLs expire quickly)
- returns the merged progressive URL directly when one exists
- otherwise merges the video-only + audio-only DASH URLs locally via a
  child ffmpeg into a single NUT stream consumed as a Readable
- tracks the merge ffmpeg process in cleanup() so shutdown kills it too

Verified end-to-end with real YouTube URLs: yt-dlp pair → live ffmpeg
merge (NUT) → H264+opus transcode yields both streams. Added
tests/screenShareInput.test.ts covering URL / DASH-pair / error paths.
2026-08-04 13:28:55 +07:00
aseph f84bf723c5 ci: use free GHA Nix cache (disable FlakeHub cache, not subscribed) 2026-08-03 16:44:06 +07:00
asepharyana 24db0f19b1 ci: enable FlakeHub Cache (id-token: write + use-flakehub) 2026-08-03 16:20:15 +07:00
asepharyana ce5db6aa3c fix(media): normalize activeMode in backend MediaState (FE relies on it)
Gateway publishes {playing, activeMode, musicVolume, current, queue} but the
backend MediaState interface + normalizeMediaState dropped activeMode, so the
FE's 'Screen share active' / 'Music playing' badge never rendered. Carry it
through so the FE↔BE media contract stays in sync.
2026-08-03 10:09:19 +07:00
asepharyana 44a0358b0c fix(voice): screen share restore is best-effort — Discord session teardown race
GoLive (dank074 Streamer) needs the single voice session; after the stream
ends, an automatic @discordjs/voice reconnect often races Discord's session
teardown and times out (AbortError). Restore is now best-effort with a 5s
delay; if it fails the FE shows disconnected and the user clicks Connect —
an accepted tradeoff for one-voice-session-per-user.
2026-08-03 09:33:29 +07:00
asepharyana d36c8777fe fix(voice): delay voice restore after screen share — avoid session teardown race
Immediate reconnect after Streamer.stop() races Discord's voice session
teardown → AbortError. Wait 4s so the old session is fully released before
re-joining with @discordjs/voice.
2026-08-03 09:21:10 +07:00
asepharyana 9fd4ded9c8 fix(voice): restore voice after screen share — pass pre-release status to callbacks
The restore callback previously read getVoiceStatus() AFTER disconnectGuild
had already cleared it, so it never knew which guild/channel to reconnect.
Now release/restore receive the status captured BEFORE the audio connection
is released, so reconnect actually happens after the GoLive stream ends.
2026-08-03 09:07:57 +07:00
asepharyana 55d28dc928 style(voice): biome format media handler + screen controller 2026-08-03 08:49:59 +07:00
asepharyana 02e2243a98 fix(voice): screen share releases audio connection so Streamer owns voice session
The dank074 Streamer creates its own WebRTC voice connection, but Discord
allows only ONE voice session per user. When VoiceController (audio) was
already connected, the Streamer join hung forever (never got
VOICE_SERVER_UPDATE). Now:

1. ScreenShareController takes releaseVoice/restoreVoice callbacks.
2. Before joining, it disconnects the @discordjs audio connection via
   VoiceController.disconnectGuild.
3. Streamer joins + streams GoLive.
4. After the stream ends, restoreVoice reconnects the audio connection so
   mic/listen keep working.
5. media.handler wires these via a new setVoiceController accessor from
   commandHandler; VoiceController is the single source of truth.

Also adds caller-bound timeouts & safe .catch() everywhere so a stream
failure can never become an unhandledRejection again.
2026-08-03 08:41:00 +07:00
asepharyana 8528f2c73d fix(voice): screen share join timeout — Streamer join hangs with dual voice conn 2026-08-03 08:19:05 +07:00
asepharyana 53f26185bc fix(voice): screen share crashed gateway — Streamer never joined voice + unhandledRejection
Root cause: ScreenShareController created @dank074 Streamer but never called
streamer.joinVoiceChannel() — playStream threw 'Bot is not connected to a
voice channel', and since the code only used .finally() (no .catch), the
rejection became an unhandledRejection that took down the whole gateway
(graceful shutdown triggered, systemd restarted).

Fixes:
1. Resolve active channel + streamer.joinVoiceChannel(channel) before
   prepareStream/playStream (dank074 needs its OWN WebRTC voice connection).
2. .catch() on the playStream done promise — log + kill ffmpeg instead of
   crashing the process.
3. .catch() on playback.done in media.handler too.
4. stop() now kills ffmpeg AND stops the streamer's voice connection.
2026-08-03 08:05:02 +07:00
asepharyana a57eeb2e22 fix(voice): media queue field mismatch, voice joinable filter, connect error toast
Audit voice (kirim/terima/music/screenshare) menemukan 3 masalah:
1. media:queue SILENT no-op — backend publish {source,mode} tapi gateway
   handler baca payload.url → selalu 'received without a URL'. Backend
   sekarang kirim {url,mode}, gateway terima url ATAU source (robust).
2. Voice connect gagal diam-diam saat user pilih channel tanpa permission
   (joinable=false, contoh Music 32/64/128/256k). Backend+gateway sekarang
   expose joinable; FE disable channel 'no akses' + empty state.
3. FE tidak kasih feedback saat connect gagal — tambah toast.error dengan
   pesan dari backend.

Verified live: @discordjs/voice connect ke Lofi Radio joinable sukses
(VOICE READY, DAVE session OK) — pipeline voice sebenarnya sehat, masalah
utama UX. media:queue fix akan di-verify setelah deploy.
2026-08-03 07:48:50 +07:00
asepharyana 9abb09dd33 feat(frontend): add light mode with runtime theme toggle
- globals.css: split theme tokens into :root (light default) + .dark
  overrides; switch @theme inline → @theme so utilities reference
  var(--color-*) and a runtime class swap actually re-skins the UI
  (inline inlines literal values and ignores .dark overrides)
- layout.tsx: drop the beforeInteractive inline theme script (it caused
  hydration instability); theme is applied client-side only
- top-nav: apply persisted theme on mount, default light, toggle
  updates <html> class + localStorage
- glass-intense: light variant (white card on light canvas); chart grid
  line uses var(--color-border); attachment chip uses bg-glass-bg
- Verified in static export: toggle dark↔light both directions,
  chatbot panel renders clean in both themes
2026-08-03 07:20:09 +07:00
asepharyana 831254bb71 fix(nix): filter build artifacts from frontend source
path: literals in flakes do NOT respect .gitignore, so a dirty local
out/ (stale chunks from previous builds, e.g. 3y39nidcm2n_s.js from
the removed quick-prompt button) leaked into the sandbox and got
served forever. Add filterSource helper that excludes out, .next,
node_modules, pnpm-lock.yaml from the frontend derivation source.
2026-08-03 06:53:46 +07:00
asepharyana 9718940258 fix(chatbot): FAB opens chat directly — remove extra toggle + UX polish
- chatbot-container: remove chatOpen layer — the minimized bubble now
  expands straight into the chat panel (single click, no extra button)
- Remove the redundant 'Tanya soal server...' quick-prompt button and
  the PanelLeft collapse toggle (one less state to fight)
- Bubble fixed h-[460px], chat panel flex-fills remaining space
- chat-panel: suggestion chips on empty state (suasana server, channel
  paling ramai, total pesan, pesan bermasalah) so first-time users can
  start with a single click
- Input: roomier padding, more descriptive placeholder, bigger send
  button, autoComplete off
- Drop chatOpen/setChatOpen from context (dead state)
2026-08-03 06:44:23 +07:00
asepharyana d1c1f3e4a7 feat(chatbot): per-user history via X-User-Id + agentic tools calling
Backend:
- New chatbot.tools.ts: 4 tools (get_server_stats, get_top_channels,
  get_recent_activity, get_top_flagged) with real DB executors
- chatbot.service: agentic loop — stream:true, parse SSE, execute
  tool_calls, feed results back, up to 4 rounds
- controller: resolve userId from X-User-Id header (no-login device
  uuid) with auth middleware precedence; history/clear scoped per user

Frontend:
- use-chatbot-user: mint UUID in localStorage, send as X-User-Id
- chatbotApi.send/getHistory/clearHistory accept userId header
- client.ts: apiRequest supports custom headers per call
- provider: history load + send + clear keyed to device user id
2026-08-03 06:24:19 +07:00
asepharyana 7513681b4b feat(frontend): remove ugly canvas placeholder from chatbot
- Delete chatbot-canvas.tsx (placeholder face canvas) + its export
- Remove canvas area from chatbot container; chat panel gets the freed
  space (248px → 300px) and bubble height drops 440px → 400px
2026-08-03 06:11:06 +07:00
asepharyana 2b815e156c feat(frontend): rebuild chatbot UI to match backend — wider panel, guild context, Indonesian
- Chatbot bubble: 220px → 320px wide, 440px tall; proper header with
  drag handle; quick-prompt row when chat is closed
- ChatPanel: show ALL history (not last 8), Indonesian placeholder/empty
  state/error copy (backend speaks Indonesian), timestamps (id-ID),
  clear-history button, typing indicator bubbles, Enter-to-send
- Send active guildId as context so backend answers reference the real
  server (serverInsights path in chatbot.service)
- GuildId sync from layout → ChatbotProvider via ChatbotGuildSync
- chatbotApi.send(message, guildId) → POST /api/chat {message, context}
  matching BE zod schema (guildId optional)
2026-08-03 06:04:25 +07:00
asepharyana 03d59f0738 feat(frontend): lazy-load images, add lightbox viewer, polish AI panel
- Add loading=lazy + decoding=async to all <img> (message card preview,
  attachments grid, image grid, avatars via ui/avatar)
- New Lightbox component: fullscreen image viewer with keyboard nav
  (←/→/Esc), counter, click-to-close; wired into messages page + detail
- Attachments grid: click image to open, image counter badge, grouped
  non-image attachments
- AI analysis panel: line-clamp-3 with Show more/less toggle
- Message card: preview image is now a clickable button opening detail
2026-08-03 05:08:08 +07:00
asepharyana 5cc0f8a243 docs: frontend README dev note 2026-08-02 16:46:22 +07:00
asepharyana 12c55ef486 docs: sync remaining md (AGENTS, ARCHITECTURE, READMEs to 4001/4009, Nix) 2026-08-02 16:42:57 +07:00
asepharyana 38c27eb5bb chore: ARCHITECTURE.md DB pool example 2026-08-02 16:23:01 +07:00
asepharyana bd044e95c3 chore: env.test.example and ARCHITECTURE pool 6432 2026-08-02 16:22:48 +07:00
asepharyana ec64a078bf chore: fix-missing-tables.sql imrnes IP 2026-08-02 16:22:30 +07:00
asepharyana 37defa5915 chore: Dockerfile.backend expose port 4001 2026-08-02 16:21:48 +07:00
asepharyana 39421c39cb chore: sync port references and docs to 4000s infra 2026-08-02 16:20:27 +07:00
asepharyana 1d27f67788 chore: update gmw-proxy nginx template ports to 4009/4001 2026-08-02 15:16:16 +07:00
asepharyana d1e6f3b47a chore: update ports to 4000-range (4000/4001) 2026-08-02 14:30:54 +07:00
asepharyana dbcf9d68f2 fix(media): publish status when a track ends naturally
The Redis media:status key was only rewritten after a command received via
Redis. When the last track ended naturally (AudioPlayer Idle -> advanceQueue
with an empty queue), currentTrackItem was cleared but the status key was not
persisted — so the backend's cached status and the frontend's 10s polling
stayed stuck showing the finished track as 'playing' forever.

Wire a media-status sink (commandHandler provides the real redisPub to
MediaHandler) and re-publish status after auto-advance, so natural track end
updates the UI.
2026-08-02 10:43:34 +07:00
asepharyana ef4281cd1f fix(voice): activity tab rendered a permanently empty chart
VoiceActivityTimeline was never given a data prop — the Activity tab always
showed an empty Recharts bar chart while the connection tab already had live
speaker state. Replace the dead chart with a live speaker/activity list fed
from the same WebSocket data, so the tab reflects real state instead of
misleading empty bars.
2026-08-02 10:38:02 +07:00
asepharyana 25f6609a9f fix(recordings): stop faking duration from file size + render edited content in search
The voice_recordings table has no duration column, but the backend aliased
duration_bytes = size_bytes (file size in bytes) and RecordingCard divided it
by 60 as if it were seconds — a 3MB MP3 rendered as a nonsensical '55924:3'
fake timestamp. Drop the fabricated field and show real file size instead.

Also render edited_content fallback in the analysis search results for
consistency with message cards/detail.
2026-08-02 10:32:58 +07:00
asepharyana 3f199aa70d fix(messages): merge partial WS updates + display edited content
message_updated broadcasts only {id, edited_content, edited_at} (+ reset
ai_* fields), but the frontend replaced the whole cached record, wiping
username/content/channel_id/created_at -> blank cards and the
'the channel_id of undefined' crash on /messages. Merge partials over the
existing record (list + detail), make list-patching channel-filter aware,
show edited content/badge, and fix the message_updated WS type.

Also broadcast type:'edited' + ai reset in message_updated so the live UI
matches the DB update.
2026-08-02 10:27:41 +07:00
asepharyana a82265f4a9 chore: remove outdated README.md file 2026-08-02 10:18:56 +07:00
asepharyana 78d514b73d feat(dashboard): message activity timeline + moderation donut
Backend:
- GET /api/dashboard/activity?days=1..90 — daily buckets (messages,
  flagged, active_users) + hourly distribution last 24h
- clamped days param, reuses existing indexes (idx_messages_created,
  ai_status_created)

Frontend:
- ActivityChart: area chart messages+flagged per day, 7/14/30d range
- HourlyActivityChart: 24h bars with peak highlight
- ModerationDonut: clean/flagged/warned/error breakdown with live
  summary line (server X% clean)
- Dashboard layout: activity 2/3 + donut 1/3, hourly + top channels

Verified: endpoint returns real data from prod DB (784/1897/8 msgs
per day), tsc clean backend+frontend.
2026-08-01 23:06:00 +07:00
asepharyana 7d2bd75f6c ci: exclude e2e.test.ts from CI unit run (needs live backend API_BASE) 2026-08-01 22:21:55 +07:00
asepharyana b3a2f2ec10 ci: add test+typecheck gate before deploy
Sebelumnya CI hanya build nix -> deploy tanpa verifikasi — placeholder
tests sempat rusak berbulan-bulan tanpa terdeteksi. Job 'test' baru:
- pnpm install + tsc --noEmit + vitest run untuk backend & discord-gateway
- biome check (errors fail, warnings pass)
- build-and-deploy now needs: test
2026-08-01 22:10:28 +07:00
asepharyana 6293d588bc chore(lint): biome cleanup across services — format, sort imports, drop unused
- discord-gateway: 74 lint errors -> 0 (format, import sorting, unused
  imports/vars, dead breath var)
- backend: format + sort imports (11 warnings left: noExplicitAny)
- frontend: remove unused imports, drop dead breathing var, fix
  useExhaustiveDependencies (scroll keyed on messages), a11y biome-ignore
  for drag surface + stopPropagation container (mouse-only gestures)
- remaining warnings are false positives: index keys on static lists,
  <img> in static export (next/image unsupported), noExplicitAny

tsc --noEmit clean on all 3 services; vitest green (60+36).
2026-08-01 22:09:02 +07:00
asepharyana 2357421841 fix(test): repair placeholder tests referencing deleted @bete/shared package
packages/shared dihapus (5802d02), modul pindah ke src/shared/. Update
import placeholder.test.ts (gateway + backend) ke path lokal + aktifkan
tests/ di backend vitest config dengan alias @. Sebelumnya vitest run
gateway selalu gagal; sekarang 60+36 tests pass.
2026-08-01 22:08:54 +07:00
asepharyana 308be9f05a fix(nix): restrict flake to x86_64-linux (nixpkgs 26.11 dropped darwin) 2026-08-01 18:03:41 +07:00
asepharyana a0b3f7e9b2 ci: publish flake to FlakeHub (rolling) 2026-08-01 17:58:19 +07:00
asepharyana 98064d1dd9 feat(fe): recordings — visible playing/loading/paused states
- recording-card: kartu aktif di-highlight (ring primary + glow pulse),
  badge 'Now Playing'/'Loading'/'Paused', tombol play berubah jadi Pause
  saat playing dan spinner saat loading, waveform equalizer beranimasi
  (animate-eq, delay per bar) saat playing / pulse saat loading.
- recording-player: jadi now-playing panel — tombol play/pause + spinner
  loading, progress bar + waktu (current/duration), status 'loading…',
  audio element pindah ke sini + event onPlay/onPause/onWaiting/onCanPlay/
  onPlaying/onError naik ke page.
- recordings/page: state isPlaying/isLoadingAudio + audioRef, togglePlay
  (klik card lain = ganti track, klik card sama = pause/resume).
- globals.css: keyframes eq-bounce + card-glow.

Verified: FE tsc0, next build 10/10 static pages.
2026-08-01 16:54:58 +07:00
asepharyana 0daee56213 ci: migrate CI to GitHub Actions (deploy nix + mirror ke Gitea backup)
Mirror to Gitea / mirror (push) Successful in 26s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Failing after 32m40s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Failing after 18m0s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Failing after 18m0s
2026-08-01 16:40:27 +07:00
asepharyana 762e78d6b6 feat(fe): play Discord voice live + fix recording play/download
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m42s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 4m11s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 11m4s
Voice page (connection tab):
- ListenControl baru: toggle Listen (Headphones) — mulai PcmPlayer dari
  user gesture, subscribe onPcm WS, volume slider, bar level per-user
  REAL dari PCM (bukan random).
- lib/audio/pcm-player.ts (baru): ScriptProcessorNode mixer — ring buffer
  2s per user (hash FNV-1a sama dengan gateway), upsampling 24k→48k
  linear, mix semua user ke mono, gain volume, cleanup ring diam 5s.
- useVoiceListen + hashUserId di hooks; auto-stop saat disconnect.

Recordings:
- recording-player: reset src+load+play() eksplisit (bukan autoPlay doang),
  tampilkan filename + error state 'playback failed' kalau file rusak.
- recording-card: tombol Download fetch blob (CORS tele open) → objectURL
  → force download dengan nama asli; fallback buka tab baru kalau fetch
  gagal; spinner saat mendownload.

Verified: FE tsc 0, next build 10/10 static pages.
2026-08-01 16:30:56 +07:00
asepharyana 6ce784471e fix(gateway): repair Ogg page CRCs + deliver recordings as MP3
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m44s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 4m13s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 11m33s
Root cause rekaman gak bisa dibuka: prism-media OggLogicalBitstream
dipanggil dengan crc:false (node-crc dihapus dari dependency tree) —
semua page Ogg punya checksum 0 — player strict (ffmpeg, iOS) tolak
dengan 'CRC mismatch / End of file'. Rekaman 13:11 terbukti CRC-invalid.

Fix:
1. recorder/oggCrc.ts (baru): recompute CRC-32 (RFC3533, poly 0x04c11db7,
   initial 0, MSB-first) tiap page OggS in-place — pure JS tanpa native dep.
2. segmentFinalizer.ts: panggil fixOggCrc sebelum upload/merge.
3. recorder/uploader.ts: transcode segment ke MP3 (libmp3lame 128k 48k
   stereo) sebelum upload tele — universal playback. filename+size DB
   di-update; source OGG tetap untuk transkripsi.
4. muxer.ts + recorder.ts: merged session file juga .mp3.

Verified: ffprobe baca segmen yang tadinya CRC mismatch, MP3 valid.
2026-08-01 16:05:51 +07:00
asepharyana 0ef2b715c4 fix(gateway): enable stream for all LLM calls — router always streams SSE
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m36s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 4m9s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 11m29s
Audit lanjutan: 6x 'LLM API request failed: Request was aborted' per jam.
Root cause: 9router/omniroute SELALU balas SSE (data: chunks) walau request
tanpa stream:true — SDK OpenAI non-stream menunggu FULL body sebelum parse,
jadi batch moderasi besar yang upstream-nya lambat kena timeout 30-60s dan
di-abort. llmClient sudah punya agregasi streaming (chunks → ChatCompletion).

Fix: stream:true di llmCaller (moderasi batch/individual), llmVision,
cultureLearner, userProfileLearner. Verified: SDK stream test 806ms vs
sebelumnya abort. Caller lain (recovery worker dll) lewat llmCaller sama.
2026-08-01 15:00:59 +07:00
asepharyana dfe689bdec fix(gateway): mediaAnalysis ffprobe path, fallback error-log, generic closer sanitize
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m47s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 4m11s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 11m36s
Audit log produksi (sejak deploy13:38) menemukan 3 isu:
1. mediaDownloader.ts spawn /usr/bin/ffprobe + /usr/bin/ffmpeg (path keras) —
   ENOENT di Nix karena binary cuma di ffmpeg-headless closure. Pakai
   PATH-resolved ('ffprobe'/'ffmpeg') seperti voice-recording module
   (ffmpegProcess.ts/transmitter.ts) — 5 media warning hilang.
2. individualFallbackProcessor log error 'Success' di level50 tiap fallback
   BERHASIL (logModerationError dengan new Error('Success')) — ganti
   logger.info dengan verdict yang sama; error log cuma untuk error asli.
3. moderationResponseParser: strip frasa penutup generik ('Tidak ada
   indikasi pelanggaran.') yang masih sering dikeluarkan LLM walau prompt
   melarang (277/1486 analisis mengandung frasa, termasuk hari ini).
   sanitizeGenericCleanCloser hanya mencocok frasa di AKHIR, teks substantif
   tetap utuh. Unit test: 6/6 pass.
2026-08-01 14:00:43 +07:00
asepharyana ada7a768f8 fix(gateway): bump 0011 voice_transcription journal when above applied max
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 3m15s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m23s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 10m59s
Migration 0011 (add voice_recordings.transcription) when=1781388000000
lebih kecil dari 0010 (1781390000000) yang sudah ter-apply — drizzle
skip diam-diam (folderMillis <= max(created_at)), kolom transcription
tidak pernah dibuat. Recording OGG sukses tapi INSERT voice_recordings
gagal 42703 di produksi.

Fix: when=1785600000000 (> max applied 1785551832190) + apply manual
ALTER TABLE + insert row __drizzle_migrations dengan hash file yang
sama (c368acb0...) supaya gateway restart berikutnya skip (idempotent).
2026-08-01 13:15:03 +07:00
asepharyana 493bca590d fix(infra): build native voice deps (opus, datachannel) in Nix closure
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 4m21s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 4m22s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 11m31s
pnpm 11 rebuild hanya jalanin script package yang di-approve via
pnpm-workspace.yaml (allowBuilds) DAN abort di kegagalan pertama.
Yaml harus tracked (flake source cuma ikut file git). Tapi pnpm rebuild
tetap gagal karena: (1) node-crc MSRV cargo:: error — dead dep, dihapus
dari deps+patch; (2) sharp install script gagal di sandbox — binary-nya
prebuilt @img, script cuma validasi — dikeluarkan dari approval list;
(3) node-datachannel prebuild CLI TypeError + cmake-js butuh cwd benar.

Fix: loop eksplisit di buildPhase gateway yang jalankan install script
tiap native dep (opus/node-datachannel/zeromq) dengan cwd package dir,
+ cmake (dontUseCmakeConfigure biar stdenv nggak auto-configure),
+ opensslDevEnv = symlinkJoin pkgsStatic.openssl.out (libcrypto.a —
CMakeLists set OPENSSL_USE_STATIC_LIBS=TRUE; pkgs.openssl default
output = bin tanpa lib) + openssl.dev (headers),
+ git (libdatachannel FetchContent clone dari GitHub).

Verifikasi: result store punya opus.node (compile source), node_datachannel.node
(compile source), node-av prebuilt, zeromq prebuilt; runtime smoke test:
PeerConnection instantiate+close OK, OpusEncoder encode OK, dank074
Streamer/prepareStream/playStream load OK.
2026-08-01 12:44:01 +07:00
asepharyana 823b484497 chore(gateway): pnpm 11 build-script approvals (allowBuilds) for native voice deps
pnpm 11.17 mengabaikan field pnpm.onlyBuiltDependencies di package.json.
Native deps voice (@discordjs/opus, @lng2004/node-datachannel, zeromq, dll)
tidak pernah kebangun di Nix store karena flake pnpmInstall pakai
--ignore-scripts dan pnpm rebuild tanpa approval. Hasil: receiver/rekaman/
GoLive diam-diam tanpa decoder/encoder native.

pnpm approve-builds --all menulis allowBuilds:true per package di
pnpm-workspace.yaml (harus tracked — flake source cuma ikut file git).
node-crc tetap gagal build (MSRV cargo:: check) tapi tidak pernah
di-import di source — harmless.
2026-08-01 11:47:01 +07:00
asepharyana 891c1305f0 feat(gateway): restore Discord GoLive screenshare (dulu pernah ada, hilang saat split microservices)
User: 'dulu sharescreen juga bisa'. Terbukti: commit d50ce86 (Mei 2026)
punya src/media/screenShareController.ts + vendor @dank074/discord-video-stream,
hilang saat rombak monolith -> microservices. Interface ScreenShareController
masih ada di mediaTypes.ts tapi implementasinya tidak.

Restore:
- dep @dank074/discord-video-stream@6.0.0 (npm, dibangun untuk
  discord.js-selfbot-v13 — cocok dengan stack gateway)
- mediaSource.getDirectVideoUrl (yt-dlp --get-url bestvideo+bestaudio)
- screenShareController.ts (BARU): Streamer(client) + prepareStream H264
  720p30 + playStream go-live; owner check via discordPlayer
- media.handler: mode:'screen' di media:queue -> screen path; status
  expose activeMode; stop matiin screen
- FE: tombol Screen di MusicPlayer + hook useMediaQueue({url, mode})

Verifikasi: gateway tsc PASS, FE tsc PASS, biome 0 error, next build PASS.
Nix build pending (dep native @lng2004/node-datachannel butuh pnpm rebuild).
2026-08-01 11:36:40 +07:00
asepharyana 189ab1c1f6 feat(fe): real mic capture for voice transmit (kirim suara)
Sebelumnya tombol Live/Muted cuma kirim voice:transmit:start/stop ke
gateway — TIDAK ADA audio yang dikirim (0 getUserMedia/AudioContext di
frontend). MicControl cuma toggle state kosong.

- lib/audio/mic-transmit.ts (BARU): getUserMedia → AudioContext 48kHz →
  AudioWorklet (downsample 24kHz mono s16le + volume + chunk 20ms) →
  frame 'PCM\0' + Int16LE → ws.sendBinary. Worklet inline via Blob URL
  (aman untuk static export).
- useMicTransmit(ws): aktif = start capture + voice:transmit:start;
  nonaktif = stop capture + voice:transmit:stop; setVolume untuk slider.
- voice page: volume slider sekarang beneran ngatur gain mic; disconnect
  ikut matiin mic.

Verifikasi: tsc PASS, biome 0 error, next build PASS. Test mic butuh
real device (headless browser tidak punya mic) — protokol: connect voice
→ Live → ngomong → orang di channel denger.
2026-08-01 11:26:18 +07:00
asepharyana 9d60f00934 fix(infra): add ffmpeg + yt-dlp to gmw-discord-gateway runtime
Root cause voice tidak berfungsi di produksi: ffmpeg & yt-dlp cuma ada
di devShell, bukan di package discord-gateway. Bukti dari log gateway:
'FFmpeg/avconv not found!' saat voice:transmit:start (mic -> Discord),
yang juga mematikan music playback (StreamType.Arbitrary butuh ffmpeg)
dan segment muxing rekaman.

- buildInputs: pkgs.ffmpeg-headless + pkgs.yt-dlp
- wrapper export PATH ke keduanya sebelum exec node
Verifikasi: nix build PASS; closure berisi ffmpeg-8.1.2 + yt-dlp-2026.07.04;
wrapper PATH mengarah ke keduanya; ffmpeg/yt-dlp jalan.
2026-08-01 11:22:20 +07:00
asepharyana 903e7c4aeb feat(fe): remove Settings page from dashboard
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 3m7s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m28s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 3m28s
Hapus halaman /settings + nav item Settings (TopNav/HiddenSidebar
via navItems). GlassDivider ikut dihapus (cuma dipakai settings).
useConfig tetap (dipakai guild-selector).

Verifikasi: tsc PASS, biome 0 error, next build PASS (10 halaman
static, settings hilang).
2026-08-01 11:13:47 +07:00
asepharyana 0771e62223 feat(fe): remove Live tab from dashboard
Hapus tab Live + komponennya (LiveStream, ModQueue) dari halaman
dashboard — tab Stats/Users/Channels tetap. useReview tetap dipakai
messages page (review tab), jadi hook tidak dihapus.

Verifikasi: tsc PASS, biome 0 error, next build PASS.
2026-08-01 11:10:36 +07:00
asepharyana 8e7ba67248 fix: message detail shows only its own images, not everyone's
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 2m47s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 3m12s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m12s
useMessageDetail fetch attachments per-channel -> detail view nampilin
10 image terbaru di channel itu (semua orang), bukan image pesan yang
diklik. Root cause: getAttachmentsByChannel cuma filter channel_id.

- backend: messageQuerySchema + messageId optional; repository
  tambah eq(message_id) pas messageId ada. Sudah ada index
  idx_attachments_message.
- frontend: getAttachments(channelId, limit, cursor, messageId);
  useMessageDetail pass id pesan.

Verifikasi: backend tsc PASS, frontend tsc PASS + next build PASS.
E2E local (tsx + env prod, port 3901): tanpa messageId -> 3 row
(termasuk image.png punya message lain); dengan messageId -> tepat
2 row milik pesan itu. psql juga konfirmasi channel-only 3 vs
message-filtered 2.
2026-08-01 10:59:54 +07:00
asepharyana 8a4024f619 fix(fe): crash 'reading charAt' on message avatar fallback
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 2m47s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 3m2s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m19s
msg.username bisa undefined saat pesan live masuk lewat WS
(message_updated Partial payload / capture tidak lengkap) ->
msg.username.charAt(0) TypeError, halaman /messages mati.

- message-card + search-panel: username?.charAt(0) ?? '?'
- created_at di-guard juga biar tidak render 'Invalid Date'
Verifikasi: tsc PASS, build PASS, biome 0 error. DB saat ini 0 row
null username (1176 total) — crash murni dari jalur WS live.
2026-08-01 10:37:21 +07:00
asepharyana ec89d64dbf fix(fe): crash 'reading channel_id' + recharts width/height warnings
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 2m14s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 2m53s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m15s
- useMessageDetail: guard attachments fetcher — revalidate bisa race
  detail load, detail.data undefined saat fetcher jalan ->
  TypeError 'Cannot read properties of undefined (reading channel_id)'
  yang bikin halaman /messages mati (Next error boundary). Sekarang
  fetcher balikin [] kalau channel_id belum ada; hapus non-null
  assertion. Diverifikasi: /messages sebelumnya crash, sekarang render
  dengan data asli (list, verdict, confidence, sticker, emoji).
- ResponsiveContainer (recharts 3.8): initialDimension -1 di render
  pertama -> warning 'width(-1) and height(-1)'. Pakai height numerik
  tetap (192/160/48) + minWidth/minHeight 0 -> calculatedHeight >0,
  warning hilang; width tetap responsif via ResizeObserver. Chart
  baru dirender setelah mount (useMounted) biar container punya ukuran.
  Diverifikasi console: 0 warning, 0 error di dashboard & voice.
2026-08-01 10:21:23 +07:00
asepharyana 6f41c22cb5 fix(db): migration 0013 rename mascot_chat_messages to chatbot_messages
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 2m17s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 2m59s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m7s
/api/chat/history 500 (relation "chatbot_messages" does not exist):
codebase di-rename mascot->chatbot (977a6f9) tapi tabel DB tidak
pernah di-migrate — backend SELECT dari chatbot_messages, DB masih
mascot_chat_messages dengan kolom mascot_response.

- Migration 0013 (idempotent): ALTER TABLE mascot_chat_messages RENAME
  TO chatbot_messages, RENAME COLUMN mascot_response -> bot_response,
  RENAME INDEX -> idx_chatbot_messages_user_created; journal when >
  max(created_at) di __drizzle_migrations (0012)
- Diterapkan live via psql; riwayat chat lama tetap ada
- Verifikasi: GET /api/chat/history 200 + data, POST /api/chat 200
  + tersimpan (total history 1 -> 2)
2026-08-01 09:38:27 +07:00
asepharyana 01c18b2060 refactor(fe): replace TanStack Query with SWR + UI/data cleanup
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 2m56s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 3m42s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m59s
Rombak data layer frontend:
- Hapus @tanstack/react-query (package.json, lockfile, provider di
  dashboard layout) — ganti SWR 2.4.2 + SWRConfig (revalidateOnFocus
  false, deduping 10s, no retry on 404)
- Semua hooks data ditulis ulang ke useSWR; useAction() helper baru
  pengganti useMutation dengan surface kompatibel (mutate/mutateAsync/
  isPending/error)
- useMessages + useMessagesHasMore share satu SWR key — probe cursor
  yang tadinya dobel fetch API sekarang deduped
- WS sync (messages/media/recordings) pindah dari queryClient ke
  SWR mutate dengan filter key + revalidate:false
- useMessageSearch() dipakai search-panel & search-overlay; search
  overlay backdrop div -> button (fix a11y lint)

Rapikan UI + isi data:
- Tab stats recordings: placeholder 'coming soon' diganti stat asli
  (total, ukuran, speaker unik, top speakers)
- Empty states konsisten via EmptyState (images/review/recordings),
  EmptyState terima className
- biome check --write: 0 error, 8 warning pre-existing
- Verifikasi: tsc --noEmit PASS, next build PASS (11 halaman static),
  API live dicek — semua endpoint dashboard/messages/guilds/config/
  voice/media/recordings/review balikin data
2026-08-01 09:19:39 +07:00
asepharyana 1f91f99de3 feat(automod): render sticker, role & user names in moderation views
QoL lanjutan dari fix60084b3: content pesan mentah masih nampilin
snowflake (<@&roleid>, <@userid>, <:emoji:id>) di log moderasi dan
prompt LLM. Sekarang dirender ke nama yang bisa dibaca:

- Gateway capture: metadata menyimpan mentionedRoles + mentionedUsers
  (id+name) dari message.mentions, disimpan ke metadata JSON
- renderDiscordMentions(): <@&id> -> @RoleName, <@id> -> @Username,
  <:name:id> -> :name:, fallback @role/@user — dipakai di
  conversationContext (konteks LLM) dan moderationBuilders
  (getAnalysisContent) sehingga LLM lihat nama role/user beneran,
  bukan placeholder generik
- Frontend renderMessageContent() (mirror gateway) dipasang di semua
  tempat nampilin content: message-card, message-detail(-view),
  search-overlay, search-panel, users/channels section, live-stream,
  mod-queue, review list; sticker-only message tetap [Sticker: name],
  pesan teks+sticker kini ikut nampilin nama sticker
- tsc --noEmit PASS di gateway & frontend; renderDiscordMentions
  diverifikasi manual (6 kasus: role/user/emoji/unknown/plain)
2026-08-01 08:56:10 +07:00
Developer 6df4f306dd refactor: remove unused text analysis module and integrate Qdrant enhancements
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 2m30s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 3m7s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m20s
- Deleted the text analysis prompt constants and helpers as they are no longer needed.
- Added batch search functionality for Qdrant to optimize vector searches.
- Implemented methods for deleting expired Qdrant points and invalidating cache based on content hash.
- Updated text batch processor to use new timeout configurations and modified content building for moderation prompts.
- Enhanced text cache store to support new Qdrant integration and improved cache invalidation logic.
- Introduced a new user reputation model with a more nuanced trust scoring system, including penalties and rewards for user behavior.
- Added unit tests for the new trust model to ensure correctness of penalty and trust gain calculations.
- Updated configuration schema to reflect new timeout settings and removed deprecated OpenAI moderation keys.
2026-07-31 23:09:00 +07:00
Developer fc475dfbb7 feat(automod): store semantic cache embeddings in Qdrant
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 3m7s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 2m21s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 2m33s
New qdrantClient.ts (zero-dep fetch REST): ensure collection with cosine
distance (auto-recreate on vector-size change), upsert point w/ verdict
payload, search w/ expires_at filter + score threshold.

textCacheStore: when QDRANT_URL set, embeddings are upserted to Qdrant
(primary) and searched there first; Postgres embedding column remains as
legacy fallback for pre-Qdrant rows. Config: QDRANT_URL/COLLECTION/API_KEY.
QDRANT_URL already in repo .env; added to VPS env + GATEWAY_ENV secret.
2026-07-31 21:30:43 +07:00
Developer dc119b5d5a chore(env): switch AI_LLM_BASE_URL to omniroute (imrnes:20128)
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 1m38s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 2m22s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 2m38s
9router → omniroute router. /api/v1 exposes OpenAI-compatible chat
(model 'text' → gemma-4-31b-it, verified) + embeddings (verified:
gemini-embedding-001/-2, nemotron-embed-vl-1b-v2:free, qwen3-embedding).
Runtime env already switched via /etc/gmw/discord-gateway.env +
GATEWAY_ENV secret; gateway restarted 21:10, embedding writes flowing.
2026-07-31 21:11:39 +07:00
Developer 2f298cfe22 fix(db): correct migration 0012 timestamp — drizzle skips when < last applied
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 1m36s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 2m30s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 2m33s
Drizzle 0.45 migrate() runs only migrations with folderMillis > the
latest created_at in __drizzle_migrations. Hand-written when was
1781580000000 (June 16) < stored 1781672400000 (June 17), so 0012 was
silently skipped and the embedding column never created. Bumped when to
now; migrator will apply it on next deploy.
2026-07-31 20:22:21 +07:00
Developer 7ab9a7fd2d fix(automod): force float encoding for embeddings — Nvidia models reject base64
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 1m35s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 2m24s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 2m51s
OpenAI SDK v6 defaults to encoding_format=base64; llama-nemotron-embed
(Nvidia-backed) returns 400 'do not support base64'. Semantic cache was
silently disabled in prod. encoding_format: 'float' fixes it.
2026-07-31 20:14:18 +07:00
Developer 67e432564d ci(deploy): declarative env — CI writes service env from Gitea secrets
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 1m34s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 2m22s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 2m37s
Previously /etc/gmw/*.env was managed by hand on the VPS; AI_LLM_* and
other vars drifted silently (AI_LLM_EMBEDDING_MODEL was missing until
added manually today). Now:
- BACKEND_ENV / GATEWAY_ENV secrets hold the full env block per service
- deploy.yml streams the secret to the VPS via stdin before the deploy
  (never through argv — no shell escaping issues, no secret exposure)
- env file chown'd gmw:gmw, chmod 600; empty secret = skip (no clobber)
- .env.example documents the declarative workflow

Update production env = edit the Gitea secret + push, never SSH by hand.
2026-07-31 20:08:46 +07:00
Developer f2b797e6ce refactor(deploy): remove max-parallel setting from job strategy
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 3m6s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 2m23s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 2m37s
2026-07-31 19:46:19 +07:00
Developer 8480407167 fix(automod): parenthesize ?? chain in autoDeleteNotify — Node runtime SyntaxError
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 3m4s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 2m22s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 2m31s
TS compiled this fine, but the JS spec forbids mixing || and ??
without explicit parens; Node threw 'Unexpected token ??' at startup,
crash-looping gmw-discord-gateway (restart counter 250). Wrap the
fallback chain in parens so the expression is valid.
2026-07-31 19:46:07 +07:00
Developer 1249ae81d8 perf(automod): compress prompts ~40% + semantic cache via AI_LLM_EMBEDDING_MODEL
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 3m4s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 2m29s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 2m33s
Prompt overhaul (token-frugal, same quality):
- rules.ts 28KB -> 10.3KB: every normative rule kept (safe lists, SARA
  6 kategori, LGBT/Israel zero tolerance, anti-evasion, decision tree,
  evasi hierarchy, image rules) with duplicated phrasing removed
- examples.ts 24.7KB -> 20KB: all 31 teaching examples kept; analysis
  strings shortened, redundant categories/policy_version dropped from
  example outputs (both optional in the response schema)
- output.ts 13.8KB -> 6.8KB: compressed schema + personality + format
  rules; CRITICAL bans on generic analysis and reply-context requirement
  retained
- system.ts: MEDIA_INSTRUCTIONS compressed, key rules kept

Semantic moderation cache (AI_LLM_EMBEDDING_MODEL):
- New embeddingClient.ts: OpenAI-compatible embeddings + cosine
  similarity; degrades gracefully when model/key unset
- textCacheStore: stores embedding JSON per verdict, findSimilarTextModeration
  reuses near-duplicate verdicts (min 0.97 cosine, processing locks skipped)
- moderationOrchestrator: after exact-hash miss, embed text-only targets
  and reuse stored verdict for near-duplicates -> skips expensive chat
  completion for spam variants; fresh verdicts written back with embedding
- Config: AI_LLM_EMBEDDING_MODEL / MIN_SIMILARITY (0.97) / MAX_CANDIDATES (30)
- Migration 0012: ADD COLUMN embedding to text_analysis_cache (idempotent)
- .env.example documents the new vars
2026-07-31 19:37:53 +07:00
Developer 60084b3cc3 fix(automod): flow real LLM analysis + descriptive fallback
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 3m2s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 2m25s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 2m40s
Root cause: ai-analysis-worker read llmResult.explanation and
llmResult.toxicityScore — fields the LLM pipeline never produces
(canonical AnalysisResult uses analysis/score). Every message fell back
to the bare template "Tidak ada indikasi pelanggaran." and the stored
score was always 0.

- Map analysis/score correctly; fallback now quotes the message content
- Prompt: ban generic analysis phrasing, require reply context
- LLM context: include replied-to message content (metadata.reference)
  so the model can explain what the user is replying to
- Frontend: show thread/channel names from metadata instead of raw IDs
  (message card, detail views, search overlay); detail panel now
  displays the ai_analysis text
- Auto-delete log/DM include the descriptive analysis as the reason
2026-07-31 19:11:13 +07:00
Developer 0bd4369ae9 refactor(automod): remove regex classifier — LLM is the sole judge
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 3m2s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 2m20s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 2m26s
Delete fastClassifier.ts (manual regex patterns for phone/email/IP/crypto/
spam/toxicity) and simpleFallback.ts. These hardcoded patterns were the
source of false positives (Discord emoji snowflakes matched phone_number,
URL digits matched phone, etc.) and produced heuristic verdicts whenever
the LLM failed.

New flow: Message → LLM (with conversation context, media evidence, user
reputation) → verdict. On LLM failure the message is marked 'error' and
retried by the recovery worker — no heuristic verdicts, ever.

Discord markdown tokens (custom emoji/mentions/timestamps) are normalized
to readable placeholders ([emoji:name], @user, @role, #channel, [time])
before reaching the LLM via discordTokens.ts.
2026-07-31 17:55:02 +07:00
Developer a2cda745f7 fix(automod): sanitize Discord tokens + boundary phone regex in Layer 1
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 3m2s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 2m21s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 2m29s
Custom emoji (<:name:id>), user/role/channel mentions and timestamps embed
long numeric snowflakes that tripped the phone_number / personal_info /
ip_address_sharing patterns — e.g. <:mambotongue:1463255254220148939> was
flagged as phone_number. Strip Discord markdown tokens before pattern
matching and require phone matches to not sit inside a longer digit run.
2026-07-31 17:25:00 +07:00
Developer 460857b4eb docs(fe): record backend/gateway data-flow contract in AGENTS.md
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 1m36s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 2m33s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 2m31s
2026-07-31 16:55:20 +07:00
Developer 69213ebd75 refactor(fe): align dashboard with backend & gateway data flow
- API/WS clients: same-origin by default, drop dead imphnen hardcode
- chatbot history: map BE rows {user_message,bot_response,created_at}
- message_deleted WS payload: object {id,deleted_at}, not bare string
- dashboard: wire top-channels chart + live mod queue from /api/review,
  add Users & Channels tabs consuming /api/dashboard/users|channels
- recordings: live WS sync via voice_recording_uploaded; duration_bytes optional
- remove dead widgets with no BE data source (trend chart, heatmap)
2026-07-31 16:54:41 +07:00
Developer 2addfb6492 fix(proxy): relative redirects so Traefik doesn't leak :8080 2026-07-31 16:54:41 +07:00
Developer ce899e9c56 refactor: remove outdated frontend and services specifications
- Deleted the frontend refactor design document to streamline project scope.
- Removed the services refactoring design document to eliminate redundancy.
- Eliminated the visual redesign document as part of the cleanup process.
- Purged the Discord Automod redesign document to focus on current objectives.
2026-07-31 16:34:23 +07:00
371 changed files with 29628 additions and 14350 deletions
+22 -10
View File
@@ -1,5 +1,16 @@
# Discord Bot Configuration
# =============================================================================
#
# PRODUCTION ENV IS DECLARATIVE:
# The runtime env files on the VPS (/etc/gmw/backend.env,
# /etc/gmw/discord-gateway.env) are WRITTEN BY CI from Gitea Actions secrets
# (BACKEND_ENV, GATEWAY_ENV) — see .gitea/workflows/deploy.yml.
# To change production env: update the secret in Gitea repo settings
# (Settings → Actions → Secrets), then push any commit to main. Never
# SSH into the VPS to edit env files by hand — CI will overwrite them.
#
# This file documents every variable; values for production live in the
# secrets, not here.
# === Discord ===
DISCORD_TOKEN=your_bot_token_here # REQUIRED
@@ -28,7 +39,7 @@ AUDIO_CHANNELS=2 # Number of audio channels (default: 2)
AVATAR_SIZE=64 # User avatar size in pixels (default: 64)
# === Webserver ===
WEBSERVER_PORT=3001 # Backend HTTP/WS server port (default: 3001)
WEBSERVER_PORT=4001 # Backend HTTP/WS server port (default: 4001)
# === Connection ===
VOICE_CONNECTION_TIMEOUT_MS=15000 # Voice connection timeout in ms (default: 15000)
@@ -44,7 +55,7 @@ VERBOSE=false # Enable verbose/debug logging (default:
# === Database (PostgreSQL) ===
# Option 1: Connection string (overrides individual params)
# DATABASE_URL=postgresql://user:password@localhost:5432/discord_bot
DATABASE_URL=postgresql://asephs:***@100.121.180.82:6432/dcbot
# Option 2: Individual connection parameters
POSTGRES_HOST=localhost # PostgreSQL host (default: localhost)
@@ -56,11 +67,11 @@ POSTGRES_POOL_MIN=2 # Minimum pool connections (default: 2)
POSTGRES_POOL_MAX=10 # Maximum pool connections (default: 10)
# === Redis ===
REDIS_URL=redis://localhost:6379 # Redis connection string (default: redis://localhost:6379)
REDIS_URL=redis://100.121.180.82:6379 # Redis connection string (default: redis://localhost:6379)
# === Voice PCM WebSocket (direct gateway→backend, bypasses Redis) ===
VOICE_PCM_WS_ENABLED=true # Use direct WS for PCM audio (default: true)
BACKEND_WS_URL=ws://backend:3000/ws # Backend WebSocket URL for gateway PCM streaming
BACKEND_WS_URL=ws://backend:4001/ws # Backend WebSocket URL for gateway PCM streaming
BACKEND_WS_TOKEN= # REQUIRED if VOICE_PCM_WS_ENABLED=true. Internal shared secret
# === Attachments ===
@@ -74,13 +85,19 @@ BACKLOG_SYNC_BATCH_SIZE=100 # Messages per backlog batch, max 100 (d
# === 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_BASE_URL=http://100.121.180.82:20128/api/v1 # LLM API base URL (omniroute on imrnes; /api/v1 exposes OpenAI-compatible chat+embeddings)
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_EMBEDDING_MODEL= # Embedding model for semantic moderation cache (optional; enables near-duplicate text reuse to save LLM calls)
# AI_LLM_EMBEDDING_MIN_SIMILARITY=0.97 # Min cosine similarity to reuse a cached verdict (default: 0.97)
QDRANT_URL=http://100.121.180.82:6333 # Qdrant vector store for embeddings (semantic cache); when set, vectors are stored/searched in Qdrant instead of Postgres
# QDRANT_COLLECTION=gmw_text_moderation # Qdrant collection name (default: gmw_text_moderation)
# QDRANT_API_KEY= # Qdrant API key (optional)
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)
AI_LLM_TEXT_ANALYSIS_TIMEOUT_MS=30000 # Timeout in ms for text-only analysis calls (default: 30000)
# === AI Analysis Tuning ===
AI_ANALYSIS_DEBOUNCE_MS=500 # Debounce window for batching messages in ms (default: 500)
@@ -94,11 +111,6 @@ AI_ANALYSIS_PROCESSING_TIMEOUT_MS=120000 # Conversation lock timeout in ms (defa
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)
# === 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)
# === 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)
+2 -2
View File
@@ -2,5 +2,5 @@ NODE_ENV=test
# Use a separate database/data area for tests. It may be on the same PostgreSQL host,
# but the database name must clearly be a test database so destructive test setup
# cannot touch production data.
TEST_DATABASE_URL=postgres://root:root@100.108.1.124:5432/hub_test
DATABASE_URL=postgres://root:root@100.108.1.124:5432/hub_test
TEST_DATABASE_URL=postgres://root:root@100.121.180.82:6432/hub_test
DATABASE_URL=postgres://root:root@100.121.180.82:6432/hub_test
-70
View File
@@ -1,70 +0,0 @@
name: Build & Deploy (Nix)
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 1
matrix:
service: [backend, discord-gateway, proxy]
steps:
- name: Check out repository
run: |
git clone https://git.imrnes.team/MythEclipse/GMW.git .
git checkout ${{ github.sha }}
- name: Build & Deploy ${{ matrix.service }}
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
run: |
set -eu
# --- Install Nix & Build ---
curl -fsSL https://install.determinate.systems/nix \
| sh -s -- install linux --no-confirm --init none 2>&1
mkdir -p /etc/nix
echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
SERVICE="${{ matrix.service }}"
echo "=== Building: $SERVICE ==="
nix build ".#$SERVICE" --impure --option sandbox false 2>&1
STORE_PATH=$(readlink result)
echo "=== Store path: $STORE_PATH"
# --- Deploy ---
NIX_BIN="/nix/var/nix/profiles/default/bin"
PROFILE="/nix/var/nix/profiles/gmw-$SERVICE"
key_file=$(mktemp /tmp/deploy-key.XXXXXX)
printf '%s\n' "$VPS_SSH_KEY" > "$key_file"
chmod 600 "$key_file"
export NIX_SSHOPTS="-i $key_file -o StrictHostKeyChecking=no"
nix copy --to "ssh://${VPS_USER}@${VPS_HOST}" "$STORE_PATH" 2>&1
ssh -i "$key_file" -o StrictHostKeyChecking=no \
"${VPS_USER}@${VPS_HOST}" "
if [ -d $PROFILE ] && [ ! -L $PROFILE ]; then
rm -rf $PROFILE
fi
export PATH=\$PATH:$NIX_BIN
nix-env --profile $PROFILE --set $STORE_PATH
systemctl daemon-reload
systemctl restart gmw-$SERVICE
sleep 3
systemctl status gmw-$SERVICE --no-pager 2>&1 | head -12
" 2>&1
+260
View File
@@ -0,0 +1,260 @@
name: Build & Deploy (Nix)
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: gmw-deploy
cancel-in-progress: false
permissions:
contents: read
id-token: write
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: false
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
run: corepack enable && corepack prepare pnpm@11 --activate
- name: Install deps (backend)
working-directory: services/backend
run: pnpm install --ignore-scripts --no-frozen-lockfile
- name: Typecheck + test (backend)
working-directory: services/backend
run: |
./node_modules/.bin/tsc --noEmit
# e2e.test.ts requires a live backend (API_BASE) — run unit tests only
./node_modules/.bin/vitest run --exclude "src/e2e.test.ts"
- name: Install deps (discord-gateway)
working-directory: services/discord-gateway
run: pnpm install --ignore-scripts --no-frozen-lockfile
- name: Typecheck + test (discord-gateway)
working-directory: services/discord-gateway
run: |
./node_modules/.bin/tsc --noEmit
./node_modules/.bin/vitest run
- name: Biome check (all services)
run: |
cd services/backend && ./node_modules/.bin/biome check src/ tests/
cd ../discord-gateway && ./node_modules/.bin/biome check src/
build-and-deploy:
needs: test
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
service: [backend, discord-gateway, proxy, frontend]
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: false
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@v22
with:
determinate: false
extra-conf: |
sandbox = false
accept-flake-config = true
# Attic binary cache as substituter on the runner: lets CI pull the
# prebuilt attic client (and any cached deps/builds) over HTTPS,
# no SSH round-trip needed. extra-substituters (NOT
# extra-trusted-substituters) is required — Determinate Nix never
# merges trusted-* substituters for nix-store CLI clients.
extra-substituters = https://attic.asepharyana.my.id/gmw
extra-trusted-public-keys = gmw:Fq2Anzuhkb+T/hftWnPcveHSi21/RzIgIOeG8pCJa88=
# NOTE: nix-installer-action unconditionally injects
# 'build-provenance-tags' into /etc/nix/nix.conf (a Determinate
# Nix-only setting). With determinate:false the runner's upstream
# nix warns 'unknown setting build-provenance-tags' on every
# invocation — benign, cosmetic. Switching determinate:true would
# silence it but changes the runner's nix flavor.
- name: Cache Nix
uses: DeterminateSystems/magic-nix-cache-action@v14
with:
use-flakehub: false
- name: Build ${{ matrix.service }}
id: build
run: |
nix build .#${{ matrix.service }} --impure --option sandbox false --print-build-logs
STORE_PATH=$(readlink result)
echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT"
echo "Build OK ${{ matrix.service }}: $STORE_PATH"
- name: Setup SSH key
env:
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
sed -i 's/\r$//' ~/.ssh/id_ed25519
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
# Push build result to Attic binary cache (attic.asepharyana.my.id) so
# the VPS can substitute it instead of a single-stream `nix copy ssh://`.
#
# Fast path: push DIRECTLY from the runner to the public attic endpoint
# (validated 2026-08-10: token auth over public HTTPS works without
# Tailscale). This skips the ~794MB closure SSH copy to the VPS that
# used to take 25+ minutes per new store path.
#
# The attic client is NOT in nixpkgs anymore and has no prebuilt
# releases, so we pull the same prebuilt closure the VPS uses
# (/nix/store/fygyy3yk4rqdknxkiwkqambpnhyax0k4-attic-0.1.0, ~52MB).
# The closure itself lives in the attic cache (pushed once from the
# VPS), so the runner bootstraps it over HTTPS via the configured
# extra-substituters — no SSH round-trip. If that fails we fall back
# to `nix copy --from ssh://`, then the old VPS-hop flow (SSH copy to
# VPS, then attic push from the VPS over Tailscale) so the deploy step
# always has a working closure path.
- name: Push to Attic cache
env:
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }}
run: |
if [ -z "$ATTIC_TOKEN" ]; then
echo "ATTIC_TOKEN not set; skipping attic push"
exit 0
fi
STORE_PATH="${{ steps.build.outputs.store-path }}"
ATTIC_DIR="/nix/store/fygyy3yk4rqdknxkiwkqambpnhyax0k4-attic-0.1.0"
ATTIC_BIN="$ATTIC_DIR/bin/attic"
attic_push_vps_hop() {
echo "Fallback: VPS-hop attic push"
# Copy closure to VPS (fast if attic already has it via substitute)
ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-store --realise '$STORE_PATH'" 2>/dev/null \
|| nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH"
# Push from VPS → Attic over Tailscale.
# --ignore-upstream-cache-filter is REQUIRED: without it, attic skips
# writing the narinfo to gmw when chunks exist in the upstream
# cache.nixos.org — leaving the path 404 on gmw so the VPS deploy's
# nix-store --realise can't find it and falls back to ssh copy.
# sudo: attic must read root's config (~/.config/attic), which has
# the imrnes-ts server → Tailscale. Non-root users' configs only
# have the public `pub` server → "Server imrnes-ts does not exist".
ssh "$VPS_USER@$VPS_HOST" "sudo $ATTIC_BIN push imrnes-ts:gmw '$STORE_PATH' --jobs 4 --ignore-upstream-cache-filter" \
|| echo "attic push failed (non-fatal; ssh copy fallback below)"
}
# ── Get an attic client on the runner ────────────────────────────
# Order: PATH → pull the prebuilt closure from the attic cache
# itself (extra-substituters configured in Install Nix step, HTTPS
# only, no SSH) → pull over ssh from the VPS → VPS-hop.
# The attic client closure is stored in the attic cache (pushed
# once from the VPS), so the fast path never depends on SSH.
ATTIC_BIN=""
if command -v attic >/dev/null 2>&1; then
ATTIC_BIN="$(command -v attic)"
elif nix-store --realise "$ATTIC_DIR" 2>/tmp/attic-bootstrap.err; then
echo "✅ Pulled attic client from attic cache (HTTPS substituter)"
ATTIC_BIN="$ATTIC_DIR/bin/attic"
elif nix copy --from "ssh://$VPS_USER@$VPS_HOST" "$ATTIC_DIR" 2>>/tmp/attic-bootstrap.err; then
echo "✅ Pulled attic client from VPS over ssh"
ATTIC_BIN="$ATTIC_DIR/bin/attic"
else
echo "attic client unavailable on runner; using VPS-hop flow"
echo "--- bootstrap errors (stderr) ---"
tail -5 /tmp/attic-bootstrap.err 2>/dev/null || true
attic_push_vps_hop
exit 0
fi
# ── Direct push: runner → attic public endpoint ──────────────────
# --ignore-upstream-cache-filter forces the narinfo write even when
# the path's chunks already exist in upstream cache.nixos.org (which
# attic would otherwise skip, leaving the path 404 on the gmw cache).
mkdir -p "$HOME/.config/attic"
cat > "$HOME/.config/attic/config.toml" <<EOF
default-server = "pub"
[servers.pub]
endpoint = "https://attic.asepharyana.my.id"
token = "$ATTIC_TOKEN"
EOF
# Retry the direct push — a transient 502 (e.g. atticd restart,
# Traefik blip) must not abort the whole closure upload. attic push
# is idempotent, so re-running only uploads what's still missing.
push_ok=""
for attempt in 1 2 3; do
if "$ATTIC_BIN" push pub:gmw "$STORE_PATH" --jobs 4 --ignore-upstream-cache-filter; then
echo "✅ Pushed $STORE_PATH to attic directly from runner"
push_ok=1
break
fi
echo "⚠️ Direct attic push attempt $attempt/3 failed; retrying in 10s..."
sleep 10
done
if [ -z "$push_ok" ]; then
echo "Direct attic push failed after 3 attempts; using VPS-hop flow"
attic_push_vps_hop
fi
# NOTE: env files /etc/gmw/backend.env & /etc/gmw/discord-gateway.env are
# managed MANUALLY on the VPS (source of truth). CI only builds & deploys.
- name: Deploy ${{ matrix.service }} to VPS
run: |
STORE_PATH="${{ steps.build.outputs.store-path }}"
echo "=== Copying ${{ matrix.service }}: $STORE_PATH ==="
if [ -n "${{ secrets.ATTIC_TOKEN }}" ] && ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-store --realise '$STORE_PATH'" 2>/dev/null; then
echo "Substituted ${{ matrix.service }} from Attic cache"
else
echo "Attic substitute failed; falling back to ssh copy"
nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH"
fi
echo "=== Updating profile ==="
ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-env --profile /nix/var/nix/profiles/gmw-${{ matrix.service }} --set '$STORE_PATH'"
echo "=== Restarting service ===\n"
ssh "$VPS_USER@$VPS_HOST" \
"sudo systemctl daemon-reload && sudo systemctl restart gmw-${{ matrix.service }} && for i in \$(seq 1 15); do state=\$(sudo systemctl is-active gmw-${{ matrix.service }} 2>/dev/null || echo inactive); [ \"\$state\" = \"active\" ] && break; sleep 2; done; echo \"final-state=\$state\"; [ \"\$state\" = \"active\" ]"
echo "✅ gmw-${{ matrix.service }} deployed"
cleanup:
# Bersihkan sampah Nix di VPS SETELAH semua deploy selesai: hapus generasi
# profile lama + nix store gc. Profil yang sedang dipakai tidak disentuh.
needs: build-and-deploy
if: always()
runs-on: ubuntu-latest
steps:
- name: Nix GC on VPS
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
ssh "$VPS_USER@$VPS_HOST" "sudo /usr/local/bin/nix-gc-vps.sh" || echo "⚠️ Nix GC gagal (non-fatal)"
@@ -0,0 +1,20 @@
name: Publish to FlakeHub
on:
push:
branches: [main, master]
workflow_dispatch:
jobs:
flakehub-publish:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: DeterminateSystems/determinate-nix-action@main
- uses: DeterminateSystems/flakehub-push@main
with:
visibility: public
rolling: true
+26
View File
@@ -0,0 +1,26 @@
name: Mirror to Gitea
on:
push:
branches: [main, master]
workflow_dispatch:
permissions:
contents: write
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Mirror to Gitea
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
git remote add gitea "https://oauth2:${GITEA_TOKEN}@git.imrnes.team/MythEclipse/GMW.git"
git push --mirror gitea
echo "✅ Mirrored to Gitea (MythEclipse/GMW)"
+1 -1
View File
@@ -12,7 +12,7 @@ worktrees/
.worktrees/
services/frontend/frontend/dist/
target/
nix/
# Gitea CI runner logs
.gitea/workflows/*.log
-695
View File
@@ -1,695 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**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 **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 |
| `frontend` | `services/frontend` | Next.js 16 (React 19) static dashboard, Tailwind v4, shadcn/ui |
| `@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
```
Discord
|
v
discord-gateway ---- Redis ---- backend ---- WebSocket ---- frontend
| pub/sub (broadcast) (Next.js static)
| |
| |
<------------------+
(command channel)
```
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`).
2. **backend** subscribes to Redis channels, broadcasts events to WebSocket clients, and serves REST API endpoints.
3. **frontend** connects via WebSocket and HTTP to the backend, provides a dashboard for live monitoring (text, voice, media) and AI moderation oversight.
4. **Command flow (reverse):** Frontend -> Backend HTTP/WS -> Redis (`backend:command`) -> discord-gateway (command handler) - for actions like connect voice, play media, moderate message.
### Data Flow
```
Message Capture:
Discord -> messageCapture.ts -> messageStore.ts (PostgreSQL)
|
+> eventBroadcaster -> Redis -> backend -> WS clients
Voice Recording:
Discord -> voiceController.ts -> recorder.ts -> OGG files on disk
| |
+> eventBroadcaster +> decoder.ts -> PCM -> Redis -> WS clients
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 (all public):**
- `GET /api/health` — Health check with optional `?verbose=true`
- `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` — AI chatbot 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
- `messages/` — Message + attachment CRUD, review, reanalyze
- `voice/` — Voice connection, guilds, channels
- `media/` — Music/screenshare player control
- `analysis/` — Full-text search across analyzed messages
- `chatbot/` — AI chatbot with server context
- `recordings/` — Voice recording listing
- `ui-state/` — Persistent UI state for dashboard
- `config/` — App config endpoint
**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: "<base64 PCM>" }` — 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`)
Next.js 16 (React 19) static export dashboard, built with TypeScript + Tailwind v4 + shadcn/ui + base-ui.
**Tech stack:**
- Next.js 16 (App Router, static export)
- React 19 with React Compiler
- TypeScript strict
- Tailwind v4 + shadcn/ui + base-ui components
- lucide-react icons
**Feature structure:**
- `src/app/` — App Router pages (login, dashboard with tabs)
- `src/features/` — Feature components (dashboard, messages, live, chatbot)
- `src/lib/` — Shared utilities (types, API client, WebSocket, hooks)
- `src/components/` — Shared UI components (layout, ui)
### 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 all dependencies
pnpm install
# 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 via next dev (port 3000)
# Build
pnpm run build:backend
pnpm run build:discord-gateway
pnpm run build:web # next build (static export)
# Type checking
pnpm run typecheck # Node services (pnpm -r)
pnpm run typecheck:web # Frontend typecheck (next build)
# Lint (Biome)
pnpm run lint
# Format (Biome)
pnpm run format
# Run tests across all packages
pnpm run test
# 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
# Deploy to VPS (build + hot-patch running containers)
./deploy.sh # Build + deploy all services
./deploy.sh --frontend # Frontend (Next.js) only
./deploy.sh --backend # Backend TypeScript only
./deploy.sh --no-build # Skip build, just copy files
```
## Configuration
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`
### 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)
### 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)
- `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**. Currently minimal test coverage. Test directories should be created per service:
```
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 strict rules
- **Language**: TypeScript with strict mode
- **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 guild matches MONITOR_GUILD_ID
3. Extract message metadata (user, channel, content, timestamp)
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'`
### AI Moderation Pipeline
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
### Voice Recording Lifecycle
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
### 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: "<base64 PCM>" }` for mic-to-Discord
- JSON `{ type: "voice_command", command: "..." }` for voice control
### Graceful Shutdown
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
### Public API
All backend endpoints are publicly accessible — no authentication required.
## Recording Structure
```
recordings/
+-- <user-id>/
| +-- <user-id>-<session-start>-0.ogg
| +-- <user-id>-<session-start>-0.json
| +-- <user-id>-<session-start>-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 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. Create route handler in `services/backend/src/modules/<module>/<name>.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. 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=<text>` 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/<user-id>/`
- Check `voice_recordings` table for upload status
## CodeGraph Usage (Required)
- Use CodeGraph first for repo-level questions: architecture, dependencies, references, callers/callees, impact, flow, routes, components.
- If graph is missing or stale, run scan first to refresh `.codegraph/graph.json`.
- Prefer graph-backed flow:
1. scan-codegraph (build/refresh graph)
2. query-codegraph (find definitions/references/callers/dependencies)
3. analyze-codegraph (architecture, impact, risk, cycles, orphans, hotspots)
4. export-codegraph (json/mermaid/dot/markdown/html when needed)
5. open-codegraph-ui (interactive visualization when requested)
- Avoid broad grep/find or repeated wide file reads before graph lookup, except for exact literal search or known single-file edits.
-118
View File
@@ -1,118 +0,0 @@
# Bete — Discord Moderation Dashboard
Bot monitoring Discord yang merekam voice channel, menangkap pesan teks, menyimpan attachment, menjalankan analisis AI opsional, dan menyediakan dashboard web real-time.
**Stack utama:** Node.js (Express 5), pnpm, TypeScript, React 19 (Next.js 16), Tailwind v4, shadcn/ui, Drizzle ORM, PostgreSQL, WebSocket, Redis pub/sub.
## Prasyarat
- Node.js 22+
- pnpm 11.x
- FFmpeg di `PATH` (untuk audio muxing dan playback media)
- `yt-dlp` di `PATH` (untuk resolve audio YouTube/Spotify)
- Bun (untuk frontend dev — opsional, bisa pake pnpm)
- PostgreSQL 15+
## Setup
```bash
pnpm install
cp .env.example .env
# Edit .env sesuai konfigurasi server
```
## Menjalankan
```bash
# Backend (port 3001)
pnpm run dev:backend
# Discord Gateway (capture messages, voice, dll)
pnpm run dev:discord-gateway
# Frontend (port 3000)
pnpm run dev:web
```
## Build
```bash
pnpm run build:backend
pnpm run build:discord-gateway
pnpm run build:web # next build — static export ke out/
pnpm run build # build semua service
```
## Deploy
```bash
./deploy.sh # Build + deploy semua service ke VPS
./deploy.sh --frontend # Frontend only
./deploy.sh --backend # Backend only
./deploy.sh --no-build # Skip build, copy files aja
```
## Service Architecture
```
Discord
|
v
discord-gateway ←→ Redis ←→ backend (Express 5) ←→ frontend (Next.js)
| pub/sub | |
| +— REST API (/api/*) |
| +— WebSocket (/ws) |
+— message capture +— AI moderation |
+— voice recording +— dashboard data +— dashboard UI
+— attachment upload +— real-time updates
```
## Fitur
- **Message capture**: Capture pesan baru, edit, dan delete dari Discord
- **Voice recording**: Rekam voice channel ke segmen OGG per user, streaming PCM real-time ke WebSocket
- **Attachment upload**: Download + upload attachment ke external storage
- **AI moderation**: Analisis pesan opsional via LLM, auto-delete, queue management
- **Dashboard**: Messages feed, AI analysis review, voice connection, music player, recordings, user/channel stats
- **Media playback**: Playback dari URL, file lokal, YouTube, Spotify
- **WebSocket**: Real-time event streaming untuk semua aktivitas
- **Public API**: Semua endpoint REST dan WebSocket dapat diakses tanpa autentikasi
## Struktur Proyek
```
services/
├── backend/ # Express 5 REST API + WebSocket server
│ ├── src/modules/ # Feature modules (messages, voice, media, dll)
│ └── src/http/ # Express app setup, middleware
├── discord-gateway/ # Discord client, voice recording, AI analysis
│ ├── src/modules/ # message-capture, voice-recording, ai-moderation
│ └── src/shared/ # Config, database, Discord client
└── frontend/ # Next.js 16 dashboard (static export)
├── src/app/ # Pages (login, dashboard tabs)
├── src/features/ # Feature components (dashboard, live, messages)
└── src/lib/ # API client, WebSocket, types
packages/
└── shared/ # Shared types, errors, logger, utilities
```
## Database
PostgreSQL via Drizzle ORM. Migrasi:
```bash
pnpm run db:generate # Generate migration
pnpm run db:migrate # Apply migration
pnpm run db:studio # Drizzle Studio
```
## WebSocket Events
Backend broadcast event berikut ke frontend via WebSocket:
- `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`
- `media_state`
- `reaction_*`, `thread_*`, `presence_updated`, `guild_member_*`
@@ -1,542 +0,0 @@
# CI/CD Overhaul Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Migrate from hybrid CI/CD (GitHub Actions + GitLab CI + hot-deploy) to single Gitea CI pipeline with container registry — VPS pulls only.
**Architecture:** Three Docker images (backend, discord-gateway, proxy) built in Gitea CI, pushed to `git.imrnes.team/MythEclipse/GMW/*`, VPS pulls and restarts via SSH. No more hot-deploy bind-mounts.
**Tech Stack:** Gitea CI (Act Runner, GitHub Actions-compatible syntax), Docker Buildx, Gitea Container Registry, appleboy/ssh-action
## Global Constraints
- Docker images must be self-contained (no bind-mount overlay at runtime)
- All three images must be built from monorepo root using `infra/docker/Dockerfile.*`
- Frontend static export built inside proxy Dockerfile (multi-stage, Next.js → Nginx)
- Gitea CI variables: GITEA_REGISTRY_TOKEN (secret), VPS_HOST (secret), VPS_USER (secret), VPS_SSH_KEY (secret), ENV_FILE (secret), GITEA_REGISTRY (variable)
- Registry URL: `git.imrnes.team/MythEclipse/GMW/`
- Work on `main` branch only
- Must preserve voice recordings volume persistence across container restarts
---
### Task 1: Create Gitea CI workflow
**Files:**
- Create: `.gitea/workflows/deploy.yml`
**Interfaces:**
- Consumes: Dockerfiles at `infra/docker/Dockerfile.{backend,discord-gateway,proxy}`
- Produces: Docker images pushed to `git.imrnes.team/MythEclipse/GMW/bete-*:latest` and `:{sha}`
- Depends on: Task 2 (proxy Dockerfile), Task 3 (backend Dockerfile) — but workflow can reference files that are being written in the same commit
- [ ] **Step 1: Create `.gitea/workflows/` directory and `deploy.yml`**
```bash
mkdir -p .gitea/workflows
```
- [ ] **Step 2: Write the workflow file**
Create `.gitea/workflows/deploy.yml`:
```yaml
name: Build & Deploy
run-name: "Build & Deploy ${{ gitea.sha }}"
on:
push:
branches: [main]
jobs:
build-and-push:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 2
matrix:
service: [backend, discord-gateway, proxy]
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to Gitea Registry
uses: docker/login-action@v4
with:
registry: ${{ vars.GITEA_REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.GITEA_REGISTRY_TOKEN }}
- name: Build & Push ${{ matrix.service }}
uses: docker/build-push-action@v7
with:
context: .
file: infra/docker/Dockerfile.${{ matrix.service }}
push: true
tags: |
${{ vars.GITEA_REGISTRY }}/MythEclipse/GMW/bete-${{ matrix.service }}:${{ gitea.sha }}
${{ vars.GITEA_REGISTRY }}/MythEclipse/GMW/bete-${{ matrix.service }}:latest
cache-from: type=gha,scope=bete-${{ matrix.service }}
cache-to: type=gha,mode=max,scope=bete-${{ matrix.service }}
build-args: |
VITE_BE_API_URL=https://imphnen.asepharyana.my.id
VITE_BE_WS_URL=wss://imphnen.asepharyana.my.id
deploy:
needs: build-and-push
runs-on: ubuntu-latest
if: gitea.ref == 'refs/heads/main'
steps:
- name: Deploy to VPS
uses: appleboy/ssh-action@v1.2.5
env:
ENV_FILE: ${{ secrets.ENV_FILE }}
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
envs: ENV_FILE
script: |
set -eu
APP_DIR=/opt/imphenbot
cd "$APP_DIR/infra/docker"
printf '%s\n' "$ENV_FILE" | tr -d '\r' > .env
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f
```
Note: Gitea's Act Runner supports `gitea.*` context variables (`gitea.sha`, `gitea.actor`, `gitea.ref`). If `gitea.*` vars don't resolve, fall back to `github.*` equivalents (Act Runner emulates GitHub context).
- [ ] **Step 3: Commit**
```bash
git add .gitea/workflows/deploy.yml
git commit -m "ci: add Gitea CI workflow for build & deploy
Gitea CI builds three Docker images (backend, discord-gateway, proxy),
pushes to Gitea Container Registry, then deploys to VPS via SSH pull.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: Rewrite Dockerfile.proxy for Next.js static export
**Files:**
- Rewrite: `infra/docker/Dockerfile.proxy`
**Interfaces:**
- Consumes: `services/frontend/` (Next.js app), `packages/shared/` (workspace dep), `infra/docker/nginx/nginx.conf`
- Produces: Nginx image serving Next.js static export at `/usr/share/nginx/html/`
- [ ] **Step 1: Rewrite Dockerfile.proxy**
Replace entire content with:
```dockerfile
# ---- Stage 1: Build Next.js static export ----
FROM node:22-slim AS frontend-builder
WORKDIR /app
# Install pnpm
RUN corepack enable
# Install build essentials for native deps
RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \
python3 make g++ && rm -rf /var/lib/apt/lists/*
# Copy dependency manifests first for layer caching
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/shared/package.json ./packages/shared/package.json
COPY services/frontend/package.json ./services/frontend/package.json
COPY services/frontend/tsconfig.json ./services/frontend/tsconfig.json
# Install dependencies (frontend + shared)
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --filter './packages/shared' --filter './services/frontend'
# Copy source code
COPY packages/shared/ ./packages/shared/
COPY services/frontend/ ./services/frontend/
# Pass API/WS URLs as build args for the frontend
ARG VITE_BE_API_URL
ARG VITE_BE_WS_URL
ENV VITE_BE_API_URL=${VITE_BE_API_URL}
ENV VITE_BE_WS_URL=${VITE_BE_WS_URL}
# Build shared lib first, then frontend static export
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm --filter './packages/shared' run build
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm --filter frontend run build
# ---- Stage 2: Nginx ----
FROM nginx:alpine
# Nginx config (API/WS proxy + static file serving)
COPY infra/docker/nginx/nginx.conf /etc/nginx/conf.d/default.conf
# Static export from frontend builder
COPY --from=frontend-builder /app/services/frontend/out/ /usr/share/nginx/html/
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:80/ || exit 1
CMD ["nginx", "-g", "daemon off;"]
```
- [ ] **Step 2: Validate nginx.conf handles static files correctly**
Read and confirm `infra/docker/nginx/nginx.conf`.
```bash
cat infra/docker/nginx/nginx.conf
```
Verify it has:
- Static file location with `try_files $uri /index.html` (SPA fallback)
- `/api` and `/ws` proxied to `http://backend:3000`
- [ ] **Step 3: Commit**
```bash
git add infra/docker/Dockerfile.proxy
git commit -m "docker(proxy): rewrite for Next.js static export
Replaced stale Rust WASM build with multi-stage Docker build:
stage 1 builds Next.js static export, stage 2 serves via Nginx.
Includes VITE_BE_API_URL/VITE_BE_WS_URL build args.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: Add build args to Dockerfile.backend
**Files:**
- Modify: `infra/docker/Dockerfile.backend`
- [ ] **Step 1: Add VITE build args to Dockerfile.backend**
Insert after `WORKDIR /app`:
```dockerfile
# Build args for frontend API URLs (passed through for future use)
ARG VITE_BE_API_URL
ARG VITE_BE_WS_URL
ENV VITE_BE_API_URL=${VITE_BE_API_URL}
ENV VITE_BE_WS_URL=${VITE_BE_WS_URL}
```
Note: These are consumed by the proxy Dockerfile (Task 2), not needed by backend itself but passed through the CI workflow to all three images for consistency.
- [ ] **Step 2: Commit**
```bash
git add infra/docker/Dockerfile.backend
git commit -m "docker(backend): add VITE_BE_API_URL and VITE_BE_WS_URL build args
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: Rewrite docker-compose.yml for Gitea registry + no bind-mounts
**Files:**
- Rewrite: `infra/docker/docker-compose.yml`
- [ ] **Step 1: Write new docker-compose.yml**
Replace entire content:
```yaml
version: '3.8'
services:
proxy:
image: ${GITEA_REGISTRY}/MythEclipse/GMW/bete-proxy:${IMAGE_TAG:-latest}
container_name: imphenbot-proxy
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.imphenbot.rule=Host(`imphnen.asepharyana.my.id`)"
- "traefik.http.routers.imphenbot.entrypoints=websecure"
- "traefik.http.routers.imphenbot.tls=true"
- "traefik.http.services.imphenbot.loadbalancer.server.port=80"
depends_on:
- backend
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]
interval: 30s
timeout: 5s
retries: 3
deploy:
resources:
limits:
memory: 64M
networks:
- app-shared-net
backend:
image: ${GITEA_REGISTRY}/MythEclipse/GMW/bete-backend:${IMAGE_TAG:-latest}
container_name: imphenbot-backend
restart: unless-stopped
env_file:
- .env
environment:
NODE_ENV: production
WEBSERVER_PORT: 3000
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
start_period: 15s
retries: 3
deploy:
resources:
limits:
memory: 256M
networks:
- app-shared-net
discord-gateway:
image: ${GITEA_REGISTRY}/MythEclipse/GMW/bete-discord-gateway:${IMAGE_TAG:-latest}
container_name: imphenbot-discord-gateway
restart: unless-stopped
env_file:
- .env
environment:
NODE_ENV: production
volumes:
- recordings:/app/recordings
healthcheck:
test: ["CMD-SHELL", "kill -0 1 || exit 1"]
interval: 30s
timeout: 5s
start_period: 30s
retries: 3
deploy:
resources:
limits:
memory: 512M
networks:
- app-shared-net
volumes:
recordings:
networks:
app-shared-net:
name: app-shared-net
external: true
```
Key changes:
- Image refs: `registry.gitlab.com/...``${GITEA_REGISTRY}/MythEclipse/GMW/...`
- Removed all bind-mount volumes: `./backend-dist`, `./gateway-dist`, `./frontend-dist`, `./shared-dist`
- Changed `./recordings` bind-mount → named volume `recordings:` (persists across restarts)
- Added `depends_on: backend` to proxy (proxy needs backend for API/WS, though Nginx handles startup gracefully)
- [ ] **Step 2: Commit**
```bash
git add infra/docker/docker-compose.yml
git commit -m "docker(compose): switch to Gitea registry, remove bind-mounts
Images now come from git.imrnes.team/MythEclipse/GMW. All hot-deploy
bind-mounts removed — containers are fully self-contained. Voice
recordings use a named volume instead of bind-mount.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 5: Create lightweight deploy.sh
**Files:**
- Create: `deploy.sh`
- [ ] **Step 1: Write deploy.sh**
```bash
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INFRA_DIR="$SCRIPT_DIR/infra/docker"
: "${VPS_HOST:?required}"
: "${VPS_USER:?required}"
: "${VPS_SSH_KEY:?required}"
echo "=== Deploy to $VPS_HOST ==="
# Copy local .env if it exists (overrides CI env)
if [ -f "$INFRA_DIR/.env" ]; then
scp -i "$VPS_SSH_KEY" "$INFRA_DIR/.env" "$VPS_USER@$VPS_HOST:/opt/imphenbot/infra/docker/.env"
fi
ssh -i "$VPS_SSH_KEY" "$VPS_USER@$VPS_HOST" << 'REMOTESCRIPT'
set -eu
cd /opt/imphenbot/infra/docker
echo "=== Pulling images ==="
docker compose pull
echo "=== Restarting containers ==="
docker compose up -d --remove-orphans
echo "=== Cleaning up ==="
docker image prune -f
echo "=== Active containers ==="
docker ps --filter "name=imphenbot" --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
REMOTESCRIPT
echo "=== Deploy complete ==="
```
- [ ] **Step 2: Make executable**
```bash
chmod +x deploy.sh
```
- [ ] **Step 3: Commit**
```bash
git add deploy.sh
git commit -m "chore: rewrite deploy.sh as lightweight SSH pull script
Replaced hot-deploy tar-pipe script with simple SSH-based deploy
that pulls latest images from Gitea registry and restarts containers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 6: Disable old CI files
**Files:**
- Disable: `.github/workflows/deploy-docker.yml`
- Keep: `.gitlab-ci.yml` if exists (already may have been removed)
- [ ] **Step 1: Rename GitHub Actions workflow to .disabled**
```bash
mv .github/workflows/deploy-docker.yml .github/workflows/deploy-docker.yml.disabled
```
- [ ] **Step 2: Remove docker compose file's old frontend-dist directory from git** (if tracked)
```bash
# Check if frontend-dist is tracked (it should be gitignored, but check)
git ls-files infra/docker/frontend-dist 2>/dev/null || echo "Not tracked — OK"
```
- [ ] **Step 3: Commit**
```bash
git add .github/workflows/deploy-docker.yml.disabled
git rm --cached .github/workflows/deploy-docker.yml 2>/dev/null || true
git commit -m "ci: disable GitHub Actions workflow
Renamed to .disabled. All CI now goes through Gitea CI (.gitea/workflows/).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 7: Update .gitignore
**Files:**
- Modify: `.gitignore`
- [ ] **Step 1: Add .gitea exclusion note and any missing entries**
Read current `.gitignore`:
```bash
cat .gitignore
```
Then append (only if not already present):
```
# Gitea workflow logs (local runners)
.gitea/workflows/*.log
```
The `.gitea/workflows/` YAML files themselves should be tracked in git.
- [ ] **Step 2: Commit**
```bash
git add .gitignore
git commit -m "chore: update gitignore for Gitea CI artifacts
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 8: Push and verify CI pipeline
- [ ] **Step 1: Verify all changes**
```bash
git status
git log --oneline -10
```
Expected: clean working tree, all 7 commits ready to push.
- [ ] **Step 2: Push to main**
```bash
git push origin main
```
- [ ] **Step 3: Monitor CI run**
Watch Gitea CI at `https://git.imrnes.team/MythEclipse/GMW/actions`.
Expected outcome:
1. `build-and-push` job runs 3 matrix builds (backend, discord-gateway, proxy) in parallel (max 2)
2. Each image is pushed to `git.imrnes.team/MythEclipse/GMW/bete-*` with both `:latest` and `:{sha}` tags
3. `deploy` job SSHes into VPS, pulls images, restarts containers
4. All 3 containers `imphenbot-proxy`, `imphenbot-backend`, `imphenbot-discord-gateway` are running
- [ ] **Step 4: Verify containers on VPS**
```bash
# SSH into VPS and check
ssh -i "$VPS_SSH_KEY" "$VPS_USER@$VPS_HOST" "
docker ps --filter 'name=imphenbot' --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
docker compose -f /opt/imphenbot/infra/docker/docker-compose.yml ps
"
```
- [ ] **Step 5: Verify no hot-deploy artifacts remain**
```bash
ssh -i "$VPS_SSH_KEY" "$VPS_USER@$VPS_HOST" "
ls -la /opt/imphenbot/infra/docker/ | grep -E 'dist$' || echo 'No dist dirs — clean'
"
```
---
## Rollback
If the pipeline fails at any point:
1. **Fix and re-push**: Edit the broken file, commit, push to main — CI re-runs automatically
2. **Emergency rollback**: SSH to VPS, run `docker compose up -d` with a known-good IMAGE_TAG:
```bash
IMAGE_TAG=<last-working-sha> docker compose up -d
```
3. **Restore old CI**: Move `.github/workflows/deploy-docker.yml.disabled` back and push
File diff suppressed because it is too large Load Diff
@@ -1,736 +0,0 @@
# Backend & Gateway Refactoring — Phase 1 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Clean up ~130 lines of dead/duplicate code, consolidate duplicated database initialization, and simplify the MessageStore layering in discord-gateway.
**Architecture:** Three independent tasks that can be done in any order. Task 1 consolidates database pool/drizzle init into `@bete/shared` so both services use one canonical pattern. Task 2 removes backward-compat function wrappers from `messageStore.ts`. Task 3 deletes dead files and functions.
**Tech Stack:** TypeScript, Node.js, Drizzle ORM, PostgreSQL, pnpm workspace
## Global Constraints
- All imports use `.js` extensions (ESM convention)
- Follow existing code style (Biome, 2-space indent)
- Keep `@bete/shared` as the single source of truth for shared infrastructure
- No package.json changes needed — `@bete/shared` already has `drizzle-orm` and `pg` as dependencies
- Do not change any business logic — only structural refactoring
---
### Task 1: Consolidate Database Initialization into `@bete/shared`
**Files:**
- Create: `packages/shared/src/database/init.ts`
- Modify: `packages/shared/src/database/pool.ts` — add `getPool()` export
- Modify: `packages/shared/src/index.ts` — export new `./database/init.js`
- Modify: `packages/shared/package.json` — add `"./database/init"` export entry
- Modify: `services/backend/src/shared/database/index.ts` — re-export from shared
- Modify: `services/discord-gateway/src/shared/database/drizzle.ts` — re-export from shared
- Delete: (functions migrate, no file deletion here — both local files stay as thin wrappers)
**Interfaces:**
- Produces:
- `@bete/shared/database/init` exports:
- `let db: ReturnType<typeof drizzle> | null` (module-level, for getDatabase())
- `let rawPool: Pool | null` (module-level, for getPool())
- `initializeDatabase(schema?: Record<string, unknown>): Promise<ReturnType<typeof drizzle>>` — creates pool via `createPoolFromConfig`, wraps with `drizzle()`. Accepts optional schema object (gateway needs it, backend doesn't). Reads config from env/config module internally.
- `getDatabase(): ReturnType<typeof drizzle>` — throws if not initialized
- `getPool(): Pool` — returns raw pool for raw SQL queries, throws if not initialized
- `closeDatabase(): Promise<void>` — closes pool and nullifies references
- `executeAll(sql: string, params?: unknown[]): Promise<unknown[]>` — raw SQL query, returns all rows
- `executeGet(sql: string, params?: unknown[]): Promise<unknown>` — raw SQL query, returns first row or null
- `withDatabaseClient<T>(callback: (client: PoolClient) => Promise<T>): Promise<T>`
- [ ] **Step 1: Create `packages/shared/src/database/init.ts`**
This is the canonical database initialization module. It merges what both services currently do:
```typescript
import { createChildLogger } from "@bete/shared/logger";
import { closePool, createPoolFromConfig } from "@bete/shared/database/pool";
import { drizzle } from "drizzle-orm/node-postgres";
import type { Pool, PoolClient } from "pg";
import { config } from "../config/index.js";
const logger = createChildLogger("database.init");
let db: ReturnType<typeof drizzle> | null = null;
let rawPool: Pool | null = null;
export async function initializeDatabase(schema?: Record<string, unknown>) {
if (db !== null) return db;
const pool = config.DATABASE_URL
? createPoolFromConfig({
url: config.DATABASE_URL,
min: config.POSTGRES_POOL_MIN,
max: config.POSTGRES_POOL_MAX,
})
: createPoolFromConfig({
host: config.POSTGRES_HOST,
port: config.POSTGRES_PORT,
user: config.POSTGRES_USER,
password: config.POSTGRES_PASSWORD,
database: config.POSTGRES_DB,
min: config.POSTGRES_POOL_MIN,
max: config.POSTGRES_POOL_MAX,
});
rawPool = pool;
db = drizzle(pool, schema ? { schema } : undefined);
// Test connection
try {
const client = await pool.connect();
client.release();
logger.info("Database connection successful");
} catch (err) {
logger.error({ err }, "Failed to connect to database");
throw err;
}
return db;
}
export function getDatabase() {
if (db === null) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
return db;
}
export function getPool() {
if (!rawPool) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
return rawPool;
}
export async function closeDatabase() {
if (rawPool !== null) {
await closePool(rawPool);
}
rawPool = null;
db = null;
logger.info("Database connection closed");
}
function convertPlaceholdersForPostgres(sql: string) {
let i = 0;
return sql.replace(/\?/g, () => `$${++i}`);
}
export async function executeAll(sql: string, params?: unknown[]) {
if (!rawPool) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
const query = convertPlaceholdersForPostgres(sql);
const result = await rawPool.query(query, params || []);
return result.rows;
}
export async function executeGet(sql: string, params?: unknown[]) {
if (!rawPool) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
const query = convertPlaceholdersForPostgres(sql);
const result = await rawPool.query(query, params || []);
return result.rows[0] ?? null;
}
export async function withDatabaseClient<T>(
callback: (client: PoolClient) => Promise<T>,
): Promise<T> {
if (!rawPool) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
const client = await rawPool.connect();
try {
return await callback(client);
} finally {
client.release();
}
}
```
**Note:** This uses `config` from `@bete/shared/config`. The backend's config proxies to that already (`services/backend/src/shared/config/index.ts` re-exports from `@bete/shared/config`). The gateway's config at `services/discord-gateway/src/shared/config/config.ts` has the same field names but is its own Zod schema. Since `@bete/shared/config` doesn't have the PostgreSQL pool config fields currently, we need to check what it exports.
Actually — `@bete/shared/config` may not have `POSTGRES_HOST` etc. Let me adjust: the `initializeDatabase` function should accept config values as parameters instead of reading from a shared config.
Revised approach for `packages/shared/src/database/init.ts`:
```typescript
import { createChildLogger } from "@bete/shared/logger";
import { closePool, createPoolFromConfig } from "./pool.js";
import { drizzle } from "drizzle-orm/node-postgres";
import type { Pool, PoolClient } from "pg";
const logger = createChildLogger("database.init");
let db: ReturnType<typeof drizzle> | null = null;
let rawPool: Pool | null = null;
export interface DatabaseConfig {
DATABASE_URL?: string;
POSTGRES_HOST?: string;
POSTGRES_PORT?: number;
POSTGRES_USER?: string;
POSTGRES_PASSWORD?: string;
POSTGRES_DB?: string;
POSTGRES_POOL_MIN?: number;
POSTGRES_POOL_MAX?: number;
}
export async function initializeDatabase(
cfg: DatabaseConfig,
schema?: Record<string, unknown>,
) {
if (db !== null) return db;
const pool = cfg.DATABASE_URL
? createPoolFromConfig({
url: cfg.DATABASE_URL,
min: cfg.POSTGRES_POOL_MIN,
max: cfg.POSTGRES_POOL_MAX,
})
: createPoolFromConfig({
host: cfg.POSTGRES_HOST,
port: cfg.POSTGRES_PORT,
user: cfg.POSTGRES_USER,
password: cfg.POSTGRES_PASSWORD,
database: cfg.POSTGRES_DB,
min: cfg.POSTGRES_POOL_MIN,
max: cfg.POSTGRES_POOL_MAX,
});
rawPool = pool;
db = drizzle(pool, schema ? { schema } : undefined);
try {
const client = await pool.connect();
client.release();
logger.info("Database connection successful");
} catch (err) {
logger.error({ err }, "Failed to connect to database");
throw err;
}
return db;
}
export function getDatabase() {
if (db === null) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
return db;
}
export function getPool() {
if (!rawPool) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
return rawPool;
}
export async function closeDatabase() {
if (rawPool !== null) {
await closePool(rawPool);
}
rawPool = null;
db = null;
logger.info("Database connection closed");
}
function convertPlaceholdersForPostgres(sql: string) {
let i = 0;
return sql.replace(/\?/g, () => `$${++i}`);
}
export async function executeAll(sql: string, params?: unknown[]) {
if (!rawPool) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
const query = convertPlaceholdersForPostgres(sql);
const result = await rawPool.query(query, params || []);
return result.rows;
}
export async function executeGet(sql: string, params?: unknown[]) {
if (!rawPool) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
const query = convertPlaceholdersForPostgres(sql);
const result = await rawPool.query(query, params || []);
return result.rows[0] ?? null;
}
export async function withDatabaseClient<T>(
callback: (client: PoolClient) => Promise<T>,
): Promise<T> {
if (!rawPool) {
throw new Error("Database not initialized. Call initializeDatabase() first.");
}
const client = await rawPool.connect();
try {
return await callback(client);
} finally {
client.release();
}
}
```
- [ ] **Step 2: Add export to `packages/shared/src/index.ts`**
```typescript
export * from "./database/init.js";
```
- [ ] **Step 3: Add export to `packages/shared/package.json`**
```json
"./database/init": "./dist/database/init.js",
```
- [ ] **Step 4: Build the shared package to verify it compiles**
```bash
cd /home/code/GMW/packages/shared
pnpm run build
```
- [ ] **Step 5: Rewrite `services/backend/src/shared/database/index.ts`**
Change to a thin wrapper that imports from `@bete/shared/database/init` and passes the backend's config:
```typescript
import { createChildLogger } from "@bete/shared/logger";
import { initializeDatabase as sharedInit, getDatabase as sharedGetDb, getPool as sharedGetPool, closeDatabase as sharedCloseDb } from "@bete/shared/database/init";
import { config } from "../config/index.js";
const logger = createChildLogger("database");
const dbConfig = {
DATABASE_URL: config.DATABASE_URL,
POSTGRES_HOST: config.POSTGRES_HOST,
POSTGRES_PORT: config.POSTGRES_PORT,
POSTGRES_USER: config.POSTGRES_USER,
POSTGRES_PASSWORD: config.POSTGRES_PASSWORD,
POSTGRES_DB: config.POSTGRES_DB,
POSTGRES_POOL_MIN: config.POSTGRES_POOL_MIN,
POSTGRES_POOL_MAX: config.POSTGRES_POOL_MAX,
};
export async function initializeDatabase() {
logger.info("Initializing database");
return sharedInit(dbConfig);
}
export function getDatabase() {
return sharedGetDb();
}
export function getPool() {
return sharedGetPool();
}
export async function closeDatabase() {
logger.info("Closing database");
return sharedCloseDb();
}
```
- [ ] **Step 6: Rewrite `services/discord-gateway/src/shared/database/drizzle.ts`**
Change to a thin wrapper:
```typescript
import { createChildLogger } from "@bete/shared/logger";
import { initializeDatabase as sharedInit, getDatabase as sharedGetDb, closeDatabase as sharedCloseDb, executeAll as sharedExecAll, executeGet as sharedExecGet, withDatabaseClient as sharedWithClient } from "@bete/shared/database/init";
import { config } from "../../shared/config/config.js";
import * as schema from "./schema.js";
const logger = createChildLogger("drizzle");
const dbConfig = {
DATABASE_URL: config.DATABASE_URL,
POSTGRES_HOST: config.POSTGRES_HOST,
POSTGRES_PORT: config.POSTGRES_PORT,
POSTGRES_USER: config.POSTGRES_USER,
POSTGRES_PASSWORD: config.POSTGRES_PASSWORD,
POSTGRES_DB: config.POSTGRES_DB,
POSTGRES_POOL_MIN: config.POSTGRES_POOL_MIN,
POSTGRES_POOL_MAX: config.POSTGRES_POOL_MAX,
};
export async function initializeDatabase() {
return sharedInit(dbConfig, schema);
}
export function getDatabase() {
return sharedGetDb();
}
export { sharedCloseDb as closeDatabase };
export { sharedExecAll as executeAll, sharedExecGet as executeGet, sharedWithClient as withDatabaseClient };
```
- [ ] **Step 7: Run typecheck on all packages to verify**
```bash
cd /home/code/GMW
pnpm run typecheck
```
- [ ] **Step 8: Commit**
```bash
git add packages/shared/src/database/init.ts packages/shared/src/index.ts packages/shared/package.json
git add services/backend/src/shared/database/index.ts services/discord-gateway/src/shared/database/drizzle.ts
git commit -m "refactor: consolidate database initialization into @bete/shared/database/init"
```
---
### Task 2: Remove Backward-Compat Function Wrappers from MessageStore
**Files:**
- Modify: `services/discord-gateway/src/modules/message-capture/messageStore.ts` — remove lines 310-398 (backward-compat wrappers), export singleton directly
- Modify: `services/discord-gateway/src/modules/message-capture/index.ts` — update re-exports to use `messageStore` singleton
- Modify: `services/discord-gateway/src/modules/message-capture/messageCapture.ts` — update imports to use `messageStore.methodName()`
- Modify: `services/discord-gateway/src/modules/ai-moderation/batchProcessor.ts` — update imports
- Modify: `services/discord-gateway/src/modules/ai-moderation/batchScheduler.ts` — update imports
- Modify: `services/discord-gateway/src/modules/ai-moderation/individualFallbackProcessor.ts` — update imports
- Modify: `services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts` — update imports
- Modify: `services/discord-gateway/src/modules/ai-moderation/aiAnalysisWorker.ts` — update imports (uses `getConversationContextBefore` and `updateMessagesAIAnalysisBulk`)
- Modify: `services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts` — update imports (uses many functions)
- Possibly modify: other files that import the wrapper functions
**Interfaces:**
- Consumes: Existing `MessageStore` class methods (unchanged signatures)
- Produces: Singleton `messageStore` instance as the single export point
The key insight: the backward-compat wrappers at lines 310-398 of `messageStore.ts` are function-level exports that delegate to `getInstance()`. Every importer can instead import the singleton `messageStore` instance and call methods on it directly.
Current importers of wrapper functions:
| File | Functions Used |
|------|---------------|
| `messageCapture.ts` | `getMessageById`, `insertMessageEdit`, `updateMessageAsEdited`, `updateMessageAsDeleted`, `upsertMessageForCapture` |
| `batchProcessor.ts` | `updateMessagesAIAnalysisBulk` |
| `batchScheduler.ts` | `getPendingMessagesByConversation` |
| `individualFallbackProcessor.ts` | `updateMessagesAIAnalysisBulk` |
| `moderationBuilders.ts` | `getMessageById` |
| `aiAnalysisWorker.ts` | `getConversationContextBefore`, `updateMessagesAIAnalysisBulk` |
| `aiAnalyzer.ts` | `getConversationKeysWithIncompleteAnalysis`, `getIncompleteMessagesByConversation`, `getMessageById`, `getPendingConversationKeys`, `updateMessageAIAnalysis` |
- [ ] **Step 1: Modify `messageStore.ts`** — replace backward-compat wrappers with a singleton export
Replace lines 22-33 (lazy singleton pattern) and lines 310-398 (wrapper functions) with:
```typescript
// ─── Singleton instance ─────────────────────────────────────────────────────
const logger = createChildLogger("message-store");
const database = getDatabase() as unknown as NodePgDatabase<typeof schema>;
export const messageStore = new MessageStore(database, logger);
```
Then remove everything from line 310 onward (the backward-compat function wrappers section).
- [ ] **Step 2: Update `message-capture/index.ts`**
Change the re-exports from individual functions to the `messageStore` singleton:
```typescript
export { messageStore } from "../message-capture/messageStore.js";
export {
getDisplayContent,
getMessageLocation,
getMessageMetadata,
} from "../message-capture/messageMetadata.js";
// ... rest unchanged
```
Also remove the individual function re-exports since they no longer exist.
- [ ] **Step 3: Update `messageCapture.ts`**
Change imports from:
```typescript
import {
getMessageById,
insertMessageEdit,
upsertMessageForCapture,
updateMessageAsDeleted,
updateMessageAsEdited,
} from "./messageStore.js";
```
To:
```typescript
import { messageStore } from "./messageStore.js";
```
Then update every call site:
- `upsertMessageForCapture(messageRecord)``messageStore.upsertMessageForCapture(messageRecord)`
- `insertMessageEdit(...)``messageStore.insertMessageEdit(...)`
- `updateMessageAsEdited(...)``messageStore.updateMessageAsEdited(...)`
- `updateMessageAsDeleted(...)``messageStore.updateMessageAsDeleted(...)`
- `getMessageById(...)``messageStore.getMessageById(...)`
- [ ] **Step 4: Update `batchProcessor.ts`**
Change from:
```typescript
import { updateMessagesAIAnalysisBulk } from "../message-capture/messageStore.js";
```
To:
```typescript
import { messageStore } from "../message-capture/messageStore.js";
```
Then update call sites:
- `updateMessagesAIAnalysisBulk(updates)``messageStore.messages.updateMessagesAIAnalysisBulk(updates)`
Wait — `updateMessagesAIAnalysisBulk` is actually defined in `MessagesAnalysis` class, which is called via `MessageStore``MessagesDb``MessagesAnalysis`. Let me check the actual delegation chain.
Looking at the wrapper functions:
```typescript
export const updateMessagesAIAnalysisBulk = (
updates: Array<{ messageId: string; result: AIAnalysisUpdate }>,
): Promise<MessageRecord[]> =>
getInstance().updateMessagesAIAnalysisBulk(updates);
```
And in the class:
```typescript
class MessageStore {
readonly messages: MessagesDb;
// ...
}
class MessagesDb {
readonly analysis: MessagesAnalysis;
// ...
updateMessagesAIAnalysisBulk(...) {
return this.analysis.updateMessagesAIAnalysisBulk(...)
}
}
```
So the call chain is: `messageStore.messages.updateMessagesAIAnalysisBulk()`. But actually, looking at `MessagesDb`, it might have its own `updateMessagesAIAnalysisBulk` that delegates to `this.analysis.updateMessagesAIAnalysisBulk()`. Let me verify...
Actually, for simplicity and to minimize changes, let me look at whether `MessagesDb` has `updateMessagesAIAnalysisBulk` or if only the wrapper has it.
Let me check:
Actually I already read that `MessagesDb` has methods. Let me look at what methods `MessagesDb` exposes vs the wrapper functions.
Instead of guessing, the safe approach is to keep the thin function wrappers but simplify them. Actually, a better approach for this task:
**Revised approach:** Instead of making all importers use `messageStore.messages.analysis.methodName()`, add all the forwarded methods directly to the `MessageStore` class (which it already does for most), and just have external files import the singleton and call `messageStore.methodName()`.
Let me check what methods `MessageStore` already has vs what's only available as backward-compat wrappers:
Looking at the code:
- `insertMessageEdit` — EXISTS in MessageStore class (line 54)
- `upsertMessageForCapture` — EXISTS in MessageStore class
- `updateMessageAsEdited` — EXISTS in MessageStore class
- `updateMessageAsDeleted` — EXISTS in MessageStore class
- `getMessagesByChannel` — EXISTS in MessageStore class
- `updateMessageAIAnalysis` — EXISTS in MessageStore class
- `updateMessagesAIAnalysisBulk` — EXISTS in MessageStore class
- `getPendingAIAnalysisMessages` — EXISTS in MessageStore class
- `getMessageById` — EXISTS in MessageStore class
- `listMessages` — EXISTS in MessageStore class (delegates to MessagesPagination)
- `listReviewMessages` — EXISTS in MessageStore class (delegates to MessagesPagination)
- `getConversationContextBefore` — EXISTS in MessageStore class
- `getPendingMessagesByConversation` — EXISTS in MessageStore class
- `getPendingConversationKeys` — EXISTS in MessageStore class
- `getConversationKeysWithIncompleteAnalysis` — EXISTS in MessageStore class
- `getIncompleteMessagesByConversation` — EXISTS in MessageStore class
So every function wrapper has a corresponding method on `MessageStore` class. The change is straightforward.
Now, after creating the singleton `messageStore`, all importers just do `messageStore.updateMessagesAIAnalysisBulk(...)` instead of calling the bare function.
But there's one complication: `MessagesDb.updateMessagesAIAnalysisBulk` is actually calling `this.analysis.updateMessagesAIAnalysisBulk()`. Does the `MessageStore` class have its own direct `updateMessagesAIAnalysisBulk`? Let me check the class definition...
Actually, I already saw from the grep output that `MessageStore` class has `updateMessagesAIAnalysisBulk` — the wrapper says `getInstance().updateMessagesAIAnalysisBulk(updates)`, and the class has that method.
OK so the mapping is 1:1 between wrapper functions and MessageStore class methods. This is safe.
- [ ] **Step 5: Update `batchScheduler.ts`**
```typescript
// Before:
import { getPendingMessagesByConversation } from "../message-capture/messageStore.js";
// After:
import { messageStore } from "../message-capture/messageStore.js";
```
And call: `messageStore.getPendingMessagesByConversation(...)`
- [ ] **Step 6: Update `individualFallbackProcessor.ts`**
```typescript
// Before:
import { updateMessagesAIAnalysisBulk } from "../message-capture/messageStore.js";
// After:
import { messageStore } from "../message-capture/messageStore.js";
```
And call: `messageStore.updateMessagesAIAnalysisBulk(...)`
- [ ] **Step 7: Update `moderationBuilders.ts`**
```typescript
// Before:
import { getMessageById } from "../message-capture/messageStore.js";
// After:
import { messageStore } from "../message-capture/messageStore.js";
```
And call: `messageStore.getMessageById(...)`
- [ ] **Step 8: Update `aiAnalysisWorker.ts`**
```typescript
// Before:
import { getConversationContextBefore, updateMessagesAIAnalysisBulk } from "../message-capture/messageStore.js";
// After:
import { messageStore } from "../message-capture/messageStore.js";
```
And update all call sites.
- [ ] **Step 9: Update `aiAnalyzer.ts`**
```typescript
// Before:
import {
getConversationKeysWithIncompleteAnalysis,
getIncompleteMessagesByConversation,
getMessageById,
getPendingConversationKeys,
updateMessageAIAnalysis,
} from "../message-capture/messageStore.js";
// After:
import { messageStore } from "../message-capture/messageStore.js";
```
And update all call sites.
- [ ] **Step 10: Update `message-capture/index.ts`**
Remove individual function re-exports, replace with `messageStore`:
```typescript
export { messageStore } from "./messageStore.js";
export {
getDisplayContent,
getMessageLocation,
getMessageMetadata,
} from "./messageMetadata.js";
export type {
AIRecommendedAction,
AISeverity,
AIStatus,
AttachmentRecord,
MessageRecord,
VoiceSegmentRecord,
} from "./types.js";
export type { TextCaptureTarget } from "./messageCapture.js";
export {
captureMessage,
registerMessageCapture,
setEventBroadcaster,
} from "./messageCapture.js";
```
- [ ] **Step 11: Run typecheck**
```bash
cd /home/code/GMW
pnpm run typecheck
```
- [ ] **Step 12: Commit**
```bash
git add services/discord-gateway/src/modules/message-capture/
git add services/discord-gateway/src/modules/ai-moderation/
git commit -m "refactor: remove backward-compat function wrappers from messageStore"
```
---
### Task 3: Remove Dead Code
**Files:**
- Delete: `services/backend/src/modules/response.ts` — empty deprecated file
- Delete: `services/discord-gateway/src/modules/webhook-notifications/webhookNotifier.ts`
- Delete: `services/discord-gateway/src/modules/webhook-notifications/index.ts`
- Delete: `services/discord-gateway/src/modules/webhook-notifications/` (directory)
- Modify: `services/backend/src/ws/server.ts` — remove duplicate `broadcastBinaryToFrontend()` function, keep only `broadcastBinary()`
**Interfaces:**
- None — these are deletions only, no consumer impact
- [ ] **Step 1: Delete `modules/response.ts`**
```bash
rm /home/code/GMW/services/backend/src/modules/response.ts
```
- [ ] **Step 2: Fix `ws/server.ts`** — remove duplicate `broadcastBinaryToFrontend`
In `ws/server.ts`, `broadcastBinaryToFrontend` (line 238) and `broadcastBinary` (line 267) do exactly the same thing. Replace the `broadcastBinaryToFrontend(data)` call on line 147 with a call to `broadcastBinary(data)`, then delete the `broadcastBinaryToFrontend` function.
Edit line 147:
```typescript
// Before:
broadcastBinaryToFrontend(data);
// After:
broadcastBinary(data);
```
Remove the `broadcastBinaryToFrontend` function (lines 238-248):
```typescript
// Remove this entire function:
function broadcastBinaryToFrontend(data: Buffer) {
for (const client of frontendClients) {
if (client.readyState === WebSocket.OPEN) {
try {
client.send(data);
} catch (err) {
logger.error({ err }, "Failed to send binary to frontend client");
}
}
}
}
```
- [ ] **Step 3: Check if anything imports `webhook-notifications`**
```bash
grep -rn "webhook-notifications\|webhookNotifier\|triggerWebhook" /home/code/GMW/services/ --include='*.ts' | grep -v "node_modules" | grep -v "services/discord-gateway/src/modules/webhook-notifications/"
```
Expected: empty (confirmed earlier)
- [ ] **Step 4: Delete webhook-notifications module**
```bash
rm -rf /home/code/GMW/services/discord-gateway/src/modules/webhook-notifications/
```
- [ ] **Step 5: Run typecheck to verify no broken imports**
```bash
cd /home/code/GMW
pnpm run typecheck
```
- [ ] **Step 6: Commit**
```bash
git add services/backend/src/modules/response.ts services/backend/src/ws/server.ts
git add services/discord-gateway/src/modules/webhook-notifications/
git commit -m "chore: remove dead code (response.ts, broadcastBinaryToFrontend, webhook-notifications)"
```
@@ -1,579 +0,0 @@
# Services Refactoring Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Refactor backend (4.2k lines) and discord-gateway (17.9k lines) for consistency, reduced file sizes, deduplication, and pattern uniformity across 11 phases.
**Architecture:** Phase1-3 target backend unchanged; Phase4-8 split large gateway files; Phase9 deduplicates shared database init; Phase10-11 are minor consolidation. Each phase is independently testable by verifying the service still compiles and runs.
**Tech Stack:** TypeScript (ESM), Express 5, ws, Discord.js selfbot, Drizzle ORM, Redis (ioredis), pino logger, Biome (formatter)
## Global Constraints
- All files use ESM (`.js` extensions in imports)
- Biome formatter handles formatting — run `pnpm run format` after each phase
- TypeScript strict mode — run `pnpm run typecheck` after each phase (for node services)
- Logging uses `createChildLogger(context)` from `@bete/shared/logger`
- Import via barrel files where available
- No logic changes — pure refactoring
---
## Task 1: Fix messages.controller.ts pattern (Phase 1)
**Files:**
- Modify: `services/backend/src/modules/messages/messages.controller.ts`
**Interfaces:**
- Consumes: `asyncHandler` from `../../shared/middlewares/index.js`
- Produces: Same exported handler functions, but using decorator pattern
- [ ] **Step 1: Read current messages.controller.ts**
The file currently uses the convoluted pattern:
```ts
export function handleListMessages(req, res, next) {
return asyncHandler(async (req, res) => {
// ...
})(req, res, next);
}
```
- [ ] **Step 2: Rewrite all handlers to decorator pattern**
Replace every handler to use the clean decorator pattern:
```ts
import { createChildLogger } from "@bete/shared/logger";
import type { Request, Response } from "express";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { messageQuerySchema } from "./messages.schema.js";
import { messagesService } from "./messages.service.js";
const logger = createChildLogger("messages.controller");
export const handleListMessages = asyncHandler(async (req: Request, res: Response) => {
const query = messageQuerySchema.parse(req.query);
logger.debug({ query }, "Handling list messages request");
const result = await messagesService.listMessages(query);
res.json(result);
});
export const handleGetMessagesByChannel = asyncHandler(async (req: Request, res: Response) => {
const channelId = String(req.params.channelId ?? "");
if (!channelId) {
res.status(400).json({ error: "MISSING_CHANNEL_ID" });
return;
}
const query = messageQuerySchema.parse(req.query);
logger.debug({ channelId, query }, "Handling get messages by channel");
const result = await messagesService.getMessagesByChannel(channelId, query);
res.json(result);
});
export const handleGetMessageById = asyncHandler(async (req: Request, res: Response) => {
const id = String(req.params.id ?? "");
if (!id) {
res.status(400).json({ error: "MISSING_ID" });
return;
}
logger.debug({ id }, "Handling get message by ID");
const result = await messagesService.getMessageById(id);
res.json(result);
});
export const handleGetImageMessages = asyncHandler(async (req: Request, res: Response) => {
const guildId = String(req.query.guildId ?? "");
if (!guildId) {
res.status(400).json({ error: "MISSING_GUILD_ID" });
return;
}
const limit = Number(req.query.limit) || 50;
logger.debug({ guildId, limit }, "Handling get image messages");
const result = await messagesService.getImageMessages(guildId, limit);
res.json(result);
});
export const handleGetAttachmentsByChannel = asyncHandler(async (req: Request, res: Response) => {
const channelId = String(req.params.channelId ?? "");
if (!channelId) {
res.status(400).json({ error: "MISSING_CHANNEL_ID" });
return;
}
const query = messageQuerySchema.parse(req.query);
logger.debug({ channelId, query }, "Handling get attachments by channel");
const result = await messagesService.getAttachmentsByChannel(channelId, query);
res.json(result);
});
```
NOTE: The old pattern used `requireParam` from middlewares to validate params. The new pattern uses simple string checks with early returns. This is equivalent since `requireParam` threw `ValidationError` which the errorHandler middleware catches — but for these handlers the decorator pattern can't throw synchronously in the handler wrapper; the `asyncHandler` catches async rejects. Early return with explicit error response is cleaner.
- [ ] **Step 3: Verify the module still compiles**
Run: `cd /home/code/GMW && pnpm run typecheck`
Expected: No TypeScript errors
- [ ] **Step 4: Run biome format**
Run: `cd /home/code/GMW && pnpm run format`
- [ ] **Step 5: Commit**
```bash
git add services/backend/src/modules/messages/messages.controller.ts
git commit -m "refactor(backend): fix messages.controller.ts to use decorator pattern
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: Clean up response.ts usage (Phase 2)
**Files:**
- Modify: `services/backend/src/modules/health/health.controller.ts` (remove `success()` usage, use plain `res.json()`)
- Modify: `services/backend/src/modules/response.ts` (deprecate/remove)
**Interfaces:**
- Consumes: all response-producing route files
- Produces: consistent plain `res.json()` pattern everywhere
- [ ] **Step 1: Check all places that import from response.ts**
Run: `grep -r 'from.*response\.js' services/backend/src/`
- [ ] **Step 2: Remove `success()` usage from health.controller.ts**
Replace:
```ts
import { success } from "../response.js";
// ...
res.status(status).json(success(result));
```
With:
```ts
res.status(status).json({ success: true, data: result });
```
- [ ] **Step 3: Run biome format + typecheck**
Run: `cd /home/code/GMW && pnpm run format && pnpm run typecheck`
- [ ] **Step 4: Commit**
```bash
git add services/backend/src/modules/health/health.controller.ts services/backend/src/modules/response.ts
git commit -m "refactor(backend): remove response.ts helpers, inline health response
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: Add ws/ barrel (Phase 3)
**Files:**
- Create: `services/backend/src/ws/index.ts`
- [ ] **Step 1: Create barrel file**
```ts
export { setBroadcastFunctions, clearBroadcastFunctions, broadcastEvent, broadcastBinary } from "./broadcast.js";
export { startRedisBridge, stopRedisBridge } from "./redis-bridge.js";
export { createWebSocketServer, closeWebSocketServer } from "./server.js";
```
- [ ] **Step 2: Run typecheck**
Run: `cd /home/code/GMW && pnpm run typecheck`
- [ ] **Step 3: Commit**
```bash
git add services/backend/src/ws/index.ts
git commit -m "refactor(backend): add ws barrel index
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: Split moderationPrompt.ts (Phase 4)
**Files:**
- Create: `services/discord-gateway/src/modules/ai-moderation/prompts/text-analysis.ts`
- Create: `services/discord-gateway/src/modules/ai-moderation/prompts/media-analysis.ts`
- Create: `services/discord-gateway/src/modules/ai-moderation/prompts/stickers.ts`
- Create: `services/discord-gateway/src/modules/ai-moderation/prompts/emojis.ts`
- Create: `services/discord-gateway/src/modules/ai-moderation/prompts/system.ts`
- Modify: `services/discord-gateway/src/modules/ai-moderation/moderationPrompt.ts` (become barrel re-export)
- [ ] **Step 1: Read the full moderationPrompt.ts**
Read the file to identify all exports and their dependencies.
- [ ] **Step 2: Create `prompts/system.ts` — system prompt builder + shared helpers**
Move: `buildSystemPrompt` function, `sanitizeAiContent`, `escapeXml`, `buildCustomEmojiVisionPrompt`, any shared helper functions.
- [ ] **Step 3: Create `prompts/text-analysis.ts` — text moderation prompts**
Move: All text-specific prompt strings and builders.
- [ ] **Step 4: Create `prompts/media-analysis.ts` — image/video prompts**
Move: `buildGeneralImageVisionPrompt` and related media prompt builders.
- [ ] **Step 5: Create `prompts/stickers.ts` — sticker prompts**
Move: `buildStickerVisionPrompt`, `buildStickerTextOnlyWarning`.
- [ ] **Step 6: Create `prompts/emojis.ts` — emoji prompts**
Move: `buildCustomEmojiVisionPrompt` if it exists separately.
- [ ] **Step 7: Replace moderationPrompt.ts with barrel re-exports**
```ts
export { buildSystemPrompt, sanitizeAiContent } from "./prompts/system.js";
export { buildGeneralImageVisionPrompt } from "./prompts/media-analysis.js";
export { buildStickerVisionPrompt, buildStickerTextOnlyWarning } from "./prompts/stickers.js";
export { buildCustomEmojiVisionPrompt } from "./prompts/emojis.js";
```
- [ ] **Step 8: Run typecheck**
Run: `cd /home/code/GMW && pnpm run typecheck`
Expected: No errors. Existing importers continue to work via the barrel.
- [ ] **Step 9: Run biome format**
Run: `cd /home/code/GMW && pnpm run format`
- [ ] **Step 10: Commit**
```bash
git add services/discord-gateway/src/modules/ai-moderation/prompts/ services/discord-gateway/src/modules/ai-moderation/moderationPrompt.ts
git commit -m "refactor(gateway): split moderationPrompt.ts into domain-specific prompt files
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 5: Split moderationOrchestrator.ts (Phase 5)
**Files:**
- Create: `services/discord-gateway/src/modules/ai-moderation/textBatchProcessor.ts`
- Create: `services/discord-gateway/src/modules/ai-moderation/mediaBatchProcessor.ts`
- Create: `services/discord-gateway/src/modules/ai-moderation/simpleFallback.ts`
- Modify: `services/discord-gateway/src/modules/ai-moderation/moderationOrchestrator.ts` (extract & re-export)
- Modify: `services/discord-gateway/src/modules/ai-moderation/index.ts` (update exports if needed)
- [ ] **Step 1: Read full moderationOrchestrator.ts**
Map all exports and dependencies.
- [ ] **Step 2: Extract `runTextOnlyBatch` into `textBatchProcessor.ts`**
Move the function and its helper `buildCorrectedFewShotExamples`. Export it.
- [ ] **Step 3: Extract `runMediaBatch` into `mediaBatchProcessor.ts`**
Move the function and all its dependencies. Export it.
- [ ] **Step 4: Extract `runSimpleTextFallback` into `simpleFallback.ts`**
Move the function. Export it.
- [ ] **Step 5: Update moderationOrchestrator.ts**
Replace extracted functions with imports:
```ts
export { runTextOnlyBatch } from "./textBatchProcessor.js";
export { runMediaBatch } from "./mediaBatchProcessor.js";
export { runSimpleTextFallback } from "./simpleFallback.js";
```
Keep the `runModerationAnalysis` entry point function which orchestrates text + media + caching.
- [ ] **Step 6: Run typecheck**
Run: `cd /home/code/GMW && pnpm run typecheck`
- [ ] **Step 7: Run biome format**
Run: `cd /home/code/GMW && pnpm run format`
- [ ] **Step 8: Commit**
```bash
git add services/discord-gateway/src/modules/ai-moderation/textBatchProcessor.ts services/discord-gateway/src/modules/ai-moderation/mediaBatchProcessor.ts services/discord-gateway/src/modules/ai-moderation/simpleFallback.ts services/discord-gateway/src/modules/ai-moderation/moderationOrchestrator.ts
git commit -m "refactor(gateway): split moderationOrchestrator into dedicated processors
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 6: Split mediaAnalysisClient.ts (Phase 6)
**Files:**
- Create: `services/discord-gateway/src/modules/ai-moderation/mediaCache.ts`
- Create: `services/discord-gateway/src/modules/ai-moderation/mediaDownloader.ts`
- Create: `services/discord-gateway/src/modules/ai-moderation/visionAnalyzer.ts`
- Modify: `services/discord-gateway/src/modules/ai-moderation/mediaAnalysisClient.ts` (become barrel)
- [ ] **Step 1: Read full mediaAnalysisClient.ts**
Map all exports and dependencies across the 826 lines.
- [ ] **Step 2: Extract all cache logic into `mediaCache.ts`**
Move: LRU cache, phash dedup, `getCachedMediaAnalysis`, `setCachedMediaAnalysis`, `computeImagePhash`, `deleteCachedMediaAnalysis`, `acquireMediaAnalysisLock`.
- [ ] **Step 3: Extract all download logic into `mediaDownloader.ts`**
Move: Image download, video download, ffmpeg frame extraction, temporary file handling.
- [ ] **Step 4: Extract vision LLM logic into `visionAnalyzer.ts`**
Move: Vision LLM calls, message preparation for vision, `prepareMediaMessage`.
- [ ] **Step 5: Update mediaAnalysisClient.ts to re-export**
```ts
export { getCachedMediaAnalysis, setCachedMediaAnalysis, computeImagePhash } from "./mediaCache.js";
export { downloadAndExtractFrame } from "./mediaDownloader.js";
export { prepareMediaMessage, hasMediaContent } from "./visionAnalyzer.js";
```
- [ ] **Step 6: Run typecheck**
Run: `cd /home/code/GMW && pnpm run typecheck`
- [ ] **Step 7: Run biome format**
Run: `cd /home/code/GMW && pnpm run format`
- [ ] **Step 8: Commit**
```bash
git add services/discord-gateway/src/modules/ai-moderation/mediaCache.ts services/discord-gateway/src/modules/ai-moderation/mediaDownloader.ts services/discord-gateway/src/modules/ai-moderation/visionAnalyzer.ts services/discord-gateway/src/modules/ai-moderation/mediaAnalysisClient.ts
git commit -m "refactor(gateway): split mediaAnalysisClient into cache, downloader, and vision analyzer
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 7: Extract retention cleanup from bootstrap.ts (Phase 7)
**Files:**
- Create: `services/discord-gateway/src/app/retention.ts`
- Modify: `services/discord-gateway/src/app/bootstrap.ts`
- [ ] **Step 1: Create `app/retention.ts`**
Move `deleteExpiredRecords` and `startRetentionCleanup` from `bootstrap.ts`:
```ts
import { createChildLogger } from "@bete/shared/logger";
import { lt, inArray } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { config } from "../shared/config/config.js";
import { getDatabase } from "../shared/database/drizzle.js";
import * as schema from "../shared/database/schema.js";
import { messagesTable, attachmentsTable, voiceRecordingsTable } from "../shared/database/schema.js";
const log = createChildLogger("retention");
// ... move deleteExpiredRecords here ...
// ... move startRetentionCleanup here ...
export { startRetentionCleanup };
```
- [ ] **Step 2: Remove inline retention code from bootstrap.ts**
- Remove the `deleteExpiredRecords` function
- Remove the `startRetentionCleanup` function
- Add: `import { startRetentionCleanup } from "./retention.js";`
- Replace the call: call `startRetentionCleanup()` directly
- [ ] **Step 3: Run typecheck**
Run: `cd /home/code/GMW && pnpm run typecheck`
- [ ] **Step 4: Run biome format**
Run: `cd /home/code/GMW && pnpm run format`
- [ ] **Step 5: Commit**
```bash
git add services/discord-gateway/src/app/retention.ts services/discord-gateway/src/app/bootstrap.ts
git commit -m "refactor(gateway): extract retention cleanup from bootstrap into dedicated module
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 8: Consolidate EventBroadcaster (Phase 8)
**Files:**
- Modify: `services/discord-gateway/src/modules/event-broadcaster/eventBroadcaster.ts`
- Modify: `services/discord-gateway/src/modules/event-broadcaster/index.ts`
- [ ] **Step 1: Read current eventBroadcaster.ts**
Identify `RedisEventPublisher` and `EventBroadcaster` classes.
- [ ] **Step 2: Merge RedisEventPublisher into EventBroadcaster**
Inline `RedisEventPublisher` as a private detail inside `EventBroadcaster`. Keep the public API unchanged.
- [ ] **Step 3: Update index.ts if needed**
Ensure the barrel still exports `EventBroadcaster`.
- [ ] **Step 4: Run typecheck**
Run: `cd /home/code/GMW && pnpm run typecheck`
- [ ] **Step 5: Run biome format**
Run: `cd /home/code/GMW && pnpm run format`
- [ ] **Step 6: Commit**
```bash
git add services/discord-gateway/src/modules/event-broadcaster/eventBroadcaster.ts services/discord-gateway/src/modules/event-broadcaster/index.ts
git commit -m "refactor(gateway): merge RedisEventPublisher into EventBroadcaster
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 9: Cross-cutting database initialization dedup (Phase 9)
**Files:**
- Modify: `packages/shared/src/database/schema.ts` — add database lifecycle helpers
- Modify: `services/backend/src/shared/database/index.ts` — use shared helpers
- Modify: `services/discord-gateway/src/shared/database/drizzle.ts` — use shared helpers
- [ ] **Step 1: Check current shared database setup**
Read `packages/shared/` structure to see if there's already a database module.
- [ ] **Step 2: Add pool creation helper in @bete/shared**
In `packages/shared/src/database/schema.ts` or create `packages/shared/src/database/pool.ts`:
```ts
import { Pool } from "pg";
export function createPostgresPool(url: string, opts?: { min?: number; max?: number }): Pool {
return new Pool({
connectionString: url,
min: opts?.min ?? 2,
max: opts?.max ?? 10,
});
}
export interface PoolConfig {
host?: string;
port?: number;
user?: string;
password?: string;
database?: string;
url?: string;
min?: number;
max?: number;
}
export function createPoolFromConfig(cfg: PoolConfig): Pool {
if (cfg.url) return createPostgresPool(cfg.url, { min: cfg.min, max: cfg.max });
return new Pool({
host: cfg.host,
port: cfg.port,
user: cfg.user,
password: cfg.password,
database: cfg.database,
min: cfg.min ?? 2,
max: cfg.max ?? 10,
});
}
```
Export from `packages/shared/src/database/schema.ts` or create a barrel.
- [ ] **Step 3: Update backend's shared/database/index.ts**
Replace inline Pool creation with `createPoolFromConfig` from `@bete/shared`.
- [ ] **Step 4: Update gateway's shared/database/drizzle.ts**
Replace inline Pool creation with `createPoolFromConfig` from `@bete/shared`.
- [ ] **Step 5: Run typecheck across all services**
Run: `cd /home/code/GMW && pnpm run typecheck`
- [ ] **Step 6: Run biome format**
Run: `cd /home/code/GMW && pnpm run format`
- [ ] **Step 7: Commit**
```bash
git add packages/shared/src/database/ services/backend/src/shared/database/index.ts services/discord-gateway/src/shared/database/drizzle.ts
git commit -m "refactor: extract shared database pool creation into @bete/shared
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 10: Audit moderationState vs conversationState overlap (Phase 10)
**Files:**
- Read: `services/discord-gateway/src/modules/ai-moderation/moderationState.ts`
- Read: `services/discord-gateway/src/modules/ai-moderation/conversationState.ts`
- [ ] **Step 1: Read both files and identify overlap**
Look for duplicated state management (maps, sets, timers).
- [ ] **Step 2: If overlap found, merge into one file**
Otherwise, just add comments documenting the boundary.
- [ ] **Step 3: Commit**
```bash
git add services/discord-gateway/src/modules/ai-moderation/
git commit -m "refactor(gateway): consolidate conversattion/moderation state management
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 11: Redis connection audit (Phase 11)
**Files:**
- Read: all Redis connection sites in gateway
- [ ] **Step 1: Identify all Redis connections**
Search for `new Redis(` patterns in gateway.
- [ ] **Step 2: Verify each has a valid reason for a separate connection**
Document with comments if needed.
- [ ] **Step 3: Commit (if any changes made)**
File diff suppressed because it is too large Load Diff
@@ -1,455 +0,0 @@
# CI/CD Overhaul: Gitea CI + Container Registry Design
**Status:** Draft
**Last updated:** 2026-07-27
## 1. Problem Statement
The current CI/CD pipeline has multiple issues:
1. **Split across 3 CI systems**: GitHub Actions (build + deploy), GitLab CI (build only, no deploy), and `deploy.sh` (hot-deploy bind-mounts)
2. **Registry mismatch**: GitHub Actions pushes to `ghcr.io` but `docker-compose.yml` references `registry.gitlab.com` — the deploy route is unclear
3. **Hot-deploy complexity**: `deploy.sh` builds locally, tars dist files, SSH pipes, and binds into containers at runtime. Fragile and not reproducible
4. **No frontend in Docker**: Frontend is never built into an image — only hot-deployed via bind-mounts
5. **Stale Dockerfile**: `Dockerfile.proxy` builds a Rust WASM frontend that no longer exists
6. **Dockerfile.frontend is missing**: Frontend image doesn't exist at all
7. **Shared package fragility**: The previous refactor added `@bete/shared/database/init` export, but Docker images built from `master` don't have it — containers crash
## 2. Goal
Single CI/CD pipeline that:
- Builds Docker images for all 3 services (backend, discord-gateway, proxy-serving-frontend)
- Pushes them to Gitea's built-in Container Registry
- On the VPS, only pulls images and restarts containers — no more hot-deploy bind-mounts
- All 3 services built in one pipeline, deployed together atomically
## 3. Architecture
```
Developer pushes to main
┌────────────────────────────┐
│ Gitea Runner (server X) │
│ │
│ Job 1: build-and-push │
│ ├── bete-backend:latest │──────────▶ Gitea Container Registry
│ ├── bete-discord-gateway │──────────▶ git.imrnes.team/MythEclipse/GMW/
│ │ :latest │ bete-backend:{sha,latest}
│ └── bete-proxy:latest │──────────▶ bete-discord-gateway:{sha,latest}
│ │──────────▶ bete-proxy:{sha,latest}
│ Job 2: deploy (SSH) │
│ └─── SSH ke VPS ──────────┤
└────────────────────────────┘
┌────────────────────────────┐
│ VPS Production │
│ /opt/imphenbot/infra/ │
│ docker/ │
│ │
│ docker compose pull │
│ docker compose up -d │
│ docker image prune -f │
│ │
│ 3 containers: │
│ ┌────────┐ ┌──────────┐ │
│ │ proxy │ │ backend │ │
│ │ :80 │ │ :3000 │ │
│ └───┬────┘ └──────────┘ │
│ │ ┌─────────────┐ │
│ └────┤discord- │ │
│ │gateway │ │
│ └─────────────┘ │
└────────────────────────────┘
```
### 3.1 Service Images
| Image | From | Runs |
|-------|------|------|
| `bete-backend` | `Dockerfile.backend` | Express HTTP/WS on port 3000 |
| `bete-discord-gateway` | `Dockerfile.discord-gateway` | Discord client, internal only |
| `bete-proxy` | `Dockerfile.proxy` (rewritten) | Nginx serving frontend + proxying `/api` and `/ws` to backend |
### 3.2 Registry
Gitea provides a built-in container registry per repository at:
```
git.imrnes.team/MythEclipse/GMW/<image-name>:<tag>
```
Images are tagged with both `latest` and the commit SHA for traceability.
## 4. Files to Create / Modify
### 4.1 Create: `.gitea/workflows/deploy.yml`
One workflow, two jobs:
```yaml
name: Build & Deploy
on:
push:
branches: [main]
jobs:
build-and-push:
runs-on: ubuntu-latest
strategy:
matrix:
service: [backend, discord-gateway, proxy]
max-parallel: 2
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.GITEA_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITEA_REGISTRY_TOKEN }}
- name: Build & Push
uses: docker/build-push-action@v6
with:
context: .
file: infra/docker/Dockerfile.${{ matrix.service }}
push: true
tags: |
${{ vars.GITEA_REGISTRY }}/${{ github.repository }}/bete-${{ matrix.service }}:${{ github.sha }}
${{ vars.GITEA_REGISTRY }}/${{ github.repository }}/bete-${{ matrix.service }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
runs-on: ubuntu-latest
needs: build-and-push
if: github.ref == 'refs/heads/main'
steps:
- name: SSH & Deploy
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /opt/imphenbot/infra/docker
echo "${{ secrets.ENV_FILE }}" > .env
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f
```
Note: Gitea CI uses GitHub Actions-compatible syntax (Act Runner). The above uses the standard `actions/*` actions and `docker/*` actions that work with both GitHub and Gitea. If Gitea's runner doesn't fully support `docker/build-push-action`, fallback to inline `docker build` and `docker push` commands.
Sensitive variables: `GITEA_REGISTRY_TOKEN`, `VPS_HOST`, `VPS_USER`, `VPS_SSH_KEY`, `ENV_FILE` set in Gitea repo Settings → Actions → Secrets. Non-sensitive: `GITEA_REGISTRY` as a Variable.
### 4.2 Rewrite: `Dockerfile.proxy`
Current proxy Dockerfile builds a Rust WASM frontend (stale — no longer exists in codebase). Replace with multi-stage build:
```dockerfile
# Stage 1: Build frontend (Next.js 16 static export)
FROM node:22-slim AS frontend-builder
WORKDIR /app
# Install pnpm
RUN corepack enable
# Copy dependency manifests
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/shared/package.json ./packages/shared/package.json
COPY services/frontend/package.json ./services/frontend/package.json
# Install dependencies
RUN pnpm install --frozen-lockfile --filter './services/frontend' --filter '@bete/shared'
# Copy source code
COPY packages/shared/ ./packages/shared/
COPY services/frontend/ ./services/frontend/
# Build Next.js static export
RUN pnpm --filter frontend run build
# Result in services/frontend/out/
# Stage 2: Nginx
FROM nginx:alpine
# Nginx config
COPY infra/docker/nginx/nginx.conf /etc/nginx/conf.d/default.conf
# Static frontend files
COPY --from=frontend-builder /app/services/frontend/out/ /usr/share/nginx/html/
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:80/ || exit 1
```
### 4.3 Modify: `Dockerfile.backend`
Add `VITE_BE_API_URL` and `VITE_BE_WS_URL` build args (already listed in GitHub Actions but not in Dockerfile):
```dockerfile
# Add to existing Dockerfile.backend — after FROM, before WORKDIR
ARG VITE_BE_API_URL
ARG VITE_BE_WS_URL
ENV VITE_BE_API_URL=${VITE_BE_API_URL}
ENV VITE_BE_WS_URL=${VITE_BE_WS_URL}
```
These build args are now consumed at build time for future-proofing even though they were previously only needed for frontend builds (which now lives in the proxy Dockerfile).
### 4.4 Modify: `Dockerfile.discord-gateway`
No structural changes needed — verify Drizzle migrations path:
```dockerfile
# COPY drizzle, line in existing Dockerfile.discord-gateway:
COPY services/discord-gateway/drizzle/ ./services/discord-gateway/drizzle/
# This should work as-is since workspace is copied at /app
```
### 4.5 Rewrite: `deploy.sh`
From hot-deploy tar-pipe SSH to lightweight SSH exec:
```bash
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INFRA_DIR="$SCRIPT_DIR/infra/docker"
: "${VPS_HOST:?required}"
: "${VPS_USER:?required}"
: "${VPS_SSH_KEY:?required}"
echo "=== Deploy to $VPS_HOST ==="
# Copy .env if it exists locally
if [ -f "$INFRA_DIR/.env" ]; then
scp -i "$VPS_SSH_KEY" "$INFRA_DIR/.env" "$VPS_USER@$VPS_HOST:/opt/imphenbot/infra/docker/.env"
fi
ssh -i "$VPS_SSH_KEY" "$VPS_USER@$VPS_HOST" << 'REMOTESCRIPT'
set -e
cd /opt/imphenbot/infra/docker
echo "=== Pulling images ==="
docker compose pull
echo "=== Restarting containers ==="
docker compose up -d --remove-orphans
echo "=== Cleaning up ==="
docker image prune -f
echo "=== Verify ==="
docker ps --filter "name=imphenbot" --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
REMOTESCRIPT
echo "=== Deploy complete ==="
```
### 4.6 Rewrite: `infra/docker/docker-compose.yml`
Replace all GitLab registry image references with Gitea registry. Remove bind-mounts. Add recordings named volume.
```yaml
version: "3.8"
services:
proxy:
image: ${GITEA_REGISTRY}/${GITEA_REPO}/bete-proxy:${IMAGE_TAG:-latest}
container_name: imphenbot-proxy
restart: unless-stopped
ports:
- "127.0.0.1:8080:80"
networks:
- app-shared-net
healthcheck:
test: wget -qO- http://localhost:80/ || exit 1
interval: 30s
timeout: 3s
start_period: 10s
retries: 3
deploy:
resources:
limits:
memory: 64M
labels:
traefik.enable: "true"
traefik.http.routers.imphenbot.rule: "Host(`imphnen.asepharyana.my.id`)"
traefik.http.routers.imphenbot.entrypoints: websecure
traefik.http.routers.imphenbot.tls: "true"
traefik.http.services.imphenbot.loadbalancer.server.port: "80"
backend:
image: ${GITEA_REGISTRY}/${GITEA_REPO}/bete-backend:${IMAGE_TAG:-latest}
container_name: imphenbot-backend
restart: unless-stopped
env_file:
- .env
environment:
NODE_ENV: production
WEBSERVER_PORT: 3000
networks:
- app-shared-net
healthcheck:
test: wget -qO- http://localhost:3000/api/health || exit 1
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
deploy:
resources:
limits:
memory: 256M
depends_on:
- proxy
discord-gateway:
image: ${GITEA_REGISTRY}/${GITEA_REPO}/bete-discord-gateway:${IMAGE_TAG:-latest}
container_name: imphenbot-discord-gateway
restart: unless-stopped
env_file:
- .env
environment:
NODE_ENV: production
volumes:
- recordings:/app/recordings
networks:
- app-shared-net
healthcheck:
test: sh -c "kill -0 1"
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
deploy:
resources:
limits:
memory: 512M
volumes:
recordings:
networks:
app-shared-net:
external: true
```
Key changes:
- Image refs: `registry.gitlab.com/mytheclipse-group/gmw/...``${GITEA_REGISTRY}/${GITEA_REPO}/...`
- **All bind-mounts removed** (`./backend-dist`, `./gateway-dist`, `./frontend-dist`, `./shared-dist`)
- `recordings` → named volume (persists across container restarts/recreates)
- `proxy` binds to `127.0.0.1:8080` instead of host port 80 (Traefik handles external routing)
- Added `depends_on: proxy` to backend for startup ordering
### 4.7 Remove: GitHub Actions & GitLab CI files
After Gitea CI is verified working:
- Delete `.github/workflows/deploy-docker.yml` (or rename to `.github/workflows/deploy-docker.yml.disabled`)
- Delete `.gitlab-ci.yml` (or rename to `.gitlab-ci.yml.disabled`)
### 4.8 Ensure: `.gitea/workflows/` directory
The directory must exist in git. Some setups ignore `.gitea/` — verify `.gitignore` does not exclude it.
## 5. Gitea Registry Integration
### 5.1 Enable Container Registry in Gitea
In Gitea Admin Settings:
- Go to Settings → Repository → Enable "Container Registry"
- Default registry URL format: `gitea.<domain>/<owner>/<repo>`
### 5.2 Registry Token
Create a Gitea access token with `read` and `write` access to packages:
- Settings → Applications → Generate Token → `registry-token` → scope: `write:packages`
### 5.3 CI Variables
Set these in Gitea repo → Settings → Actions → Secrets:
| Name | Example Value | Notes |
|------|---------------|-------|
| `GITEA_REGISTRY_TOKEN` | `gitea_token_abc123` | Docker login password |
| `VPS_HOST` | `123.123.123.123` | VPS IP/hostname |
| `VPS_USER` | `root` | SSH user |
| `VPS_SSH_KEY` | `-----BEGIN OPENSSH PRIVATE KEY-----...` | Private key |
| `ENV_FILE` | full .env content | Written to VPS before compose |
As Variables (not secrets, visible but non-sensitive):
| Name | Example Value | Notes |
|------|---------------|-------|
| `GITEA_REGISTRY` | `git.imrnes.team` | Registry hostname — no protocol prefix |
### 5.4 VPS Setup (one-time)
```bash
# 1. Docker login to Gitea registry
docker login git.imrnes.team
# Use Gitea username + access token (with write:packages scope)
# 2. Create recordings named volume
docker volume create imphenbot_recordings
# 3. Remove old bind-mount directories (after verifying old containers stopped)
rm -rf /opt/imphenbot/infra/docker/backend-dist
rm -rf /opt/imphenbot/infra/docker/gateway-dist
rm -rf /opt/imphenbot/infra/docker/shared-dist
rm -rf /opt/imphenbot/infra/docker/frontend-dist
# 4. Ensure compose file is updated (via git pull)
cd /opt/imphenbot && git pull origin main
```
## 6. Migration Plan
### Phase 1: Prepare (this session)
1. Write `.gitea/workflows/deploy.yml`
2. Rewrite `Dockerfile.proxy` for Next.js
3. Modify `infra/docker/docker-compose.yml` for Gitea registry + named volumes
4. Rewrite `deploy.sh` to SSH-only
5. Mark old CI files as disabled (rename, not delete yet)
6. Add VITE_BE_API_URL/VITE_BE_WS_URL build args to backend Dockerfile
### Phase 2: VPS Preparation (one-time SSH)
7. User runs `docker login` to Gitea registry on VPS
8. User sets CI secrets in Gitea UI
9. User creates `imphenbot_recordings` named volume
### Phase 3: Deploy
10. Commit and push to `main`
11. Gitea CI triggers — builds 3 images, pushes to registry
12. Deploy job SSHes into VPS, pulls images, restarts containers
13. Verify with `docker ps` and health checks
### Phase 4: Cleanup
14. After all services running stably for 1-2 pushes: delete old CI files
15. Remove old Dockerfiles if no longer referenced
## 7. Rollback Plan
If something goes wrong:
1. **Quick rollback**: `docker compose up -d` with previous `IMAGE_TAG` (pin to last working SHA)
2. **Full rollback**: Revert git changes, push to `main` — Gitea CI will rebuild with old config
3. **Emergency**: SSH to VPS, use `docker compose` commands to restart specific containers
## 8. Future Considerations
- **Auto-deploy on tag**: Optionally trigger CI only on version tags (`v*`) instead of every `main` push
- **Health check notifications**: Add webhook notification on deploy failure
- **Multi-architecture builds**: Add `--platform linux/amd64,linux/arm64` for future ARM VPS migration
- **Secrets management**: Consider HashiCorp Vault or Gitea's built-in encrypted secrets for larger teams
@@ -1,154 +0,0 @@
# Frontend Refactor: Cleanup, API Alignment & Rebrand
## Goal
Refactor the frontend (`services/frontend/`) to be cleaner, more maintainable, properly aligned with backend API, and rebranded from "bete/GMW" to "Discord Automod" and from "chatbot" to "chatbot".
## Scope
### A. Code Quality & Structure
1. **Extract inline page components** into dedicated files under `components/<feature>/`
2. **Remove dead code** (`live-stats.tsx`, `useSearch`, `Item`, etc.)
3. **Remove duplicate code** (merge `extractImage`/`extractFirstImage`, consolidate `WsHook` type, consolidate `isActive` functions)
4. **Fix Tailwind v4 dynamic class** (`grid-cols-${columns}`) in `LoadingSkeleton`
5. **Fix navigation icon** (Settings should use `Settings`, not `BarChart3`)
### B. API Layer Separation
- Split `voiceApi` into `voiceApi` + `mediaApi`
- Keep `chatbot.ts` as is (frontend already uses "chatbot" naming)
### C. Data Fetching Consistency
- `GuildSelector` → use `useGuilds` + `useConfig` React Query hooks
- `useVoiceChannels` → convert from manual `useState` to `useQuery`
- Chatbot → convert to `useQuery` + `useMutation` (user approved this)
### D. Rebrand
- **bete/GMW → Discord Automod**: page title, sidebar, settings, comments
- **chatbot → chatbot**: the frontend already uses "chatbot" naming for the component and API module; backend paths (`/api/chatbot/chat`) stay unchanged on frontend since they reference the actual backend path
### E. Dead Code Removal
- Remove `components/landing/` (including `live-stats.tsx`)
- Remove `components/ui/item.tsx` (unused)
- Remove `useSearch` from `use-messages.ts`
- Remove unused shadcn/ui components (verified by grep)
## Target Directory Structure
```
src/
app/(dashboard)/
messages/page.tsx # slim → imports from components/messages/
dashboard/page.tsx # slim
voice/page.tsx # slim
media/page.tsx # slim
recordings/page.tsx # slim
analysis/page.tsx # slim
settings/page.tsx # slim
layout.tsx # unchanged
app/layout.tsx # update title
app/page.tsx # unchanged (redirect)
components/
messages/
message-card.tsx # from inline in messages/page.tsx
message-detail-view.tsx # from inline DetailView
ai-status-badge.tsx # from inline AiStatusBadge
images-grid.tsx # images tab content
review-list.tsx # review tab content
dashboard/
stats-section.tsx
users-section.tsx
user-detail-section.tsx
channels-section.tsx
channel-detail-section.tsx
voice/
voice-connection-card.tsx
active-speakers-panel.tsx
microphone-card.tsx
media/
music-player.tsx
recordings/
recording-list.tsx
analysis/
search-panel.tsx
shared/ # existing
layout/ # existing
chatbot/ # existing
ui/ # shadcn — remove unused
hooks/
use-messages.ts # cleaned, use shared WsHook type
use-dashboard.ts
use-voice.ts # cleaned
use-media.ts # cleaned
use-recordings.ts # cleaned
use-guilds.ts
use-config.ts
use-mobile.ts
index.ts
lib/
ws-hook.ts # NEW: shared WsHook type
api/
client.ts
messages.ts
voice.ts # voice-only
media.ts # NEW: extracted from voiceApi
dashboard.ts
recordings.ts
config.ts
chatbot.ts
ui-state.ts
index.ts
types/ # no structural changes, verify alignment
ws/ # no structural changes
format.ts
navigation.ts
utils.ts
```
## Key Changes Detail
### 1. Component Extraction
Each page file that has inline components (messages=689 lines, dashboard=570 lines) will have those components extracted into dedicated files. The page file becomes a thin composition layer.
### 2. WsHook Type Consolidation
Three files define `type WsHook = { on: <E>(eventType: E, handler: ...) => () => void }`. This moves to `lib/ws-hook.ts` and all three hooks import it.
### 3. LoadingSkeleton Fix
Replace dynamic `grid-cols-${columns}` with explicit Tailwind classes or inline style:
```tsx
const gridCols = columns === 2 ? "grid-cols-1 md:grid-cols-2" : "grid-cols-1";
```
### 4. API Separation
```typescript
// lib/api/voice.ts — voice + guilds only
export const voiceApi = {
getGuilds, getTextChannels, getVoiceChannels,
getStatus, connect, disconnect, sendCommand,
};
// lib/api/media.ts — media player only (NEW)
export const mediaApi = {
getStatus, queue, skip, stop, volume,
};
```
### 5. Data Fetching Consistency
`GuildSelector` will use `useGuilds()` and `useConfig()` hooks instead of manual fetch in useEffect.
`useVoiceChannels` will use `useQuery` with `enabled: !!guildId`.
Chatbot will use `useQuery` for history and `useMutation` for send.
### 6. Rebrand
- `app/layout.tsx`: title → "Discord Automod"
- Sidebar brand: keep "DC Automod" (already done)
- Settings page: keep "DC Automod" reference
- Comments referencing "bete" → update
- No changes to package names or external references (backend still "bete" internally)
### Non-Goals
- No changes to backend API paths
- No changes to package.json names (pnpm workspace naming)
- No changes to Router/App Router structure
- No changes to CSS/styling system
- No functional changes — visual behavior identical
@@ -1,134 +0,0 @@
# Refactoring Backend & Discord-Gateway Services
**Date:** 2026-07-27
**Status:** Draft
## Overview
Comprehensive refactoring of `services/backend` (4.2k lines) and `services/discord-gateway` (17.9k lines) targeting code consistency, file-size reduction, deduplication, and pattern uniformity.
## Scope
### Phase 1 — Backend Controller Consistency
**Problem:** Two competing controller patterns.
- `messages.controller.ts`, `chatbot-chat.controller.ts` use convoluted `asyncHandler` inside function body (Gaya A)
- `voice.controller.ts`, `health.controller.ts` use clean `asyncHandler` decorator (Gaya B)
**Fix:** Convert all controllers to **Gaya B** (decorator pattern).
Before (Gaya A):
```ts
export function handleListMessages(req, res, next) {
return asyncHandler(async (req, res) => {
// ...
})(req, res, next);
}
```
After (Gaya B):
```ts
export const handleListMessages = asyncHandler(async (req, res) => {
// ...
});
```
**Files affected:**
- `modules/messages/messages.controller.ts`
- `modules/chatbot-chat/chatbot-chat.controller.ts`
### Phase 2 — Backend `response.ts` Cleanup
**Problem:** `success()`/`error()` helpers exist but are unused (except health controller).
**Fix:** Apply `success()` consistently to all API responses that are successful data returns. Remove `error()` if unused after audit.
**Files affected:** All route/service files that `res.json()` data.
### Phase 3 — Backend `ws/` Barrel
**Problem:** `ws/broadcast.ts`, `ws/redis-bridge.ts`, `ws/server.ts` — no barrel.
**Fix:** Add `ws/index.ts` barrel.
### Phase 4 — Gateway: Split `moderationPrompt.ts` (1015 lines)
**Problem:** Monolithic prompt file mixing all prompt types.
**Fix:** Split into:
- `prompts/text-analysis.ts` — Text moderation prompts
- `prompts/media-analysis.ts` — Image/video analysis prompts
- `prompts/stickers.ts` — Sticker analysis prompts
- `prompts/emojis.ts` — Custom emoji prompts
- `prompts/system.ts` — System prompt builder and shared helpers
### Phase 5 — Gateway: Split `moderationOrchestrator.ts` (955 lines)
**Problem:** Entry point that also contains inline text-only batch, media batch, and simple fallback.
**Fix:** Extract into:
- `textBatchProcessor.ts` — All text-only batching logic
- `mediaBatchProcessor.ts` — All media batching logic
- `simpleFallback.ts` — The `runSimpleTextFallback` function
### Phase 6 — Gateway: Split `mediaAnalysisClient.ts` (826 lines)
**Problem:** Cache logic (LRU + phash + DB), download logic (image/video + ffmpeg), and vision LLM in one file.
**Fix:** Extract into:
- `mediaCache.ts` — All caching layers (LRU, phash dedup, DB)
- `mediaDownloader.ts` — Image/video download, ffmpeg frame extraction
- `visionAnalyzer.ts` — Vision LLM orchestration
### Phase 7 — Gateway: Consolidate `bootstrap.ts`
**Problem:** 304-line bootstrap that embeds retention cleanup inline.
**Fix:** Extract `startRetentionCleanup` into `app/retention.ts`. Leave event registrations in bootstrap as they're inherently app-wide wiring.
### Phase 8 — Gateway: Simplify EventBroadcaster
**Problem:** `RedisEventPublisher` wrapping is thin — only adds a `publish` wrapper.
**Fix:** Merge `RedisEventPublisher` into `EventBroadcaster` as a private inner detail.
### Phase 9 — Cross-cutting: Database initialization dedup
**Problem:** Backend (`shared/database/index.ts`) and gateway (`shared/database/drizzle.ts`) have near-identical pool creation and lifecycle code.
**Fix:** Extract common pool/drizzle lifecycle into `@bete/shared`:
```ts
// packages/shared/src/database/index.ts
export function createDatabasePool(url: string, opts?: PoolOpts): Pool
export function createDrizzleClient(pool: Pool): DrizzleClient
export function closePool(pool: Pool): Promise<void>
```
Both services keep their own getDatabase/close wrappers but delegate pool creation to shared.
### Phase 10 — Gateway: Consolidate `moderationState.ts` / `conversationState.ts`
**Problem:** Two state files with overlapping concerns.
**Fix:** Audit both for overlap, merge if significant duplication found.
### Phase 11 — Gateway: Redis connection usage audit
**Problem:** Multiple independent Redis connections for EventBroadcaster and CommandHandler.
**Fix:** Both already need separate connections (Redis pub/sub limits). Document the pattern. No structural change.
## Files Changed
| Phase | Files | Type |
|-------|-------|------|
| 1 | 3 | edit |
| 2 | ~15 | edit |
| 3 | 1 | create |
| 4 | ~6 | split |
| 5 | ~4 | split |
| 6 | ~4 | split |
| 7 | 2 | split |
| 8 | 2 | refactor |
| 9 | 2 | refactor |
| 10 | 1-2 | audit+merge |
@@ -1,37 +0,0 @@
# Visual Redesign: Discord Automod Dashboard
## Design Direction
**Vibe:** "Monitoring hub" — deep, technical, trustworthy. Think security operations center meets modern dev tool.
## Palette
**Dark (primary):**
| Token | Value | Role |
|-------|-------|------|
| `--bg` | `oklch(0.09 0.015 245)` | Deeper navy canvas |
| `--card` | `oklch(0.13 0.02 245)` | Surface with subtle separation |
| `--primary` | `oklch(0.62 0.17 215)` | Teal-cyan accent (shift from sky blue) |
| `--accent` | `oklch(0.7 0.18 260)` | Electric blue-purple for secondary highlights |
| `--warn` | `oklch(0.7 0.17 75)` | Amber-gold for warnings (distinct from red) |
| `--border` | `oklch(1 0 0 / 0.06)` | Softer borders |
## Typography
- Geist Sans (body) + Geist Mono (code/data) — already loaded
- H1: `text-lg font-semibold tracking-tight`
- Card titles: `text-sm font-semibold tracking-tight`
- Labels/captions: `text-xs text-muted-foreground tracking-wide uppercase`
## Layout Changes
1. **Background**: Subtle dot-grid pattern (`radial-gradient(circle, oklch(1 0 0 / 0.03) 1px, transparent 1px)`) — monitoring station feel
2. **Sidebar**: Slightly wider (w-64), active item gets a glow bar + subtle teal tint background, connection dot with breathing animation
3. **Cards**: Hover state adds a thin teal border-top glow, softer shadow
4. **Stat cards**: Gradient background per stat type (like live-stats had), with icon in colored bubble
5. **Severity indicators**: Colored dot + label instead of just colored border
6. **Mobile nav**: Tighter spacing, active indicator as dot above icon
7. **Header**: Clean, thin bottom border glow, page title larger
## Signature Element
- **Grid background** + **teal glow** on active/interactive elements
- **Gradient accent bar** on sidebar active item (wider, glowing)
@@ -1,508 +0,0 @@
---
name: "Discord Automod — Neo Surveillance Redesign"
version: "1.0.0"
date: "2026-07-28"
status: "approved"
inspiration:
- "Summit Cloud Migration Platform (glassmorphic, dark premium)"
- "AeroNet Visualization (data panels, modular layout)"
colors:
canvas: "oklch(0.07 0.015 250)"
surface: "oklch(0.11 0.02 245 / 0.6)"
surface-hover: "oklch(0.15 0.02 245 / 0.7)"
border: "oklch(1 0 0 / 0.06)"
border-glow: "oklch(0.62 0.17 215 / 0.3)"
primary: "oklch(0.62 0.17 215)"
primary-glow: "oklch(0.62 0.17 215 / 0.4)"
accent-purple: "oklch(0.65 0.2 280)"
accent-amber: "oklch(0.7 0.17 75)"
text-primary: "oklch(0.93 0.01 245)"
text-secondary: "oklch(0.55 0.02 245)"
text-mono: "oklch(0.62 0.17 215)"
glass-bg: "oklch(1 0 0 / 0.04)"
glass-border: "oklch(1 0 0 / 0.08)"
glass-shadow: "0 8px 32px oklch(0 0 0 / 0.4)"
typography:
display: "Inter 28-48px weight 600"
body: "Inter 14-16px weight 400"
mono: "JetBrains Mono 11-13px weight 500-600"
data: "JetBrains Mono 24-36px weight 600, teal tint"
radius:
card: "16px"
panel: "12px"
control: "8px"
pill: "9999px"
---
# Discord Automod — Neo Surveillance Redesign
Full frontend redesign for Discord Automod, a Discord moderation watcher dashboard. Complete rewrite of layout, design system, navigation, and page architecture.
---
## 1. Design Philosophy
**"Neo Surveillance"** — a Security Operations Center (SOC) inspired dashboard where monitoring feels immersive and powerful. Full-screen glass panels float over a dark animated canvas. No persistent sidebar clutter. The interface disappears into the background, letting live data and alerts take center stage.
Key pillars:
- **Immersion** — Full-viewport canvas with ambient motion, glass panels float over content
- **Awareness** — Live data streams, real-time voice waveforms, animated moderation alerts
- **Presence** — Live2D vtuber chatbot character as chatbot interface, reacts to server events
---
## 2. Layout & Navigation System
### 2.1 Global Structure
```
┌──────────────────────────────────────────────────────┐
│ ● Discord Automod Dashboard Msgs Voice … 🟢 ● │ ← Floating Top Bar (~44px)
├──────────────────────────────────────────────────────┤
│ [Sub-navigation tabs] ← muncul per-page │
│━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━│
│ │
│ ┌─────────┐ ┌──────────────┐ │
│ │ Glass │ │ Content │ │
│ │ Panels │ │ Area │ │
│ │ │ │ (scroll) │ │
│ └─────────┘ └──────────────┘ │
│ │
├──────────────────────────────────────────────────────┤
│ 🎵 [Mini-player] ← bottom-left 🎭 [Chatbot] ← BR │
└──────────────────────────────────────────────────────┘
```
### 2.2 Floating Top Navigation Bar
- **Style:** Glass (`backdrop-blur-xl`), subtle glow border bottom, `h-11` (44px)
- **Left:** App logo "Discord Automod" with teal live dot indicator + current page name
- **Center:** Horizontal nav links — Dashboard, Messages, Voice, Recordings, Settings
- Icon + label, active state with glow underline (`box-shadow` teal)
- Hover: text brighter, no background fill
- **Search** has no nav link — triggered globally via Cmd+K or `/` shortcut, opens spotlight
- **Right:** Connection status dot (pulse when connected) + theme toggle (sun/moon icon)
- **Hover sidebar hotspot:** Left edge 4px trigger → slide-in sidebar with guild selector, bookmarks, recent channels (auto-hide 300ms after mouse leave)
### 2.3 Sub-navigation
Each page has its own tab bar below the top nav, also glass-styled:
- Dashboard: Stats | Live | Activity
- Messages: All | Images | Review
- Voice: Connection | Activity
- Recordings: Library | Stats
- Settings: Connection | Appearance | Config | About
### 2.4 Hidden Sidebar (Hover-activated)
- Trigger: 4px hotspot at left screen edge
- Slide-in animation (150ms, ease-out-expo)
- Contains: guild selector dropdown, bookmarked channels, recent activity shortcuts
- Auto-hide on mouse leave with 300ms delay
### 2.5 Floating Media Player
- No dedicated Media page — persistent floating mini-player at bottom-left
- Visible only when a track is active
- Click to expand: full queue management overlay
- Controls: play/pause, skip, stop, volume slider, progress bar
---
## 3. Design Tokens
### 3.1 Color Palette
| Token | Value | Usage |
|-------|-------|-------|
| `--canvas` | `oklch(0.07 0.015 250)` | Deep navy background |
| `--surface` | `oklch(0.11 0.02 245 / 0.6)` | Glass card base |
| `--surface-hover` | `oklch(0.15 0.02 245 / 0.7)` | Card hover state |
| `--border` | `oklch(1 0 0 / 0.06)` | Subtle border |
| `--border-glow` | `oklch(0.62 0.17 215 / 0.3)` | Active card border glow |
| `--primary` | `oklch(0.62 0.17 215)` | Teal-cyan accent, buttons |
| `--primary-glow` | `oklch(0.62 0.17 215 / 0.4)` | Active state glow |
| `--accent-purple` | `oklch(0.65 0.2 280)` | Moderation flagged items |
| `--accent-amber` | `oklch(0.7 0.17 75)` | Warnings |
| `--text-primary` | `oklch(0.93 0.01 245)` | Body text |
| `--text-secondary` | `oklch(0.55 0.02 245)` | Secondary labels |
| `--text-mono` | `oklch(0.62 0.17 215)` | Data metrics (teal tint) |
| `--glass-bg` | `oklch(1 0 0 / 0.04)` | Glass base |
| `--glass-border` | `oklch(1 0 0 / 0.08)` | Glass border |
| `--glass-shadow` | `0 8px 32px oklch(0 0 0 / 0.4)` | Glass shadow |
### 3.2 Typography
| Role | Font | Size / Weight |
|------|------|--------------|
| Display | Inter | 28-48px, weight 600 |
| Body | Inter | 14-16px, weight 400 |
| Label / Mono | JetBrains Mono | 11-13px, weight 500-600 |
| Data metrics | JetBrains Mono | 24-36px, weight 600, teal tint |
### 3.3 Radius System
| Token | Value |
|-------|-------|
| Card | 16px |
| Panel | 12px |
| Button / Control | 8px |
| Pill | 9999px |
### 3.4 Motion Tokens
```css
--ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
--duration-fast: 150ms;
--duration-normal: 250ms;
--duration-slow: 400ms;
```
---
## 4. Component System
### 4.1 Glass Card System
- **Base:** `glass-bg` + `glass-border` + border-radius `16px`
- **Elevated:** Deeper shadow + subtle primary glow border
- **Interactive:** Hover `scale(1.01)` + border glow intensify
- **Danger:** Red-tinted border for critical items
- Inner padding: `20px` (card), `16px` (panel), `12px` (dense)
### 4.2 Button Variants
| Variant | Style |
|---------|-------|
| Primary | `bg-primary` + `shadow-[0_0_12px] shadow-primary/40` (glow) |
| Secondary | `glass-bg` + `border` |
| Ghost | Transparent, hover → subtle glass bg |
| Icon | Size 32px, rounded 8px |
| Danger | Red-tinted variant for destructive actions |
### 4.3 Status Indicators
- **Live dot:** Pulsing ring animation (`pulse-ring` 1.5s)
- **AI Badge:** Teal pill with sparkle icon, mono font
- **Severity badges:** Clean (green), Warn (amber), Flagged (purple), Critical (red)
- **Connection:** Green (connected), Yellow (connecting), Red (disconnected)
### 4.4 Charts (Recharts)
Custom theme matching design tokens:
- Line/Area: gradient fill (primary → transparent)
- Bar: rounded bars, teal-cyan gradient
- Heatmap: activity by hour × weekday
- Radar: multi-axis for moderation categories
### 4.5 Live2D Chatbot / Chatbot
- Replaces the existing `Chatbot` component entirely — chatbot panel is the new chat interface
- **Location:** Floating panel, bottom-right corner, draggable
- **Default size:** Compact — upper body visible (~200×280px)
- **Click character:** Expand with full chat panel
- **Dynamic expressions:**
- Idle: subtle breathing, blink every 4s
- New message: head tilt "listening"
- Flagged detected: eyes widen, ! bubble
- User click: happy wave
- Voice active: ear/head tilt toward audio
- Chat reply: mouth sync animation
- Disconnect: sad expression
- **Technology:** Live2D Cubism SDK (WebGL via pixi.js wrapper), `.model3.json` + `.moc3` format
- **Chat panel:** Glass-styled input + message history, context-aware (server context)
### 4.6 Loading States
- **Skeleton:** Glass card shape with shimmer gradient (teal → transparent → teal)
- **Button loading:** Spinner within button
- **Full page:** Glass skeleton grid matching target layout
---
## 5. Page Layouts
### 5.1 Dashboard — "Ops Center"
Full-viewport command center:
- **Stat cards row:** Total Messages, Today, Users, Active 24h, Flagged, Clean — each with micro sparkline chart (Recharts mini area) behind the number
- **Live Message Stream:** Auto-scrolling glass panel showing recent messages, fade-in animation, click for detail
- **Mod Queue:** Flagged messages with quick action buttons (approve/delete/escalate)
- **Message Trend Chart:** 7-day area chart
- **Activity Heatmap:** Hour × day-of-week, moderation event density
- **Top Channels:** Bar chart with channel names
- **Chatbot visible** floating bottom-right
### 5.2 Messages — Split Pane
- **Left pane:** Scrollable message list, glass cards with severity badge, channel tag, timestamp
- **Right pane:** Detail/preview — full message content, attachments gallery, AI analysis breakdown (severity, flags, confidence, categories)
- **Global search bar** in top area: spotlight-style overlay (Cmd+K)
- **State in URL params:** `?guild=xxx&channel=yyy&selected=msg123&tab=all`
- **Tabs:** All | Images (grid view) | Review (flagged queue)
- **Actions:** Reanalyze, Moderate (dropdown: delete/warn/escalate)
### 5.3 Voice — Connection Center
- **Connection card:** Guild/channel selectors, status with live dot + duration
- **Active Speakers:** Per-user waveform visualization (canvas-based, 100ms update)
- **Microphone/Transmit:** Toggle mic, volume slider
- **Voice Activity Timeline:** Bar chart showing who spoke and total duration
- **Recordings quick link** to Recordings page
### 5.4 Recordings — Voice Library
- **Search + filter bar:** By user, channel, date range
- **Recording cards:** Glass card, waveform preview (canvas), duration, timestamp
- **Inline playback:** Play button, audio player without leaving page
- **Actions:** Download, Copy Link
### 5.5 Settings
- **Sections:** Connection (WebSocket status, guild info), Appearance (theme toggle), Server Config (read-only), About
- All glass cards, mono font for config values
- Toggle switches with glass styling
---
## 6. Animations & Micro-interactions
### 6.1 Ambient Background
- Gradient mesh with slow-shift (30s cycle)
- 2-3 soft color blobs (teal, purple, amber), opacity 0.03-0.06
- Grid dot pattern: `radial-gradient(circle, oklch(1 0 0 / 0.025) 1px, transparent 1px)`, 24px spacing
### 6.2 Page Transitions
- Route change: `fade-in-up` 200ms ease-out
- Content section: `scale(0.98→1)` + `opacity(0.6→1)`
### 6.3 Card Interactions
- Hover: `scale(1.01)` + border glow intensify + shadow lift
- Click: `scale(0.98)` brief (100ms)
- Panel enter: `translateY(-4px)` + `opacity` fade-in
- Stat counter: count-up animation (JS tween, 400ms)
### 6.4 Live Data
- Message stream: fade-in from top, slide down as new arrive
- Voice waveform: real-time canvas draw, 100ms interval
- Recording: pulsing dot + ring expansion (1.5s loop)
- Connection: slow pulse when connected
- Flagged: brief red/purple border flash on new flagged message
### 6.5 Micro-interactions
- Toggle: slide with glow
- Scrollbar: custom thin (6px), auto-hide, rounded
- Drag handle: subtle dot grip for split pane
- Copy: brief "Copied!" toast
- Reanalyze: 360° icon rotation
---
## 7. Data Flow & State Management
### 7.1 Architecture
```
WS Provider (auto-reconnect, typed events, event buffer)
TanStack Query (fetches + cache)
Query invalidation on WS events
Optimistic cache updates for real-time data
```
### 7.2 WS → Cache Strategy
| WS Event | Action |
|----------|--------|
| `message_created` | Optimistic insert to message list + dashboard stats |
| `message_analyzed` | Update AI fields in message cache |
| `message_deleted` | Remove from cache + update counters |
| `voice_recording_started` | Update voice status |
| `voice_pcm_data` | Buffer to waveform canvas (bypass React) |
| `voice_active_user` | Update speakers cache |
| `analysis_queue_status` | Update queue progress |
### 7.3 Query Config
- `staleTime: 10_000` (10s)
- `gcTime: 5 * 60 * 1000` (5 min)
- `refetchOnWindowFocus: false`
### 7.4 Global State (React Context)
- `useMediaPlayer()` — current track, queue, play/skip/stop/volume
- `useChatbot()` — expression, minimized, chatHistory, setExpression
- Externally triggerable: `chatbot.setExpression("surprise")` on flagged message, `("listening")` on voice activity
### 7.5 URL State
Persistent page state via search params (not React state):
```
/messages?guild=xxx&channel=yyy&selected=msg123&tab=all
```
### 7.6 Error Boundaries
Each page has its own error boundary. One page failure doesn't affect others.
---
## 8. Technology Stack
- **Framework:** Next.js 16 (App Router, static export)
- **Language:** TypeScript strict
- **Styling:** Tailwind v4 + CSS custom properties
- **UI Base:** shadcn/ui components (adapted for glass theme)
- **Icons:** lucide-react
- **State/data:** @tanstack/react-query v5
- **Charts:** Recharts 3.8 (with custom theme)
- **3D/Chatbot:** Live2D Cubism SDK WebGL (pixi.js wrapper)
- **Audio:** Web Audio API for waveform visualization
- **Animation:** CSS animations + transitions (no GSAP/framer-motion dependency unless specifically needed)
---
## 9. File Structure (New)
```
src/
├── app/
│ ├── layout.tsx # Root layout (fonts, theme script, Toaster)
│ ├── page.tsx # Redirect → /dashboard
│ ├── globals.css # Complete redesign CSS (tokens, glass, animations)
│ └── (dashboard)/
│ ├── layout.tsx # Dashboard layout (top nav, QueryClient, WS, chatbot)
│ ├── dashboard/
│ │ └── page.tsx # Ops Center
│ ├── messages/
│ │ └── page.tsx # Split pane messages
│ ├── voice/
│ │ └── page.tsx # Voice connection center
│ ├── recordings/
│ │ └── page.tsx # Recording library
│ └── settings/
│ └── page.tsx # Settings page
├── components/
│ ├── layout/
│ │ ├── top-nav.tsx # Floating top navigation bar
│ │ ├── sub-nav.tsx # Per-page sub-navigation tabs
│ │ ├── hidden-sidebar.tsx # Hover-activated guild sidebar
│ │ └── mobile-nav.tsx # Mobile bottom nav (updated design)
│ │
│ ├── glass/
│ │ ├── card.tsx # Glass card component (base, elevated, interactive)
│ │ ├── panel.tsx # Glass panel wrapper
│ │ └── divider.tsx # Glass-styled separator
│ │
│ ├── dashboard/
│ │ ├── stat-card.tsx # Stat card with micro sparkline
│ │ ├── live-stream.tsx # Auto-scrolling message stream
│ │ ├── mod-queue.tsx # Moderation queue with quick actions
│ │ ├── message-trend-chart.tsx # 7-day area chart
│ │ ├── activity-heatmap.tsx # Hour × day heatmap
│ │ └── top-channels-chart.tsx # Top channels bar chart
│ │
│ ├── messages/
│ │ ├── message-list.tsx # Left pane — scrollable message list
│ │ ├── message-card.tsx # Individual message card (redesigned)
│ │ ├── message-detail.tsx # Right pane — full detail
│ │ ├── attachments-grid.tsx # Attachments gallery
│ │ ├── ai-analysis-panel.tsx # AI analysis breakdown
│ │ └── search-overlay.tsx # Cmd+K search spotlight
│ │
│ ├── voice/
│ │ ├── connection-card.tsx # Guild/channel selector + status
│ │ ├── speaker-waveform.tsx # Canvas waveform per speaker
│ │ ├── mic-control.tsx # Mic toggle + volume
│ │ └── activity-timeline.tsx # Voice activity bar chart
│ │
│ ├── recordings/
│ │ ├── recording-card.tsx # Glass card with waveform preview
│ │ └── recording-player.tsx # Inline audio player
│ │
│ ├── chatbot/
│ │ ├── chatbot-container.tsx # Floating L2D container
│ │ ├── chatbot-canvas.tsx # WebGL canvas for L2D rendering
│ │ ├── chat-panel.tsx # Chat input + history
│ │ └── chatbot-context.tsx # Context provider
│ │
│ ├── media/
│ │ └── mini-player.tsx # Floating mini media player
│ │
│ ├── shared/
│ │ ├── error-state.tsx # Error boundary fallback
│ │ ├── loading-skeleton.tsx # Glass shimmer skeleton
│ │ └── empty-state.tsx # Empty state illustration
│ │
│ └── ui/ # shadcn/ui components (adapted to glass)
│ ├── button.tsx, badge.tsx, dialog.tsx, ...
├── lib/
│ ├── api/ # Existing API client (unchanged)
│ ├── ws/
│ │ ├── context.tsx # WS provider (unchanged)
│ │ └── types.ts # WS event types
│ ├── hooks/ # Existing hooks + new ones
│ │ ├── use-media-player.ts # Global media state
│ │ ├── use-chatbot.ts # Chatbot context hook
│ │ └── use-heatmap.ts # Heatmap data hook
│ ├── types/ # Existing types (unchanged)
│ ├── navigation.ts # Nav items (updated)
│ └── format.ts # Format utilities
```
---
## 10. Implementation Order
### Phase 1 — Foundation
1. Update `globals.css` with new design tokens (colors, glass, radius, typography, animations)
2. Rewrite root `layout.tsx` with theme system
3. Build glass component system (`card.tsx`, `panel.tsx`)
4. Build `top-nav.tsx`, `sub-nav.tsx`, `hidden-sidebar.tsx`
5. Update dashboard layout with new nav
### Phase 2 — Dashboard Ops Center
6. Build `stat-card.tsx` with micro sparkline
7. Build `live-stream.tsx`
8. Build `mod-queue.tsx`
9. Build charts: `message-trend-chart.tsx`, `activity-heatmap.tsx`, `top-channels-chart.tsx`
10. Rewrite dashboard page
### Phase 3 — Messages (Split Pane)
11. Build `message-list.tsx`, `message-card.tsx` (redesigned)
12. Build `message-detail.tsx`, `ai-analysis-panel.tsx`, `attachments-grid.tsx`
13. Build `search-overlay.tsx`
14. Rewrite messages page with split-pane layout
### Phase 4 — Voice, Recordings, Settings
15. Build voice components and rewrite voice page
16. Build recording components and rewrite recordings page
17. Rewrite settings page
### Phase 5 — Floating Elements
18. Build `mini-player.tsx` for media
19. Build chatbot components (L2D integration)
---
## 11. Testing
- Visual regression checks per component
- WS integration tests for cache updates
- Responsive breakpoint testing (mobile bottom nav)
- L2D chatbot load + expression trigger
---
## 12. Non-Goals (Out of Scope)
- Authentication — remains public
- Backend API changes — only frontend redesign
- Database changes — no schema modifications
- New backend WebSocket events — reuse existing
- L2D model creation — integration only (model file provided separately)
+158 -23
View File
@@ -7,10 +7,37 @@
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
flake-utils.lib.eachSystem [ "x86_64-linux" ] (system:
let
pkgs = import nixpkgs { inherit system; };
# libdatachannel for the GoLive N-API binding. nixpkgs 0.24.1 is built
# against this host's glibc and ships both lib + dev headers, so the
# binding links cleanly inside the Nix sandbox (no manual cmake build).
libdatachannel = pkgs.libdatachannel;
# Source filter: `path:` literals do NOT respect .gitignore by default,
# so a dirty local out/ (stale chunks from previous builds) leaks into
# the sandbox. Filter out build artifacts explicitly.
filterSource = { dir, ignore }: builtins.path {
path = dir;
name = "source";
filter = (path: type: let base = baseNameOf path; in !(builtins.elem base ignore));
};
frontendSrc = filterSource {
dir = ./services/frontend;
ignore = [ "out" ".next" "node_modules" "pnpm-lock.yaml" ];
};
# OpenSSL headers (.dev output) + STATIC libs (pkgsStatic.openssl.out —
# node-datachannel's CMakeLists sets OPENSSL_USE_STATIC_LIBS=TRUE, and
# the default `pkgs.openssl` resolves to `bin` which has no lib/) merged
# into one tree so FindOpenSSL resolves both via OPENSSL_ROOT_DIR.
opensslDevEnv = pkgs.symlinkJoin {
name = "openssl-dev-env";
paths = [ pkgs.pkgsStatic.openssl.out pkgs.openssl.dev ];
};
# ---- Shared build tools ----
nodejs = pkgs.nodejs_22;
pnpm = pkgs.pnpm.override { nodejs = nodejs; };
@@ -37,6 +64,36 @@
pnpm rebuild 2>&1 || true
'';
# Shrink the shipped node_modules to production deps only. The full
# install's .pnpm virtual store carries dev-only packages (biome,
# typescript, esbuild, drizzle-kit, vitest, ... ~150MB+) that are never
# needed at runtime, so we delete every .pnpm dir that is not part of
# the resolved production graph (`pnpm list --prod`).
#
# NOTE: do NOT use `pnpm install --prod` here — it collapses the
# public-hoist dir (.pnpm/node_modules) that runtime peer resolution
# relies on (e.g. @lng2004/node-datachannel and @seydx/node-av-linux-x64
# are only reachable through it), silently breaking voice/screenshare.
# Instead we keep the full install's symlink layout and only prune
# orphaned package dirs + broken symlinks.
# Must run AFTER tsc (typescript is a devDep) and after native builds.
pruneProd = ''
echo "=== Pruning devDependencies (production-only node_modules) ==="
pnpm list --prod --depth 999 --parseable 2>/dev/null \
| grep -o '\.pnpm/[^/]*' | sort -u > $TMPDIR/prod-pnms.txt
( cd node_modules/.pnpm \
&& for d in */; do \
d="''${d%/}"; \
[ "$d" = "node_modules" ] && continue; \
grep -qF ".pnpm/$d" $TMPDIR/prod-pnms.txt || rm -rf "$d"; \
done ) || true
# Drop symlinks whose .pnpm target was pruned (top-level, scoped dirs,
# hoist, .bin any depth). Mirrors stdenv's noBrokenSymlinks check,
# which would otherwise fail the fixupPhase.
find node_modules -type l ! -exec test -e {} \; -delete 2>/dev/null || true
du -sh node_modules
'';
# ---- Backend ----
backend = pkgs.stdenv.mkDerivation {
pname = "gmw-backend";
@@ -75,7 +132,7 @@
console.log('Fixed ' + count + ' files');
"
echo "=== Build complete ==="
'';
'' + pruneProd;
installPhase = ''
mkdir -p $out/lib/gmw-backend
@@ -105,15 +162,58 @@ WRAPPER
nativeBuildInputs = [
nodejs pnpm
pkgs.python3 pkgs.gnumake pkgs.gcc
pkgs.python3 pkgs.gnumake pkgs.gcc pkgs.cmake
pkgs.rustc pkgs.cargo
pkgs.pkg-config
pkgs.openssl
pkgs.openssl.dev
libdatachannel.dev # rtc/rtc.hpp headers for the GoLive binding
pkgs.git # libdatachannel FetchContent clones from GitHub
pkgs.cacert
];
# Runtime tools for the voice pipeline: ffmpeg (mic transmit encode,
# music stream decode, segment muxing) and yt-dlp (YouTube/Spotify/
# search media resolution). Must be on PATH inside the wrapper below.
buildInputs = [ pkgs.ffmpeg-headless pkgs.yt-dlp ];
# cmake is only needed for node-datachannel's postinstall build —
# do NOT let stdenv run its own cmake configure phase on the source.
dontUseCmakeConfigure = true;
buildPhase = pnpmInstall + ''
echo "=== Compiling TypeScript ==="
echo "=== Building native voice deps ==="
# pnpm rebuild aborts on the first failing package and runs scripts
# from the wrong cwd build each native dep explicitly with its own
# install script. Each failure is tolerated (|| true); the packages
# that matter (opus) are verified at runtime.
for pkg in \
node_modules/.pnpm/@discordjs+opus@*/node_modules/@discordjs/opus
do
if [ -d "$pkg" ]; then
echo "--- native build: $pkg ---"
(cd "$pkg" && npm run install 2>&1 || true)
fi
done
echo "=== Building libdatachannel-min N-API binding ==="
# The GoLive screen-share stack uses a minimal N-API binding
# (native/libdatachannel-min) over nixpkgs libdatachannel.
(
cd native/libdatachannel-min
# binding.gyp resolves include/lib from env (LDC_INCLUDE = .dev
# include root, LDC_LIB = lib output dir, NAPI_INCLUDE =
# node-addon-api include root).
NAPI_INCLUDE=$(find ../../node_modules/.pnpm -maxdepth 3 \
-type d -path "*node_modules/node-addon-api" | head -1)
echo "NAPI_INCLUDE=$NAPI_INCLUDE"
LDC_INCLUDE=${libdatachannel.dev} LDC_LIB=${libdatachannel.out}/lib/libdatachannel.so.0.24.1 \
NAPI_INCLUDE=$NAPI_INCLUDE \
npx node-gyp rebuild 2>&1 || true
ls -la build/Release/datachannel_min.node 2>/dev/null \
&& echo "libdatachannel-min binding OK: $(stat -c%s build/Release/datachannel_min.node) bytes" \
|| echo "WARN: libdatachannel-min binding build FAILED (screen share disabled)"
)
echo "=== Compiling TypeScript ===="
npx tsc 2>&1
echo "=== Fixing @/ path aliases to relative paths ==="
node -e "
@@ -141,12 +241,28 @@ WRAPPER
console.log('Fixed ' + count + ' files');
"
echo "=== Build complete ==="
'';
'' + pruneProd;
installPhase = ''
mkdir -p $out/lib/gmw-discord-gateway
cp -r dist node_modules package.json tsconfig.json $out/lib/gmw-discord-gateway/
# GoLive native binding loadNative resolves it relative to
# dist/goLive/native.js, i.e. <root>/native/libdatachannel-min/
# build/Release/datachannel_min.node; libdatachannel .so must sit
# next to it and be on LD_LIBRARY_PATH at runtime.
mkdir -p $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release
cp native/libdatachannel-min/build/Release/datachannel_min.node \
$out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release/ 2>/dev/null || true
mkdir -p $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/ldc
cp -rL native/libdatachannel-min/build/ldc/libdatachannel.so* \
$out/lib/gmw-discord-gateway/native/libdatachannel-min/build/ldc/ 2>/dev/null || true
# If the binding failed to build, screen share is simply disabled
# the gateway itself must still start.
if [ ! -f $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release/datachannel_min.node ]; then
echo "WARN: datachannel_min.node missing GoLive screen share disabled in this build"
fi
# Also include drizzle migrations if they exist
cp -r drizzle $out/lib/gmw-discord-gateway/ 2>/dev/null || true
@@ -154,6 +270,8 @@ WRAPPER
cat > $out/bin/gmw-discord-gateway << WRAPPER
#!${pkgs.runtimeShell}
cd $out/lib/gmw-discord-gateway
export PATH=${pkgs.ffmpeg-headless}/bin:${pkgs.yt-dlp}/bin:\$PATH
export LD_LIBRARY_PATH=${libdatachannel.out}/lib:\$LD_LIBRARY_PATH
exec ${nodejs}/bin/node dist/index.js
WRAPPER
chmod +x $out/bin/gmw-discord-gateway
@@ -165,39 +283,57 @@ WRAPPER
};
};
# ---- Frontend (Next.js static export) ----
# ---- Frontend (Next.js SSR standalone) ----
frontend = pkgs.stdenv.mkDerivation {
pname = "gmw-frontend";
version = "1.0.0";
src = ./services/frontend;
src = frontendSrc;
nativeBuildInputs = [ nodejs pnpm pkgs.gnumake pkgs.gcc pkgs.cacert ];
buildPhase = pnpmInstall + ''
echo "=== Building Next.js static export ==="
# Build args are provided as env vars
echo "=== Building Next.js SSR (standalone) ==="
export NEXT_TELEMETRY_DISABLED=1
export GMW_BACKEND_URL=http://127.0.0.1:4001
npx next build 2>&1
'';
installPhase = ''
mkdir -p $out/share/gmw-frontend
cp -r out $out/share/gmw-frontend/out 2>/dev/null || \
cp -r dist $out/share/gmw-frontend/dist 2>/dev/null || \
cp -r .next $out/share/gmw-frontend/.next 2>/dev/null || true
echo "=== Packaging standalone server ==="
mkdir -p $out/lib/gmw-frontend/standalone
# The standalone server bundles its own minimal node_modules but
# needs the build assets + public copied INSIDE its tree.
cp -r .next/standalone/. $out/lib/gmw-frontend/standalone/
mkdir -p $out/lib/gmw-frontend/standalone/.next
cp -r .next/static $out/lib/gmw-frontend/standalone/.next/static
cp -r public $out/lib/gmw-frontend/standalone/public 2>/dev/null || true
# Copy node_modules for standalone mode if it exists
cp -r node_modules $out/share/gmw-frontend/ 2>/dev/null || true
# Remove dangling symlinks left by pnpm's hoisted .pnpm layout
# (e.g. node_modules/.pnpm/node_modules/...). The standalone server
# never resolves those at runtime it bundles its own node_modules
# and they trip stdenv's noBrokenSymlinks check.
find $out/lib/gmw-frontend/standalone -type l \
! -exec test -e {} \; -delete 2>/dev/null || true
mkdir -p $out/bin
cat > $out/bin/gmw-frontend << WRAPPER
#!${pkgs.runtimeShell}
cd $out/lib/gmw-frontend/standalone
export PORT=''${GMW_FRONTEND_PORT:-4017}
export HOSTNAME=127.0.0.1
exec ${nodejs}/bin/node server.js
WRAPPER
chmod +x $out/bin/gmw-frontend
'';
meta = {
description = "GMW Frontend Next.js static dashboard";
description = "GMW Frontend Next.js SSR dashboard";
platforms = pkgs.lib.platforms.linux;
};
};
# ---- Proxy (nginx serving frontend) ----
# ---- Proxy (nginx: / -> Next SSR, /api + /ws -> backend) ----
proxy = pkgs.stdenv.mkDerivation {
pname = "gmw-proxy";
version = "1.0.0";
@@ -212,11 +348,10 @@ WRAPPER
mkdir -p $out/bin $out/etc $out/share
# Substitute placeholders in nginx template
sed \
-e "s|@NGINX_MIME@|${pkgs.nginx}/conf/mime.types|g" \
-e "s|@FRONTEND_ROOT@|${frontend}/share/gmw-frontend/out|g" \
${./infra/nix/nginx.conf.template} \
> $out/etc/nginx.conf
sed -e "s|@NGINX_MIME@|${pkgs.nginx}/conf/mime.types|g" \
-e "s|@NEXT_PORT@|4017|g" \
${./infra/nix/nginx.conf.template} \
> $out/etc/nginx.conf
cat > $out/bin/gmw-proxy << WRAPPER
#!${pkgs.runtimeShell}
@@ -226,7 +361,7 @@ WRAPPER
'';
meta = {
description = "GMW Proxy nginx serving frontend";
description = "GMW Proxy nginx -> Next.js + backend";
platforms = pkgs.lib.platforms.linux;
};
};
+2 -2
View File
@@ -33,9 +33,9 @@ COPY --from=builder --chown=node:node /build/node_modules ./node_modules
COPY --from=builder --chown=node:node /build/package.json ./
USER node
EXPOSE 3000
EXPOSE 4001
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/health',r=>process.exit(r.statusCode===200?0:1))"
CMD node -e "require('http').get('http://localhost:4001/api/health',r=>process.exit(r.statusCode===200?0:1))"
CMD ["node", "dist/index.js"]
+2 -2
View File
@@ -33,9 +33,9 @@ services:
- .env
environment:
NODE_ENV: production
WEBSERVER_PORT: 3000
WEBSERVER_PORT: 4001
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
test: ["CMD", "wget", "-qO-", "http://localhost:4001/api/health"]
interval: 30s
timeout: 10s
start_period: 15s
+43 -9
View File
@@ -11,24 +11,44 @@ http {
'' close;
}
# Next.js standalone SSR server (backend-fetching on every render).
# Not for hand-editing: @NEXT_PORT@ is substituted at build time.
upstream gmw_next {
server 127.0.0.1:@NEXT_PORT@;
keepalive 16;
}
upstream gmw_backend {
server 127.0.0.1:4001;
keepalive 16;
}
server {
listen 8080;
listen 4009;
server_name _;
# Use relative redirects (Location: /dashboard/) instead of absolute
# URLs that leak the internal listen port (4009) through the reverse proxy.
absolute_redirect off;
gzip on;
gzip_types text/plain text/css application/json application/javascript application/wasm image/svg+xml;
gzip_min_length 256;
# ── Backend REST ───────────────────────────────────────────────
location ^~ /api {
proxy_pass http://127.0.0.1:3001$uri$is_args$args;
proxy_pass http://gmw_backend$uri$is_args$args;
proxy_http_version 1.1;
proxy_set_header Connection ""; # keepalive to backend
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ── Backend WebSocket (realtime shared state + voice PCM) ──────
location ^~ /ws {
proxy_pass http://127.0.0.1:3001$uri$is_args$args;
proxy_pass http://gmw_backend$uri$is_args$args;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
@@ -41,16 +61,30 @@ http {
proxy_send_timeout 86400s;
}
location /assets/ {
root @FRONTEND_ROOT@;
# ── Next.js build assets — immutable, edge/shareable ───────────
location ^~ /_next/static/ {
proxy_pass http://gmw_next$uri$is_args$args;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
expires 1y;
add_header Cache-Control "public, immutable";
}
# ── Everything else → Next.js server (SSR) ──
location / {
root @FRONTEND_ROOT@;
index index.html;
try_files $uri $uri/ /index.html;
proxy_pass http://gmw_next$uri$is_args$args;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Next-Prefetch $http_x_next_prefetch;
proxy_buffering off;
proxy_read_timeout 30s;
}
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
-- Fix: missing messages and attachments tables on VPS
-- Run: PGPASSWORD=hunterz psql -h 100.108.1.124 -U asephs -d hub -f scripts/fix-missing-tables.sql
-- Run: PGPASSWORD=hunterz psql -h 100.121.180.82 -U asephs -d hub -f scripts/fix-missing-tables.sql
BEGIN;
+6 -6
View File
@@ -225,21 +225,21 @@ All config via environment variables (`.env`), validated with Zod in `shared/con
```env
# Server
WEBSERVER_PORT=3001
WEBSERVER_PORT=4001
NODE_ENV=development
LOG_LEVEL=info
# Database
DATABASE_URL=postgresql://user:pass@localhost:5432/discord_moderation
DATABASE_URL=postgresql://asephs:***@100.121.180.82:6432/discord_moderation
# OR
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_HOST=100.121.180.82
DATABASE_PORT=6432
DATABASE_NAME=discord_moderation
DATABASE_USER=postgres
DATABASE_PASSWORD=secret
# Redis (optional, for pub/sub)
REDIS_URL=redis://localhost:6379
REDIS_URL=redis://100.121.180.82:6379
# Discord
MONITOR_GUILD_ID=123456789
@@ -263,7 +263,7 @@ Use Vitest with mocked database and services.
2. **Implement repository queries** for each module using Drizzle ORM
3. **Add WebSocket server** in `src/ws/server.ts` with Redis pub/sub listener
4. **Create Discord Gateway service** in `services/discord-gateway/` (separate microservice)
5. **Add Docker & CI/CD** for multi-service deployment
5. **Add Nix & CI/CD** for multi-service deployment (flake.nix + GitHub Actions → nix copy → systemd)
6. **Write integration tests** for full request flow
## Circular Dependency Check
+2 -2
View File
@@ -1,10 +1,10 @@
/**
* E2E API tests — runs against a running backend instance.
* Usage: API_BASE=http://localhost:3001 vitest run
* Usage: API_BASE=http://localhost:4001 vitest run
*/
import { describe, expect, it } from "vitest";
const BASE = process.env.API_BASE ?? "http://localhost:3001/api";
const BASE = process.env.API_BASE ?? "http://localhost:4001/api";
async function api(path: string, init?: RequestInit) {
const res = await fetch(`${BASE}${path}`, {
+4 -2
View File
@@ -1,4 +1,3 @@
import { createChildLogger } from "@/shared/logger/index";
import express, {
type Express,
type NextFunction,
@@ -6,13 +5,15 @@ import express, {
type Response,
} from "express";
import helmet from "helmet";
import { createChildLogger } from "@/shared/logger/index";
import { createAnalysisRouter } from "../modules/analysis/index.js";
import { createChatbotRouter } from "../modules/chatbot/index.js";
import { createConfigRouter } from "../modules/config/index.js";
import { createDashboardRouter } from "../modules/dashboard/index.js";
import { createHealthRouter } from "../modules/health/index.js";
import { createChatbotRouter } from "../modules/chatbot/index.js";
import { createMediaRouter } from "../modules/media/index.js";
import { createMessagesRouter } from "../modules/messages/index.js";
import { createModerationRouter } from "../modules/moderation/index.js";
import { createRecordingsRouter } from "../modules/recordings/index.js";
import { createUiStateRouter } from "../modules/ui-state/index.js";
import { createVoiceRouter } from "../modules/voice/index.js";
@@ -69,6 +70,7 @@ export function createHttpApp(): Express {
app.use("/api", createUiStateRouter());
app.use("/api", createMediaRouter());
app.use("/api", createVoiceRouter());
app.use("/api", createModerationRouter());
// 404 handler
app.use((_req: Request, res: Response) => {
+11
View File
@@ -24,6 +24,15 @@ async function main() {
async function shutdown(signal: string) {
logger.info({ signal }, "Shutting down gracefully");
// Failsafe: graceful shutdown must never hang the process forever.
// httpServer.close() waits for ALL open connections (including lingering
// WebSocket/keep-alive sockets), so on a stuck connection the process would
// otherwise sit zombie and systemd (Restart=always) can never revive it.
const forceExitTimer = setTimeout(() => {
logger.error({ signal }, "Graceful shutdown timed out; forcing exit");
process.exit(1);
}, 10_000);
try {
// 1. Stop accepting new HTTP connections
if (httpServer) {
@@ -54,9 +63,11 @@ async function shutdown(signal: string) {
);
logger.info("Graceful shutdown completed");
clearTimeout(forceExitTimer);
process.exit(0);
} catch (err) {
logger.error({ err }, "Error during graceful shutdown");
clearTimeout(forceExitTimer);
process.exit(1);
}
}
@@ -1,7 +1,7 @@
import { pgMessagesTable } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { and, desc, eq, ilike, type SQL } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
import { pgMessagesTable } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import {
type MappedMessage,
mapMessageRow,
@@ -1,6 +1,6 @@
import { createChildLogger } from "@/shared/logger/index";
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { analysisService } from "./analysis.service.js";
@@ -1,5 +1,5 @@
import { createChildLogger } from "@/shared/logger/index";
import type { Request, Response } from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { chatbotService } from "./chatbot.service.js";
@@ -9,6 +9,18 @@ interface AuthenticatedRequest extends Request {
userId?: string;
}
/**
* Resolve the actor id for a request. Frontend (no-login) sends a per-device
* UUID via X-User-Id so chat history stays isolated per visitor; a registered
* auth middleware userId takes precedence when present.
*/
function resolveUserId(req: Request): string {
const authId = (req as AuthenticatedRequest).userId;
if (authId) return authId;
const header = (req.headers["x-user-id"] as string | undefined)?.trim();
return header || "anonymous";
}
export const handleChatbotChat = asyncHandler(
async (req: Request, res: Response) => {
const { message, context } = req.body as {
@@ -24,8 +36,8 @@ export const handleChatbotChat = asyncHandler(
});
}
// Get user ID from auth middleware (if available)
const userId = (req as AuthenticatedRequest).userId || "anonymous";
// Get user ID from X-User-Id header (no-login device uuid) or auth
const userId = resolveUserId(req);
logger.debug(
{ userId, messageLength: message.length, context },
@@ -59,7 +71,7 @@ export const handleChatbotChat = asyncHandler(
export const getChatbotHistory = asyncHandler(
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId || "anonymous";
const userId = resolveUserId(req);
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
const history = await chatbotService.getChatHistory(userId, limit);
@@ -73,7 +85,7 @@ export const getChatbotHistory = asyncHandler(
export const clearChatbotHistory = asyncHandler(
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId || "anonymous";
const userId = resolveUserId(req);
await chatbotService.clearChatHistory(userId);
@@ -1,7 +1,7 @@
import { pgChatbotMessagesTable, pgMessagesTable } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
import { pgChatbotMessagesTable, pgMessagesTable } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
const logger = createChildLogger("chatbot.repository");
@@ -10,11 +10,7 @@ import { chatRequestSchema } from "./chatbot.schema.js";
export function createChatbotRouter(): Router {
const router = express.Router();
router.post(
"/chat",
validateBody(chatRequestSchema),
handleChatbotChat,
);
router.post("/chat", validateBody(chatRequestSchema), handleChatbotChat);
router.get("/chat/history", getChatbotHistory);
router.delete("/chat/history", clearChatbotHistory);
@@ -6,6 +6,7 @@ import type {
SaveConversationInput,
} from "./chatbot.repository.js";
import { chatbotRepository } from "./chatbot.repository.js";
import { executeTool, tools } from "./chatbot.tools.js";
const logger = createChildLogger("chatbot.service");
@@ -118,41 +119,104 @@ Gaya ngobrol:
try {
const { default: axios } = await import("axios");
// Gateway tidak handle role system — gabung konteks ke user message
// Gateway tidak handle role system — gabung konteks ke user message.
// The system section stays visible to the model as the first user turn.
const contextPrefixed = `${systemPrompt}\n\nPertanyaan user: ${userMessage}`;
const messages: Array<{ role: "user" | "assistant"; content: string }> = [
...history,
{ role: "user", content: contextPrefixed },
];
// Seed conversation: prior turns + current question.
const messages: Array<
| { role: "user" | "assistant"; content: string }
| {
role: "assistant";
content: string | null;
tool_calls: Array<{
id: string;
type: "function";
function: { name: string; arguments: string };
}>;
}
| { role: "tool"; tool_call_id: string; content: string }
> = [...history, { role: "user", content: contextPrefixed }];
const response = await axios.post(
`${baseUrl}/chat/completions`,
{
model,
messages,
max_tokens: 500,
temperature: 0.4,
},
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
// ── Agentic tool loop ─────────────────────────────────────────
const MAX_TOOL_ROUNDS = 4;
for (let round = 0; round <= MAX_TOOL_ROUNDS; round += 1) {
const response = await axios.post(
`${baseUrl}/chat/completions`,
{
model,
messages,
tools,
tool_choice: "auto",
max_tokens: 600,
temperature: 0.4,
stream: true,
},
timeout: 30_000,
},
);
{
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
timeout: 45_000,
// 9router returns SSE even without stream:true; force stream:true
// in the body and read the raw SSE text.
responseType: "text",
},
);
const result = response.data as {
choices?: Array<{ message?: { content?: string } }>;
};
const content = result?.choices?.[0]?.message?.content?.trim();
// Parse SSE `data:` lines → content + tool_calls.
const { content, toolCalls } = this.parseSse(response.data as string);
if (content) {
return content;
logger.debug(
{
round,
hasToolCalls: toolCalls.length > 0,
toolNames: toolCalls.map((t) => t.name),
},
"LLM round parsed",
);
if (toolCalls.length > 0) {
// Execute each tool, append tool results, continue loop.
for (const tc of toolCalls) {
messages.push({
role: "assistant",
content: null,
tool_calls: [
{
id: tc.id,
type: "function",
function: { name: tc.name, arguments: tc.arguments },
},
],
});
let result = "";
try {
result = await executeTool(tc.name, tc.args);
} catch (e) {
result = `Tool error: ${(e as Error).message}`;
}
messages.push({
role: "tool",
tool_call_id: tc.id,
content: result,
});
}
if (round === MAX_TOOL_ROUNDS) {
logger.warn("Hit max tool rounds; returning what we have");
}
continue;
}
if (content?.trim()) {
return content.trim();
}
logger.warn("LLM returned empty response (no tools, no content)");
return this.fallbackResponse(userMessage);
}
logger.warn({ response: result }, "LLM returned empty response");
logger.warn("Tool loop exhausted without final content");
return this.fallbackResponse(userMessage);
} catch (error) {
logger.warn({ error }, "LLM call failed, using fallback response");
@@ -160,6 +224,93 @@ Gaya ngobrol:
}
}
/**
* Parse an SSE stream body into accumulated content + any tool_calls.
* 9router (and most OpenAI-compatible routers) emit `data: {json}` lines
* even when stream is only implied; we must collect deltas manually.
*/
private parseSse(body: string): {
content: string;
toolCalls: Array<{
id: string;
name: string;
arguments: string;
args: Record<string, unknown>;
}>;
} {
const contentParts: string[] = [];
const toolById = new Map<
string,
{ id: string; name: string; arguments: string }
>();
const lines = body.split("\n");
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
try {
const json = JSON.parse(payload) as {
choices?: Array<{
delta?: {
content?: string;
tool_calls?: Array<{
id?: string;
index?: number;
type?: string;
function?: { name?: string; arguments?: string };
}>;
};
finish_reason?: string | null;
}>;
};
const delta = json.choices?.[0]?.delta;
if (!delta) continue;
if (delta.content) contentParts.push(delta.content);
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const idx = String(tc.index ?? 0);
const cur = toolById.get(idx) ?? {
id: tc.id ?? "",
name: "",
arguments: "",
};
// Keep the first non-empty id for this call index.
if (tc.id && !cur.id) cur.id = tc.id;
if (tc.function?.name) cur.name += tc.function.name;
if (tc.function?.arguments) cur.arguments += tc.function.arguments;
toolById.set(idx, cur);
}
}
} catch {
// Skip malformed lines (keepalives, etc.)
}
}
// Build a de-duplicated id for any call the stream never assigned one.
let fallbackId = 0;
const toolCalls = Array.from(toolById.values()).map((tc) => {
const id = tc.id || `tool_${fallbackId++}_${Date.now()}`;
return {
id,
name: tc.name,
arguments: tc.arguments,
args: this.safeJsonParse(tc.arguments),
};
});
return { content: contentParts.join(""), toolCalls };
}
private safeJsonParse(s: string): Record<string, unknown> {
try {
return JSON.parse(s) as Record<string, unknown>;
} catch {
return {};
}
}
private fallbackResponse(input: string): string {
const lower = input.toLowerCase();
@@ -0,0 +1,232 @@
import { sql } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
/**
* Tools the chatbot LLM can call. Definitions describe the schema to the
* model; the executor implements each one against the real database.
* This turns the chatbot from "blind stats guesser" into an agent that
* pulls real, current server data on demand.
*/
export type ToolResult = string;
/** JSON schema for a tool definition (OpenAI function-calling format). */
export interface ToolDef {
type: "function";
function: {
name: string;
description: string;
parameters: {
type: "object";
properties: Record<string, unknown>;
required?: string[];
};
};
}
export const tools: ToolDef[] = [
{
type: "function",
function: {
name: "get_server_stats",
description:
"Ambil statistik ringkas server/guild saat ini: total pesan, user aktif, jumlah pesan flagged, dan jumlah warning. Panggil ini untuk menjawab pertanyaan umum tentang kondisi server. Opsional fill guild_id untuk scope ke guild tertentu, channel_id untuk scope ke channel.",
parameters: {
type: "object",
properties: {
guildId: {
type: "string",
description: "ID guild/server (opsional). Kosongkan = semua data.",
},
channelId: {
type: "string",
description: "ID channel (opsional).",
},
},
},
},
},
{
type: "function",
function: {
name: "get_top_channels",
description:
"Ambil daftar channel paling aktif (jumlah pesan terbanyak) di server. Panggil buat jawab 'channel mana paling ramai' atau aktivitas per-channel.",
parameters: {
type: "object",
properties: {
guildId: {
type: "string",
description: "ID server (opsional).",
},
limit: {
type: "number",
description: "Jumlah channel teratas (default 5, max 10).",
},
},
},
},
},
{
type: "function",
function: {
name: "get_recent_activity",
description:
"Ambil aktivitas/pesan terbaru di server: siapa yang baru ngomong, di channel mana, jam berapa. Panggil buat jawaban soal 'lagi ngapain' / aktivitas terbaru di server.",
parameters: {
type: "object",
properties: {
guildId: {
type: "string",
description: "ID server (opsional).",
},
limit: {
type: "number",
description: "Jumlah pesan terakhir (default 5).",
},
},
},
},
},
{
type: "function",
function: {
name: "get_top_flagged",
description:
"Ambil pesan yang paling sering di-flag atau kena warning. Panggil buat jawab soal pesan bermasalah / moderator.",
parameters: {
type: "object",
properties: {
guildId: {
type: "string",
description: "ID server (opsional).",
},
limit: {
type: "number",
description: "Jumlah pesan (default 5).",
},
},
},
},
},
];
/** Executes a tool call against the real DB and returns a readable result. */
export async function executeTool(
name: string,
args: Record<string, unknown>,
): Promise<string> {
const guildId =
typeof args.guildId === "string" && args.guildId ? args.guildId : undefined;
const channelId =
typeof args.channelId === "string" && args.channelId
? args.channelId
: undefined;
const limitRaw =
typeof args.limit === "number" ? args.limit : Number(args.limit) || 5;
const limit = Math.min(Math.max(1, Math.round(limitRaw)), 10);
try {
switch (name) {
case "get_server_stats":
return await serverStats(guildId, channelId);
case "get_top_channels":
return await topChannels(guildId, limit);
case "get_recent_activity":
return await recentActivity(guildId, limit);
case "get_top_flagged":
return await topFlagged(guildId, limit);
default:
return `Unknown tool: ${name}`;
}
} catch (error) {
// Best-effort: if a tool fails, return readable error instead of crashing
return `Terjadi kesalahan saat ambil data: ${(error as Error).message ?? "unknown"}`;
}
}
// ── Tool executors ──────────────────────────────────────────
async function serverStats(
guildId?: string,
channelId?: string,
): Promise<string> {
const db = getDatabase();
const conditions: string[] = [];
if (guildId) conditions.push(`guild_id = '${guildId}'`);
if (channelId) conditions.push(`channel_id = '${channelId}'`);
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const result = await db.execute(
sql.raw(
`SELECT COUNT(*)::int AS total_messages,
COUNT(DISTINCT user_id)::int AS active_users,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned
FROM messages ${cond}`,
),
);
const rows =
(result as unknown as { rows: Record<string, unknown>[] }).rows ?? [];
const r = rows[0] ?? {};
return JSON.stringify({
total_messages: r.total_messages ?? 0,
active_users: r.active_users ?? 0,
flagged: r.flagged ?? 0,
warned: r.warned ?? 0,
});
}
async function topChannels(guildId?: string, limit = 5): Promise<string> {
const db = getDatabase();
const conditions: string[] = [];
if (guildId) conditions.push(`guild_id = '${guildId}'`);
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const result = await db.execute(
sql.raw(
`SELECT channel_id,
COUNT(*)::int AS count
FROM messages ${cond}
GROUP BY channel_id
ORDER BY count DESC
LIMIT ${limit}`,
),
);
const rows = (result as unknown as { rows: unknown[] }).rows ?? [];
return JSON.stringify(rows.slice(0, limit));
}
async function recentActivity(guildId?: string, limit = 5): Promise<string> {
const db = getDatabase();
const conditions: string[] = [];
if (guildId) conditions.push(`guild_id = '${guildId}'`);
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const result = await db.execute(
sql.raw(
`SELECT username, content, channel_id, created_at
FROM messages ${cond}
ORDER BY created_at DESC
LIMIT ${limit}`,
),
);
return JSON.stringify((result as unknown as { rows: unknown[] }).rows ?? []);
}
async function topFlagged(guildId?: string, limit = 5): Promise<string> {
const db = getDatabase();
const conditions = ["ai_status IN ('flagged', 'warn')"];
if (guildId) conditions.push(`guild_id = '${guildId}'`);
const cond = `WHERE ${conditions.join(" AND ")}`;
const result = await db.execute(
sql.raw(
`SELECT username, content, channel_id, ai_status, created_at
FROM messages ${cond}
ORDER BY created_at DESC
LIMIT ${limit}`,
),
);
return JSON.stringify((result as unknown as { rows: unknown[] }).rows ?? []);
}
@@ -1,3 +1,6 @@
import type { SQL } from "drizzle-orm";
import { sql } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
import {
pgChannelCulturesTable,
pgMessagesTable,
@@ -5,9 +8,6 @@ import {
pgUserReputationsTable,
pgVoiceRecordingsTable,
} from "../../shared/index.js";
import type { SQL } from "drizzle-orm";
import { sql } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
import type { ListUsersQuery } from "./dashboard.service.js";
export class DashboardRepository {
@@ -82,6 +82,52 @@ export class DashboardRepository {
};
}
async getActivity(days: number) {
const db = getDatabase();
const sinceMs = Date.now() - days * 86400000;
const dayAgoMs = Date.now() - 86400000;
// Daily buckets (last N days)
const daily = await db.execute(sql`
SELECT
to_char(to_timestamp(created_at / 1000), 'YYYY-MM-DD') AS day,
COUNT(*)::int AS messages,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
COUNT(DISTINCT user_id)::int AS active_users
FROM ${pgMessagesTable}
WHERE created_at >= ${sinceMs}
GROUP BY day
ORDER BY day
`);
// Hourly distribution (last 24h)
const hourly = await db.execute(sql`
SELECT
EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour,
COUNT(*)::int AS messages,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged
FROM ${pgMessagesTable}
WHERE created_at >= ${dayAgoMs}
GROUP BY hour
ORDER BY hour
`);
return {
days,
daily: (daily.rows as Record<string, unknown>[]).map((r) => ({
day: String(r.day),
messages: Number(r.messages),
flagged: Number(r.flagged),
active_users: Number(r.active_users),
})),
hourly: (hourly.rows as Record<string, unknown>[]).map((r) => ({
hour: Number(r.hour),
messages: Number(r.messages),
flagged: Number(r.flagged),
})),
};
}
async listUsers(query: ListUsersQuery) {
const db = getDatabase();
const limit = query.limit ?? 20;
@@ -285,6 +331,100 @@ export class DashboardRepository {
};
}
async getTopReactions(limit: number) {
const db = getDatabase();
const cap = Math.min(Math.max(limit || 20, 1), 50);
// Top messages by net reactions (adds minus removes), joined to message content
const result = await db.execute(sql`
SELECT
m.id AS message_id,
m.content,
m.username,
m.channel_id,
m.created_at,
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
r.reaction_count::int
FROM (
SELECT message_id,
(COUNT(*) FILTER (WHERE reaction_type = 'add')
- COUNT(*) FILTER (WHERE reaction_type = 'remove'))::int AS reaction_count
FROM message_reactions
GROUP BY message_id
) r
JOIN messages m ON m.id = r.message_id
WHERE r.reaction_count > 0
ORDER BY r.reaction_count DESC
LIMIT ${cap}
`);
const rows = (result.rows as Record<string, unknown>[]) || [];
if (rows.length === 0) return [];
// Top emoji per message (adds only) for the breakdown
const ids = rows.map((r) => String(r.message_id));
const emojiResult = await db.execute(sql`
SELECT message_id, emoji, COUNT(*)::int AS c
FROM message_reactions
WHERE reaction_type = 'add' AND message_id IN (${sql.join(ids, sql`, `)})
GROUP BY message_id, emoji
ORDER BY message_id, c DESC
`);
const emojiByMessage = new Map<
string,
Array<{ emoji: string; count: number }>
>();
for (const e of emojiResult.rows as Record<string, unknown>[]) {
const mid = String(e.message_id);
const list = emojiByMessage.get(mid) ?? [];
list.push({ emoji: String(e.emoji), count: Number(e.c) });
emojiByMessage.set(mid, list);
}
return rows.map((r) => ({
message_id: String(r.message_id),
content: r.content ? String(r.content) : "",
username: r.username ? String(r.username) : null,
channel_id: String(r.channel_id),
channel_name: r.channel_name ? String(r.channel_name) : null,
created_at: r.created_at ? Number(r.created_at) : null,
reaction_count: Number(r.reaction_count),
top_emojis: (emojiByMessage.get(String(r.message_id)) ?? []).slice(0, 3),
}));
}
async getTopReactors(limit: number) {
const db = getDatabase();
const cap = Math.min(Math.max(limit || 20, 1), 50);
// Top users by net reactions given (adds minus removes)
const result = await db.execute(sql`
SELECT
user_id,
username,
(COUNT(*) FILTER (WHERE reaction_type = 'add')
- COUNT(*) FILTER (WHERE reaction_type = 'remove'))::int AS net_count,
COUNT(*) FILTER (WHERE reaction_type = 'add')::int AS adds_count,
COUNT(DISTINCT message_id)::int AS messages_reacted,
COUNT(DISTINCT emoji)::int AS emojis_used
FROM message_reactions
GROUP BY user_id, username
ORDER BY net_count DESC
LIMIT ${cap}
`);
return ((result.rows as Record<string, unknown>[]) || []).map((r) => ({
user_id: String(r.user_id),
username: String(r.username ?? "unknown"),
net_count: Number(r.net_count),
adds_count: Number(r.adds_count),
messages_reacted: Number(r.messages_reacted),
emojis_used: Number(r.emojis_used),
}));
}
async getUserDetail(userId: string) {
const db = getDatabase();
@@ -1,6 +1,6 @@
import { createChildLogger } from "@/shared/logger/index";
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { dashboardService } from "./dashboard.service.js";
@@ -19,6 +19,16 @@ export function createDashboardRouter(): Router {
}),
);
// GET /api/dashboard/activity?days=14 — message volume over time
router.get(
"/dashboard/activity",
asyncHandler(async (req: Request, res: Response) => {
const days = Math.min(Math.max(Number(req.query.days) || 14, 1), 90);
const activity = await dashboardService.getActivity(days);
res.json(activity);
}),
);
// GET /api/dashboard/users — paginated user list with profiles
router.get(
"/dashboard/users",
@@ -77,5 +87,25 @@ export function createDashboardRouter(): Router {
}),
);
// GET /api/dashboard/reactions — top reacted messages
router.get(
"/dashboard/reactions",
asyncHandler(async (req: Request, res: Response) => {
const limit = Number(req.query.limit) || 20;
const reactions = await dashboardService.getTopReactions(limit);
res.json(reactions);
}),
);
// GET /api/dashboard/reactors — top users by reactions given
router.get(
"/dashboard/reactors",
asyncHandler(async (req: Request, res: Response) => {
const limit = Number(req.query.limit) || 20;
const reactors = await dashboardService.getTopReactors(limit);
res.json(reactors);
}),
);
return router;
}
@@ -15,6 +15,11 @@ export class DashboardService {
return dashboardRepository.getStats();
}
async getActivity(days: number) {
logger.debug({ days }, "Fetching dashboard activity");
return dashboardRepository.getActivity(days);
}
async listUsers(query: ListUsersQuery) {
logger.debug({ query }, "Listing dashboard users");
return dashboardRepository.listUsers(query);
@@ -38,6 +43,16 @@ export class DashboardService {
logger.debug({ channelId }, "Fetching channel detail");
return dashboardRepository.getChannelDetail(channelId);
}
async getTopReactions(limit: number) {
logger.debug({ limit }, "Fetching top reactions");
return dashboardRepository.getTopReactions(limit);
}
async getTopReactors(limit: number) {
logger.debug({ limit }, "Fetching top reactors");
return dashboardRepository.getTopReactors(limit);
}
}
export const dashboardService = new DashboardService();
@@ -1,5 +1,5 @@
import { createChildLogger } from "@/shared/logger/index";
import { sql } from "drizzle-orm";
import { createChildLogger } from "@/shared/logger/index";
import { getDatabase } from "../../shared/database/index.js";
const logger = createChildLogger("health.repository");
@@ -1,9 +1,9 @@
import { createChildLogger } from "@/shared/logger/index";
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
import { mediaQueueSchema, mediaVolumeSchema } from "./media.schema.js";
import { getStatus, queue, setVolume, skip, stop } from "./media.service.js";
import { mediaLoopSchema, mediaQueueSchema } from "./media.schema.js";
import { getStatus, queue, setLoop, skip, stop } from "./media.service.js";
const logger = createChildLogger("media.routes");
@@ -55,14 +55,14 @@ export function createMediaRouter(): Router {
}),
);
// POST /api/media/volume
// POST /api/media/loop
router.post(
"/media/volume",
validateBody(mediaVolumeSchema),
"/media/loop",
validateBody(mediaLoopSchema),
asyncHandler(async (req: Request, res: Response) => {
const { volume } = req.body as { volume: number };
logger.debug({ volume }, "Media volume requested");
const state = await setVolume(volume);
const { loop } = req.body as { loop: boolean };
logger.debug({ loop }, "Media loop requested");
const state = await setLoop(loop);
res.json(state);
}),
);
@@ -5,9 +5,9 @@ export const mediaQueueSchema = z.object({
mode: z.enum(["music", "screen"]).default("music"),
});
export const mediaVolumeSchema = z.object({
volume: z.number().min(0).max(1).default(1.0),
export const mediaLoopSchema = z.object({
loop: z.boolean().default(false),
});
export type MediaQueueInput = z.infer<typeof mediaQueueSchema>;
export type MediaVolumeInput = z.infer<typeof mediaVolumeSchema>;
export type MediaLoopInput = z.infer<typeof mediaLoopSchema>;
@@ -1,14 +1,14 @@
import {
COMMAND_MEDIA_QUEUE,
COMMAND_MEDIA_SKIP,
COMMAND_MEDIA_STOP,
COMMAND_MEDIA_VOLUME,
MEDIA_STATUS_KEY,
} from "../../shared/index.js";
import {
createChildLogger,
tryCommandThenFallback,
} from "../../shared/commandHelper.js";
import {
COMMAND_MEDIA_LOOP,
COMMAND_MEDIA_QUEUE,
COMMAND_MEDIA_SKIP,
COMMAND_MEDIA_STOP,
MEDIA_STATUS_KEY,
} from "../../shared/index.js";
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
const logger = createChildLogger("media.service");
@@ -28,7 +28,10 @@ export interface MediaItem {
export interface MediaState {
playing: boolean;
/** null/absent when idle; "music" | "screen" while a track is active. */
activeMode?: "music" | "screen" | null;
musicVolume: number;
loop: boolean;
current: MediaItem | null;
queue: MediaItem[];
}
@@ -41,7 +44,9 @@ const DEFAULT_COMMAND_TIMEOUT_MS = 5000;
const DEFAULT_STATE: MediaState = {
playing: false,
musicVolume: 1.0,
activeMode: null,
musicVolume: 0.3,
loop: false,
current: null,
queue: [],
};
@@ -56,9 +61,14 @@ function normalizeMediaState(raw: Record<string, unknown>): MediaState {
rawPlaying === true ||
rawPlaying === "playing" ||
rawPlaying === "buffering";
const mode = raw.activeMode;
const activeMode: "music" | "screen" | null =
mode === "music" || mode === "screen" ? mode : null;
return {
playing,
musicVolume: Number(raw.musicVolume ?? 1.0),
activeMode,
musicVolume: Number(raw.musicVolume ?? 0.3),
loop: Boolean(raw.loop ?? false),
current: (raw.current as MediaItem | null) ?? null,
queue: (raw.queue as MediaItem[]) ?? [],
};
@@ -99,7 +109,9 @@ export async function queue(
() =>
publishCommand<MediaState>(
COMMAND_MEDIA_QUEUE,
{ source, mode },
// NOTE: gateway MediaHandler reads `payload.url` (not `source`) —
// keep the field name aligned or playback silently no-ops.
{ url: source, mode },
DEFAULT_COMMAND_TIMEOUT_MS,
),
() => readStatusFallback(),
@@ -142,18 +154,18 @@ export async function stop(): Promise<MediaState> {
}
/**
* Set volume via Redis command to discord-gateway.
* Toggle loop mode (replay current track on natural end) via Redis command.
*/
export async function setVolume(volume: number): Promise<MediaState> {
logger.info({ volume }, "setVolume called");
export async function setLoop(loop: boolean): Promise<MediaState> {
logger.info({ loop }, "setLoop called");
return tryCommandThenFallback(
() =>
publishCommand<MediaState>(
COMMAND_MEDIA_VOLUME,
{ volume },
COMMAND_MEDIA_LOOP,
{ loop },
DEFAULT_COMMAND_TIMEOUT_MS,
),
() => readStatusFallback(),
"setVolume",
"setLoop",
);
}
@@ -1,5 +1,5 @@
import { createChildLogger } from "@/shared/logger/index";
import type { Request, Response } from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { messageQuerySchema } from "./messages.schema.js";
import { messagesService } from "./messages.service.js";
@@ -1,6 +1,3 @@
import type { PageResult } from "../../shared/index.js";
import { pgAttachmentsTable, pgMessagesTable } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import {
and,
desc,
@@ -9,13 +6,16 @@ import {
isNull,
like,
lt,
ne,
notInArray,
or,
type SQL,
sql,
} from "drizzle-orm";
import { config } from "../../shared/config/index.js";
import { getDatabase } from "../../shared/database/index.js";
import type { PageResult } from "../../shared/index.js";
import { pgAttachmentsTable, pgMessagesTable } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { mapMessageRow } from "../../shared/utils/messageMapper.js";
import type {
MessageCreate,
@@ -115,6 +115,27 @@ export class MessagesRepository {
return mapMessageRow(row as Record<string, unknown>);
}
/**
* Edit history for a message: previous content snapshots (newest first).
* Stored in message_edits by the gateway's message-capture module.
*/
async getEditHistory(
messageId: string,
): Promise<Array<{ old_content: string; edited_at: number }>> {
const db = getDatabase();
const result = await db.execute(sql`
SELECT old_content, edited_at
FROM message_edits
WHERE message_id = ${messageId}
ORDER BY edited_at DESC
LIMIT 50
`);
return ((result.rows as Record<string, unknown>[]) || []).map((r) => ({
old_content: String(r.old_content ?? ""),
edited_at: Number(r.edited_at ?? 0),
}));
}
async findByChannel(
channelId: string,
query: MessageQuery,
@@ -223,58 +244,6 @@ export class MessagesRepository {
return mapMessageRow(row as Record<string, unknown>);
}
/**
* Bulk-reset ai_status from 'error' to 'pending' so the DG recovery worker
* picks them up on its next poll cycle.
*
* Accepts optional scope filters (guildId, channelId) or a list of explicit
* message IDs. Returns the count of rows that were actually updated.
*/
async reanalyzeErrorBatch(opts: {
guildId?: string;
channelId?: string;
messageIds?: string[];
}): Promise<number> {
const db = getDatabase();
const conditions: SQL[] = [eq(pgMessagesTable.ai_status, "error")];
if (opts.messageIds && opts.messageIds.length > 0) {
conditions.push(inArray(pgMessagesTable.id, opts.messageIds));
}
if (opts.guildId) {
conditions.push(eq(pgMessagesTable.guild_id, opts.guildId));
}
if (opts.channelId) {
conditions.push(eq(pgMessagesTable.channel_id, opts.channelId));
}
const result = await db
.update(pgMessagesTable)
.set({ ai_status: "pending" })
.where(and(...conditions));
const count = result.rowCount ?? 0;
logger.info({ count, ...opts }, "Batch reanalyze triggered");
return count;
}
/**
* Mark a single message for re-analysis by resetting ai_status to 'pending'.
* Skips messages already in 'pending' state to avoid write amplification.
*/
async markForReanalysis(id: string): Promise<void> {
const db = getDatabase();
await db
.update(pgMessagesTable)
.set({ ai_status: "pending" })
.where(
and(
eq(pgMessagesTable.id, id),
ne(pgMessagesTable.ai_status, "pending"),
),
);
}
/**
* Retrieve messages flagged for review (ai_status IN ('warn', 'flagged')).
* Optionally filtered by channelId, with configurable limit.
@@ -385,6 +354,12 @@ export class MessagesRepository {
const limit = query.limit ?? 50;
const conditions: SQL[] = [eq(pgAttachmentsTable.channel_id, channelId)];
// Detail view: narrow to the selected message so we don't show
// everyone else's images from the same channel.
if (query.messageId) {
conditions.push(eq(pgAttachmentsTable.message_id, query.messageId));
}
if (query.cursor) {
conditions.push(lt(pgAttachmentsTable.created_at, Number(query.cursor)));
}
@@ -1,7 +1,7 @@
import { createChildLogger } from "@/shared/logger/index";
import type { Request, Response, Router } from "express";
import express from "express";
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js";
import {
handleGetAttachmentsByChannel,
handleGetImageMessages,
@@ -9,27 +9,10 @@ import {
handleGetMessagesByChannel,
handleListMessages,
} from "./messages.controller.js";
import { reanalyzeBatchSchema } from "./messages.schema.js";
import { messagesService } from "./messages.service.js";
const logger = createChildLogger("messages.routes");
/**
* Per-message in-flight guard for the single reanalyze endpoint.
* Prevents concurrent spam-clicks from issuing duplicate UPDATE + recovery
* worker triggers for the same message.
*/
const reanalyzeInFlight = new Set<string>();
/**
* Per-scope in-flight guard for the batch reanalyze endpoint.
* Scope key = "guildId:channelId" (empty string used for undefined parts).
* Two concurrent batch-reanalyze requests for the same scope are rejected
* with 409 so the recovery worker is not triggered multiple times for the
* same set of error messages.
*/
const reanalyzeBatchInFlight = new Set<string>();
export function createMessagesRouter(): Router {
const router = express.Router();
@@ -51,74 +34,6 @@ export function createMessagesRouter(): Router {
// (uses /detail/ prefix to avoid collision with :channelId route above)
router.get("/messages/detail/:id", handleGetMessageById);
// POST /api/messages/reanalyze-batch — Bulk retry all errored messages
// MUST be registered BEFORE /messages/:id/reanalyze so "reanalyze-batch"
// is not captured as an :id param.
router.post(
"/messages/reanalyze-batch",
validateBody(reanalyzeBatchSchema),
asyncHandler(async (req: Request, res: Response) => {
const { guildId, channelId, messageIds } = req.body as {
guildId?: string;
channelId?: string;
messageIds?: string[];
};
// Idempotency guard: one concurrent batch-reanalyze per scope.
// Prevents two admin sessions clicking simultaneously from each
// triggering the recovery worker for the same set of messages.
const scopeKey = `${guildId ?? ""}:${channelId ?? ""}`;
if (reanalyzeBatchInFlight.has(scopeKey)) {
res
.status(409)
.json({ error: "REANALYZE_BATCH_IN_PROGRESS", scope: scopeKey });
return;
}
reanalyzeBatchInFlight.add(scopeKey);
let count = 0;
try {
count = await messagesService.reanalyzeErrorBatch({
guildId,
channelId,
messageIds,
});
} finally {
reanalyzeBatchInFlight.delete(scopeKey);
}
logger.info({ count, guildId, channelId }, "Batch reanalyze completed");
res.status(200).json({ ok: true, count });
}),
);
// POST /api/messages/:id/reanalyze - Mark single message for re-analysis
router.post(
"/messages/:id/reanalyze",
asyncHandler(async (req: Request, res: Response) => {
const id = String(req.params.id ?? "");
if (!id) {
res.status(400).json({ error: "MISSING_ID" });
return;
}
// Idempotency guard: reject concurrent duplicate requests for the same ID.
if (reanalyzeInFlight.has(id)) {
res.status(409).json({ error: "REANALYZE_IN_PROGRESS", messageId: id });
return;
}
reanalyzeInFlight.add(id);
try {
await messagesService.markForReanalysis(id);
} finally {
reanalyzeInFlight.delete(id);
}
res.status(200).json({ ok: true });
}),
);
// GET /api/review - Get flagged/warned messages for review
router.get(
"/review",
@@ -8,6 +8,8 @@ export const messageQuerySchema = z.object({
limit: z.coerce.number().int().positive().default(50),
offset: z.coerce.number().int().nonnegative().default(0),
cursor: z.string().optional(),
// Filter attachments to a single message (used by the message detail view)
messageId: z.string().optional(),
});
export const messageCreateSchema = z.object({
@@ -36,13 +38,6 @@ export const messageUpdateSchema = z.object({
aiConfidence: z.number().optional(),
});
export const reanalyzeBatchSchema = z.object({
guildId: z.string().optional(),
channelId: z.string().optional(),
messageIds: z.array(z.string()).optional(),
});
export type MessageQuery = z.infer<typeof messageQuerySchema>;
export type MessageCreate = z.infer<typeof messageCreateSchema>;
export type MessageUpdate = z.infer<typeof messageUpdateSchema>;
export type ReanalyzeBatchInput = z.infer<typeof reanalyzeBatchSchema>;
@@ -34,7 +34,12 @@ export class MessagesService {
throw new NotFoundError(`Message with ID ${id} not found`);
}
return message;
const editHistory = await messagesRepository.getEditHistory(id);
return {
...message,
edit_count: editHistory.length,
edit_history: editHistory,
};
}
async getAttachmentsByChannel(channelId: string, query: MessageQuery) {
@@ -58,15 +63,6 @@ export class MessagesService {
return messagesRepository.getImageMessages(guildId, limit);
}
async markForReanalysis(id: string): Promise<void> {
if (!id) {
throw new ValidationError("message ID is required");
}
logger.debug({ id }, "Marking message for re-analysis");
await messagesRepository.markForReanalysis(id);
}
async getReviewMessages(
channelId?: string,
limit?: number,
@@ -74,25 +70,6 @@ export class MessagesService {
logger.debug({ channelId, limit }, "Getting review messages");
return messagesRepository.getReviewMessages(channelId, limit);
}
async reanalyzeErrorBatch(opts: {
guildId?: string;
channelId?: string;
messageIds?: string[];
}) {
if (
!opts.guildId &&
!opts.channelId &&
(!opts.messageIds || opts.messageIds.length === 0)
) {
throw new ValidationError(
"At least one of guildId, channelId, or messageIds[] is required",
);
}
logger.info(opts, "Batch reanalyzing errored messages");
return messagesRepository.reanalyzeErrorBatch(opts);
}
}
export const messagesService = new MessagesService();
@@ -0,0 +1 @@
export { createModerationRouter } from "./moderation.routes.js";
@@ -0,0 +1,141 @@
import { sql } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
export interface ListModerationQuery {
status?: string;
actionType?: string;
limit?: number;
cursor?: number;
}
const ACTION_TYPES = [
"delete_message",
"mute_user",
"warn_user",
"kick_user",
"ban_user",
] as const;
const STATUSES = ["pending", "executed", "failed"] as const;
export class ModerationRepository {
async getStats() {
const db = getDatabase();
const result = await db.execute(sql`
SELECT action_type, status, COUNT(*)::int AS c
FROM moderation_actions
GROUP BY action_type, status
`);
const rows = (result.rows as Record<string, unknown>[]) || [];
let executed = 0;
let failed = 0;
let pending = 0;
const byAction: Record<
string,
{ executed: number; failed: number; pending: number }
> = {};
for (const r of rows) {
const actionType = String(r.action_type ?? "unknown");
const status = String(r.status ?? "unknown");
const count = Number(r.c ?? 0);
byAction[actionType] ??= { executed: 0, failed: 0, pending: 0 };
if (status === "executed") {
executed += count;
byAction[actionType].executed += count;
} else if (status === "failed") {
failed += count;
byAction[actionType].failed += count;
} else {
pending += count;
byAction[actionType].pending += count;
}
}
const total = executed + failed + pending;
return {
total,
executed,
failed,
pending,
failed_rate: total > 0 ? Number(((failed / total) * 100).toFixed(1)) : 0,
by_action: byAction,
};
}
async listActions(query: ListModerationQuery) {
const db = getDatabase();
const limit = Math.min(Math.max(query.limit ?? 50, 1), 200);
const conditions: string[] = [];
if (
query.status &&
(STATUSES as readonly string[]).includes(query.status)
) {
conditions.push(`a.status = '${query.status}'`);
}
if (
query.actionType &&
(ACTION_TYPES as readonly string[]).includes(query.actionType)
) {
conditions.push(`a.action_type = '${query.actionType}'`);
}
if (query.cursor) {
conditions.push(`a.created_at < ${Number(query.cursor)}`);
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const result = await db.execute(
sql.raw(`
SELECT
a.id,
a.message_id,
a.user_id,
a.guild_id,
a.action_type,
a.reason,
a.executed_by,
a.status,
a.error,
a.created_at,
a.executed_at,
m.username,
LEFT(m.content, 300) AS content
FROM moderation_actions a
LEFT JOIN messages m ON m.id = a.message_id
${whereClause}
ORDER BY a.created_at DESC
LIMIT ${limit + 1}
`),
);
const rows = (result.rows as Record<string, unknown>[]) || [];
const data = rows.slice(0, limit).map((r) => ({
id: String(r.id ?? ""),
message_id: r.message_id ? String(r.message_id) : null,
user_id: r.user_id ? String(r.user_id) : null,
guild_id: String(r.guild_id ?? ""),
action_type: String(r.action_type ?? "unknown"),
reason: r.reason ? String(r.reason) : null,
executed_by: r.executed_by ? String(r.executed_by) : null,
status: String(r.status ?? "unknown"),
error: r.error ? String(r.error) : null,
created_at: r.created_at ? Number(r.created_at) : null,
executed_at: r.executed_at ? Number(r.executed_at) : null,
username: r.username ? String(r.username) : null,
content: r.content ? String(r.content) : null,
}));
const lastRow = rows[limit - 1] as Record<string, unknown> | undefined;
const nextCursor =
rows.length > limit ? String(lastRow?.created_at ?? "") : null;
return { data, nextCursor };
}
}
export const moderationRepository = new ModerationRepository();
@@ -0,0 +1,43 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "../../shared/logger/index.js";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { moderationService } from "./moderation.service.js";
const logger = createChildLogger("moderation.routes");
export function createModerationRouter(): Router {
const router = express.Router();
// GET /api/moderation/stats — moderation action summary
router.get(
"/moderation/stats",
asyncHandler(async (_req: Request, res: Response) => {
const stats = await moderationService.getStats();
res.json(stats);
}),
);
// GET /api/moderation/actions — paginated moderation action log
router.get(
"/moderation/actions",
asyncHandler(async (req: Request, res: Response) => {
const limit = Number(req.query.limit) || 50;
const status = req.query.status as string | undefined;
const actionType = req.query.actionType as string | undefined;
const cursor = req.query.cursor as string | undefined;
const result = await moderationService.listActions({
limit,
status,
actionType,
cursor: cursor ? Number(cursor) : undefined,
});
logger.debug({ count: result.data.length }, "Moderation actions listed");
res.json(result);
}),
);
return router;
}
@@ -0,0 +1,21 @@
import { createChildLogger } from "../../shared/logger/index.js";
import {
type ListModerationQuery,
moderationRepository,
} from "./moderation.repository.js";
const logger = createChildLogger("moderation.service");
export class ModerationService {
async getStats() {
logger.debug("Fetching moderation stats");
return moderationRepository.getStats();
}
async listActions(query: ListModerationQuery) {
logger.debug({ query }, "Listing moderation actions");
return moderationRepository.listActions(query);
}
}
export const moderationService = new ModerationService();
@@ -1,6 +1,6 @@
import { createChildLogger } from "@/shared/logger/index";
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { recordingsService } from "./recordings.service.js";
@@ -1,7 +1,7 @@
import { pgVoiceRecordingsTable } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { and, desc, eq, lt, type SQL } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
import { pgVoiceRecordingsTable } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
const logger = createChildLogger("recordings.service");
@@ -20,7 +20,6 @@ export interface RecordingRow {
upload_error: string | null;
created_at: number;
uploaded_at: number | null;
duration_bytes: number;
}
export interface PaginatedRecordings {
@@ -69,7 +68,6 @@ export class RecordingsService {
upload_error: pgVoiceRecordingsTable.upload_error,
created_at: pgVoiceRecordingsTable.created_at,
uploaded_at: pgVoiceRecordingsTable.uploaded_at,
duration_bytes: pgVoiceRecordingsTable.size_bytes,
})
.from(pgVoiceRecordingsTable)
.where(where)
@@ -1,6 +1,6 @@
import { createChildLogger } from "@/shared/logger/index";
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { uiStateService } from "./ui-state.service.js";
@@ -1,5 +1,5 @@
import { createChildLogger } from "@/shared/logger/index";
import { sql } from "drizzle-orm";
import { createChildLogger } from "@/shared/logger/index";
import { getDatabase } from "../../shared/database/index.js";
const logger = createChildLogger("ui-state.service");
@@ -0,0 +1,83 @@
/**
* Authoritative live-voice store.
*
* Single source of truth for who is present / speaking in voice. The backend
* WebSocket server is the one relay every frontend client connects to, so it
* is the correct place to aggregate the gateway's `voice_active_user` deltas
* into a shared snapshot. A late-joining browser must be able to see the same
* state as everyone else — this store makes that possible (seeded into the WS
* initial states and served via GET /api/voice/status).
*/
export interface LiveSpeaker {
userId: string;
username: string;
avatar?: string | null;
speaking: boolean;
/** Epoch ms of the most recent activity (start OR end of speech). */
lastActiveAt: number;
}
const speakers = new Map<string, LiveSpeaker>();
const MAX_SPEAKERS = 200;
/**
* Record a voice_active_user event. `speaking: true` upserts the speaker as
* active; `speaking: false` marks them inactive while keeping them for the
* activity timeline.
*/
/**
* recordSpeaker(data) — apply a `voice_active_user` event. `speaking: true`
* upserts the speaker as ACTIVE; `speaking: false` marks them inactive while
* keeping them for the activity timeline.
*/
export function recordSpeaker(data: {
userId: string;
username?: string;
avatar?: string | null;
speaking: boolean;
}): void {
const { userId, speaking } = data;
const existing = speakers.get(userId);
const speaker: LiveSpeaker = {
userId,
username: data.username ?? existing?.username ?? "Unknown",
avatar: data.avatar ?? existing?.avatar ?? null,
speaking,
lastActiveAt: Date.now(),
};
if (speakers.size >= MAX_SPEAKERS && !existing) {
// Drop the least-recently-active non-speaking speaker to stay bounded.
let oldestId: string | null = null;
let oldestTs = Infinity;
for (const [id, s] of speakers) {
if (!s.speaking && s.lastActiveAt < oldestTs) {
oldestTs = s.lastActiveAt;
oldestId = id;
}
}
if (oldestId) speakers.delete(oldestId);
else return;
}
speakers.set(userId, speaker);
}
/** All known speakers, most recently active first. */
export function getActiveSpeakers(): LiveSpeaker[] {
return [...speakers.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt);
}
/** Only speakers currently flagged as speaking. */
export function getSpeakingSpeakers(): LiveSpeaker[] {
return [...speakers.values()]
.filter((s) => s.speaking)
.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
}
/** Drop all tracked speakers (used on backend restart). */
export function resetLiveSpeakers(): void {
speakers.clear();
}
@@ -1,5 +1,5 @@
import { createChildLogger } from "@/shared/logger/index";
import type { Request, Response } from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { publishCommandNoReply } from "../../shared/redis/index.js";
import type { ConnectVoiceInput, VoiceCommandInput } from "./voice.schema.js";
@@ -1,6 +1,6 @@
import { createChildLogger } from "@/shared/logger/index";
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
import {
handleConnectVoice,
@@ -1,3 +1,9 @@
import { eq } from "drizzle-orm";
import {
createChildLogger,
tryCommandThenFallback,
} from "../../shared/commandHelper.js";
import { getDatabase } from "../../shared/database/index.js";
import {
COMMAND_GUILDS_LIST,
COMMAND_GUILDS_TEXT_CHANNELS,
@@ -8,13 +14,8 @@ import {
pgMessagesTable,
VOICE_STATUS_KEY,
} from "../../shared/index.js";
import { eq } from "drizzle-orm";
import {
createChildLogger,
tryCommandThenFallback,
} from "../../shared/commandHelper.js";
import { getDatabase } from "../../shared/database/index.js";
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
import { getActiveSpeakers, type LiveSpeaker } from "./live-speaker.js";
const logger = createChildLogger("voice.service");
@@ -28,6 +29,8 @@ export interface Channel {
id: string;
name: string;
type: "voice" | "text";
/** Whether the selfbot account can actually join this voice channel. */
joinable?: boolean;
}
export interface GuildVoiceEntry {
@@ -43,6 +46,12 @@ export interface VoiceStatus {
activeChannelId: string | null;
activeChannelName: string | null;
connections: GuildVoiceEntry[];
/**
* Authoritative shared voice snapshot — who is present / speaking right
* now, aggregated server-side from the gateway's `voice_active_user`
* deltas. All browsers converge on this same list.
*/
activeSpeakers: LiveSpeaker[];
}
export const DEFAULT_VOICE_STATUS: VoiceStatus = {
@@ -51,8 +60,16 @@ export const DEFAULT_VOICE_STATUS: VoiceStatus = {
activeChannelId: null,
activeChannelName: null,
connections: [],
activeSpeakers: [],
};
/** Attach the live speaker snapshot to any voice status payload. */
function withActiveSpeakers<T extends Partial<VoiceStatus>>(
status: T,
): T & { activeSpeakers: LiveSpeaker[] } {
return { ...status, activeSpeakers: getActiveSpeakers() };
}
/**
* Wraps tryCommandThenFallback with a cleaner signature for use within this module.
* Attempts a Redis command first; on failure, falls back to the provided function.
@@ -66,8 +83,10 @@ async function withFallback<T>(
}
function readVoiceStatusFallback(): Promise<VoiceStatus> {
return readRedisStatus(VOICE_STATUS_KEY).then(
(cached) => (cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
return readRedisStatus(VOICE_STATUS_KEY).then((cached) =>
withActiveSpeakers(
(cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
),
);
}
@@ -137,7 +156,9 @@ export async function getVoiceChannels(guildId: string): Promise<Channel[]> {
export async function getVoiceStatus(): Promise<VoiceStatus> {
logger.debug("getVoiceStatus called");
const cached = await readRedisStatus(VOICE_STATUS_KEY);
return (cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS;
return withActiveSpeakers(
(cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
);
}
/**
@@ -160,8 +160,6 @@ export const configSchema = z
.default(30000)
.describe("Timeout for individual LLM moderation calls"),
// ── AI Analysis Timing ──────────────────────────────────────────────
AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500),
AI_ANALYSIS_RECOVERY_INTERVAL_MS: z.coerce
+1 -1
View File
@@ -1,6 +1,6 @@
import { createChildLogger } from "../logger/index.js";
import { drizzle } from "drizzle-orm/node-postgres";
import type { Pool, PoolClient } from "pg";
import { createChildLogger } from "../logger/index.js";
import { closePool, createPoolFromConfig } from "./pool.js";
const logger = createChildLogger("database.init");
@@ -592,5 +592,4 @@ export type DbRetentionPolicyInsert =
// Chatbot Messages
export type ChatbotMessage = typeof chatbotMessagesTable.$inferSelect;
export type ChatbotMessageInsert =
typeof chatbotMessagesTable.$inferInsert;
export type ChatbotMessageInsert = typeof chatbotMessagesTable.$inferInsert;
@@ -1,7 +1,7 @@
import { AppError, ValidationError } from "@/shared/errors/index";
import { createChildLogger } from "@/shared/logger/index";
import type { NextFunction, Request, Response } from "express";
import type { ZodSchema } from "zod";
import { AppError, ValidationError } from "@/shared/errors/index";
import { createChildLogger } from "@/shared/logger/index";
const logger = createChildLogger("middleware");
@@ -62,6 +62,7 @@ export const COMMAND_MEDIA_QUEUE = "media:queue";
export const COMMAND_MEDIA_SKIP = "media:skip";
export const COMMAND_MEDIA_STOP = "media:stop";
export const COMMAND_MEDIA_VOLUME = "media:volume";
export const COMMAND_MEDIA_LOOP = "media:loop";
export const COMMAND_MODERATION_ACTION = "moderation:action";
export const DISCORD_VOICE_ANALYZED = "discord:voice:analyzed";
+2 -2
View File
@@ -1,4 +1,6 @@
import { randomUUID } from "node:crypto";
import Redis from "ioredis";
import { config } from "../config/index.js";
import {
BACKEND_COMMAND,
BACKEND_COMMAND_REPLY_PREFIX,
@@ -6,8 +8,6 @@ import {
type CommandReply,
} from "../index.js";
import { createChildLogger } from "../logger/index.js";
import Redis from "ioredis";
import { config } from "../config/index.js";
const logger = createChildLogger("redis.command-channel");
+27 -2
View File
@@ -1,7 +1,12 @@
import { DISCORD_CHANNEL_TO_WS_EVENT, DISCORD_VOICE_PCM } from "../shared/index.js";
import { createChildLogger } from "../shared/logger/index.js";
import Redis from "ioredis";
import { recordSpeaker } from "../modules/voice/live-speaker.js";
import { config } from "../shared/config/index.js";
import {
DISCORD_CHANNEL_TO_WS_EVENT,
DISCORD_VOICE_ACTIVE_USER,
DISCORD_VOICE_PCM,
} from "../shared/index.js";
import { createChildLogger } from "../shared/logger/index.js";
import { broadcastBinary, broadcastEvent } from "./broadcast.js";
const logger = createChildLogger("ws.redis-bridge");
@@ -59,6 +64,26 @@ function handleSubscriptionMessage(channel: string, message: string): void {
}
}
// Aggregate live-voice state authoritatively BEFORE broadcasting.
// Every browser hears the same `voice_active_user` deltas, so the backend
// can maintain the single shared snapshot for late-joining clients.
if (channel === DISCORD_VOICE_ACTIVE_USER) {
const speaker = data as {
userId?: string;
username?: string;
avatar?: string | null;
speaking?: boolean;
};
if (speaker?.userId) {
recordSpeaker({
userId: speaker.userId,
username: speaker.username,
avatar: speaker.avatar,
speaking: Boolean(speaker.speaking),
});
}
}
logger.debug({ channel, eventType }, "Broadcasting Redis event");
broadcastEvent(eventType, data);
}
+18 -2
View File
@@ -1,8 +1,8 @@
import type { Server } from "node:http";
import { BACKEND_COMMAND, BACKEND_VOICE_TRANSMIT } from "../shared/index.js";
import { createChildLogger } from "../shared/logger/index.js";
import { WebSocket, WebSocketServer } from "ws";
import { config } from "../shared/config/index.js";
import { BACKEND_COMMAND, BACKEND_VOICE_TRANSMIT } from "../shared/index.js";
import { createChildLogger } from "../shared/logger/index.js";
import { setBroadcastFunctions } from "./broadcast.js";
const logger = createChildLogger("ws.server");
@@ -66,6 +66,22 @@ async function sendInitialStates(ws: WebSocket): Promise<void> {
} catch (err) {
logger.warn({ err }, "Failed to send initial media_state");
}
// Send initial live-voice snapshot (shared authoritative state — a browser
// joining mid-call sees the same speakers as everyone else, not an empty DB).
try {
const { getActiveSpeakers } = await import(
"../modules/voice/live-speaker.js"
);
ws.send(
JSON.stringify({
type: "voice_state",
state: { activeSpeakers: getActiveSpeakers() },
}),
);
} catch (err) {
logger.warn({ err }, "Failed to send initial voice_state");
}
}
export function closeWebSocketServer(): void {
+6 -6
View File
@@ -1,4 +1,6 @@
// ─── Shared Error Classes ────────────────────────────────────────────────────
import { afterEach, describe, expect, it, vi } from "vitest";
import {
AppError,
ConfigError,
@@ -6,7 +8,9 @@ import {
NotFoundError,
UnauthorizedError,
ValidationError,
} from "@bete/shared/errors";
} from "../src/shared/errors/index.js";
// ─── Backend middleware ──────────────────────────────────────────────────────
import { asyncHandler, requireParam } from "../src/shared/middlewares/index.js";
// ─── Shared utilities ─────────────────────────────────────────────────────────
import {
decodeCursor,
@@ -14,11 +18,7 @@ import {
encodeCursor,
pageResult,
retryWithBackoff,
} from "@bete/shared/utils";
import { afterEach, describe, expect, it, vi } from "vitest";
// ─── Backend middleware ──────────────────────────────────────────────────────
import { asyncHandler, requireParam } from "../src/shared/middlewares/index.js";
} from "../src/shared/utils/index.js";
// ═══════════════════════════════════════════════════════════════════════════════
// 1. AppError / Error Hierarchy Tests
+7 -1
View File
@@ -1,10 +1,16 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
testTimeout: 15000,
},
});
+4 -4
View File
@@ -243,10 +243,10 @@ On SIGINT/SIGTERM/uncaughtException/unhandledRejection:
- Connect to Backend HTTP API
- Subscribe to WebSocket events
3. **Docker & CI/CD**
- Dockerfile for Discord Gateway
- Docker Compose for multi-service setup
- GitHub Actions for build/deploy
3. **Nix & CI/CD**
- flake.nix package for Discord Gateway
- systemd services (gmw-backend, gmw-discord-gateway)
- GitHub Actions for build/deploy (nix copy → systemctl restart)
4. **Documentation**
- API documentation
+1 -1
View File
@@ -7,6 +7,6 @@ export default defineConfig({
dbCredentials: {
url:
process.env.DATABASE_URL ||
"postgresql://postgres:postgres@localhost:5432/bete",
"postgresql://asephs:***@100.121.180.82:6432/dcbot",
},
});
@@ -0,0 +1,2 @@
ALTER TABLE text_analysis_cache
ADD COLUMN IF NOT EXISTS embedding text;
@@ -0,0 +1,19 @@
-- Rename mascot chat tables/columns to chatbot (code rename in 977a6f9,
-- DB was never migrated). Idempotent: no-ops on databases that already
-- carry the new names (e.g. after a manual hotfix).
ALTER TABLE IF EXISTS "mascot_chat_messages" RENAME TO "chatbot_messages";
--> statement-breakpoint
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'chatbot_messages' AND column_name = 'mascot_response'
) THEN
ALTER TABLE "chatbot_messages" RENAME COLUMN "mascot_response" TO "bot_response";
END IF;
END $$;
--> statement-breakpoint
ALTER INDEX IF EXISTS "idx_mascot_chat_messages_user_created" RENAME TO "idx_chatbot_messages_user_created";
@@ -82,9 +82,23 @@
{
"idx": 11,
"version": "7",
"when": 1781388000000,
"when": 1785600000000,
"tag": "0011_add_voice_transcription",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1785500000000,
"tag": "0012_add_embedding_to_text_cache",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1785551832190,
"tag": "0013_rename_mascot_chat_to_chatbot",
"breakpoints": true
}
]
}
@@ -0,0 +1,3 @@
node_modules/
build/
package-lock.json
@@ -0,0 +1,526 @@
// libdatachannel-min — minimal N-API binding to libdatachannel.
// Exposes ONLY what GMW GoLive needs:
// PeerConnection (offer/answer, ICE, SDP), DataChannel (signaling),
// Track send (added in media phase).
// Built against libdatachannel 0.24.0 (built from source in /tmp/ldc-build).
#include <napi.h>
#include <rtc/rtc.hpp>
#include <functional>
#include <memory>
#include <string>
#include <variant>
using namespace Napi;
namespace {
std::string stateToString(rtc::PeerConnection::State s) {
switch (s) {
case rtc::PeerConnection::State::New: return "new";
case rtc::PeerConnection::State::Connecting: return "connecting";
case rtc::PeerConnection::State::Connected: return "connected";
case rtc::PeerConnection::State::Disconnected: return "disconnected";
case rtc::PeerConnection::State::Failed: return "failed";
case rtc::PeerConnection::State::Closed: return "closed";
default: return "unknown";
}
}
std::string binaryToString(const rtc::binary& data) {
// rtc::binary is std::vector<std::byte> in libdatachannel >= 0.21
std::string msg(data.size(), '\0');
for (size_t i = 0; i < data.size(); i++) {
msg[i] = static_cast<char>(data[i]);
}
return msg;
}
// Holds a Napi::Promise::Deferred so it can be moved into TSFN lambdas
// without invalid copies (node-addon-api 8.x Deferred is not movable).
struct DeferredHolder {
Promise::Deferred deferred;
explicit DeferredHolder(Promise::Deferred d) : deferred(d) {}
};
class DataChannelWrap : public Napi::ObjectWrap<DataChannelWrap> {
public:
static Function Init(Napi::Env env) {
Function func = DefineClass(env, "DataChannel", {
InstanceMethod("send", &DataChannelWrap::Send),
InstanceMethod("isOpen", &DataChannelWrap::IsOpen),
InstanceMethod("close", &DataChannelWrap::Close),
InstanceMethod("onMessage", &DataChannelWrap::OnMessage),
InstanceMethod("onOpen", &DataChannelWrap::OnOpen),
});
dcConstructor = Napi::Persistent(func);
return func;
}
// Create a JS wrapper (calls the JS constructor, returns instance).
static Object NewInstance(Napi::Env env) {
return dcConstructor.New({});
}
DataChannelWrap(const Napi::CallbackInfo& info)
: Napi::ObjectWrap<DataChannelWrap>(info) {}
void Init(std::shared_ptr<rtc::DataChannel> dc) {
dc_ = dc;
dc_->onMessage([this](rtc::message_variant data) {
std::string msg;
if (std::holds_alternative<rtc::binary>(data)) {
msg = binaryToString(std::get<rtc::binary>(data));
} else {
msg = std::get<std::string>(data);
}
if (msgCb_) {
msgCb_->BlockingCall([msg](Napi::Env env, Function cb) {
cb.Call({String::New(env, msg)});
});
}
});
dc_->onOpen([this]() {
if (openCb_) {
openCb_->BlockingCall([](Napi::Env env, Function cb) {
cb.Call({});
});
}
});
}
private:
static FunctionReference dcConstructor;
std::shared_ptr<rtc::DataChannel> dc_;
std::shared_ptr<ThreadSafeFunction> msgCb_;
std::shared_ptr<ThreadSafeFunction> openCb_;
void Send(const Napi::CallbackInfo& info) {
std::string msg = info[0].As<String>().Utf8Value();
if (dc_) dc_->send(msg);
}
Napi::Value IsOpen(const Napi::CallbackInfo& info) {
bool open = dc_ && dc_->isOpen();
return Boolean::New(info.Env(), open);
}
void Close(const Napi::CallbackInfo& info) {
if (dc_) dc_->close();
}
void OnMessage(const Napi::CallbackInfo& info) {
Function cb = info[0].As<Function>();
msgCb_ = std::make_shared<ThreadSafeFunction>(
ThreadSafeFunction::New(info.Env(), cb, "dc-message", 0, 1));
}
void OnOpen(const Napi::CallbackInfo& info) {
Function cb = info[0].As<Function>();
openCb_ = std::make_shared<ThreadSafeFunction>(
ThreadSafeFunction::New(info.Env(), cb, "dc-open", 0, 1));
}
};
class TrackWrap : public Napi::ObjectWrap<TrackWrap> {
public:
static Function Init(Napi::Env env) {
Function func = DefineClass(env, "Track", {
InstanceMethod("send", &TrackWrap::Send),
InstanceMethod("isOpen", &TrackWrap::IsOpen),
InstanceMethod("close", &TrackWrap::Close),
InstanceMethod("setPacketizer", &TrackWrap::SetPacketizer),
InstanceMethod("sendFrame", &TrackWrap::SendFrame),
InstanceMethod("addTimestamp", &TrackWrap::AddTimestamp),
});
trackConstructor = Napi::Persistent(func);
return func;
}
static Object NewInstance(Napi::Env env) {
return trackConstructor.New({});
}
TrackWrap(const Napi::CallbackInfo& info)
: Napi::ObjectWrap<TrackWrap>(info) {}
void Init(std::shared_ptr<rtc::Track> track, Napi::Env env) {
track_ = track;
(void)env;
}
private:
static FunctionReference trackConstructor;
std::shared_ptr<rtc::Track> track_;
std::shared_ptr<rtc::RtpPacketizationConfig> rtpConfig_;
void Send(const Napi::CallbackInfo& info) {
Buffer<uint8_t> buf = info[0].As<Buffer<uint8_t>>();
if (!track_) return;
rtc::binary data(buf.Length());
for (size_t i = 0; i < buf.Length(); i++) data[i] = (std::byte)buf[i];
try {
track_->send(data);
} catch (const std::exception& e) {
fprintf(stderr, "[binding] track.send THREW: %s\n", e.what());
}
}
// setPacketizer(kind, ssrc, payloadType, clockRate, playoutDelayId,
// playoutDelayMin, playoutDelayMax)
// kind: "audio" | "h264" | "h265" | "av1"
// Builds the media-handler chain (packetizer → RTCP SR → NACK → pacing for
// video) exactly like @dank074's WebRtcWrapper does via node-datachannel.
void SetPacketizer(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
if (!track_) throw Error::New(env, "track closed");
std::string kind = info[0].As<String>().Utf8Value();
uint32_t ssrc = info[1].As<Number>().Uint32Value();
uint8_t pt = (uint8_t)info[2].As<Number>().Uint32Value();
uint32_t clockRate = info[3].As<Number>().Uint32Value();
uint8_t playoutDelayId = (uint8_t)info[4].As<Number>().Uint32Value();
uint16_t playoutDelayMin = (uint16_t)info[5].As<Number>().Uint32Value();
uint16_t playoutDelayMax = (uint16_t)info[6].As<Number>().Uint32Value();
try {
auto cfg = std::make_shared<rtc::RtpPacketizationConfig>(
ssrc, "", pt, clockRate);
cfg->playoutDelayId = playoutDelayId;
cfg->playoutDelayMin = playoutDelayMin;
cfg->playoutDelayMax = playoutDelayMax;
std::shared_ptr<rtc::MediaHandler> handler;
if (kind == "audio") {
handler = std::make_shared<rtc::OpusRtpPacketizer>(cfg);
} else if (kind == "h264") {
handler = std::make_shared<rtc::H264RtpPacketizer>(
rtc::NalUnit::Separator::StartSequence, cfg);
} else if (kind == "h265") {
handler = std::make_shared<rtc::H265RtpPacketizer>(
rtc::NalUnit::Separator::StartSequence, cfg);
} else if (kind == "av1") {
handler = std::make_shared<rtc::AV1RtpPacketizer>(
rtc::AV1RtpPacketizer::Packetization::Obu, cfg);
} else {
throw std::runtime_error("unknown packetizer kind: " + kind);
}
handler->addToChain(std::make_shared<rtc::RtcpSrReporter>(cfg));
handler->addToChain(std::make_shared<rtc::RtcpNackResponder>());
if (kind != "audio") {
handler->addToChain(std::make_shared<rtc::PacingHandler>(
25.0 * 1000 * 1000, std::chrono::milliseconds(1)));
}
track_->setMediaHandler(handler);
rtpConfig_ = cfg;
} catch (const std::exception& e) {
fprintf(stderr, "[binding] setPacketizer THREW: %s\n", e.what());
throw Error::New(env, e.what());
}
}
// sendFrame(buffer) — sends an ENCODED frame (AnnexB H264 / raw opus /
// OBU AV1). The media-handler chain packetizes it into RTP.
void SendFrame(const Napi::CallbackInfo& info) {
Buffer<uint8_t> buf = info[0].As<Buffer<uint8_t>>();
if (!track_) return;
rtc::binary data(buf.Length());
for (size_t i = 0; i < buf.Length(); i++) data[i] = (std::byte)buf[i];
try {
track_->send(data);
} catch (const std::exception& e) {
fprintf(stderr, "[binding] track.sendFrame THREW: %s\n", e.what());
}
}
// addTimestamp(delta) — advances the packetizer RTP timestamp by delta
// (clock-rate units). Called by JS after each frame, matching the
// node-datachannel contract (WebRtcWrapper does the same increment).
void AddTimestamp(const Napi::CallbackInfo& info) {
uint32_t delta = info[0].As<Number>().Uint32Value();
if (rtpConfig_) rtpConfig_->timestamp += delta;
}
Napi::Value IsOpen(const Napi::CallbackInfo& info) {
bool open = track_ && track_->isOpen();
return Boolean::New(info.Env(), open);
}
void Close(const Napi::CallbackInfo& info) {
if (track_) track_->close();
}
void OnStateChange(const Napi::CallbackInfo& info) {
// libdatachannel Track has no state-change callback; kept for API parity.
(void)info;
}
};
class PeerConnectionWrap : public Napi::ObjectWrap<PeerConnectionWrap> {
public:
static Function Init(Napi::Env env) {
Function func = DefineClass(env, "PeerConnection", {
InstanceMethod("state", &PeerConnectionWrap::State),
InstanceMethod("createOffer", &PeerConnectionWrap::CreateOffer),
InstanceMethod("createAnswer", &PeerConnectionWrap::CreateAnswer),
InstanceMethod("setRemoteDescription",
&PeerConnectionWrap::SetRemoteDescription),
InstanceMethod("close", &PeerConnectionWrap::Close),
InstanceMethod("onStateChange", &PeerConnectionWrap::OnStateChange),
InstanceMethod("createDataChannel", &PeerConnectionWrap::CreateDataChannel),
InstanceMethod("onDataChannel", &PeerConnectionWrap::OnDataChannel),
InstanceMethod("addTrack", &PeerConnectionWrap::AddTrack),
});
return func;
}
PeerConnectionWrap(const Napi::CallbackInfo& info)
: Napi::ObjectWrap<PeerConnectionWrap>(info) {
Napi::Env env = info.Env();
if (!info[0].IsObject()) {
throw TypeError::New(env, "config object required");
}
Object config = info[0].As<Object>();
rtc::Configuration rtcConfig;
if (config.Has("iceServers")) {
Array servers = config.Get("iceServers").As<Array>();
for (uint32_t i = 0; i < servers.Length(); i++) {
std::string url = servers.Get(i).As<String>().Utf8Value();
rtcConfig.iceServers.emplace_back(url);
}
}
pc_ = std::make_shared<rtc::PeerConnection>(rtcConfig);
// IMPORTANT: register description/gathering callbacks HERE (constructor),
// BEFORE any createDataChannel call. libdatachannel only fires
// onLocalDescription for negotiations that start AFTER the callback is
// registered — if createDataChannel runs first, the offer callback never
// fires (verified in C++ spike: test3 vs test2).
pc_->onLocalDescription([this](rtc::Description desc) {
latestLocalDesc_ = std::string(desc);
fprintf(stderr, "[binding] trickle desc, %zu bytes\n",
latestLocalDesc_.size());
});
pc_->onGatheringStateChange([this](rtc::PeerConnection::GatheringState gs) {
fprintf(stderr, "[binding] gathering state: %d\n", (int)gs);
if (gs == rtc::PeerConnection::GatheringState::Complete) {
// Use the getter — it returns the FULL SDP including candidates after
// gathering (trickle callbacks only carry the initial fragment).
auto ld = pc_->localDescription();
if (ld) {
latestLocalDesc_ = std::string(*ld);
fprintf(stderr, "[binding] final desc, %zu bytes\n",
latestLocalDesc_.size());
}
resolvePendingLocalDesc_();
}
});
}
private:
std::shared_ptr<rtc::PeerConnection> pc_;
std::shared_ptr<ThreadSafeFunction> stateCb_;
std::shared_ptr<ThreadSafeFunction> dcCb_;
std::string latestLocalDesc_;
std::shared_ptr<DeferredHolder> pendingDescDeferred_;
std::shared_ptr<ThreadSafeFunction> pendingDescTsfn_;
void resolvePendingLocalDesc_() {
if (!pendingDescDeferred_ || !pendingDescTsfn_) return;
auto holder = pendingDescDeferred_;
auto tsfn = pendingDescTsfn_;
pendingDescDeferred_.reset();
pendingDescTsfn_.reset();
std::string sdp = latestLocalDesc_;
tsfn->BlockingCall([sdp, holder](Napi::Env e, Function) {
holder->deferred.Resolve(String::New(e, sdp));
});
}
Napi::Value State(const Napi::CallbackInfo& info) {
return String::New(info.Env(),
pc_ ? stateToString(pc_->state()) : "closed");
}
// createOffer() -> Promise<string> — sets local description, waits for
// ICE gathering to complete (so candidates are in the SDP), resolves SDP.
Napi::Value CreateOffer(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
auto holder = std::make_shared<DeferredHolder>(Promise::Deferred::New(env));
if (!pc_) {
holder->deferred.Reject(Error::New(env, "peer closed").Value());
return holder->deferred.Promise();
}
// createDataChannel already triggers negotiation in libdatachannel 0.24 —
// if gathering already completed, resolve immediately from the cached SDP.
if (!latestLocalDesc_.empty()) {
auto tsfn = std::make_shared<ThreadSafeFunction>(ThreadSafeFunction::New(
env, Function::New(env, [](const CallbackInfo&) {}), "desc", 0, 1));
std::string sdp = latestLocalDesc_;
tsfn->BlockingCall([sdp, holder](Napi::Env e, Function) {
holder->deferred.Resolve(String::New(e, sdp));
});
return holder->deferred.Promise();
}
if (pendingDescDeferred_) {
pendingDescDeferred_->deferred.Reject(
Error::New(env, "previous negotiation still pending").Value());
}
pendingDescDeferred_ = holder;
pendingDescTsfn_ = std::make_shared<ThreadSafeFunction>(
ThreadSafeFunction::New(env, Function::New(env, [](const CallbackInfo&) {}),
"desc", 0, 1));
fprintf(stderr, "[binding] calling setLocalDescription(Offer)\n");
try {
pc_->setLocalDescription(rtc::Description::Type::Offer);
fprintf(stderr, "[binding] setLocalDescription returned OK\n");
} catch (const std::exception& e) {
pendingDescDeferred_.reset();
fprintf(stderr, "[binding] setLocalDescription THREW: %s\n", e.what());
throw Error::New(env, e.what());
}
return holder->deferred.Promise();
}
// createAnswer(offerSdp: string) -> Promise<string>
Napi::Value CreateAnswer(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
std::string offer = info[0].As<String>().Utf8Value();
auto holder = std::make_shared<DeferredHolder>(Promise::Deferred::New(env));
if (!pc_) {
holder->deferred.Reject(Error::New(env, "peer closed").Value());
return holder->deferred.Promise();
}
if (pendingDescDeferred_) {
pendingDescDeferred_->deferred.Reject(
Error::New(env, "previous negotiation still pending").Value());
}
pendingDescDeferred_ = holder;
pendingDescTsfn_ = std::make_shared<ThreadSafeFunction>(
ThreadSafeFunction::New(env, Function::New(env, [](const CallbackInfo&) {}),
"desc", 0, 1));
try {
pc_->setRemoteDescription(
rtc::Description(offer, rtc::Description::Type::Offer));
fprintf(stderr, "[binding] answer: setRemoteDescription OK\n");
// libdatachannel 0.24 AUTO-GENERATES the answer when a remote offer is
// applied (verified in C++ spike test8/9: B desc type=Answer fires
// immediately with a=setup:active). Calling setLocalDescription() again
// would OVERWRITE it with a role=actpass SDP, which A rejects with
// "Illegal role actpass in remote answer description". So we do NOT call
// setLocalDescription here — we just wait for gathering complete and
// resolve with the auto-generated answer. This also matches @dank074's
// Discord voice flow.
} catch (const std::exception& e) {
pendingDescDeferred_.reset();
fprintf(stderr, "[binding] answer THREW: %s\n", e.what());
holder->deferred.Reject(Error::New(env, e.what()).Value());
}
return holder->deferred.Promise();
}
void SetRemoteDescription(const Napi::CallbackInfo& info) {
std::string sdp = info[0].As<String>().Utf8Value();
std::string type = info[1].As<String>().Utf8Value();
rtc::Description::Type t = (type == "answer")
? rtc::Description::Type::Answer
: rtc::Description::Type::Offer;
if (pc_) pc_->setRemoteDescription(rtc::Description(sdp, t));
}
void Close(const Napi::CallbackInfo& info) {
if (pc_) pc_->close();
}
void OnStateChange(const Napi::CallbackInfo& info) {
Function cb = info[0].As<Function>();
stateCb_ = std::make_shared<ThreadSafeFunction>(
ThreadSafeFunction::New(info.Env(), cb, "pc-state", 0, 1));
std::shared_ptr<rtc::PeerConnection> pc = pc_;
pc->onStateChange([this](rtc::PeerConnection::State state) {
if (stateCb_) {
std::string s = stateToString(state);
stateCb_->BlockingCall([s](Napi::Env env, Function cb) {
cb.Call({String::New(env, s)});
});
}
});
}
Napi::Value CreateDataChannel(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
std::string label = info[0].As<String>().Utf8Value();
fprintf(stderr, "[binding] createDataChannel(%s)\n", label.c_str());
auto dc = pc_->createDataChannel(label);
Object obj = DataChannelWrap::NewInstance(env);
DataChannelWrap::Unwrap(obj)->Init(dc);
return obj;
}
Napi::Value AddTrack(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
std::string mid = info[0].As<String>().Utf8Value();
std::string kind = info[1].As<String>().Utf8Value();
if (!pc_) throw Error::New(env, "peer closed");
fprintf(stderr, "[binding] addTrack(%s, %s) start\n", mid.c_str(), kind.c_str());
try {
std::shared_ptr<rtc::Track> track;
if (kind == "audio") {
// Opus payload type 120 (matches @dank074 CodecPayloadType.opus)
auto desc = rtc::Description::Audio(mid);
desc.addOpusCodec(120);
track = pc_->addTrack(desc);
} else {
// All video codecs with their payload types, matching WebRtcWrapper:
// H264 101/102, H265 103/104, VP8 105/106, VP9 107/108, AV1 109/110
auto desc = rtc::Description::Video(mid);
desc.addH264Codec(101);
desc.addRtxCodec(102, 101, 90000);
desc.addH265Codec(103);
desc.addRtxCodec(104, 103, 90000);
desc.addVP8Codec(105);
desc.addRtxCodec(106, 105, 90000);
desc.addVP9Codec(107);
desc.addRtxCodec(108, 107, 90000);
desc.addAV1Codec(109);
desc.addRtxCodec(110, 109, 90000);
track = pc_->addTrack(desc);
}
Object obj = TrackWrap::NewInstance(env);
TrackWrap::Unwrap(obj)->Init(track, env);
return obj;
} catch (const std::exception& e) {
fprintf(stderr, "[binding] addTrack THREW: %s\n", e.what());
throw Error::New(env, e.what());
}
}
void OnDataChannel(const Napi::CallbackInfo& info) {
Function cb = info[0].As<Function>();
dcCb_ = std::make_shared<ThreadSafeFunction>(
ThreadSafeFunction::New(info.Env(), cb, "dc", 0, 1));
std::shared_ptr<rtc::PeerConnection> pc = pc_;
pc->onDataChannel([this](std::shared_ptr<rtc::DataChannel> dc) {
if (dcCb_) {
auto dcPtr = dc;
dcCb_->BlockingCall([dcPtr](Napi::Env env, Function cb) {
Object obj = DataChannelWrap::NewInstance(env);
DataChannelWrap::Unwrap(obj)->Init(dcPtr);
cb.Call({obj});
});
}
});
}
};
Object InitAll(Napi::Env env, Object exports) {
exports.Set("PeerConnection", PeerConnectionWrap::Init(env));
exports.Set("DataChannel", DataChannelWrap::Init(env));
exports.Set("Track", TrackWrap::Init(env));
return exports;
}
NODE_API_MODULE(libdatachannel_min, InitAll)
// Definition for the static constructor references.
FunctionReference DataChannelWrap::dcConstructor;
FunctionReference TrackWrap::trackConstructor;
} // namespace
@@ -0,0 +1,21 @@
{
"targets": [
{
"target_name": "libdatachannel_min",
"sources": ["binding.cpp"],
"include_dirs": [
"<!(node -e \"console.log(process.env.NAPI_INCLUDE || (() => { try { return require('node-addon-api').include; } catch { return '/nonexistent'; } })())\")",
"<!(node -e \"const s=process.env.LDC_INCLUDE||'/nix/store/39a85gpfjqy3h3k8jwrwh7m9yc3inqw7-source';console.log(s+'/include')\")"
],
"libraries": [
"<!(node -e \"console.log(process.env.LDC_LIB || '/tmp/ldc-build/libdatachannel.so.0.24.0')\")"
],
"cflags": ["-std=c++17", "-fexceptions"],
"cflags_cc": ["-std=c++17", "-fexceptions"],
"defines": ["NAPI_CPP_EXCEPTIONS"],
"conditions": [
["OS=='linux'", { "cflags": ["-fvisibility=hidden"] }]
]
}
]
}
@@ -0,0 +1,3 @@
// libdatachannel-min — JS entry.
const native = require("./build/Release/datachannel_min.node");
module.exports = native;
@@ -0,0 +1,17 @@
{
"name": "libdatachannel-min",
"version": "0.1.0",
"description": "Minimal N-API binding to libdatachannel — PeerConnection, DataChannel, ICE, SDP (+ media tracks for GoLive)",
"main": "index.js",
"gypfile": true,
"scripts": {
"build": "node-gyp rebuild",
"test": "node test-handshake.js"
},
"dependencies": {
"node-addon-api": "^8.3.0"
},
"devDependencies": {
"node-gyp": "^11.5.0"
}
}
@@ -0,0 +1,82 @@
// Phase 0 spike: prove the minimal binding can do a full WebRTC handshake
// (offer/answer + ICE + DataChannel) between two local PeerConnections.
"use strict";
const { PeerConnection } = require("./build/Release/datachannel_min.node");
function log(...args) {
console.log("[spike]", ...args);
}
async function main() {
const pcA = new PeerConnection({ iceServers: [] });
const pcB = new PeerConnection({ iceServers: [] });
const stateLog = [];
pcA.onStateChange((s) => {
stateLog.push(`A:${s}`);
log("A state:", s);
});
pcB.onStateChange((s) => {
stateLog.push(`B:${s}`);
log("B state:", s);
});
// B waits for incoming DataChannel
const received = new Promise((resolve) => {
pcB.onDataChannel((dc) => {
log("B got incoming DataChannel");
dc.onOpen(() => log("B DataChannel open"));
dc.onMessage((msg) => {
log("B received message:", msg);
dc.send("pong from B");
resolve(msg);
});
});
});
// A creates an outgoing DataChannel
const dcA = pcA.createDataChannel("test");
dcA.onOpen(() => {
log("A DataChannel open — sending hello");
dcA.send("hello from A");
});
dcA.onMessage((msg) => {
log("A received reply:", msg);
});
// Offer/answer dance
log("A createOffer...");
const offer = await pcA.createOffer();
log("Offer SDP bytes:", offer.length);
log("B createAnswer...");
const answer = await pcB.createAnswer(offer);
log("Answer SDP bytes:", answer.length);
const setupMatch = answer.match(/a=setup:(\S+)/);
log("Answer setup role:", setupMatch ? setupMatch[1] : "NONE");
pcA.setRemoteDescription(answer, "answer");
// Wait for message roundtrip
const msg = await Promise.race([
received,
new Promise((_, rej) => setTimeout(() => rej(new Error("TIMEOUT waiting for datachannel message")), 15000)),
]);
log("ROUNDTRIP OK — B got:", msg);
log("States:", stateLog.join(" | "));
const aState = pcA.state();
const bState = pcB.state();
log("Final states — A:", aState, "B:", bState);
pcA.close();
pcB.close();
if (msg !== "hello from A") throw new Error("wrong message");
if (aState !== "connected" && aState !== "disconnected") throw new Error("A not connected: " + aState);
log("SPIKE PASSED ✅");
}
main().catch((e) => {
console.error("SPIKE FAILED:", e.message);
process.exit(1);
});
@@ -0,0 +1,80 @@
// Verify setPacketizer + sendFrame: two peers connect, audio+video tracks
// packetize real encoded frames (opus + AnnexB H264), RTP flows without crash.
"use strict";
const { PeerConnection } = require("./build/Release/datachannel_min.node");
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
async function main() {
const pcA = new PeerConnection({ iceServers: [] });
const pcB = new PeerConnection({ iceServers: [] });
const aAudio = pcA.addTrack("0", "audio");
const aVideo = pcA.addTrack("1", "video");
pcB.addTrack("0", "audio");
pcB.addTrack("1", "video");
let states = { a: "", b: "" };
pcA.onStateChange((s) => (states.a = s));
pcB.onStateChange((s) => (states.b = s));
// A: offer (createDataChannel not needed — tracks trigger negotiation)
const offer = await pcA.createOffer();
pcB.setRemoteDescription(offer, "offer");
const answer = await pcB.createAnswer(offer);
pcA.setRemoteDescription(answer, "answer");
// Wait for connected
for (let i = 0; i < 50; i++) {
if (states.a === "connected" && states.b === "connected") break;
await sleep(100);
}
console.log("[pkt] states:", states.a, states.b);
if (states.a !== "connected" || states.b !== "connected") {
console.log("PKT TEST FAILED: not connected");
process.exit(1);
}
// Setup packetizers on A (sender)
aAudio.setPacketizer("audio", 1234, 120, 48000, 5, 0, 1);
aVideo.setPacketizer("h264", 5678, 101, 90000, 5, 0, 10);
// Fake opus frame (20ms @48kHz stereo — payload can be any bytes)
const opusFrame = Buffer.alloc(160);
for (let i = 0; i < 160; i++) opusFrame[i] = i & 0xff;
// Fake AnnexB H264 frame: SPS + PPS + IDR slice
const sps = Buffer.from([0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0xc0, 0x1e, 0xd9, 0x01, 0x40, 0x7e]);
const pps = Buffer.from([0x00, 0x00, 0x00, 0x01, 0x68, 0xce, 0x3c, 0x80]);
const idr = Buffer.from([0x00, 0x00, 0x00, 0x01, 0x65, 0x88, 0x84, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]);
const h264Frame = Buffer.concat([sps, pps, idr]);
// Send 10 audio frames (20ms each) + 3 video frames (33ms each)
for (let i = 0; i < 10; i++) {
aAudio.sendFrame(opusFrame);
aAudio.addTimestamp(960); // 20ms @ 48kHz
}
for (let i = 0; i < 3; i++) {
aVideo.sendFrame(h264Frame);
aVideo.addTimestamp(3000); // 33ms @ 90kHz
}
await sleep(500);
console.log("[pkt] after send: states:", states.a, states.b);
console.log("[pkt] audio track open:", aAudio.isOpen(), "| video track open:", aVideo.isOpen());
const ok = states.a === "connected" && aAudio.isOpen() && aVideo.isOpen();
console.log(ok ? "PKT TEST PASSED" : "PKT TEST FAILED");
pcA.close();
pcB.close();
process.exit(ok ? 0 : 1);
}
main().catch((e) => {
console.error("[pkt] FAILED:", e.message);
process.exit(1);
});
setTimeout(() => {
console.error("[pkt] TIMEOUT");
process.exit(1);
}, 25000);
@@ -0,0 +1,33 @@
// Verify addTrack produces SDP with audio+video media sections.
"use strict";
const { PeerConnection } = require("./build/Release/datachannel_min.node");
const pc = new PeerConnection({ iceServers: [] });
const audioTrack = pc.addTrack("0", "audio");
const videoTrack = pc.addTrack("1", "video");
pc.onStateChange((s) => console.log("[test-track] state:", s));
pc.createOffer().then((sdp) => {
const hasAudio = /^m=audio\s/m.test(sdp);
const hasVideo = /^m=video\s/m.test(sdp);
const audioPts = sdp.match(/a=rtpmap:(\d+) opus/g) || [];
const videoPts = sdp.match(/a=rtpmap:(\d+) H264/g) || [];
console.log("[test-track] SDP bytes:", sdp.length);
console.log("[test-track] m=audio:", hasAudio, "| m=video:", hasVideo);
console.log("[test-track] opus pt:", audioPts, "| H264 pt:", videoPts);
console.log("[test-track] audio track send ok:", typeof audioTrack.send === "function");
console.log("[test-track] video track send ok:", typeof videoTrack.send === "function");
const ok = hasAudio && hasVideo && audioPts.length > 0 && videoPts.length > 0;
console.log(ok ? "TRACK TEST PASSED" : "TRACK TEST FAILED");
pc.close();
process.exit(ok ? 0 : 1);
}).catch((e) => {
console.error("[test-track] FAILED:", e.message);
process.exit(1);
});
setTimeout(() => {
console.error("[test-track] TIMEOUT");
process.exit(1);
}, 20000);
+2 -11
View File
@@ -7,16 +7,9 @@
"pnpm": {
"onlyBuiltDependencies": [
"@discordjs/opus",
"@lng2004/node-datachannel",
"esbuild",
"node-av",
"node-crc",
"sharp",
"zeromq"
],
"patchedDependencies": {
"node-crc@4.0.0": "./patches/node-crc@4.0.0.patch"
}
"sharp"
]
},
"scripts": {
"dev": "tsx watch src/index.ts",
@@ -33,14 +26,12 @@
"@snazzah/davey": "^0.1.11",
"axios": "^1.16.1",
"discord.js-selfbot-v13": "^3.7.1",
"@dank074/discord-video-stream": "latest",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"imghash": "^1.1.4",
"ioredis": "^5.11.0",
"libsodium-wrappers": "^0.8.4",
"lru-cache": "^11.5.1",
"node-crc": "^4.0.0",
"openai": "^6.38.0",
"opusscript": "^0.0.8",
"p-limit": "^7.3.0",
@@ -1,13 +0,0 @@
diff --git a/Cargo.toml b/Cargo.toml
index a967508960d8b6b686b23401ea14333b858a675c..d0282a30ee931563a65d774ed783c83d6d2fdd5e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,7 +3,7 @@ name = "node-crc"
version = "4.0.0"
authors = ["Magic Len <len@magiclen.org>"]
edition = "2021"
-rust-version = "1.65"
+rust-version = "1.77.0"
repository = "https://github.com/magiclen/node-crc"
homepage = "https://magiclen.org/node-js-crc/"
keywords = ["nodejs", "crc8", "crc16", "crc32", "crc64"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
allowBuilds:
"@discordjs/opus": true
"@lng2004/node-datachannel": true
esbuild: true
node-av: true
sharp: true
zeromq: true
# pnpm 11 requires build-script approvals here (the legacy `pnpm` field in
# package.json is ignored). Native voice deps need their postinstall build.
# NOTE: sharp sengaja TIDAK ada — binary-nya dari @img/sharp-linux-x64
# (prebuilt), install script-nya cuma validasi dan gagal di Nix sandbox.
# Kalau script sharp dijalankan pnpm rebuild abort sebelum opus/datachannel
# kebangun. node-crc dihapus dari deps (tidak pernah di-import).
onlyBuiltDependencies:
- "@discordjs/opus"
- "@lng2004/node-datachannel"
- esbuild
- node-av
- zeromq
@@ -1,8 +1,8 @@
import { ConfigError, DatabaseError } from "@/shared/errors/index";
import { createChildLogger } from "@/shared/logger/index";
import { Client } from "discord.js-selfbot-v13";
import { inArray, lt } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { ConfigError, DatabaseError } from "@/shared/errors/index";
import { createChildLogger } from "@/shared/logger/index";
import { startPendingAIAnalysisWorker } from "../modules/ai-moderation/aiAnalyzer.js";
import { registerChannelTopicCapture } from "../modules/channel-topic/index.js";
import { CommandHandler } from "../modules/command-handler/commandHandler.js";
@@ -1,6 +1,6 @@
import { createChildLogger } from "@/shared/logger/index";
import { inArray, lt } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { createChildLogger } from "@/shared/logger/index";
import { config } from "../shared/config/config.js";
import { getDatabase } from "../shared/database/drizzle.js";
import type * as schema from "../shared/database/schema.js";
+1 -1
View File
@@ -1,5 +1,5 @@
import type { createChildLogger } from "@/shared/logger/index";
import type { Client } from "discord.js-selfbot-v13";
import type { createChildLogger } from "@/shared/logger/index";
import type { CommandHandler } from "../modules/command-handler/commandHandler.js";
import type { EventBroadcaster } from "../modules/event-broadcaster/index.js";
import type { stopMetricsServer } from "../modules/gateway-metrics/index.js";
@@ -0,0 +1,155 @@
/**
* AnnexB bitstream reader/writer (RBSP + emulation prevention) ported
* from @dank074/discord-video-stream AnnexBBitstreamReaderWriter.js.
*/
export class AnnexBBitstreamReader {
private _buffer: Uint8Array;
private _byteOffset = 0;
private _bitOffset = 0;
constructor(buffer: Uint8Array) {
this._buffer = buffer;
}
readBits(count: number): number {
if (count === 0) return 0;
let result = 0;
while (count > 0) {
if (this._byteOffset >= this._buffer.length) {
throw new Error("Bad byte offset");
}
if (
this._bitOffset === 0 &&
this._byteOffset >= 2 &&
this._buffer[this._byteOffset - 2] === 0 &&
this._buffer[this._byteOffset - 1] === 0 &&
this._buffer[this._byteOffset] === 3
) {
// Skip over emulation prevention
this._byteOffset++;
}
if (this._bitOffset === 0 && count >= 8) {
result = (result << 8) | this._buffer[this._byteOffset++];
count -= 8;
} else {
const numBitsToRead = Math.min(count, 8 - this._bitOffset);
const mask = (1 << numBitsToRead) - 1;
const newBits =
(this._buffer[this._byteOffset] >>
(8 - this._bitOffset - numBitsToRead)) &
mask;
result = (result << numBitsToRead) | newBits;
count -= numBitsToRead;
this._bitOffset += numBitsToRead;
if (this._bitOffset === 8) {
this._bitOffset = 0;
this._byteOffset++;
}
}
}
return result;
}
readUnsigned(bits: number): number {
return this.readBits(bits);
}
readSigned(bits: number): number {
const unsigned = this.readUnsigned(bits);
if (unsigned & (1 << (bits - 1))) return unsigned - (1 << bits);
return unsigned;
}
readUnsignedExpGolomb(): number {
let leading0 = 0;
while (this.readBits(1) === 0) leading0++;
return (1 << leading0) + this.readBits(leading0) - 1;
}
readSignedExpGolomb(): number {
const unsigned = this.readUnsignedExpGolomb();
if (unsigned % 2 === 0) return unsigned / -2;
return (unsigned + 1) / 2;
}
}
export class AnnexBBitstreamWriter {
private _arr: number[] = [];
private _pendingByte = 0;
private _bitOffset = 0;
toBuffer(): Buffer {
return Buffer.from(this._arr);
}
flush(): void {
// Emulation prevention: insert 0x03 before 00 00
if (
this._pendingByte <= 3 &&
this._arr[this._arr.length - 1] === 0 &&
this._arr[this._arr.length - 2] === 0
) {
this._arr.push(3);
}
this._arr.push(this._pendingByte);
this._pendingByte = 0;
this._bitOffset = 0;
}
writeBits(bits: number, count: number): void {
while (count > 0) {
if (this._bitOffset === 0) {
if (count >= 8) {
this._pendingByte = (bits >> (count - 8)) & 0xff;
count -= 8;
this.flush();
} else {
const mask = (1 << count) - 1;
this._pendingByte |= (bits & mask) << (8 - count);
this._bitOffset = count;
count = 0;
}
} else {
const numBitsToWrite = Math.min(8 - this._bitOffset, count);
const bitsToWrite =
(bits >> (count - numBitsToWrite)) & ((1 << numBitsToWrite) - 1);
this._pendingByte |=
bitsToWrite << (8 - this._bitOffset - numBitsToWrite);
count -= numBitsToWrite;
this._bitOffset += numBitsToWrite;
if (this._bitOffset === 8) {
this._bitOffset = 0;
this.flush();
}
}
}
}
writeUnsigned(num: number, count: number): void {
if (num < 0) throw new Error("Expected a non-negative number");
this.writeBits(num, count);
}
writeSigned(num: number, count: number): void {
if (count <= 0) return;
if (count > 32) throw new Error("writeSigned supports up to 32 bits");
const mask =
count === 32 ? 0xffffffff >>> 0 : (((1 << count) >>> 0) - 1) >>> 0;
const unsigned = (num & mask) >>> 0;
this.writeBits(unsigned, count);
}
writeUnsignedExpGolomb(num: number): void {
if (num < 0) throw new Error("Expected a non-negative number");
num++;
const bitCount = 32 - Math.clz32(num >>> 0);
this.writeBits(0, bitCount - 1);
this.writeBits(num, bitCount);
}
writeSignedExpGolomb(num: number): void {
if (num < 0) this.writeUnsignedExpGolomb(-2 * num);
else this.writeUnsignedExpGolomb(2 * num - 1);
}
}
@@ -0,0 +1,112 @@
/**
* H264/H265 NAL helpers ported from @dank074/discord-video-stream
* AnnexBHelper.js. Only the H264 parts are used by GoLive (H264 encoder),
* H265 constants kept for completeness of the port.
*/
export enum H264NalUnitTypes {
Unspecified = 0,
CodedSliceNonIDR = 1,
CodedSlicePartitionA = 2,
CodedSlicePartitionB = 3,
CodedSlicePartitionC = 4,
CodedSliceIdr = 5,
SEI = 6,
SPS = 7,
PPS = 8,
AccessUnitDelimiter = 9,
EndOfSequence = 10,
EndOfStream = 11,
FillerData = 12,
SEIExtenstion = 13,
PrefixNalUnit = 14,
SubsetSPS = 15,
}
export enum H265NalUnitTypes {
TRAIL_N = 0,
TRAIL_R = 1,
TSA_N = 2,
TSA_R = 3,
STSA_N = 4,
STSA_R = 5,
RADL_N = 6,
RADL_R = 7,
RASL_N = 8,
RASL_R = 9,
RSV_VCL_N10 = 10,
RSV_VCL_R11 = 11,
RSV_VCL_N12 = 12,
RSV_VCL_R13 = 13,
RSV_VCL_N14 = 14,
RSV_VCL_R15 = 15,
BLA_W_LP = 16,
BLA_W_RADL = 17,
BLA_N_LP = 18,
IDR_W_RADL = 19,
IDR_N_LP = 20,
CRA_NUT = 21,
RSV_IRAP_VCL22 = 22,
RSV_IRAP_VCL23 = 23,
RSV_VCL24 = 24,
RSV_VCL25 = 25,
RSV_VCL26 = 26,
RSV_VCL27 = 27,
RSV_VCL28 = 28,
RSV_VCL29 = 29,
RSV_VCL30 = 30,
RSV_VCL31 = 31,
VPS_NUT = 32,
SPS_NUT = 33,
PPS_NUT = 34,
AUD_NUT = 35,
EOS_NUT = 36,
EOB_NUT = 37,
FD_NUT = 38,
PREFIX_SEI_NUT = 39,
SUFFIX_SEI_NUT = 40,
}
export const H264Helpers = {
getUnitType(frame: Uint8Array): number {
return frame[0] & 0x1f;
},
splitHeader(frame: Uint8Array): [Uint8Array, Uint8Array] {
return [frame.subarray(0, 1), frame.subarray(1)];
},
isAUD(unitType: number): boolean {
return unitType === H264NalUnitTypes.AccessUnitDelimiter;
},
};
export const H265Helpers = {
getUnitType(frame: Uint8Array): number {
return (frame[0] >> 1) & 0x3f;
},
splitHeader(frame: Uint8Array): [Uint8Array, Uint8Array] {
return [frame.subarray(0, 2), frame.subarray(2)];
},
isAUD(unitType: number): boolean {
return unitType === H265NalUnitTypes.AUD_NUT;
},
};
export const startCode3 = Buffer.from([0, 0, 1]);
/** Split an AnnexB bitstream into NAL units (start codes stripped). */
export function splitNalu(buf: Buffer): Buffer[] {
let temp: Buffer | null = buf;
const nalus: Buffer[] = [];
while (temp?.byteLength) {
let pos: number = temp.indexOf(startCode3);
let length = 3;
if (pos > 0 && temp[pos - 1] === 0) {
pos--;
length++;
}
const nalu = pos === -1 ? temp : temp.subarray(0, pos);
temp = pos === -1 ? null : temp.subarray(pos + length);
if (nalu.byteLength) nalus.push(nalu);
}
return nalus;
}
@@ -0,0 +1,20 @@
/**
* AudioStream feeds encoded opus frames into the WebRTC connection.
* Ported from @dank074/discord-video-stream AudioStream.js.
*/
import { BaseMediaStream } from "./BaseMediaStream.js";
import type { WebRtcConnWrapper } from "./WebRtcWrapper.js";
export class AudioStream extends BaseMediaStream {
_conn: WebRtcConnWrapper;
constructor(conn: WebRtcConnWrapper, noSleep = false) {
super("audio", noSleep);
this._conn = conn;
}
async _sendFrame(frame: Buffer, frametime: number): Promise<void> {
this._conn.sendAudioFrame(frame, frametime);
}
}
@@ -0,0 +1,594 @@
/**
* Base media connection for Discord GoLive ported from
* @dank074/discord-video-stream BaseMediaConnection.js.
*
* Owns the voice WebSocket (identify/select_protocol/heartbeat/resume),
* SDP negotiation against Discord's media server, DAVE E2E voice
* (via @snazzah/davey), and speaking/video attribute signaling.
*/
import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import Davey from "@snazzah/davey";
import { CodecPayloadType } from "./CodecPayloadType.js";
import type { NativePeerConnection } from "./native.js";
import { isNativeAvailable } from "./native.js";
import { STREAMS_SIMULCAST } from "./utils.js";
import { VoiceOpCodes, VoiceOpCodesBinary } from "./VoiceOpCodes.js";
import { WebRtcConnWrapper } from "./WebRtcWrapper.js";
export interface MediaConnectionStatus {
hasSession: boolean;
hasToken: boolean;
started: boolean;
resuming: boolean;
}
export interface VideoAttribute {
fps: number;
width: number;
height: number;
}
export interface StreamerLike {
opts: Record<string, unknown>;
}
export class BaseMediaConnection extends EventEmitter {
interval: ReturnType<typeof setInterval> | null = null;
guildId: string | null = null;
channelId: string;
botId: string;
ws: WebSocket | null = null;
status: MediaConnectionStatus;
server: string | null = null; // websocket url
token: string | null = null;
session_id: string | null = null;
protected _webRtcWrapper: WebRtcConnWrapper;
_webRtcParams: {
address: string;
port: number;
audioSsrc: number;
videoSsrc: number;
rtxSsrc: number;
supportedEncryptionModes: string[];
} | null = null;
protected _closed = false;
ready: ((conn: WebRtcConnWrapper) => void) | null;
protected _streamer: StreamerLike;
protected _sequenceNumber = -1;
protected _daveSession: Davey.DAVESession | null = null;
protected _connectedUsers = new Set<string>();
protected _daveProtocolVersion = 0;
protected _davePendingTransitions = new Map<number, number>();
protected _daveDowngraded = false;
constructor(
streamer: StreamerLike,
guildId: string | null,
botId: string,
channelId: string,
callback: ((conn: WebRtcConnWrapper) => void) | null,
) {
super();
this._streamer = streamer;
this.status = {
hasSession: false,
hasToken: false,
started: false,
resuming: false,
};
this.guildId = guildId;
this.channelId = channelId;
this.botId = botId;
this.ready = callback;
this._webRtcWrapper = new WebRtcConnWrapper(this);
}
get type(): "guild" | "call" {
return this.guildId ? "guild" : "call";
}
get webRtcConn(): WebRtcConnWrapper {
return this._webRtcWrapper;
}
get webRtcParams(): BaseMediaConnection["_webRtcParams"] {
return this._webRtcParams;
}
get streamer(): StreamerLike {
return this._streamer;
}
/** daveChannelId — overridden in VoiceConnection (channelId) and StreamConnection (serverId - 1n). */
get daveChannelId(): string {
throw new Error("daveChannelId not implemented");
}
stop(): void {
this._closed = true;
this._webRtcWrapper.close();
this.ws?.close();
}
setSession(session_id: string): void {
this.session_id = session_id;
this.status.hasSession = true;
this.start();
}
setTokens(server: string, token: string): void {
this.token = token;
this.server = server;
this.status.hasToken = true;
this.start();
}
start(): void {
if (this.status.hasSession && this.status.hasToken) {
if (this.status.started) return;
this.status.started = true;
this.ws = new WebSocket(`wss://${this.server}/?v=8`);
this.ws.binaryType = "arraybuffer";
this.ws.addEventListener("open", () => {
if (this.status.resuming) {
this.status.resuming = false;
this.resume();
} else {
this.identify();
}
});
this.ws.addEventListener("error", (err) => {
console.error(err);
});
this.ws.addEventListener("close", (e) => {
const wasStarted = this.status.started;
this.interval && clearInterval(this.interval);
this.status.started = false;
const canResume = e.code === 4015 || e.code < 4000;
if (canResume && wasStarted) {
this.status.resuming = true;
this.start();
} else {
this._closed = true;
this._webRtcWrapper?.close();
}
});
this.setupEvents();
}
}
handleReady(d: {
ip: string;
port: number;
ssrc: number;
streams: { ssrc: number; rtx_ssrc: number }[];
modes: string[];
}): void {
// we hardcoded STREAMS_SIMULCAST, which will always be array of 1
const stream = d.streams[0];
this._webRtcParams = {
address: d.ip,
port: d.port,
audioSsrc: d.ssrc,
videoSsrc: stream.ssrc,
rtxSsrc: stream.rtx_ssrc,
supportedEncryptionModes: d.modes,
};
}
async handleProtocolAck(d: {
sdp?: string;
dave_protocol_version?: number;
}): Promise<void> {
if (!("sdp" in d)) throw new Error("Only WebRTC connections are allowed");
this._daveProtocolVersion = d.dave_protocol_version ?? 0;
this.initDave();
// Discord's SDP is garbage — generate our own from its pieces
let ip = "";
let port = "";
let iceUsername = "";
let icePassword = "";
let fingerprint = "";
let candidate = "";
for (const line of (d.sdp ?? "").split("\n")) {
if (line.startsWith("c=")) ip = line;
else if (line.startsWith("a=rtcp")) port = line.split(":")[1];
else if (line.startsWith("a=ice-ufrag")) iceUsername = line;
else if (line.startsWith("a=ice-pwd")) icePassword = line;
else if (line.startsWith("a=fingerprint")) fingerprint = line;
else if (line.startsWith("a=candidate")) candidate = line;
}
const audioPayloadType = CodecPayloadType.opus.payload_type;
const audioSection = `
m=audio ${port} UDP/TLS/RTP/SAVPF ${audioPayloadType}
${ip}
a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level
a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
a=setup:passive
a=mid:0
a=maxptime:60
a=inactive
${iceUsername}
${icePassword}
${fingerprint}
${candidate}
a=rtcp-mux
a=rtpmap:${audioPayloadType} opus/48000/2
a=fmtp:${audioPayloadType} minptime=10;useinbandfec=1;usedtx=1
a=rtcp-fb:${audioPayloadType} transport-cc
a=rtcp-fb:${audioPayloadType} nack
a=ice-lite
`.trim();
const videoPayloads = Object.values(CodecPayloadType).filter(
(el) => el.type === "video",
);
const videoPayloadTypes = videoPayloads.flatMap((el) => [
el.payload_type,
el.rtx_payload_type ?? 0,
]);
const videoSection = `
m=video ${port} UDP/TLS/RTP/SAVPF ${videoPayloadTypes.join(" ")}
${ip}
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
a=extmap:14 urn:ietf:params:rtp-hdrext:toffset
a=extmap:13 urn:3gpp:video-orientation
a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay
a=setup:passive
a=mid:1
a=inactive
${iceUsername}
${icePassword}
${fingerprint}
${candidate}
a=rtcp-mux
a=ice-lite
`.trim();
const videoRtpMap = videoPayloads
.flatMap((el) => [
`a=rtpmap:${el.payload_type} ${el.name}/90000`,
`a=rtpmap:${el.rtx_payload_type} rtx/90000`,
`a=fmtp:${el.rtx_payload_type} apt=${el.payload_type}`,
`a=rtcp-fb:${el.payload_type} ccm fir`,
`a=rtcp-fb:${el.payload_type} nack`,
`a=rtcp-fb:${el.payload_type} nack pli`,
`a=rtcp-fb:${el.payload_type} goog-remb`,
`a=rtcp-fb:${el.payload_type} transport-cc`,
])
.join("\n");
this._webRtcWrapper.webRtcConn?.setRemoteDescription(
[audioSection, videoSection, videoRtpMap].join("\n"),
"answer",
);
this.emit("select_protocol_ack");
}
initDave(): void {
if (this._daveProtocolVersion) {
if (this._daveSession) {
this._daveSession.reinit(
this._daveProtocolVersion,
this.botId,
this.daveChannelId,
);
} else {
this._daveSession = new Davey.DAVESession(
this._daveProtocolVersion,
this.botId,
this.daveChannelId,
);
}
this.sendOpcodeBinary(
VoiceOpCodesBinary.MLS_KEY_PACKAGE,
this._daveSession.getSerializedKeyPackage(),
);
} else if (this._daveSession) {
this._daveSession.reset();
this._daveSession.setPassthroughMode(true, 10);
}
}
processInvalidCommit(transitionId: number): void {
this.sendOpcode(VoiceOpCodes.MLS_INVALID_COMMIT_WELCOME, {
transition_id: transitionId,
});
this.initDave();
}
executePendingTransition(transitionId: number): void {
const newVersion = this._davePendingTransitions.get(transitionId);
if (newVersion === undefined) {
console.error("Unrecognized transition ID", { transitionId });
return;
}
const oldVersion = this._daveProtocolVersion;
this._daveProtocolVersion = newVersion;
if (oldVersion !== newVersion && newVersion === 0) {
// Downgraded
this._daveDowngraded = true;
} else if (transitionId > 0 && this._daveDowngraded) {
this._daveDowngraded = false;
this._daveSession?.setPassthroughMode(true, 10);
}
this._davePendingTransitions.delete(transitionId);
}
setupEvents(): void {
this.ws?.addEventListener("message", async (e) => {
if (e.data instanceof ArrayBuffer) {
this.handleBinaryMessages(Buffer.from(e.data));
return;
}
const { op, d, seq } = JSON.parse(e.data as string) as {
op: number;
// biome-ignore lint/suspicious/noExplicitAny: Discord voice WS payload is dynamically typed
d: any;
seq?: number;
};
if (seq) this._sequenceNumber = seq;
if (op === VoiceOpCodes.READY) {
this.handleReady(d);
this.setProtocols().then(() => this.ready?.(this._webRtcWrapper));
this.setVideoAttributes(false);
} else if (op >= 4000) {
console.error(`${this.constructor.name} connection error`, d);
} else if (op === VoiceOpCodes.HELLO) {
this.setupHeartbeat(d.heartbeat_interval);
} else if (op === VoiceOpCodes.SELECT_PROTOCOL_ACK) {
await this.handleProtocolAck(d);
} else if (op === VoiceOpCodes.SPEAKING) {
// ignore speaking updates
} else if (op === VoiceOpCodes.HEARTBEAT_ACK) {
// ignore heartbeat acknowledgements
} else if (op === VoiceOpCodes.RESUMED) {
this.status.started = true;
} else if (op === VoiceOpCodes.CLIENTS_CONNECT) {
d.user_ids.forEach((id: string) => {
this._connectedUsers.add(id);
});
} else if (op === VoiceOpCodes.CLIENT_DISCONNECT) {
this._connectedUsers.delete(d.user_id);
} else if (op === VoiceOpCodes.DAVE_PREPARE_TRANSITION) {
this._davePendingTransitions.set(d.transition_id, d.protocol_version);
if (d.transition_id === 0) {
this.executePendingTransition(d.transition_id);
} else {
if (d.protocol_version === 0) {
this._daveSession?.setPassthroughMode(true, 120);
}
this.sendOpcode(VoiceOpCodes.DAVE_TRANSITION_READY, {
transition_id: d.transition_id,
});
}
} else if (op === VoiceOpCodes.DAVE_EXECUTE_TRANSITION) {
this.executePendingTransition(d.transition_id);
} else if (op === VoiceOpCodes.DAVE_PREPARE_EPOCH) {
if (d.epoch === 1) {
this._daveProtocolVersion = d.protocol_version;
this.initDave();
}
}
});
}
handleBinaryMessages(msg: Buffer): void {
this._sequenceNumber = msg.readUint16BE(0);
const op = msg.readUint8(2);
switch (op) {
case VoiceOpCodesBinary.MLS_EXTERNAL_SENDER: {
this._daveSession?.setExternalSender(msg.subarray(3));
break;
}
case VoiceOpCodesBinary.MLS_PROPOSALS: {
const optype = msg.readUint8(3);
if (!this._daveSession) break;
const { commit, welcome } = this._daveSession.processProposals(
optype,
msg.subarray(4),
[...this._connectedUsers],
);
if (commit) {
this.sendOpcodeBinary(
VoiceOpCodesBinary.MLS_COMMIT_WELCOME,
welcome ? Buffer.concat([commit, welcome]) : commit,
);
}
break;
}
case VoiceOpCodesBinary.MLS_ANNOUNCE_COMMIT_TRANSITION: {
const transitionId = msg.readUInt16BE(3);
try {
this._daveSession?.processCommit(msg.subarray(5));
if (transitionId) {
this._davePendingTransitions.set(
transitionId,
this._daveProtocolVersion,
);
this.sendOpcode(VoiceOpCodes.DAVE_TRANSITION_READY, {
transition_id: transitionId,
});
}
} catch (e) {
console.debug("MLS commit errored", e);
this.processInvalidCommit(transitionId);
}
break;
}
case VoiceOpCodesBinary.MLS_WELCOME: {
const transitionId = msg.readUInt16BE(3);
try {
this._daveSession?.processWelcome(msg.subarray(5));
if (transitionId) {
this._davePendingTransitions.set(
transitionId,
this._daveProtocolVersion,
);
this.sendOpcode(VoiceOpCodes.DAVE_TRANSITION_READY, {
transition_id: transitionId,
});
}
} catch (e) {
console.debug("MLS welcome errored", e);
this.processInvalidCommit(transitionId);
}
break;
}
}
}
get daveReady(): boolean {
return !!this._daveProtocolVersion && !!this._daveSession?.ready;
}
get daveSession(): Davey.DAVESession | null {
return this._daveSession;
}
setupHeartbeat(interval: number): void {
if (this.interval) {
clearInterval(this.interval);
}
this.interval = setInterval(() => {
try {
this.sendOpcode(VoiceOpCodes.HEARTBEAT, {
t: Date.now(),
seq_ack: this._sequenceNumber,
});
} catch {
/* ignore */
}
}, interval);
}
sendOpcode(code: number, data: unknown): void {
if (this.ws?.readyState !== WebSocket.OPEN) return;
this.ws.send(JSON.stringify({ op: code, d: data }));
}
sendOpcodeBinary(code: number, data: Uint8Array): void {
if (this.ws?.readyState !== WebSocket.OPEN) return;
const buf = Buffer.allocUnsafe(data.length + 1);
buf.writeUInt8(code);
Buffer.from(data).copy(buf, 1);
this.ws.send(buf);
}
/** serverId — overridden in VoiceConnection (guildId ?? channelId) and StreamConnection (rtc_server_id). */
get serverId(): string | null {
throw new Error("serverId not implemented");
}
/** identifies with media server with credentials */
identify(): void {
if (!this.serverId) throw new Error("Server ID is null or empty");
if (!this.session_id) throw new Error("Session ID is null or empty");
if (!this.token) throw new Error("Token is null or empty");
this.sendOpcode(VoiceOpCodes.IDENTIFY, {
server_id: this.serverId,
user_id: this.botId,
session_id: this.session_id,
token: this.token,
video: true,
streams: STREAMS_SIMULCAST,
max_dave_protocol_version: Davey.DAVE_PROTOCOL_VERSION ?? 0,
});
}
resume(): void {
if (!this.serverId) throw new Error("Server ID is null or empty");
if (!this.session_id) throw new Error("Session ID is null or empty");
if (!this.token) throw new Error("Token is null or empty");
this.sendOpcode(VoiceOpCodes.RESUME, {
server_id: this.serverId,
session_id: this.session_id,
token: this.token,
seq_ack: this._sequenceNumber,
});
}
/** Sets protocols and ip data used for video and audio (vp8 video, opus audio). */
async setProtocols(): Promise<void> {
if (!this._webRtcParams) throw new Error("WebRTC parameters not set");
if (!isNativeAvailable()) {
throw new Error(
"libdatachannel-min native binding not built — cannot start GoLive",
);
}
const reconnect = () => {
const webRtcConn = this._webRtcWrapper.initWebRtc();
webRtcConn.onStateChange((state) => {
if (state === "closed" && !this._closed) reconnect();
});
this._webRtcWrapper.onLocalDescription = (sdp) => {
const rtc_connection_id = randomUUID();
this.sendOpcode(VoiceOpCodes.SELECT_PROTOCOL, {
protocol: "webrtc",
codecs: Object.values(CodecPayloadType),
data: sdp,
sdp,
rtc_connection_id,
});
};
// createOffer (binding resolves full SDP incl. candidates after gathering)
void webRtcConn.createOffer().then((sdp) => {
this._webRtcWrapper.onLocalDescription?.(sdp);
});
};
reconnect();
return new Promise((resolve) => {
this.once("select_protocol_ack", () => resolve());
});
}
setVideoAttributes(enabled: boolean, attr?: VideoAttribute): void {
if (!this._webRtcParams) throw new Error("WebRTC parameters not set");
const { audioSsrc, videoSsrc, rtxSsrc } = this._webRtcParams;
if (!enabled) {
this.sendOpcode(VoiceOpCodes.VIDEO, {
audio_ssrc: audioSsrc,
video_ssrc: 0,
rtx_ssrc: 0,
streams: [],
});
} else {
if (!attr) throw new Error("Need to specify video attributes");
this.sendOpcode(VoiceOpCodes.VIDEO, {
audio_ssrc: audioSsrc,
video_ssrc: videoSsrc,
rtx_ssrc: rtxSsrc,
streams: [
{
type: "video",
rid: "100",
ssrc: videoSsrc,
active: true,
quality: 100,
rtx_ssrc: rtxSsrc,
// hardcode the max bitrate because we don't really know anyway
max_bitrate: 10000 * 1000,
max_framerate: enabled ? attr.fps : 0,
max_resolution: {
type: "fixed",
width: attr.width,
height: attr.height,
},
},
],
});
}
}
/** Set speaking status */
setSpeaking(speaking: boolean): void {
if (!this._webRtcParams) throw new Error("WebRTC connection not ready");
this.sendOpcode(VoiceOpCodes.SPEAKING, {
delay: 0,
speaking: speaking ? 1 : 0,
ssrc: this._webRtcParams.audioSsrc,
});
}
}
export type { NativePeerConnection };
@@ -0,0 +1,175 @@
/**
* BaseMediaStream pacing/sync for GoLive frames. Ported from
* @dank074/discord-video-stream BaseMediaStream.js, minus node-av's
* AVFrame (frames are plain objects here) and debug-level (uses the GMW
* logger instead).
*/
import { Writable } from "node:stream";
import { setTimeout as sleep } from "node:timers/promises";
export interface GoLiveFrame {
data: Buffer | null;
pts: number;
duration: number;
timeBase: { num: number; den: number };
free?: () => void;
}
export class BaseMediaStream extends Writable {
_pts: number | undefined;
_syncTolerance = 20;
_noSleep: boolean;
_startTime: number | undefined;
_startPts: number | undefined;
_sync = true;
_syncStream: BaseMediaStream | undefined;
_type: string;
constructor(type: string, noSleep = false) {
super({ objectMode: true, highWaterMark: 0 });
this._type = type;
this._noSleep = noSleep;
}
get sync(): boolean {
return this._sync;
}
set sync(val: boolean) {
this._sync = val;
}
get syncStream(): BaseMediaStream | undefined {
return this._syncStream;
}
set syncStream(stream: BaseMediaStream | undefined) {
if (stream !== undefined && this === stream.syncStream) {
throw new Error("Cannot sync 2 streams with eachother");
}
this._syncStream = stream;
}
get noSleep(): boolean {
return this._noSleep;
}
set noSleep(val: boolean) {
this._noSleep = val;
if (!val) this.resetTimingCompensation();
}
get pts(): number | undefined {
return this._pts;
}
get syncTolerance(): number {
return this._syncTolerance;
}
set syncTolerance(n: number) {
if (n < 0) return;
this._syncTolerance = n;
}
async _sendFrame(_frame: Buffer, _frametime: number): Promise<void> {
throw new Error("Not implemented");
}
ptsDelta(): number | undefined {
if (this.pts !== undefined && this.syncStream?.pts !== undefined) {
return this.pts - this.syncStream.pts;
}
return undefined;
}
isAhead(): boolean {
const delta = this.ptsDelta();
return (
this.syncStream?.writableEnded === false &&
delta !== undefined &&
delta > this.syncTolerance
);
}
isBehind(): boolean {
const delta = this.ptsDelta();
return (
this.syncStream?.writableEnded === false &&
delta !== undefined &&
delta < -this.syncTolerance
);
}
resetTimingCompensation(): void {
this._startTime = this._startPts = undefined;
}
async _write(
frame: GoLiveFrame,
_encoding: BufferEncoding,
callback: (error?: Error | null) => void,
): Promise<void> {
const { data, pts, duration, timeBase } = frame;
if (!data) {
frame.free?.();
callback();
return;
}
const frametime = (Number(duration) / timeBase.den) * timeBase.num * 1000;
const start_sendFrame = performance.now();
await this._sendFrame(Buffer.from(data), frametime);
const end_sendFrame = performance.now();
this._pts = (Number(pts) / timeBase.den) * timeBase.num * 1000;
this.emit("pts", this._pts);
const sendTime = end_sendFrame - start_sendFrame;
const ratio = sendTime / frametime;
if (ratio > 1) {
// Frame takes longer to send than its frametime — warn once per 100
if (
this._lastWarnedRatio === undefined ||
ratio > this._lastWarnedRatio
) {
this._lastWarnedRatio = ratio;
}
}
this._startTime ??= start_sendFrame;
this._startPts ??= this._pts;
const sleepMs = Math.max(
0,
this._pts -
this._startPts +
frametime -
(end_sendFrame - this._startTime),
);
if (this._noSleep || sleepMs === 0) {
callback(null);
} else if (this.sync && this.isBehind()) {
// Stream is behind — don't sleep for this frame
this.resetTimingCompensation();
callback(null);
} else if (this.sync && this.isAhead()) {
// Stream is ahead — wait until the sync stream catches up
do {
await sleep(frametime);
} while (this.sync && this.isAhead());
this.resetTimingCompensation();
callback(null);
} else {
await sleep(sleepMs);
callback(null);
}
frame.free?.();
}
_lastWarnedRatio: number | undefined;
_destroy(
error: Error | null,
callback: (error?: Error | null) => void,
): void {
super._destroy(error, callback);
this.syncStream = undefined;
}
}
@@ -0,0 +1,71 @@
/** Payload types for Discord GoLive media — ported from @dank074/discord-video-stream. */
export interface CodecPayloadTypeEntry {
name: string;
type: "audio" | "video";
clockRate: number;
priority: number;
payload_type: number;
rtx_payload_type?: number;
encode?: boolean;
decode?: boolean;
}
export const CodecPayloadType: Record<string, CodecPayloadTypeEntry> = {
opus: {
name: "opus",
type: "audio",
clockRate: 48000,
priority: 1000,
payload_type: 120,
},
H264: {
name: "H264",
type: "video",
clockRate: 90000,
priority: 1000,
payload_type: 101,
rtx_payload_type: 102,
encode: true,
decode: true,
},
H265: {
name: "H265",
type: "video",
clockRate: 90000,
priority: 1000,
payload_type: 103,
rtx_payload_type: 104,
encode: true,
decode: true,
},
VP8: {
name: "VP8",
type: "video",
clockRate: 90000,
priority: 1000,
payload_type: 105,
rtx_payload_type: 106,
encode: true,
decode: true,
},
VP9: {
name: "VP9",
type: "video",
clockRate: 90000,
priority: 1000,
payload_type: 107,
rtx_payload_type: 108,
encode: true,
decode: true,
},
AV1: {
name: "AV1",
type: "video",
clockRate: 90000,
priority: 1000,
payload_type: 109,
rtx_payload_type: 110,
encode: true,
decode: true,
},
};
@@ -0,0 +1,377 @@
/**
* Lightweight demuxer replaces node-av's LibavDemuxer for GoLive.
*
* Spawns ffmpeg to remux input into H264 AnnexB on stdout (video only
* screen share doesn't need to mux audio into the demuxer; audio goes
* separately). This replaces the 114MB node-av binary with a plain ffmpeg
* spawn.
*
* Each video "frame" emitted is a complete NAL sequence terminated by a
* keyframe boundary (IDR). Audio is not extracted here for GoLive with
* audio, the NUT mux + full demuxer would be needed; screen share audio is
* handled via a separate ffmpeg instance (see getDirectScreenInput).
*/
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { createWriteStream, existsSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough } from "node:stream";
/**
* Resolve ffmpeg/ffprobe binary. Prefers explicit env override, then PATH,
* then a Nix-store ffmpeg-headless (the GMW flake provides it in the service
* profile, but dev shells / tests may not have it on PATH).
*/
function resolveBin(name: "ffmpeg"): string {
const override = process.env.FFMPEG_PATH;
if (override && existsSync(override)) return override;
// Nix store scan: <store>/<hash>-ffmpeg-headless-*/bin/<name>
const store = "/nix/store";
if (existsSync(store)) {
const entries = readdirSync(store);
for (const entry of entries) {
if (!entry.includes("ffmpeg-headless-")) continue;
const candidate = join(store, entry, "bin", name);
if (existsSync(candidate)) return candidate;
}
}
return name; // fall back to PATH
}
const FFMPEG = resolveBin("ffmpeg");
export const AVCodecID = {
AV_CODEC_ID_H264: 27,
AV_CODEC_ID_HEVC: 173,
AV_CODEC_ID_VP8: 139,
AV_CODEC_ID_VP9: 167,
AV_CODEC_ID_AV1: 225,
AV_CODEC_ID_OPUS: 86019,
} as const;
export type AVCodecID = (typeof AVCodecID)[keyof typeof AVCodecID];
export const AV_PKT_FLAG_KEY = 1;
export interface Frame {
data: Buffer | null;
pts: number;
duration: number;
timeBase: { num: number; den: number };
flags: number;
streamIndex: number;
free(): void;
}
export interface DemuxedStream {
codec: number;
codecName: string;
width: number;
height: number;
framerate_num: number;
framerate_den: number;
sample_rate: number;
stream: PassThrough;
}
/**
* Probe a media file for stream info using ffmpeg's stderr (the
* ffmpeg-headless Nix package ships ffmpeg but not ffprobe). Returns
* stream descriptors in the same shape ffprobe -show_streams would.
*/
export async function probeStreams(
url: string,
): Promise<Array<Record<string, unknown>>> {
return new Promise((resolve, reject) => {
const proc = spawn(FFMPEG, [
"-hide_banner",
"-loglevel",
"info",
"-i",
url,
"-f",
"null",
"-",
]);
let stderr = "";
proc.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
proc.on("close", () => {
// Parse "Stream #0:0: Video: h264 (High), yuv420p, 640x360, 30 fps"
const streams: Array<Record<string, unknown>> = [];
const re = /Stream #0:(\d+): (Video|Audio): ([^,]+)/g;
let m: RegExpExecArray | null;
// biome-ignore lint/suspicious/noAssignInExpressions: regex loop idiom
while ((m = re.exec(stderr)) !== null) {
const [full, idx, kind, codecRaw] = m;
void full;
const codecName = codecRaw.split(" ")[0].toLowerCase();
const stream: Record<string, unknown> = {
index: Number(idx),
codec_type: kind.toLowerCase(),
codec_name: codecName,
width: 0,
height: 0,
r_frame_rate: "0/1",
sample_rate: 0,
};
// dimensions: "640x360"
const dim = /(\d{2,5})x(\d{2,5})/.exec(stderr.slice(m.index));
if (dim) {
stream.width = Number(dim[1]);
stream.height = Number(dim[2]);
}
// fps: "30 fps" or "29.97 fps"
const fps = /(\d+(?:\.\d+)?) fps/.exec(stderr.slice(m.index));
if (fps) {
const v = Number(fps[1]);
stream.r_frame_rate = `${Math.round(v * 1000)}/1000`;
}
// sample rate for audio: "48000 Hz"
const sr = /(\d+) Hz/.exec(stderr.slice(m.index));
if (sr) stream.sample_rate = Number(sr[1]);
streams.push(stream);
}
resolve(streams);
});
proc.on("error", (err) => reject(err));
});
}
/**
* Demux input (URL string or readable stream) into video frames on a
* PassThrough. Uses ffmpeg -f h264 -c copy for video-only AnnexB output.
* Returns stream info + the video pipe. Audio is not extracted (GoLive
* screen share sends silence / uses Discord's mixed audio).
*/
export async function demux(
input: string | PassThrough,
_opts: { format: string },
): Promise<{
video: DemuxedStream | undefined;
audio: DemuxedStream | undefined;
close: () => void;
}> {
const _label = randomUUID();
const vPipe = new PassThrough({ objectMode: true, highWaterMark: 128 });
const aPipe = new PassThrough({ objectMode: true, highWaterMark: 128 });
// For stream input, spool to a temp file first so ffprobe can inspect it
// (ffprobe needs a seekable file; pipes can't be re-read). The stream is
// fully consumed before ffmpeg starts — acceptable for screen-share
// sources which are already fully buffered by yt-dlp in practice.
let spoolPath: string | null = null;
const cleanupSpool = () => {
if (spoolPath) {
import("node:fs").then(({ unlink }) => unlink(spoolPath!, () => {}));
spoolPath = null;
}
};
let effectiveInput: string;
if (typeof input === "string") {
effectiveInput = input;
} else {
spoolPath = join(tmpdir(), `golive-demux-${_label}.h264`);
const ws = createWriteStream(spoolPath);
await new Promise<void>((resolve, reject) => {
input.pipe(ws);
input.on("error", reject);
ws.on("finish", resolve);
ws.on("error", reject);
});
effectiveInput = spoolPath;
}
// Probe for codec + dimensions
let streams: Array<Record<string, unknown>> = [];
try {
streams = await probeStreams(effectiveInput);
} catch (_e) {
// probe failed (e.g. raw h264 without container) — infer h264 default
streams = [];
}
const v = streams.find((s) => s.codec_type === "video");
const a = streams.find((s) => s.codec_type === "audio");
let vInfo: DemuxedStream | undefined;
let aInfo: DemuxedStream | undefined;
if (v) {
const codecName = (v.codec_name as string) ?? "h264";
const rFrame = (v.r_frame_rate as string) ?? "0/1";
const [num, den] = rFrame.split("/").map((n) => Number(n));
vInfo = {
codec:
AVCodecID[
(codecName.toUpperCase() as keyof typeof AVCodecID) ??
"AV_CODEC_ID_H264"
] ?? AVCodecID.AV_CODEC_ID_H264,
codecName,
width: (v.width as number) ?? 0,
height: (v.height as number) ?? 0,
framerate_num: num ?? 0,
framerate_den: den ?? 1,
sample_rate: 0,
stream: vPipe,
};
} else {
// Probe failed (e.g. raw AnnexB h264 input) — still emit frames on the
// video pipe; playStream infers dimensions from the first frame.
vInfo = {
codec: AVCodecID.AV_CODEC_ID_H264,
codecName: "h264",
width: 0,
height: 0,
framerate_num: 0,
framerate_den: 1,
sample_rate: 0,
stream: vPipe,
};
}
if (a) {
const codecName = (a.codec_name as string) ?? "opus";
aInfo = {
codec:
AVCodecID[
(codecName.toUpperCase() as keyof typeof AVCodecID) ??
"AV_CODEC_ID_OPUS"
],
codecName,
width: 0,
height: 0,
framerate_num: 0,
framerate_den: 0,
sample_rate: Number(a.sample_rate) ?? 0,
stream: aPipe,
};
}
// Spawn ffmpeg — extract raw video (AnnexB for H264) to stdout
const args: string[] = [
"-hide_banner",
"-loglevel",
"error",
"-i",
effectiveInput,
"-c:v",
"copy",
"-an", // no audio in this minimal demuxer
"-f",
"h264",
"pipe:1",
];
const proc = spawn(FFMPEG, args, { stdio: ["ignore", "pipe", "pipe"] });
// Scan stdout for NAL units. Each NAL unit (between start codes) is one frame
// payload. We emit them individually; the packetizer chain handles FU-A.
let videoBuf = Buffer.alloc(0);
let frameCount = 0;
const emitFrame = (nal: Uint8Array, isKeyFrame: boolean) => {
vPipe.write({
data: Buffer.from(nal),
pts: frameCount,
duration: 1,
timeBase: { num: 1, den: 90000 },
flags: isKeyFrame ? AV_PKT_FLAG_KEY : 0,
streamIndex: 0,
free: () => {},
});
frameCount++;
};
if (proc.stdout) {
proc.stdout.on("data", (chunk: Buffer) => {
videoBuf = Buffer.concat([videoBuf, chunk]);
// Find start codes (00 00 01 or 00 00 00 01) and split NALs
let start = 0;
// If buffer starts with zeros, that's the first start code — emit from there
while (start < videoBuf.length) {
let scPos = -1;
for (let i = start + 1; i < videoBuf.length - 2; i++) {
if (
videoBuf[i] === 0 &&
videoBuf[i + 1] === 0 &&
videoBuf[i + 2] === 1
) {
scPos = i + 3;
break;
}
}
if (scPos === -1) break;
// Emit the NAL from `start` to `scPos` (but skip the start code bytes at `start`)
if (start < scPos) {
let nalStart = start;
// Skip start code bytes for the NAL itself (00 00 01)
if (
videoBuf[nalStart] === 0 &&
videoBuf[nalStart + 1] === 0 &&
videoBuf[nalStart + 2] === 1
) {
nalStart += 3;
} else if (
nalStart + 3 < scPos &&
videoBuf[nalStart] === 0 &&
videoBuf[nalStart + 1] === 0 &&
videoBuf[nalStart + 2] === 0 &&
videoBuf[nalStart + 3] === 1
) {
nalStart += 4;
}
const nal = videoBuf.subarray(nalStart, scPos);
// Trim trailing zero bytes (from start code overlap)
let end = nal.length;
while (end > 0 && nal[end - 1] === 0) end--;
if (end > 0) {
const nalTrimmed = nal.subarray(0, end);
const isIdr = (nalTrimmed[0] & 0x1f) === 5; // IDR
emitFrame(nalTrimmed, isIdr);
}
}
// Skip the 00 00 01 at scPos-3 to find next
start = scPos;
// But the next start code needs at least 3 bytes
if (start > videoBuf.length - 3) break;
}
// Keep remaining bytes (potential partial NAL or start code)
if (start > 0 && start < videoBuf.length) {
videoBuf = videoBuf.subarray(start);
} else if (videoBuf.length > 4) {
// No full NAL found, but avoid unbounded growth
// Keep a sliding window
videoBuf = videoBuf.subarray(videoBuf.length - 3);
}
});
proc.stdout.on("end", () => {
if (videoBuf.length > 0) {
let end = videoBuf.length;
while (end > 0 && videoBuf[end - 1] === 0) end--;
if (end > 0) emitFrame(videoBuf.subarray(0, end), false);
}
vPipe.end();
aPipe.end();
});
}
if (proc.stderr) {
proc.stderr.on("data", () => {
/* errors swallowed */
});
}
proc.on("close", () => {
vPipe.end();
aPipe.end();
});
const close = () => {
proc.kill("SIGTERM");
vPipe.end();
aPipe.end();
cleanupSpool();
};
return { video: vInfo, audio: aInfo, close };
}
@@ -0,0 +1,51 @@
/**
* Lightweight encoders config ported from @dank074/discord-video-stream
* encoders/software.js. Only software (libx264) is needed for GoLive.
*/
export interface EncoderSettings {
name: string;
options: string[];
outFilters?: string[];
globalOptions?: string[];
}
export interface EncoderSet {
H264: EncoderSettings;
H265: EncoderSettings;
VP8: EncoderSettings;
VP9: EncoderSettings;
AV1: EncoderSettings;
}
/** Software x264 encoder. Matches @dank074's software() defaults. */
export function software(
opts: {
x264?: { preset?: string; tune?: string };
x265?: { preset?: string; tune?: string };
} = {},
): () => EncoderSet {
const { x264, x265 } = opts;
const { preset: x264Preset = "superfast", tune: x264Tune = "film" } =
x264 ?? {};
const { preset: x265Preset = "superfast", tune: x265Tune } = x265 ?? {};
return () => ({
H264: {
name: "libx264",
options: ["-forced-idr 1", `-tune ${x264Tune}`, `-preset ${x264Preset}`],
},
H265: {
name: "libx265",
options: [
"-forced-idr 1",
...(x265Tune ? [`-tune ${x265Tune}`] : []),
`-preset ${x265Preset}`,
],
},
VP8: { name: "libvpx", options: ["-deadline 20000"] },
VP9: { name: "libvpx-vp9", options: ["-deadline 20000"] },
AV1: { name: "libsvtav1", options: [] },
});
}
export const Encoders = { software };
@@ -0,0 +1,41 @@
/** Discord gateway opcodes used by Streamer — ported from @dank074/discord-video-stream. */
export enum GatewayOpCodes {
DISPATCH = 0,
HEARTBEAT = 1,
IDENTIFY = 2,
PRESENCE_UPDATE = 3,
VOICE_STATE_UPDATE = 4,
VOICE_SERVER_PING = 5,
RESUME = 6,
RECONNECT = 7,
REQUEST_GUILD_MEMBERS = 8,
INVALID_SESSION = 9,
HELLO = 10,
HEARTBEAT_ACK = 11,
CALL_CONNECT = 13,
GUILD_SUBSCRIPTIONS = 14,
LOBBY_CONNECT = 15,
LOBBY_DISCONNECT = 16,
LOBBY_VOICE_STATES_UPDATE = 17,
STREAM_CREATE = 18,
STREAM_DELETE = 19,
STREAM_WATCH = 20,
STREAM_PING = 21,
STREAM_SET_PAUSED = 22,
REQUEST_GUILD_APPLICATION_COMMANDS = 24,
EMBEDDED_ACTIVITY_LAUNCH = 25,
EMBEDDED_ACTIVITY_CLOSE = 26,
EMBEDDED_ACTIVITY_UPDATE = 27,
REQUEST_FORUM_UNREADS = 28,
REMOTE_COMMAND = 29,
GET_DELETED_ENTITY_IDS_NOT_MATCHING_HASH = 30,
REQUEST_SOUNDBOARD_SOUNDS = 31,
SPEED_TEST_CREATE = 32,
SPEED_TEST_DELETE = 33,
REQUEST_LAST_MESSAGES = 34,
SEARCH_RECENT_MEMBERS = 35,
REQUEST_CHANNEL_STATUSES = 36,
GUILD_SUBSCRIPTIONS_BULK = 37,
GUILD_CHANNELS_RESYNC = 38,
REQUEST_CHANNEL_MEMBER_COUNT = 39,
}
@@ -0,0 +1,291 @@
/**
* H264 SPS VUI rewriter ported from @dank074/discord-video-stream
* SPSVUIRewriter.js. Rewrites the SPS so Discord's receiver applies
* bitstream restrictions (max_num_reorder_frames=0, max_dec_frame_buffering
* bounded) required for low-latency GoLive decode.
*/
import {
AnnexBBitstreamReader,
AnnexBBitstreamWriter,
} from "./AnnexBBitstreamReaderWriter.js";
export function rewriteSPSVUI(buffer: Uint8Array): Buffer {
const reader = new AnnexBBitstreamReader(buffer.subarray(1));
const writer = new AnnexBBitstreamWriter();
const readBit = (n = 1) => reader.readBits(n);
const writeBit = (v: number, n = 1) => writer.writeBits(v, n);
const readU = (n: number) => reader.readUnsigned(n);
const writeU = (v: number, n: number) => writer.writeUnsigned(v, n);
const readUE = () => reader.readUnsignedExpGolomb();
const writeUE = (v: number) => writer.writeUnsignedExpGolomb(v);
const readSE = () => reader.readSignedExpGolomb();
const writeSE = (v: number) => writer.writeSignedExpGolomb(v);
// Rewrite the NAL header
writeU(buffer[0], 8);
const profile_idc = readU(8);
writeU(profile_idc, 8);
const constraint_flags = readU(8);
writeU(constraint_flags, 8);
const level_idc = readU(8);
writeU(level_idc, 8);
const seq_parameter_set_id = readUE();
writeUE(seq_parameter_set_id);
// If profile in high profiles, additional fields
const highProfiles = new Set([
100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 144,
]);
if (highProfiles.has(profile_idc)) {
const chroma_format_idc = readUE();
writeUE(chroma_format_idc);
if (chroma_format_idc === 3) {
const separate_colour_plane_flag = readBit(1);
writeBit(separate_colour_plane_flag, 1);
}
const bit_depth_luma_minus8 = readUE();
writeUE(bit_depth_luma_minus8);
const bit_depth_chroma_minus8 = readUE();
writeUE(bit_depth_chroma_minus8);
const qpprime_y_zero_transform_bypass_flag = readBit(1);
writeBit(qpprime_y_zero_transform_bypass_flag, 1);
const seq_scaling_matrix_present_flag = readBit(1);
writeBit(seq_scaling_matrix_present_flag, 1);
if (seq_scaling_matrix_present_flag) {
const scalingCount = chroma_format_idc !== 3 ? 8 : 12;
for (let i = 0; i < scalingCount; i++) {
const seq_scaling_list_present_flag = readBit(1);
writeBit(seq_scaling_list_present_flag, 1);
if (seq_scaling_list_present_flag) {
const size = i < 6 ? 16 : 64;
let lastScale = 8;
let nextScale = 8;
for (let j = 0; j < size; j++) {
const delta = readSE();
writeSE(delta);
nextScale = (lastScale + delta + 256) % 256;
if (nextScale !== 0) lastScale = nextScale;
}
}
}
}
}
const log2_max_frame_num_minus4 = readUE();
writeUE(log2_max_frame_num_minus4);
const pic_order_cnt_type = readUE();
writeUE(pic_order_cnt_type);
if (pic_order_cnt_type === 0) {
const log2_max_pic_order_cnt_lsb_minus4 = readUE();
writeUE(log2_max_pic_order_cnt_lsb_minus4);
} else if (pic_order_cnt_type === 1) {
const delta_pic_order_always_zero_flag = readBit(1);
writeBit(delta_pic_order_always_zero_flag, 1);
const offset_for_non_ref_pic = readSE();
writeSE(offset_for_non_ref_pic);
const offset_for_top_to_bottom_field = readSE();
writeSE(offset_for_top_to_bottom_field);
const num_ref_frames_in_pic_order_cnt_cycle = readUE();
writeUE(num_ref_frames_in_pic_order_cnt_cycle);
for (let i = 0; i < num_ref_frames_in_pic_order_cnt_cycle; i++) {
const offset_for_ref_frame = readSE();
writeSE(offset_for_ref_frame);
}
}
const max_num_ref_frames = readUE();
writeUE(max_num_ref_frames);
const gaps_in_frame_num_value_allowed_flag = readBit(1);
writeBit(gaps_in_frame_num_value_allowed_flag, 1);
const pic_width_in_mbs_minus1 = readUE();
writeUE(pic_width_in_mbs_minus1);
const pic_height_in_map_units_minus1 = readUE();
writeUE(pic_height_in_map_units_minus1);
const frame_mbs_only_flag = readBit(1);
writeBit(frame_mbs_only_flag, 1);
if (frame_mbs_only_flag === 0) {
const mb_adaptive_frame_field_flag = readBit(1);
writeBit(mb_adaptive_frame_field_flag, 1);
}
const direct_8x8_inference_flag = readBit(1);
writeBit(direct_8x8_inference_flag, 1);
const frame_cropping_flag = readBit(1);
writeBit(frame_cropping_flag, 1);
if (frame_cropping_flag) {
const frame_crop_left_offset = readUE();
writeUE(frame_crop_left_offset);
const frame_crop_right_offset = readUE();
writeUE(frame_crop_right_offset);
const frame_crop_top_offset = readUE();
writeUE(frame_crop_top_offset);
const frame_crop_bottom_offset = readUE();
writeUE(frame_crop_bottom_offset);
}
// https://webrtc.googlesource.com/src/+/5f2c9278f35e47ff72eb191669d473b7400c9f3e/common_video/h264/sps_vui_rewriter.cc#283
function addBitstreamRestriction() {
// motion_vectors_over_pic_boundaries_flag: u(1) — Default is 1 when not present.
writeBit(1, 1);
// max_bytes_per_pic_denom: ue(v) — Default is 2 when not present.
writeUE(2);
// max_bits_per_mb_denom: ue(v) — Default is 1 when not present.
writeUE(1);
// log2_max_mv_length_horizontal / vertical — both default to 16.
writeUE(16);
writeUE(16);
// IMPORTANT: max_num_reorder_frames must be 0 for low latency.
writeUE(0);
writeUE(max_num_ref_frames);
}
const vui_parameters_present_flag = readBit(1);
writeBit(1, 1);
// If no VUI exists, write one
if (!vui_parameters_present_flag) {
// aspect_ratio_info_present_flag, overscan_info_present_flag. Both u(1).
writeBit(0, 2);
// video_signal_type_present_flag, u(1) — write 0, ignore color space.
writeBit(0, 1);
// chroma_loc_info_present_flag, timing_info_present_flag,
// nal_hrd_parameters_present_flag, vcl_hrd_parameters_present_flag,
// pic_struct_present_flag — all u(1)
writeBit(0, 5);
// bitstream_restriction_flag: u(1)
writeBit(1, 1);
addBitstreamRestriction();
} else {
// VUI parsing and copying
const aspect_ratio_info_present_flag = readBit(1);
writeBit(aspect_ratio_info_present_flag, 1);
if (aspect_ratio_info_present_flag) {
const aspect_ratio_idc = readU(8);
writeU(aspect_ratio_idc, 8);
if (aspect_ratio_idc === 255) {
const sar_width = readU(16);
writeU(sar_width, 16);
const sar_height = readU(16);
writeU(sar_height, 16);
}
}
const overscan_info_present_flag = readBit(1);
writeBit(overscan_info_present_flag, 1);
if (overscan_info_present_flag) {
const overscan_appropriate_flag = readBit(1);
writeBit(overscan_appropriate_flag, 1);
}
// Read the video signal type, but don't copy it
const video_signal_type_present_flag = readBit(1);
writeBit(0, 1);
if (video_signal_type_present_flag) {
readBit(3); // _video_format
readBit(1); // _video_full_range_flag
const colour_description_present_flag = readBit(1);
if (colour_description_present_flag) {
readU(8); // _colour_primaries
readU(8); // _transfer_characteristics
readU(8); // _matrix_coeffs
}
}
const chroma_loc_info_present_flag = readBit(1);
writeBit(chroma_loc_info_present_flag, 1);
if (chroma_loc_info_present_flag) {
const chroma_sample_loc_type_top_field = readUE();
writeUE(chroma_sample_loc_type_top_field);
const chroma_sample_loc_type_bottom_field = readUE();
writeUE(chroma_sample_loc_type_bottom_field);
}
const timing_info_present_flag = readBit(1);
writeBit(timing_info_present_flag, 1);
if (timing_info_present_flag) {
const num_units_in_tick = readU(32);
writeU(num_units_in_tick, 32);
const time_scale = readU(32);
writeU(time_scale, 32);
const fixed_frame_rate_flag = readBit(1);
writeBit(fixed_frame_rate_flag, 1);
}
const nal_hrd_parameters_present_flag = readBit(1);
writeBit(nal_hrd_parameters_present_flag, 1);
if (nal_hrd_parameters_present_flag) {
// hrd_parameters()
const cpb_cnt_minus1 = readUE();
writeUE(cpb_cnt_minus1);
const bit_rate_scale = readBit(4);
writeBit(bit_rate_scale, 4);
const cpb_size_scale = readBit(4);
writeBit(cpb_size_scale, 4);
for (let i = 0; i <= cpb_cnt_minus1; i++) {
const bit_rate_value_minus1 = readUE();
writeUE(bit_rate_value_minus1);
const cpb_size_value_minus1 = readUE();
writeUE(cpb_size_value_minus1);
const cbr_flag = readBit(1);
writeBit(cbr_flag, 1);
}
const initial_cpb_removal_delay_length_minus1 = readBit(5);
writeBit(initial_cpb_removal_delay_length_minus1, 5);
const cpb_removal_delay_length_minus1 = readBit(5);
writeBit(cpb_removal_delay_length_minus1, 5);
const dpb_output_delay_length_minus1 = readBit(5);
writeBit(dpb_output_delay_length_minus1, 5);
const time_offset_length = readBit(5);
writeBit(time_offset_length, 5);
}
const vcl_hrd_parameters_present_flag = readBit(1);
writeBit(vcl_hrd_parameters_present_flag, 1);
if (vcl_hrd_parameters_present_flag) {
// hrd_parameters()
const cpb_cnt_minus1 = readUE();
writeUE(cpb_cnt_minus1);
const bit_rate_scale = readBit(4);
writeBit(bit_rate_scale, 4);
const cpb_size_scale = readBit(4);
writeBit(cpb_size_scale, 4);
for (let i = 0; i <= cpb_cnt_minus1; i++) {
const bit_rate_value_minus1 = readUE();
writeUE(bit_rate_value_minus1);
const cpb_size_value_minus1 = readUE();
writeUE(cpb_size_value_minus1);
const cbr_flag = readBit(1);
writeBit(cbr_flag, 1);
}
const initial_cpb_removal_delay_length_minus1 = readBit(5);
writeBit(initial_cpb_removal_delay_length_minus1, 5);
const cpb_removal_delay_length_minus1 = readBit(5);
writeBit(cpb_removal_delay_length_minus1, 5);
const dpb_output_delay_length_minus1 = readBit(5);
writeBit(dpb_output_delay_length_minus1, 5);
const time_offset_length = readBit(5);
writeBit(time_offset_length, 5);
}
if (nal_hrd_parameters_present_flag || vcl_hrd_parameters_present_flag) {
const low_delay_hrd_flag = readBit(1);
writeBit(low_delay_hrd_flag, 1);
}
const pic_struct_present_flag = readBit(1);
writeBit(pic_struct_present_flag, 1);
const bitstream_restriction_flag = readBit(1);
writeBit(1, 1);
if (!bitstream_restriction_flag) {
addBitstreamRestriction();
} else {
const motion_vectors_over_pic_boundaries_flag = readBit(1);
writeBit(motion_vectors_over_pic_boundaries_flag, 1);
const max_bytes_per_pic_denom = readUE();
writeUE(max_bytes_per_pic_denom);
const max_bits_per_mb_denom = readUE();
writeUE(max_bits_per_mb_denom);
const log2_max_mv_length_horizontal = readUE();
writeUE(log2_max_mv_length_horizontal);
const log2_max_mv_length_vertical = readUE();
writeUE(log2_max_mv_length_vertical);
readUE(); // _num_reorder_frames
writeUE(0);
readUE(); // _max_dec_frame_buffering
writeUE(max_num_ref_frames);
}
}
writeBit(1, 1); // rbsp_stop_one_bit
writer.flush();
return writer.toBuffer();
}
@@ -0,0 +1,45 @@
/**
* StreamConnection GoLive stream connection (screen share).
* Ported from @dank074/discord-video-stream StreamConnection.js.
*/
import { BaseMediaConnection } from "./BaseMediaConnection.js";
import { VoiceOpCodes } from "./VoiceOpCodes.js";
export class StreamConnection extends BaseMediaConnection {
_streamKey: string | null = null;
_serverId: string | null = null;
setSpeaking(speaking: boolean): void {
if (!this.webRtcParams) throw new Error("WebRTC connection not ready");
this.sendOpcode(VoiceOpCodes.SPEAKING, {
delay: 0,
speaking: speaking ? 2 : 0,
ssrc: this.webRtcParams.audioSsrc,
});
}
get daveChannelId(): string {
if (this._serverId === null) {
throw new Error("Server ID not set (this shouldn't happen)");
}
const channelId = BigInt(this._serverId) - 1n;
return channelId.toString();
}
get serverId(): string | null {
return this._serverId;
}
set serverId(id: string | null) {
this._serverId = id;
}
get streamKey(): string | null {
return this._streamKey;
}
set streamKey(value: string | null) {
this._streamKey = value;
}
}

Some files were not shown because too many files have changed in this diff Show More