Compare commits

...
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
246 changed files with 15740 additions and 3899 deletions
+5 -5
View File
@@ -39,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)
@@ -55,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)
@@ -67,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 ===
@@ -90,7 +90,7 @@ 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_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)
+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
+145 -5
View File
@@ -11,6 +11,7 @@ concurrency:
permissions:
contents: read
id-token: write
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
@@ -66,7 +67,7 @@ jobs:
strategy:
fail-fast: false
matrix:
service: [backend, discord-gateway, proxy]
service: [backend, discord-gateway, proxy, frontend]
steps:
- name: Checkout
uses: actions/checkout@v7
@@ -81,9 +82,24 @@ jobs:
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
@@ -104,17 +120,141 @@ jobs:
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 ==="
nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$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 ==="
ssh "$VPS_USER@$VPS_HOST" "sudo systemctl daemon-reload && sudo systemctl restart gmw-${{ matrix.service }} && sleep 3 && sudo systemctl is-active gmw-${{ matrix.service }}"
echo "✅ gmw-${{ matrix.service }} deployed"
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)"
+1 -1
View File
@@ -12,7 +12,7 @@ worktrees/
.worktrees/
services/frontend/frontend/dist/
target/
nix/
# Gitea CI runner logs
.gitea/workflows/*.log
-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_*`
+124 -34
View File
@@ -11,6 +11,24 @@
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
@@ -46,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";
@@ -84,7 +132,7 @@
console.log('Fixed ' + count + ' files');
"
echo "=== Build complete ==="
'';
'' + pruneProd;
installPhase = ''
mkdir -p $out/lib/gmw-backend
@@ -119,6 +167,7 @@ WRAPPER
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
];
@@ -137,27 +186,34 @@ WRAPPER
# 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, datachannel, node-av) are verified at runtime.
# that matter (opus) are verified at runtime.
for pkg in \
node_modules/.pnpm/@discordjs+opus@*/node_modules/@discordjs/opus \
node_modules/.pnpm/@lng2004+node-datachannel@*/node_modules/@lng2004/node-datachannel \
node_modules/.pnpm/zeromq@*/node_modules/zeromq
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)
# node-datachannel's `prebuild -r napi` CLI is broken (TypeError:
# expected first argument to be an array) the install fallback
# populates devDeps incl. cmake-js; build directly via cmake-js.
if [ "$(basename "$pkg")" = "node-datachannel" ]; then
echo "--- datachannel cmake-js compile ---"
# Nix splits OpenSSL headers/libs across outputs merge them
# (opensslDevEnv) so FindOpenSSL finds both include + libcrypto.
(cd "$pkg" && OPENSSL_ROOT_DIR="${opensslDevEnv}" npm run compile 2>&1 || true)
fi
fi
done
echo "=== Compiling TypeScript ==="
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 "
@@ -185,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
@@ -199,6 +271,7 @@ 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
@@ -210,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";
@@ -257,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}
@@ -271,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
+40 -10
View File
@@ -11,28 +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 (8080) through Traefik.
# 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;
@@ -45,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}`, {
+2
View File
@@ -13,6 +13,7 @@ import { createDashboardRouter } from "../modules/dashboard/index.js";
import { createHealthRouter } from "../modules/health/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);
}
}
@@ -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);
@@ -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 ?? []);
}
@@ -331,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();
@@ -87,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;
}
@@ -43,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();
@@ -2,8 +2,8 @@ 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>;
@@ -3,10 +3,10 @@ import {
tryCommandThenFallback,
} from "../../shared/commandHelper.js";
import {
COMMAND_MEDIA_LOOP,
COMMAND_MEDIA_QUEUE,
COMMAND_MEDIA_SKIP,
COMMAND_MEDIA_STOP,
COMMAND_MEDIA_VOLUME,
MEDIA_STATUS_KEY,
} from "../../shared/index.js";
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
@@ -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",
);
}
@@ -6,10 +6,10 @@ 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";
@@ -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.
@@ -1,7 +1,7 @@
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 { 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",
@@ -38,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();
@@ -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)
@@ -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();
}
@@ -15,6 +15,7 @@ import {
VOICE_STATUS_KEY,
} from "../../shared/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,
);
}
/**
@@ -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";
+22
View File
@@ -1,7 +1,9 @@
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";
@@ -62,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);
}
+16
View File
@@ -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 {
+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,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);
+1 -5
View File
@@ -7,11 +7,8 @@
"pnpm": {
"onlyBuiltDependencies": [
"@discordjs/opus",
"@lng2004/node-datachannel",
"esbuild",
"node-av",
"sharp",
"zeromq"
"sharp"
]
},
"scripts": {
@@ -24,7 +21,6 @@
"test": "vitest run"
},
"dependencies": {
"@dank074/discord-video-stream": "6.0.0",
"@discordjs/opus": "^0.10.0",
"@discordjs/voice": "^0.19.2",
"@snazzah/davey": "^0.1.11",
+36 -896
View File
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ allowBuilds:
"@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.
@@ -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;
}
}
@@ -0,0 +1,280 @@
/**
* Streamer gateway-level GoLive controller. Ported from
* @dank074/discord-video-stream Streamer.js.
*
* Drives the Discord gateway (VOICE_STATE_UPDATE, STREAM_CREATE, ...) and
* hands back a VoiceConnection / StreamConnection once the media server
* session is ready.
*/
import { EventEmitter } from "node:events";
import { GatewayOpCodes } from "./GatewayOpCodes.js";
import type { NativePeerConnection } from "./native.js";
import { StreamConnection } from "./StreamConnection.js";
import { generateStreamKey, parseStreamKey } from "./utils.js";
import { VoiceConnection } from "./VoiceConnection.js";
import type { WebRtcConnWrapper } from "./WebRtcWrapper.js";
/** Minimal surface of a discord.js-selfbot-v13 client used by Streamer. */
export interface StreamerClientLike {
user: { id: string; username?: string } | null;
token: string | null;
on(
event: "raw",
listener: (packet: { t: string; d: unknown }) => void,
): unknown;
ws: {
broadcast(data: { op: number; d: unknown }): void;
};
guilds?: {
// biome-ignore lint/suspicious/noExplicitAny: discord.js-selfbot client shape is dynamic
fetch(id: string): Promise<any>;
};
}
/** Minimal channel shape accepted by joinVoiceChannel. */
export interface VoiceChannelLike {
id: string;
type: string;
guildId?: string | null;
}
export class Streamer {
_voiceConnection: VoiceConnection | null = null;
_client: StreamerClientLike;
_gatewayEmitter = new EventEmitter();
constructor(client: StreamerClientLike) {
this._client = client;
// listen for gateway dispatch events
this.client.on("raw", (packet) => {
this._gatewayEmitter.emit(packet.t, packet.d);
});
}
get client(): StreamerClientLike {
return this._client;
}
get opts(): Record<string, unknown> {
return {};
}
get voiceConnection(): VoiceConnection | null {
return this._voiceConnection;
}
sendOpcode(code: number, data: unknown): void {
this.client.ws.broadcast({ op: code, d: data });
}
joinVoiceChannel(channel: VoiceChannelLike): Promise<WebRtcConnWrapper> {
let guildId: string | null = null;
if (
channel.type === "GUILD_STAGE_VOICE" ||
channel.type === "GUILD_VOICE"
) {
guildId = channel.guildId ?? null;
}
return this.joinVoice(guildId, channel.id);
}
/**
* Joins a voice channel and resolves with the WebRtcConnWrapper when the
* media session is ready.
*/
joinVoice(
guild_id: string | null,
channel_id: string,
): Promise<WebRtcConnWrapper> {
return new Promise((resolve, reject) => {
if (!this.client.user) {
reject(new Error("Client not logged in"));
return;
}
const user_id = this.client.user.id;
const voiceConn = new VoiceConnection(
this,
guild_id,
user_id,
channel_id,
(conn) => {
resolve(conn);
},
);
this._voiceConnection = voiceConn;
this._gatewayEmitter.on(
"VOICE_STATE_UPDATE",
(d: { user_id: string; session_id: string }) => {
if (user_id !== d.user_id) return;
voiceConn.setSession(d.session_id);
},
);
this._gatewayEmitter.on(
"VOICE_SERVER_UPDATE",
(d: {
guild_id: string | null;
channel_id?: string;
endpoint: string;
token: string;
}) => {
if (guild_id !== d.guild_id) return;
// channel_id is not set for guild voice calls
if (d.channel_id && channel_id !== d.channel_id) return;
voiceConn.setTokens(d.endpoint, d.token);
},
);
this.signalVideo(false);
});
}
/** Create a GoLive stream (screen share) on top of the voice connection. */
createStream(): Promise<WebRtcConnWrapper> {
return new Promise((resolve, reject) => {
if (!this.client.user) {
reject(new Error("Client not logged in"));
return;
}
if (!this.voiceConnection) {
reject(
new Error("cannot start stream without first joining voice channel"),
);
return;
}
this.signalStream();
const {
guildId: clientGuildId,
channelId: clientChannelId,
session_id,
} = this.voiceConnection;
const clientUserId = this.client.user.id;
if (!session_id) throw new Error("Session doesn't exist yet");
const streamConn = new StreamConnection(
this,
clientGuildId,
clientUserId,
clientChannelId,
(conn) => {
resolve(conn);
},
);
this.voiceConnection.streamConnection = streamConn;
this._gatewayEmitter.on(
"STREAM_CREATE",
(d: { stream_key: string; rtc_server_id: string }) => {
const { channelId, guildId, userId } = parseStreamKey(d.stream_key);
if (
clientGuildId !== guildId ||
clientChannelId !== channelId ||
clientUserId !== userId
) {
return;
}
streamConn.serverId = d.rtc_server_id;
streamConn.streamKey = d.stream_key;
streamConn.setSession(session_id);
},
);
this._gatewayEmitter.on(
"STREAM_SERVER_UPDATE",
(d: { stream_key: string; endpoint: string; token: string }) => {
const { channelId, guildId, userId } = parseStreamKey(d.stream_key);
if (
clientGuildId !== guildId ||
clientChannelId !== channelId ||
clientUserId !== userId
) {
return;
}
streamConn.setTokens(d.endpoint, d.token);
},
);
});
}
async setStreamPreview(image: Buffer): Promise<void> {
if (!this.client.token) throw new Error("Please login :)");
if (!this.voiceConnection?.streamConnection?.guildId) return;
const data = `data:image/jpeg;base64,${image.toString("base64")}`;
const { guildId } = this.voiceConnection.streamConnection;
if (!this.client.guilds) return;
const server = await this.client.guilds.fetch(guildId);
// biome-ignore lint/suspicious/noExplicitAny: discord.js-selfbot dynamic
(server as any).members.me?.voice?.postPreview(data);
}
stopStream(): void {
const stream = this.voiceConnection?.streamConnection;
if (!stream) return;
stream.stop();
this.signalStopStream();
this.voiceConnection.streamConnection = null;
this._gatewayEmitter.removeAllListeners("STREAM_CREATE");
this._gatewayEmitter.removeAllListeners("STREAM_SERVER_UPDATE");
}
leaveVoice(): void {
this.voiceConnection?.stop();
this.signalLeaveVoice();
this._voiceConnection = null;
this._gatewayEmitter.removeAllListeners("VOICE_STATE_UPDATE");
this._gatewayEmitter.removeAllListeners("VOICE_SERVER_UPDATE");
}
signalVideo(video_enabled: boolean): void {
if (!this.voiceConnection) return;
const { guildId: guild_id, channelId: channel_id } = this.voiceConnection;
this.sendOpcode(GatewayOpCodes.VOICE_STATE_UPDATE, {
guild_id: guild_id,
channel_id,
self_mute: false,
self_deaf: true,
self_video: video_enabled,
});
}
signalStream(): void {
if (!this.voiceConnection) return;
const {
type,
guildId: guild_id,
channelId: channel_id,
botId: user_id,
} = this.voiceConnection;
this.sendOpcode(GatewayOpCodes.STREAM_CREATE, {
type,
guild_id,
channel_id,
preferred_region: null,
});
this.sendOpcode(GatewayOpCodes.STREAM_SET_PAUSED, {
stream_key: generateStreamKey(type, guild_id, channel_id, user_id),
paused: false,
});
}
signalStopStream(): void {
if (!this.voiceConnection) return;
const {
type,
guildId: guild_id,
channelId: channel_id,
botId: user_id,
} = this.voiceConnection;
this.sendOpcode(GatewayOpCodes.STREAM_DELETE, {
stream_key: generateStreamKey(type, guild_id, channel_id, user_id),
});
}
signalLeaveVoice(): void {
this.sendOpcode(GatewayOpCodes.VOICE_STATE_UPDATE, {
guild_id: null,
channel_id: null,
self_mute: true,
self_deaf: false,
self_video: false,
});
}
}
export type { NativePeerConnection };
@@ -0,0 +1,20 @@
/**
* VideoStream feeds encoded H264 frames into the WebRTC connection.
* Ported from @dank074/discord-video-stream VideoStream.js.
*/
import { BaseMediaStream } from "./BaseMediaStream.js";
import type { WebRtcConnWrapper } from "./WebRtcWrapper.js";
export class VideoStream extends BaseMediaStream {
_conn: WebRtcConnWrapper;
constructor(conn: WebRtcConnWrapper, noSleep = false) {
super("video", noSleep);
this._conn = conn;
}
async _sendFrame(frame: Buffer, frametime: number): Promise<void> {
this._conn.sendVideoFrame(frame, frametime);
}
}
@@ -0,0 +1,25 @@
/**
* VoiceConnection guild/DM voice channel GoLive connection.
* Ported from @dank074/discord-video-stream VoiceConnection.js.
*/
import { BaseMediaConnection } from "./BaseMediaConnection.js";
import type { StreamConnection } from "./StreamConnection.js";
export class VoiceConnection extends BaseMediaConnection {
streamConnection: StreamConnection | null = null;
get daveChannelId(): string {
return this.channelId;
}
get serverId(): string | null {
// for guild vc it is the guild id, for dm voice it is the channel id
return this.guildId ?? this.channelId;
}
stop(): void {
super.stop();
this.streamConnection?.stop();
}
}
@@ -0,0 +1,38 @@
/** Discord voice WebSocket opcodes — ported from @dank074/discord-video-stream. */
export enum VoiceOpCodes {
IDENTIFY = 0,
SELECT_PROTOCOL = 1,
READY = 2,
HEARTBEAT = 3,
SELECT_PROTOCOL_ACK = 4,
SPEAKING = 5,
HEARTBEAT_ACK = 6,
RESUME = 7,
HELLO = 8,
RESUMED = 9,
CLIENTS_CONNECT = 11,
VIDEO = 12,
CLIENT_DISCONNECT = 13,
SESSION_UPDATE = 14,
MEDIA_SINK_WANTS = 15,
VOICE_BACKEND_VERSION = 16,
CHANNEL_OPTIONS_UPDATE = 17,
FLAGS = 18,
SPEED_TEST = 19,
PLATFORM = 20,
DAVE_PREPARE_TRANSITION = 21,
DAVE_EXECUTE_TRANSITION = 22,
DAVE_TRANSITION_READY = 23,
DAVE_PREPARE_EPOCH = 24,
MLS_INVALID_COMMIT_WELCOME = 31,
}
/** Binary voice WebSocket opcodes (DAVE / MLS). */
export enum VoiceOpCodesBinary {
MLS_EXTERNAL_SENDER = 25,
MLS_KEY_PACKAGE = 26,
MLS_PROPOSALS = 27,
MLS_COMMIT_WELCOME = 28,
MLS_ANNOUNCE_COMMIT_TRANSITION = 29,
MLS_WELCOME = 30,
}
@@ -0,0 +1,205 @@
/**
* WebRTC connection wrapper for GoLive ported from
* @dank074/discord-video-stream WebRtcWrapper.js, with the media stack
* (packetizers, RTCP SR/NACK, pacing) provided by the libdatachannel-min
* binding instead of node-datachannel's JS-exposed media classes.
*/
import {
H264Helpers,
H264NalUnitTypes,
splitNalu,
startCode3,
} from "./AnnexBHelper.js";
import { CodecPayloadType } from "./CodecPayloadType.js";
import type { NativePeerConnection, NativeTrack } from "./native.js";
import { loadNative } from "./native.js";
import { rewriteSPSVUI } from "./SPSVUIRewriter.js";
import { normalizeVideoCodec } from "./utils.js";
export type WebRtcVideoCodec = "H264" | "H265" | "VP8" | "VP9" | "AV1";
export interface WebRtcParams {
address: string;
port: number;
audioSsrc: number;
videoSsrc: number;
rtxSsrc: number;
supportedEncryptionModes: string[];
}
/** Minimal surface of the media connection that WebRtcWrapper drives. */
export interface VideoAttribute {
fps: number;
width: number;
height: number;
}
export interface MediaConnectionLike {
daveReady: boolean;
daveSession: {
encryptOpus(frame: Buffer): Buffer;
encrypt(mediaType: number, codec: number, frame: Buffer): Buffer;
} | null;
webRtcParams: WebRtcParams | null;
setSpeaking(speaking: boolean): void;
setVideoAttributes(enabled: boolean, attr?: VideoAttribute): void;
}
/** Media types used by DAVE encryption (from @dank074). */
export enum DaveMediaType {
AUDIO = 0,
VIDEO = 1,
}
/** DAVE codec ids (from @dank074). */
export enum DaveCodec {
UNKNOWN = 0,
VP8 = 2,
VP9 = 3,
H264 = 4,
H265 = 5,
AV1 = 6,
}
export class WebRtcConnWrapper {
private _mediaConn: MediaConnectionLike;
private _webRtcConn: NativePeerConnection | null = null;
private _audioTrack: NativeTrack | null = null;
private _videoTrack: NativeTrack | null = null;
private _videoCodec: WebRtcVideoCodec | null = null;
/** Assigned by BaseMediaConnection to send the gathered SDP to Discord. */
onLocalDescription: ((sdp: string) => void) | null = null;
constructor(mediaConn: MediaConnectionLike) {
this._mediaConn = mediaConn;
}
initWebRtc(): NativePeerConnection {
const native = loadNative();
this._webRtcConn = new native.PeerConnection({
iceServers: ["stun:stun.l.google.com:19302"],
});
// Track mids must match @dank074: "0" audio, "1" video.
this._audioTrack = this._webRtcConn.addTrack("0", "audio");
this._videoTrack = this._webRtcConn.addTrack("1", "video");
return this._webRtcConn;
}
close(): void {
this._webRtcConn?.close();
this._webRtcConn = null;
}
get webRtcConn(): NativePeerConnection | null {
return this._webRtcConn;
}
get ready(): boolean {
return this._webRtcConn?.state() === "connected";
}
get mediaConnection(): MediaConnectionLike {
return this._mediaConn;
}
sendAudioFrame(frame: Buffer, frametime: number): void {
if (!this.ready || !this._audioTrack) return;
const clockRate = CodecPayloadType.opus.clockRate;
if (this.mediaConnection.daveReady && this.mediaConnection.daveSession) {
frame = this.mediaConnection.daveSession.encryptOpus(frame);
}
this._audioTrack.sendFrame(frame);
this._audioTrack.addTimestamp(Math.round((frametime * clockRate) / 1000));
}
sendVideoFrame(frame: Buffer, frametime: number): void {
if (!this.ready || !this._videoTrack) return;
const clockRate = CodecPayloadType[this._videoCodec ?? "H264"].clockRate;
if (this._videoCodec === "H264") {
let spsRewritten = false;
const nalus = splitNalu(frame).map((el) => {
if (H264Helpers.getUnitType(el) === H264NalUnitTypes.SPS) {
spsRewritten = true;
return rewriteSPSVUI(el);
}
return el;
});
if (spsRewritten)
frame = Buffer.concat(nalus.flatMap((el) => [startCode3, el]));
}
if (this.mediaConnection.daveReady && this.mediaConnection.daveSession) {
let daveCodec = DaveCodec.UNKNOWN;
switch (this._videoCodec) {
case "H264":
daveCodec = DaveCodec.H264;
break;
case "H265":
daveCodec = DaveCodec.H265;
break;
case "VP8":
daveCodec = DaveCodec.VP8;
break;
case "VP9":
daveCodec = DaveCodec.VP9;
break;
case "AV1":
daveCodec = DaveCodec.AV1;
break;
default:
break;
}
frame = this.mediaConnection.daveSession.encrypt(
DaveMediaType.VIDEO,
daveCodec,
frame,
);
}
this._videoTrack.sendFrame(frame);
this._videoTrack.addTimestamp(Math.round((frametime * clockRate) / 1000));
}
setPacketizer(videoCodec: string): void {
if (!this.mediaConnection.webRtcParams) {
throw new Error("WebRTC connection not ready");
}
const { audioSsrc, videoSsrc } = this.mediaConnection.webRtcParams;
this._videoCodec = normalizeVideoCodec(videoCodec);
// Audio packetizer: opus 120 @ 48kHz, playout delay ext id 5 (like @dank074)
this._audioTrack?.setPacketizer(
"audio",
audioSsrc,
CodecPayloadType.opus.payload_type,
CodecPayloadType.opus.clockRate,
5,
0,
1,
);
// Video packetizer: H264/H265/AV1 with their payload types
const codecEntry = CodecPayloadType[this._videoCodec];
if (!codecEntry) {
throw new Error(`Packetizer not implemented for ${this._videoCodec}`);
}
const nativeKind =
this._videoCodec === "H264"
? "h264"
: this._videoCodec === "H265"
? "h265"
: this._videoCodec === "AV1"
? "av1"
: (() => {
throw new Error(
`Packetizer not implemented for ${this._videoCodec}`,
);
})();
this._videoTrack?.setPacketizer(
nativeKind,
videoSsrc,
codecEntry.payload_type,
codecEntry.clockRate,
5,
0,
10,
);
}
}
@@ -0,0 +1,19 @@
/**
* goLive public API re-exports the ported @dank074 modules.
* Drop-in replacement for `@dank074/discord-video-stream` in
* screenShareController.ts.
*/
export { AudioStream } from "./AudioStream.js";
export { BaseMediaConnection } from "./BaseMediaConnection.js";
export { BaseMediaStream } from "./BaseMediaStream.js";
export { CodecPayloadType } from "./CodecPayloadType.js";
export { demux } from "./Demuxer.js";
export { Encoders } from "./Encoders.js";
export { playStream, prepareStream } from "./prepareStream.js";
export { StreamConnection } from "./StreamConnection.js";
export { Streamer } from "./Streamer.js";
export { normalizeVideoCodec } from "./utils.js";
export { VideoStream } from "./VideoStream.js";
export { VoiceConnection } from "./VoiceConnection.js";
export { WebRtcConnWrapper } from "./WebRtcWrapper.js";
@@ -0,0 +1,116 @@
/**
* Loader + typings for the minimal libdatachannel N-API binding
* (native/libdatachannel-min). The binding exposes ONLY what GoLive needs:
* PeerConnection, DataChannel, Track (raw RTP + media packetizer chain).
*
* The .node file is built by node-gyp against libdatachannel 0.24.0 (built
* from source nixpkgs 0.24.1 is glibc-incompatible with this host). It is
* NOT shipped via npm; the Nix flake builds it as part of the gateway.
*/
export interface NativeTrack {
/** Send a RAW RTP/RTCP packet (no media handler installed). */
send(buffer: Uint8Array): void;
/** Send an ENCODED frame; the packetizer chain turns it into RTP. */
sendFrame(buffer: Uint8Array): void;
/** Advance the packetizer RTP timestamp by delta (clock-rate units). */
addTimestamp(delta: number): void;
/** Install the media-handler chain (packetizer → RTCP SR → NACK → pacing). */
setPacketizer(
kind: "audio" | "h264" | "h265" | "av1",
ssrc: number,
payloadType: number,
clockRate: number,
playoutDelayId: number,
playoutDelayMin: number,
playoutDelayMax: number,
): void;
isOpen(): boolean;
close(): void;
}
export interface NativePeerConnection {
/** mid must be "0" (audio) or "1" (video) — matches @dank074's track defs. */
addTrack(mid: string, kind: "audio" | "video"): NativeTrack;
/** Resolves with the full SDP (incl. candidates) after gathering completes. */
createOffer(): Promise<string>;
/** Resolves with the auto-generated answer SDP. */
createAnswer(offerSdp: string): Promise<string>;
setRemoteDescription(sdp: string, type: "offer" | "answer"): void;
state(): string;
close(): void;
onStateChange(cb: (state: string) => void): void;
}
export interface NativeBinding {
PeerConnection: new (config: {
iceServers: string[];
}) => NativePeerConnection;
DataChannel: unknown;
Track: unknown;
}
let cached: NativeBinding | null = null;
/** Load the native binding. Throws only if the .node is truly missing
* callers (screen share) guard with `isNativeAvailable()`. */
export function loadNative(): NativeBinding {
if (cached) return cached;
// Resolve relative to this file: src/goLive/ → native/libdatachannel-min/
const candidates = [
new URL(
"../../native/libdatachannel-min/build/Release/datachannel_min.node",
import.meta.url,
),
new URL(
"../../../native/libdatachannel-min/build/Release/datachannel_min.node",
import.meta.url,
),
];
let lastErr: unknown;
for (const url of candidates) {
try {
// @ts-expect-error — .node modules are not typed; dynamic require via file URL
const mod = process.dlopen ? null : null;
void mod;
const nativePath = url.pathname;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const req = createRequire(import.meta.url);
const binding = req(nativePath) as NativeBinding;
if (typeof binding.PeerConnection === "function") {
cached = binding;
return binding;
}
} catch (e) {
lastErr = e;
}
}
// Fallback: plain relative require (tsx / jest environments)
try {
const req = createRequire(import.meta.url);
const binding = req(
"../../native/libdatachannel-min/build/Release/datachannel_min.node",
) as NativeBinding;
if (typeof binding.PeerConnection === "function") {
cached = binding;
return binding;
}
} catch (e) {
lastErr = e;
}
throw new Error(
`libdatachannel-min native binding not built (${String(lastErr)}). Run: cd native/libdatachannel-min && npx node-gyp rebuild`,
);
}
import { createRequire } from "node:module";
/** True when the native binding is built — screen share stays disabled otherwise. */
export function isNativeAvailable(): boolean {
try {
loadNative();
return true;
} catch {
return false;
}
}
@@ -0,0 +1,325 @@
/**
* prepareStream & playStream ported from @dank074/discord-video-stream
* newApi.js (Encoders/prepareStream/playStream), but uses `child_process.spawn`
* + ffmpeg CLI args directly instead of fluent-ffmpeg + node-av.
*
* Replaces the @dank074 video pipeline entirely:
* input (URL or Readable) ffmpeg spawn H264 AnnexB frames
* Demuxer stream VideoStream/AudioStream WebRtcConnWrapper
*/
import { type ChildProcess, spawn } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { PassThrough, type Readable } from "node:stream";
import { demux } from "./Demuxer.js";
import { type EncoderSettings, Encoders } from "./Encoders.js";
import { VideoStream } from "./VideoStream.js";
import type { WebRtcConnWrapper } from "./WebRtcWrapper.js";
export interface PrepareStreamResult {
command: ChildProcess;
output: PassThrough;
encoder: () => Record<string, EncoderSettings>;
options: Record<string, unknown>;
videoCodec: string;
width: number;
height: number;
frameRate?: number;
includeAudio: boolean;
}
function isFiniteNonZero(n: unknown): n is number {
return typeof n === "number" && !!n && Number.isFinite(n);
}
const DEFAULT_HEADERS = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36",
Connection: "keep-alive",
};
/** Resolve ffmpeg binary (env override → PATH → Nix store ffmpeg-headless). */
function resolveFfmpeg(): string {
if (process.env.FFMPEG_PATH && existsSync(process.env.FFMPEG_PATH)) {
return process.env.FFMPEG_PATH;
}
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", "ffmpeg");
if (existsSync(candidate)) return candidate;
}
}
return "ffmpeg";
}
const FFMPEG_BIN = resolveFfmpeg();
/**
* prepareStream build an ffmpeg command (as spawn args + PassThrough output)
* that transcodes the input into a pipe we can demux. Mirrors @dank074's
* prepareStream but produces a raw spawn instead of a fluent-ffmpeg command.
*/
export function prepareStream(
input: string | Readable,
options: Record<string, unknown> = {},
): PrepareStreamResult {
const mergedOptions = {
noTranscoding: false,
width: isFiniteNonZero(options.width)
? Math.round(options.width as number)
: -2,
height: isFiniteNonZero(options.height)
? Math.round(options.height as number)
: -2,
frameRate:
isFiniteNonZero(options.frameRate) && (options.frameRate as number) > 0
? options.frameRate
: undefined,
videoCodec: (options.videoCodec as string) ?? "H264",
bitrateVideo:
isFiniteNonZero(options.bitrateVideo) &&
(options.bitrateVideo as number) > 0
? Math.round(options.bitrateVideo as number)
: 5000,
bitrateVideoMax:
isFiniteNonZero(options.bitrateVideoMax) &&
(options.bitrateVideoMax as number) > 0
? Math.round(options.bitrateVideoMax as number)
: 7000,
bitrateAudio:
isFiniteNonZero(options.bitrateAudio) &&
(options.bitrateAudio as number) > 0
? Math.round(options.bitrateAudio as number)
: 128,
includeAudio: options.includeAudio ?? true,
encoder:
(options.encoder as () => Record<string, EncoderSettings>) ??
Encoders.software(),
customHeaders: {
...DEFAULT_HEADERS,
...(options.customHeaders as Record<string, string> | undefined),
},
customInputOptions: (options.customInputOptions as string[]) ?? [],
customFfmpegFlags: (options.customFfmpegFlags as string[]) ?? [],
minimizeLatency: options.minimizeLatency ?? false,
};
const output = new PassThrough();
const args: string[] = [
"-hide_banner",
"-loglevel",
"error",
...(typeof input === "string" ? ["-i", input] : ["-i", "pipe:0"]),
...mergedOptions.customInputOptions,
];
if (mergedOptions.minimizeLatency) {
args.push("-fflags", "nobuffer", "-analyzeduration", "0");
}
if (typeof input === "string" && input.startsWith("http")) {
const headerStr = Object.entries(mergedOptions.customHeaders)
.map(([k, v]) => `${k}: ${v}`)
.join("\r\n");
args.push(
"-headers",
headerStr,
"-reconnect",
"1",
"-reconnect_at_eof",
"1",
"-reconnect_streamed",
"1",
"-reconnect_delay_max",
"4294",
);
}
// Video
args.push("-map", "0:v:0");
if (mergedOptions.noTranscoding) {
args.push("-c:v", "copy");
} else {
args.push(`-vf`, `scale=${mergedOptions.width}:${mergedOptions.height}`);
if (mergedOptions.frameRate)
args.push("-r", String(mergedOptions.frameRate));
const enc = mergedOptions.encoder()[mergedOptions.videoCodec];
if (!enc)
throw new Error(
`Encoder settings not specified for ${mergedOptions.videoCodec}`,
);
// Encoder options are declared as single strings like "-forced-idr 1";
// spawn needs each flag and value as separate argv entries.
const encOptions = enc.options.flatMap((opt) =>
opt.split(/\s+/).filter(Boolean),
);
args.push(
"-b:v",
`${mergedOptions.bitrateVideo}k`,
"-maxrate:v",
`${mergedOptions.bitrateVideoMax}k`,
"-bufsize:v",
`${Math.round(mergedOptions.bitrateVideo / 2)}k`,
"-bf",
"0",
"-pix_fmt",
"yuv420p",
"-force_key_frames",
"expr:gte(t,n_forced*1)",
"-c:v",
enc.name,
...encOptions,
...(enc.globalOptions ?? []).flatMap((opt) =>
opt.split(/\s+/).filter(Boolean),
),
);
}
// Audio
if (mergedOptions.includeAudio) {
args.push("-map", "0:a:0?");
args.push(
"-c:a",
"libopus",
"-b:a",
`${mergedOptions.bitrateAudio}k`,
"-ar",
"48000",
"-ac",
"2",
);
} else {
args.push("-an");
}
args.push(...mergedOptions.customFfmpegFlags);
args.push("-f", "h264", "pipe:1");
const isUrl = typeof input === "string";
const proc: ChildProcess = isUrl
? spawn(FFMPEG_BIN, args, { stdio: ["ignore", "pipe", "pipe"] })
: spawn(FFMPEG_BIN, args, { stdio: ["pipe", "pipe", "pipe"] });
if (proc.stdin && !isUrl) {
input.on("data", (chunk: Buffer) => proc.stdin?.write(chunk));
input.on("end", () => proc.stdin?.end());
input.on("error", () => proc.stdin?.destroy());
}
proc.stdout?.pipe(output);
proc.stderr?.on("data", () => {
/* swallow ffmpeg stderr */
});
proc.on("error", (err) => {
// spawn failed (e.g. ffmpeg missing). If someone is consuming output
// (demux attaches an 'error' listener) propagate; otherwise just end.
if (output.listenerCount("error") > 0) {
output.destroy(err);
} else {
output.end();
}
});
proc.on("close", () => {
output.end();
});
return {
command: proc,
output,
encoder: mergedOptions.encoder,
options: mergedOptions,
videoCodec: mergedOptions.videoCodec,
width: mergedOptions.width,
height: mergedOptions.height,
frameRate: mergedOptions.frameRate,
includeAudio: !!mergedOptions.includeAudio,
};
}
export interface PlayStreamOptions {
type?: "go-live" | "video";
format?: string;
width?: number | ((v: unknown) => number);
height?: number | ((v: unknown) => number);
frameRate?: number | ((v: unknown) => number);
readrateInitialBurst?: number;
streamPreview?: boolean;
}
/**
* playStream demux the prepareStream output and pipe frames into the
* WebRTC connection's video/audio streams. Resolves when the video stream
* ends (natural EOF or the ffmpeg command is killed via cleanup/stop).
*/
export async function playStream(
prepared: PrepareStreamResult,
streamer: { createStream: () => Promise<WebRtcConnWrapper> },
options: PlayStreamOptions = {},
): Promise<void> {
const conn = await streamer.createStream();
const { video, close: demuxClose } = await demux(prepared.output, {
format: options.format ?? "nut",
});
if (!video) throw new Error("No video stream in media");
conn.setPacketizer(video.codecName);
conn.mediaConnection.setSpeaking(true);
const w =
typeof options.width === "function"
? options.width(video)
: (options.width ?? video.width);
const h =
typeof options.height === "function"
? options.height(video)
: (options.height ?? video.height);
const fr =
typeof options.frameRate === "function"
? options.frameRate(video)
: (options.frameRate ??
(video.framerate_num / video.framerate_den || 30));
conn.mediaConnection.setVideoAttributes(true, {
width: Math.round(w),
height: Math.round(h),
fps: Math.round(fr),
});
const vStream = new VideoStream(conn);
video.stream.pipe(vStream);
const cleanup = () => {
try {
prepared.command.kill("SIGTERM");
} catch {
/* already dead */
}
demuxClose();
try {
conn.mediaConnection.setSpeaking(false);
conn.mediaConnection.setVideoAttributes(false);
} catch {
/* connection already torn down */
}
};
return new Promise<void>((resolve) => {
vStream.once("finish", () => {
cleanup();
resolve();
});
vStream.once("error", () => {
cleanup();
resolve();
});
});
}
export { Encoders };
@@ -0,0 +1,82 @@
/** GoLive helpers — ported from @dank074/discord-video-stream/utils.js. */
export function normalizeVideoCodec(
codec: string,
): "H264" | "H265" | "VP8" | "VP9" | "AV1" {
if (/H\.?264|AVC/i.test(codec)) return "H264";
if (/H\.?265|HEVC/i.test(codec)) return "H265";
if (/VP(8|9)/i.test(codec)) return codec.toUpperCase() as "VP8" | "VP9";
if (/AV1/i.test(codec)) return "AV1";
throw new Error(`Unknown codec: ${codec}`);
}
/**
* The available video streams are sent by the client on connection to the
* voice gateway using OpCode Identify (0); the server replies with the ssrc
* and rtxssrc for each available stream using OpCode Ready (2). RID
* distinguishes simulcast streams of the same video source we only send one
* quality stream, so a single entry is hardcoded.
*/
export const STREAMS_SIMULCAST = [{ type: "screen", rid: "100", quality: 100 }];
export const max_int16bit = 2 ** 16;
export const max_int32bit = 2 ** 32;
export function isFiniteNonZero(n: unknown): n is number {
return typeof n === "number" && !!n && Number.isFinite(n);
}
export interface ParsedStreamKey {
type: "guild" | "call";
channelId: string;
guildId: string | null;
userId: string;
}
export function parseStreamKey(streamKey: string): ParsedStreamKey {
const streamKeyArray = streamKey.split(":");
const type = streamKeyArray.shift();
if (type !== "guild" && type !== "call") {
throw new Error(`Invalid stream key type: ${type}`);
}
if (
(type === "guild" && streamKeyArray.length < 3) ||
(type === "call" && streamKey.length < 2)
) {
throw new Error(`Invalid stream key: ${streamKey}`);
}
let guildId: string | null = null;
if (type === "guild") {
guildId = streamKeyArray.shift() ?? null;
}
const channelId = streamKeyArray.shift();
const userId = streamKeyArray.shift();
if (!channelId || !userId) {
throw new Error(`Invalid stream key: ${streamKey}`);
}
return { type, channelId, guildId, userId };
}
export function generateStreamKey(
type: "guild" | "call",
guildId: string | null,
channelId: string,
userId: string,
): string {
return `${type}${type === "guild" ? `:${guildId}` : ""}:${channelId}:${userId}`;
}
export interface VoiceChannelLike {
type: string;
id: string;
guildId?: string | null;
}
export function isVoiceChannel(channel: VoiceChannelLike): boolean {
return (
channel.type === "DM" ||
channel.type === "GROUP_DM" ||
channel.type === "GUILD_STAGE_VOICE" ||
channel.type === "GUILD_VOICE"
);
}
@@ -21,7 +21,11 @@ import { config } from "../../shared/config/config.js";
import { initializeDatabase } from "../../shared/database/drizzle.js";
import { messageStore } from "../message-capture/messageStore.js";
import type { MessageRecord } from "../message-capture/types.js";
import { buildConversationContext } from "./conversationContext.js";
import {
buildConversationContext,
buildLocationContext,
} from "./conversationContext.js";
import { buildConversationContextBlock } from "./moderationBuilders.js";
import { runModerationAnalysis } from "./moderationOrchestrator.js";
const logger = createChildLogger("ai-analysis-worker");
@@ -274,29 +278,54 @@ async function processBatch(job: {
contextBefore,
targets: messages,
maxTokens: config.AI_ANALYSIS_MAX_CONTEXT_TOKENS,
maxAgeMs: config.AI_ANALYSIS_CONTEXT_MAX_AGE_MS,
gapMs: config.AI_ANALYSIS_CONTEXT_GAP_MS,
});
const contextBlock = buildConversationContextBlock({
location: buildLocationContext(messages),
descriptor: contextLines.descriptor,
lines: contextLines.lines,
});
const contextText = contextLines.join("\n");
const targetIds = messages.map((m) => m.id);
const allTargetIds = messages.map((m) => m.id);
const contextIds = contextBefore.map((m) => m.id);
const attachments = await messageStore.getAttachmentsForMessages([
...targetIds,
...allTargetIds,
...contextIds,
]);
// Attachment-upload race guard: a message whose attachment is still being
// uploaded (upload_status='pending') must not be analyzed yet. Its
// uploaded_url is not ready, and falling back to the Discord CDN link often
// 404s (expired/purged) — which used to silently produce a text-only
// verdict ("lampiran yang gagal terbaca"). Leave those targets pending; the
// next worker cycle picks them up after the upload lands.
const pendingUploadTargetIds = new Set(
(attachments ?? [])
.filter((a) => a.upload_status === "pending")
.map((a) => a.message_id),
);
const readyMessages =
pendingUploadTargetIds.size === 0
? messages
: messages.filter((m) => !pendingUploadTargetIds.has(m.id));
if (readyMessages.length === 0) {
return { ok: true, conversationKey, rows: [] };
}
// The orchestrator handles text/media split + caching + parallel paths
// internally, so a 20-message batch = 1 text LLM call (+1 media call
// when media is present), not N per-message calls.
const moderationResult = await runModerationAnalysis({
targets: messages,
contextText,
targets: readyMessages,
contextBlock,
attachments,
});
const results = moderationResult.results.map((r) =>
normalizeResult(
r as unknown as AnalysisResult,
messages.find((m) => m.id === r.messageId),
readyMessages.find((m) => m.id === r.messageId),
),
);
@@ -324,9 +353,10 @@ async function processBatch(job: {
logger.info(
{
total: messages.length,
total: readyMessages.length,
saved: allRows.length,
conversationKey,
skippedPendingUpload: messages.length - readyMessages.length,
},
"LLM batch analysis complete",
);
@@ -359,8 +389,14 @@ async function processIndividual(job: {
contextBefore,
targets: [message],
maxTokens: config.AI_ANALYSIS_MAX_CONTEXT_TOKENS,
maxAgeMs: config.AI_ANALYSIS_CONTEXT_MAX_AGE_MS,
gapMs: config.AI_ANALYSIS_CONTEXT_GAP_MS,
});
const contextBlock = buildConversationContextBlock({
location: buildLocationContext([message]),
descriptor: contextLines.descriptor,
lines: contextLines.lines,
});
const contextText = contextLines.join("\n");
const contextIds = contextBefore.map((m) => m.id);
const attachments = await messageStore.getAttachmentsForMessages([
@@ -368,10 +404,21 @@ async function processIndividual(job: {
...contextIds,
]);
// Same attachment-upload race guard as the batch path: while the upload is
// still in-flight the uploaded_url is not ready and the Discord CDN fallback
// often 404s — analyzing now would silently produce a text-only verdict.
// Return no results so the message stays pending for the next cycle.
const uploadStillPending = (attachments ?? []).some(
(a) => a.message_id === message.id && a.upload_status === "pending",
);
if (uploadStillPending) {
return { ok: true, results: [] };
}
try {
const moderationResult = await runModerationAnalysis({
targets: [message],
contextText,
contextBlock,
attachments,
});
@@ -47,6 +47,40 @@ export function deriveRecommendedAction(msg: MessageRecord): string {
return "none";
}
/** Parse the flag list from a structured result or the stored column. */
export function parseModerationFlags(
message: MessageRecord,
analysisResult?: AnalysisResult,
): string[] {
const flags = analysisResult?.flags ?? null;
if (flags && flags.length > 0) return flags;
const stored = message.ai_moderation_flags;
if (!stored) return [];
try {
const parsed = JSON.parse(stored) as unknown;
return Array.isArray(parsed)
? parsed.filter((f): f is string => typeof f === "string")
: [];
} catch {
return [];
}
}
/**
* True when the ONLY violation is the member's server nickname the message
* content itself is clean. Such messages must NOT be auto-deleted; the
* correct enforcement is resetting the nickname to the default username.
* Any other flag (sara, harassment, vulgar_language, ...) keeps the normal
* delete path.
*/
export function isNicknameOnlyViolation(
message: MessageRecord,
analysisResult?: AnalysisResult,
): boolean {
const flags = parseModerationFlags(message, analysisResult);
return flags.length > 0 && flags.every((f) => f === "offensive_username");
}
/**
* Check whether a message qualifies for auto-deletion.
* Uses the structured `analysisResult` fields when provided, falling back
@@ -1,9 +1,13 @@
import type { Client, PermissionString } from "discord.js-selfbot-v13";
import { LRUCache } from "lru-cache";
import { createChildLogger } from "@/shared/logger/index";
import { config } from "../../shared/config/config.js";
import { messageStore } from "../message-capture/messageStore.js";
import type { MessageRecord } from "../message-capture/types.js";
import { isEligibleForAutoDelete } from "./autoDeleteEligibility.js";
import {
isEligibleForAutoDelete,
isNicknameOnlyViolation,
} from "./autoDeleteEligibility.js";
import { logDeletionToChannel } from "./autoDeleteLogger.js";
import { sendDeletionNotification } from "./autoDeleteNotify.js";
@@ -15,6 +19,83 @@ export interface AutoDeleteResult {
reason: string;
}
// Cooldown per guild:user — a nick violation fires per message, but the
// Discord PATCH is idempotent; hammering it on every message by the same
// member is wasteful and risks rate limits.
const recentNicknameResets = new LRUCache<string, number>({
max: 200,
ttl: config.AUTO_NICKNAME_RESET_COOLDOWN_MS ?? 10 * 60 * 1000,
});
export function isNicknameResetInCooldown(
guildId: string,
userId: string,
): boolean {
return recentNicknameResets.has(`${guildId}:${userId}`);
}
/**
* Resets a member's server nickname to the default (global username)
* Discord's `setNickname(null)` removes the custom nick so the member is
* shown under their default username. Non-blocking; failures are logged
* but never throw into the moderation pipeline.
*/
export async function resetOffensiveNickname(
client: Client | undefined,
guildId: string,
userId: string,
messageId: string,
): Promise<boolean> {
const cooldownKey = `${guildId}:${userId}`;
try {
if (!client?.user?.id) {
logger.warn(
{ messageId, guildId, userId },
"Nick reset skipped: client missing",
);
return false;
}
if (userId === client.user.id) {
logger.debug({ userId }, "Nick reset skipped: operator's own account");
return false;
}
if (recentNicknameResets.has(cooldownKey)) {
logger.debug({ guildId, userId }, "Nick reset skipped: cooldown active");
return false;
}
if (config.AUTO_NICKNAME_RESET_ENABLED === false) return false;
const guild = client.guilds.cache.get(guildId);
if (!guild) {
logger.warn(
{ messageId, guildId },
"Nick reset skipped: guild not found",
);
return false;
}
const member = await guild.members.fetch(userId);
// setNickname(null) = remove nickname → Discord shows global username
await member.setNickname(null, "[auto] nickname melanggar aturan server");
recentNicknameResets.set(cooldownKey, Date.now());
logger.info(
{ messageId, guildId, userId },
"Offensive nickname reset to default username",
);
return true;
} catch (error) {
logger.warn(
{
messageId,
guildId,
userId,
error: error instanceof Error ? error.message : String(error),
},
"Nick reset failed",
);
return false;
}
}
// ─── Error Handling Utilities ────────────────────────────────────────
function getErrorCode(error: unknown): number | string | undefined {
@@ -107,6 +188,57 @@ export async function attemptAutoDeleteFlaggedMessage(
return { deleted: false, skipped: true, reason: "disabled" };
}
// ── Nickname-only violation: reset nick, DO NOT delete ─────────────
// When the only flag is offensive_username (message content is clean),
// the problem is the server nickname, not the message. Enforcement is
// removing the nickname back to the default username — the message stays.
if (isNicknameOnlyViolation(message)) {
if (
!config.AUTO_DELETE_FLAGGED_DRY_RUN &&
config.AUTO_NICKNAME_RESET_ENABLED !== false
) {
const inCooldown = isNicknameResetInCooldown(
message.guild_id,
message.user_id,
);
if (!inCooldown) {
const resetOk = await resetOffensiveNickname(
client,
message.guild_id,
message.user_id,
message.id,
);
try {
await messageStore.createModerationAction({
message_id: message.id,
user_id: message.user_id,
guild_id: message.guild_id,
action_type: "reset_nickname",
reason:
"nickname melanggar aturan server (offensive_username); pesan dibiarkan",
executed_by: "auto-delete-manager",
status: resetOk ? "executed" : "failed",
error: resetOk ? null : "nickname_reset_failed",
executed_at: resetOk ? Date.now() : null,
});
} catch (error) {
logger.warn(
{
messageId: message.id,
error: error instanceof Error ? error.message : String(error),
},
"Failed to persist nickname reset action log",
);
}
}
}
logger.info(
{ messageId: message.id, userId: message.user_id },
"Nickname-only violation: message kept, nickname reset attempted",
);
return { deleted: false, skipped: true, reason: "nickname_only_violation" };
}
// ── Status gate ──────────────────────────────────────────────────
if (message.ai_status !== "flagged" && message.ai_status !== "warn") {
@@ -6,6 +6,7 @@ import {
} from "../message-capture/messageMetadata.js";
import type { MessageRecord } from "../message-capture/types.js";
import { sanitizeDiscordTokens } from "./discordTokens.js";
import { escapeXml, resolveDisplayName } from "./moderationBuilders.js";
const logger = createChildLogger("conversationContext");
@@ -13,6 +14,26 @@ export interface ConversationContextInput {
contextBefore: MessageRecord[];
targets: MessageRecord[];
maxTokens: number;
/**
* Hard age cap for context messages (ms). Messages older than this
* relative to the target are stale conversation noise and dropped.
*/
maxAgeMs?: number;
/**
* Silence threshold (ms). A gap between consecutive context messages
* larger than this means the conversation restarted older messages
* belong to a previous conversation and are dropped.
*/
gapMs?: number;
}
export interface ConversationContextResult {
/** Formatted context lines (oldest → newest, recency-gated). */
lines: string[];
/** One-line flow descriptor: status, span, dropped counts. */
descriptor: string;
/** Number of context messages dropped by the recency gates. */
dropped: number;
}
let _encoder: ReturnType<typeof encodingForModel> | null = null;
@@ -103,26 +124,143 @@ export function formatMessageForPrompt(
msg: MessageRecord,
label: "context" | "target",
): string {
const content = sanitizeDiscordTokens(
renderDiscordMentions(msg.edited_content ?? msg.content, msg.metadata),
const content = truncateContextLine(
sanitizeDiscordTokens(
renderDiscordMentions(msg.edited_content ?? msg.content, msg.metadata),
),
);
const timestamp = formatTimestamp(msg.created_at);
const mediaEvidence = formatMediaEvidenceForPrompt(msg.metadata);
const mediaSuffix = mediaEvidence ? ` ${mediaEvidence}` : "";
const refInfo = formatReferenceInfo(msg);
return `[${label}] id=${msg.id} time=${timestamp} user=${msg.username}: ${content}${mediaSuffix}${refInfo}`;
return `[${label}] id=${msg.id} time=${timestamp} user=${resolveDisplayName(msg)}: ${content}${mediaSuffix}${refInfo}`;
}
/** Max content chars per context line a single huge paste (log dump,
* copypasta) must not eat the whole conversation budget. */
const CONTEXT_LINE_CONTENT_MAX_CHARS = 1500;
/** Marker appended when a context line's content was cut. Distinct from the
* target-content marker so the model knows which side was truncated. */
export const CONTEXT_TRUNC_MARKER = "…[konteks dipotong: terlalu panjang]";
/** Cap one context message's content to CONTEXT_LINE_CONTENT_MAX_CHARS. */
export function truncateContextLine(content: string): string {
if (content.length <= CONTEXT_LINE_CONTENT_MAX_CHARS) return content;
return `${content.slice(0, CONTEXT_LINE_CONTENT_MAX_CHARS).trimEnd()}${CONTEXT_TRUNC_MARKER}`;
}
/**
* Builds a structured `<location_context .../>` element for the batch
* channel/thread name and age-restriction flags from captured message
* metadata. The LLM uses it to judge messages in the right channel context
* (e.g. a thread about a specific topic, or an age-restricted channel).
* Returns "" when no channel metadata was captured.
*/
export function buildLocationContext(targets: MessageRecord[]): string {
const target = targets[0];
if (!target?.metadata) return "";
try {
const meta = JSON.parse(target.metadata) as {
channel?: {
channelName?: string | null;
threadName?: string | null;
topic?: string | null;
nsfw?: boolean;
ageRestricted?: boolean;
nsfwLevel?: string | null;
} | null;
};
const ch = meta?.channel;
if (!ch) return "";
const attrs: string[] = [`channel_id="${escapeXml(target.channel_id)}"`];
if (ch.channelName)
attrs.push(`channel_name="${escapeXml(ch.channelName)}"`);
if (target.thread_id || ch.threadName) {
if (target.thread_id)
attrs.push(`thread_id="${escapeXml(target.thread_id)}"`);
if (ch.threadName)
attrs.push(`thread_name="${escapeXml(ch.threadName)}"`);
}
if (typeof ch.topic === "string" && ch.topic.trim().length > 0) {
const topic =
ch.topic.length > 200
? `${ch.topic.slice(0, 200).trimEnd()}`
: ch.topic;
attrs.push(`topic="${escapeXml(topic)}"`);
}
if (typeof ch.nsfw === "boolean") attrs.push(`nsfw="${ch.nsfw}"`);
if (typeof ch.ageRestricted === "boolean") {
attrs.push(`age_restricted="${ch.ageRestricted}"`);
}
return `<location_context ${attrs.join(" ")}/>`;
} catch {
return "";
}
}
/**
* Builds conversation historical context without including targets.
* Calculates how much token budget targets use, and fills the rest with context.
*
* Two recency gates decide whether a conversation is STILL the same one
* ("obrolan berlanjut") or already restarted:
* - `gapMs`: a silence longer than this between two context messages cuts
* the block there earlier messages belong to a previous conversation.
* - `maxAgeMs`: anything older than this relative to the target is noise.
*
* On a cold start (no recent context), the nearest messages are kept as a
* sparse anchor and the descriptor says `cold_start` instead of `ongoing`,
* so the LLM does not mistake scattered old messages for an active chat.
*/
export function buildConversationContext(
input: ConversationContextInput,
): string[] {
): ConversationContextResult {
const { contextBefore, targets, maxTokens } = input;
const maxAgeMs = input.maxAgeMs ?? 45 * 60 * 1000;
const gapMs = input.gapMs ?? 12 * 60 * 1000;
// Calculate tokens used by targets (parallel)
const targetTime = targets.reduce(
(min, t) => Math.min(min, t.created_at),
targets[0]?.created_at ?? Date.now(),
);
// ── Recency gating (walk newest → oldest) ───────────────────────────────
const gated: MessageRecord[] = [];
let latestSelected: MessageRecord | null = null;
let gapBeforeMs: number | null = null;
let dropped = 0;
for (let i = contextBefore.length - 1; i >= 0; i--) {
const msg = contextBefore[i];
// Age gate
if (targetTime - msg.created_at > maxAgeMs) {
dropped += i + 1; // everything older also exceeds the age cap
break;
}
// Gap gate — silence between this message and the newer one already selected
if (latestSelected && latestSelected.created_at - msg.created_at > gapMs) {
gapBeforeMs = latestSelected.created_at - msg.created_at;
dropped += i + 1;
break;
}
gated.push(msg);
latestSelected = msg;
}
const gatedNewestFirst = gated.reverse();
let status: "ongoing" | "cold_start" | "sparse";
if (gatedNewestFirst.length === 0) {
// Cold start — keep a small anchor of the nearest messages so the LLM
// still senses the channel, but mark it clearly.
status = "cold_start";
gatedNewestFirst.push(...contextBefore.slice(-2)); // ± 2 nearest to target
} else if (gapBeforeMs === null) {
status = "ongoing";
} else {
status = "sparse";
}
// ── Format + token budget (most recent first, like before) ─────────────
const targetLines = targets.map((msg) =>
formatMessageForPrompt(msg, "target"),
);
@@ -131,7 +269,7 @@ export function buildConversationContext(
0,
);
const contextLines = contextBefore.map((msg) =>
const contextLines = gatedNewestFirst.map((msg) =>
formatMessageForPrompt(msg, "context"),
);
const selectedContextLines: string[] = [];
@@ -148,14 +286,26 @@ export function buildConversationContext(
}
}
const descriptorParts = [
`[conversation_flow] status=${status}`,
`context_msgs=${selectedContextLines.length}`,
`dropped=${dropped}`,
];
if (gapBeforeMs !== null) {
descriptorParts.push(`gap_before_min=${Math.round(gapBeforeMs / 60000)}`);
}
const descriptor = descriptorParts.join(" ");
logger.debug(
{
targetCount: targets.length,
contextCount: selectedContextLines.length,
status,
dropped,
usedTokens,
maxTokens,
},
"Conversation context built",
);
return selectedContextLines;
return { lines: selectedContextLines, descriptor, dropped };
}
@@ -56,7 +56,16 @@ export async function withLlmConcurrency<T>(fn: () => Promise<T>): Promise<T> {
*/
type LLMResponseChunk = {
choices?: Array<{
delta?: { content?: string | null };
delta?: {
content?: string | null;
reasoning_content?: string | null;
reasoning?: string | null;
reasoning_details?: Array<{
type?: string;
text?: string;
index?: number;
}> | null;
};
message?: { content?: string | null };
finish_reason?: string | null;
text?: string;
@@ -67,6 +76,39 @@ type LLMResponseChunk = {
finish_reason?: string;
};
/**
* Extract the textual payload from a single streaming chunk. Prefers
* `delta.content`; falls back to reasoning fields so reasoning-only models
* still produce usable aggregated text. Providers differ in the field name:
* - DeepSeek-style / Cloudflare gemma `delta.reasoning_content`
* - mimo (via 9router) streams reasoning in `delta.reasoning` +
* `delta.reasoning_details[].text` (content:"") without these fallbacks
* vision aggregation came back empty ("Vision API null response").
* Exported for unit tests.
*/
export function extractChunkText(
chunk: LLMResponseChunk | null | undefined,
): string {
if (!chunk) return "";
const choice = chunk.choices?.[0];
const reasoningDetails = choice?.delta?.reasoning_details
?.map((d) => d.text ?? "")
.filter(Boolean)
.join("");
return (
choice?.delta?.content ||
choice?.delta?.reasoning_content ||
choice?.delta?.reasoning ||
reasoningDetails ||
choice?.message?.content ||
choice?.text ||
chunk?.message?.content ||
chunk?.response ||
chunk?.content ||
""
);
}
// ---------------------------------------------------------------------------
// Lazy singleton — created on first use so that config is always resolved.
// ---------------------------------------------------------------------------
@@ -167,15 +209,7 @@ export async function llmChat(
let finishReason = "stop";
for await (const chunk of response as unknown as AsyncIterable<LLMResponseChunk>) {
const choice = chunk?.choices?.[0];
const textChunk =
choice?.delta?.content ||
choice?.message?.content ||
choice?.text ||
chunk?.message?.content ||
chunk?.response ||
chunk?.content ||
"";
content += textChunk;
content += extractChunkText(chunk);
const fr = choice?.finish_reason || chunk?.finish_reason;
if (fr) finishReason = fr;
}
@@ -16,8 +16,10 @@ import { getChannelCulture } from "./channelCultureStore.js";
import type { RetryState } from "./llmCaller.js";
import { callModerationLLM } from "./llmCaller.js";
import { prepareMediaMessage } from "./mediaAnalysisClient.js";
import { buildUserProfilesBlock } from "./moderationBuilders.js";
import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js";
import { buildCorrectedFewShotExamples } from "./textBatchProcessor.js";
import { getUserProfile } from "./userProfileStore.js";
const log = createChildLogger("mediaBatchProcessor");
@@ -26,7 +28,7 @@ const log = createChildLogger("mediaBatchProcessor");
// ---------------------------------------------------------------------------
export async function runMediaBatch(
targets: MessageRecord[],
contextText: string,
contextBlock: string,
attachments: AttachmentRecord[] | undefined,
): Promise<{ results: AnalysisResult[]; raw: unknown }> {
if (!targets.length) return { results: [], raw: null };
@@ -58,14 +60,41 @@ export async function runMediaBatch(
const channelCulture = channelCultureObj?.culture_summary;
const correctedExamples = await buildCorrectedFewShotExamples();
const systemText = buildSystemPromptModular({
contextText,
mode: "mixed",
correctedExamples,
channelCulture,
});
// Gather user profiles ONCE for the whole batch and emit a deduplicated
// <user_profiles> map (with last-generated timestamp); per-message blocks
// (from prepareMediaMessage) reference it via <user_profile_ref>.
const profileByUser = new Map<
string,
{
text: string;
asOf?: number | null;
}
>();
for (const t of targets) {
if (profileByUser.has(t.user_id)) continue;
const profile = await getUserProfile(t.user_id);
profileByUser.set(t.user_id, {
text: profile?.profile_summary ?? "",
asOf: profile?.last_analyzed_at ?? null,
});
}
const userProfilesBlock = buildUserProfilesBlock(profileByUser);
const messagesBlock = prepared.map((p) => p.messageBlock).join("\n");
const userContent = `<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`;
// Data/instruction separation: the system prompt is stable per mode — all
// per-batch context (profiles, conversation) lives in the USER payload,
// ordered oldest-first so targets come last.
const userBlocks = [
userProfilesBlock?.trimEnd() ?? "",
contextBlock?.trimEnd() ?? "",
`<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`,
].filter((b) => b.trim().length > 0);
const userContent = userBlocks.join("\n\n");
const perMsgTimeout = config.AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS ?? 60000;
const batchTimeout = Math.min(
@@ -296,101 +296,137 @@ export async function downloadAndExtractFrame(
imageMap: Map<string, MessageImagePart[]>,
): Promise<void> {
const log = createChildLogger("mediaAnalysis");
const urlToUse = att.uploaded_url ?? att.discord_url ?? null;
if (!urlToUse) return;
// Prefer the upload proxy (uploaded_url); the Discord CDN link can expire
// or be purged (404), and a non-OK response used to silently drop the image
// from vision analysis (no log, empty image map → text-only verdict). Try
// each candidate URL in order and surface failures.
const urlCandidates = [
att.uploaded_url,
att.discord_url && att.discord_url !== att.uploaded_url
? att.discord_url
: null,
].filter((u): u is string => Boolean(u));
if (urlCandidates.length === 0) return;
const { controller, clear } = createAbortControllerWithTimeout(15000);
try {
const res = await fetch(urlToUse, { signal: controller.signal });
if (!res.ok || !res.body) return;
let totalBytes = 0;
const chunks: Uint8Array[] = [];
const reader = res.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
totalBytes += value.length;
if (totalBytes > 10 * 1024 * 1024) {
reader.cancel();
return;
}
chunks.push(value);
}
}
const imageBytes = Buffer.concat(chunks);
const sniffedMime = sniffImageMimeType(imageBytes);
if (!sniffedMime && att.type.startsWith("video/")) {
await extractVideoFrames(
att,
imageBytes,
targetId,
maxDimension,
imageMap,
);
return;
}
// Fallback: try attachment type metadata, then filename extension
let resolvedMime = sniffedMime;
if (!resolvedMime) {
if (att.type.startsWith("image/")) {
resolvedMime = att.type;
let imageBytes: Buffer | null = null;
let lastStatus = 0;
let lastError: string | null = null;
for (const urlToUse of urlCandidates) {
const { controller, clear } = createAbortControllerWithTimeout(15000);
try {
const res = await fetch(urlToUse, { signal: controller.signal });
if (!res.ok || !res.body) {
lastStatus = res.status;
log.warn(
{ attachmentId: att.id, filename: att.filename, type: att.type },
"Image MIME sniff failed — using attachment metadata type as fallback",
{
attachmentId: att.id,
urlHost: new URL(urlToUse).host,
status: res.status,
},
"Attachment fetch non-OK — trying next URL",
);
} else {
// Last resort: check file extension
const ext = att.filename?.toLowerCase().split(".").pop();
if (ext && ["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext)) {
const mimeMap: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
};
resolvedMime = mimeMap[ext];
log.warn(
{ attachmentId: att.id, filename: att.filename, ext },
"Image MIME sniff failed — using file extension fallback",
);
continue;
}
let totalBytes = 0;
const chunks: Uint8Array[] = [];
const reader = res.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
totalBytes += value.length;
if (totalBytes > 10 * 1024 * 1024) {
reader.cancel();
return;
}
chunks.push(value);
}
}
}
// If all fallbacks fail, still try with generic image/jpeg
if (!resolvedMime) {
resolvedMime = "image/jpeg";
imageBytes = Buffer.concat(chunks);
break;
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
log.warn(
{ attachmentId: att.id, filename: att.filename },
"All MIME detection failed — forcing image/jpeg as last resort",
{
attachmentId: att.id,
urlHost: new URL(urlToUse).host,
error: lastError,
},
"Attachment download failed — trying next URL",
);
} finally {
clear();
}
}
const { data: resizedBuffer, mimeType: resizedMime } =
await resizeImageForVision(imageBytes, maxDimension);
const dataUrl = `data:${resizedMime};base64,${resizedBuffer.toString("base64")}`;
addImageToMap(imageMap, targetId, {
type: "image_url",
image_url: { url: dataUrl },
sourceLabel: `[gambar di atas adalah attachment ${att.filename} dari pesan id=${att.message_id}]`,
});
} catch (err) {
if (!imageBytes) {
log.warn(
{
attachmentId: att.id,
error: err instanceof Error ? err.message : String(err),
filename: att.filename,
lastStatus,
lastError,
},
"Download failed",
"All attachment URLs failed — skipping media analysis",
);
} finally {
clear();
return;
}
const sniffedMime = sniffImageMimeType(imageBytes);
if (!sniffedMime && att.type.startsWith("video/")) {
await extractVideoFrames(att, imageBytes, targetId, maxDimension, imageMap);
return;
}
// Fallback: try attachment type metadata, then filename extension
let resolvedMime = sniffedMime;
if (!resolvedMime) {
if (att.type.startsWith("image/")) {
resolvedMime = att.type;
log.warn(
{ attachmentId: att.id, filename: att.filename, type: att.type },
"Image MIME sniff failed — using attachment metadata type as fallback",
);
} else {
// Last resort: check file extension
const ext = att.filename?.toLowerCase().split(".").pop();
if (ext && ["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext)) {
const mimeMap: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
};
resolvedMime = mimeMap[ext];
log.warn(
{ attachmentId: att.id, filename: att.filename, ext },
"Image MIME sniff failed — using file extension fallback",
);
}
}
}
// If all fallbacks fail, still try with generic image/jpeg
if (!resolvedMime) {
resolvedMime = "image/jpeg";
log.warn(
{ attachmentId: att.id, filename: att.filename },
"All MIME detection failed — forcing image/jpeg as last resort",
);
}
const { data: resizedBuffer, mimeType: resizedMime } =
await resizeImageForVision(imageBytes, maxDimension);
const dataUrl = `data:${resizedMime};base64,${resizedBuffer.toString("base64")}`;
addImageToMap(imageMap, targetId, {
type: "image_url",
image_url: { url: dataUrl },
sourceLabel: `[gambar di atas adalah attachment ${att.filename} dari pesan id=${att.message_id}]`,
});
}
// ---------------------------------------------------------------------------
@@ -494,8 +530,9 @@ export async function fetchUrlInline(
sourceLabel: `[gambar dari URL ${url} (inline), pesan id=${targetId}]`,
});
} else if (result.type === "text" && result.textContent) {
const titleAttr = result.title ? ` title="${escapeXml(result.title)}"` : "";
webTexts.push(
`<web_content url="${escapeXml(url)}">${escapeXml(result.textContent.slice(0, 2000))}</web_content>`,
`<web_content url="${escapeXml(url)}"${titleAttr}>${escapeXml(result.textContent.slice(0, 2000))}</web_content>`,
);
}
}
@@ -9,6 +9,7 @@ import { renderDiscordMentions } from "../message-capture/messageMetadata.js";
import { messageStore } from "../message-capture/messageStore.js";
import type { MessageRecord } from "../message-capture/types.js";
import { sanitizeDiscordTokens } from "./discordTokens.js";
import { sanitizeAiContent } from "./prompts/output.js";
/** Simple XML-escaping for content text. */
export function escapeXml(s: string): string {
@@ -19,6 +20,216 @@ export function escapeXml(s: string): string {
.replace(/"/g, "&quot;");
}
// ---------------------------------------------------------------------------
// Conversation context block — structured data for the USER message.
//
// All per-batch context lives in the USER message (not the SYSTEM prompt) so
// the system prompt is stable per mode (cacheable on routers/providers) and
// the role boundary is clean: instructions in SYSTEM, data in USER.
// ---------------------------------------------------------------------------
/** Outer char cap for the assembled `<conversation_context>` inner text. */
export const CONVERSATION_CONTEXT_MAX_CHARS = 40_000;
/**
* Wraps per-batch context data into structured XML blocks for the USER
* message:
*
* <location_context channel_id="..." channel_name="..." nsfw="..."/>
* <conversation_context>
* [conversation_flow] status=ongoing context_msgs=12 dropped=0
* [context] id=... time=... user=...: isi pesan
* ...
* </conversation_context>
*
* Empty blocks are omitted entirely (never emit a hollow `<conversation_context>`
* with no content). The inner text is AI/user-derived and passed through
* `sanitizeAiContent` (CDATA + XML-escape) to block prompt injection.
*/
export function buildConversationContextBlock(input: {
/** Pre-built `<location_context .../>` string (or ""). */
location?: string;
/** `[conversation_flow]` descriptor line from buildConversationContext. */
descriptor?: string;
/** `[context]` lines, oldest → newest. */
lines: string[];
}): string {
const blocks: string[] = [];
const location = input.location?.trim();
if (location) blocks.push(location);
const inner = [input.descriptor ?? "", ...input.lines]
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join("\n");
if (inner) {
blocks.push(
`<conversation_context>\n${sanitizeAiContent(inner, CONVERSATION_CONTEXT_MAX_CHARS)}\n</conversation_context>`,
);
}
return blocks.join("\n");
}
// ---------------------------------------------------------------------------
// Per-message content bounds — protects the LLM token budget from a single
// huge paste (stack traces, log dumps, copypasta). Truncation is explicit so
// the model never mistakes the cut for a real message boundary.
// ---------------------------------------------------------------------------
/** Max characters of a message's content sent to the LLM `<content>` payload. */
export const AI_CONTENT_MAX_CHARS = 4000;
/** Marker appended when a message is longer than AI_CONTENT_MAX_CHARS. */
export const AI_CONTENT_TRUNC_MARKER = "\n…[pesan dipotong: terlalu panjang]";
/** Truncate a message's content for the LLM `<content>` payload. */
export function truncateForAi(content: string): string {
if (content.length <= AI_CONTENT_MAX_CHARS) return content;
return `${content.slice(0, AI_CONTENT_MAX_CHARS)}${AI_CONTENT_TRUNC_MARKER}`;
}
// ---------------------------------------------------------------------------
// User profile deduplication — a batch can contain many messages from the
// same user. Instead of repeating the (up to 3000-char) profile summary on
// every message, emit a single <user_profiles> map per batch and reference
// entries per message with <user_profile_ref user_id="..."/>.
// ---------------------------------------------------------------------------
export interface UserProfileEntry {
/** Profile summary text (from user_profiles.profile_summary). */
text: string;
/** Epoch ms when the profile was last generated staleness signal for
* the LLM (a profile from months ago may not reflect current behavior). */
asOf?: number | null;
}
/** Build a deduplicated `<user_profiles>` map block, keyed by Discord user id. */
export function buildUserProfilesBlock(
profiles: ReadonlyMap<string, UserProfileEntry>,
): string {
const entries = Array.from(profiles.entries()).filter(
([, entry]) => entry.text.trim().length > 0,
);
if (entries.length === 0) return "";
const lines = entries.map(([userId, entry]) => {
const asOfAttr =
typeof entry.asOf === "number" && entry.asOf > 0
? ` as_of="${new Date(entry.asOf).toISOString()}"`
: "";
return ` <user_profile user_id="${escapeXml(userId)}"${asOfAttr}>${sanitizeAiContent(entry.text)}</user_profile>`;
});
return `<user_profiles>\n${lines.join("\n")}\n</user_profiles>`;
}
/** Per-message reference tag pointing at an entry in the `<user_profiles>` map. */
export function buildUserProfileRef(userId: string): string {
return `<user_profile_ref user_id="${escapeXml(userId)}"/>`;
}
// ---------------------------------------------------------------------------
// User reputation — richer than a bare trust score.
//
// The trust model tracks total_infractions, a clean-message streak and the
// last infraction timestamp. Feeding all of it to the LLM lets it tell a
// first-timer (same score, 1 infraction) from a repeat offender (score 50,
// 3 infractions, last one yesterday) — the same score means very different
// things in those two contexts.
// ---------------------------------------------------------------------------
export interface ReputationAttrsSource {
trust_score: number;
total_infractions: number;
clean_message_streak: number;
last_infraction_at: number | null;
}
const DAY_MS = 24 * 60 * 60 * 1000;
const REPEAT_OFFENSE_WINDOW_MS = 7 * DAY_MS;
/**
* Formats reputation fields into XML attributes for `<user_reputation .../>`.
* Derived signals: last_offense_days_ago (0 = today) and repeat_offender
* (infraction within the last 7 days) are computed here so both the text and
* media paths emit the exact same shape.
*/
export function formatReputationAttrs(
rep: ReputationAttrsSource,
now: number = Date.now(),
): string {
const attrs = [
`trust_score="${rep.trust_score}"`,
`total_infractions="${rep.total_infractions}"`,
`clean_streak="${rep.clean_message_streak}"`,
];
if (
typeof rep.last_infraction_at === "number" &&
rep.last_infraction_at > 0
) {
const daysAgo = Math.max(
0,
Math.floor((now - rep.last_infraction_at) / DAY_MS),
);
attrs.push(`last_offense_days_ago="${daysAgo}"`);
const isRepeat =
rep.total_infractions > 0 &&
now - rep.last_infraction_at <= REPEAT_OFFENSE_WINDOW_MS;
if (isRepeat) attrs.push(`repeat_offender="true"`);
}
return attrs.join(" ");
}
/**
* Builds an optional `<user_history>` block (last flagged messages) from
* getUserRecentInfractions rows. Only emitted when there is real history
* lets the LLM see the PATTERN (e.g. the same scam link posted repeatedly)
* without treating old flags as proof for the current message.
*/
export function buildUserHistoryXml(
history: Array<{
content: string;
severity: string | null;
created_at: number;
}>,
now: number = Date.now(),
): string {
const filtered = history.filter((h) => h.content?.trim());
if (filtered.length === 0) return "";
const lines = filtered.map((h) => {
const daysAgo = Math.max(0, Math.floor((now - h.created_at) / DAY_MS));
const severityAttr = h.severity
? ` severity="${escapeXml(h.severity)}"`
: "";
const snippet =
h.content.length > 100
? `${h.content.slice(0, 100).trimEnd()}`
: h.content;
return ` <infraction${severityAttr} time_ago_days="${daysAgo}">${escapeXml(snippet)}</infraction>`;
});
return `<user_history>\n${lines.join("\n")}\n</user_history>`;
}
/**
* Whether the message author was a bot (captured in metadata.author.bot).
* Bot posts (logging bots, webhook-style automation) deserve different
* scrutiny than user posts expose the flag instead of hiding it.
*/
export function resolveIsBot(msg: MessageRecord): boolean {
if (!msg.metadata) return false;
try {
const meta = JSON.parse(msg.metadata) as {
author?: { bot?: boolean } | null;
};
return Boolean(meta?.author?.bot);
} catch {
return false;
}
}
/** Whether the shown content is an EDIT of the original post (evasion signal). */
export function resolveIsEdited(msg: MessageRecord): boolean {
return Boolean(msg.edited_content);
}
/**
* Returns the real text content for AI analysis, stripping fallback text
* that getDisplayContent() synthesized ("[Attachment: ...]", "[Sticker: ...]",
@@ -36,6 +247,27 @@ export function getAnalysisContent(message: MessageRecord): string {
).trim();
}
/**
* Server nickname (member.displayName) when captured, else the author
* username. Discord shows the server nickname to other members, so the LLM
* should see the same name the channel sees and a nickname can carry
* moderation signal itself (offensive nick + clean message low warn).
*/
export function resolveDisplayName(msg: MessageRecord): string {
if (msg.metadata) {
try {
const meta = JSON.parse(msg.metadata) as {
member?: { displayName?: string | null } | null;
};
const dn = meta?.member?.displayName;
if (dn && dn.trim().length > 0) return dn;
} catch {
// malformed metadata — fall back to username
}
}
return msg.username;
}
/**
* Builds a <reference> XML element for reply/forward/crosspost context.
*/
@@ -35,7 +35,13 @@ const log = createChildLogger("moderationOrchestrator");
// ---------------------------------------------------------------------------
export interface ModerationInput {
targets: MessageRecord[];
contextText: string;
/**
* Pre-built XML context block for the USER message (from
* `buildConversationContextBlock`): `<location_context .../>` +
* `<conversation_context>...</conversation_context>`. Kept out of the
* system prompt so it stays stable/cacheable per mode.
*/
contextBlock: string;
attachments?: AttachmentRecord[];
}
@@ -62,7 +68,7 @@ export interface ModerationOutput {
export async function runModerationAnalysis(
input: ModerationInput,
): Promise<ModerationOutput> {
const { targets, contextText, attachments } = input;
const { targets, contextBlock, attachments } = input;
initSearxngCache(config.REDIS_URL);
if (!targets.length) throw new Error("No targets provided for analysis");
@@ -320,10 +326,10 @@ export async function runModerationAnalysis(
// Run both paths in parallel
const [textBatchResult, mediaBatchResult] = await Promise.all([
textOnlyTargets.length > 0
? runTextOnlyBatch(textOnlyTargets, contextText)
? runTextOnlyBatch(textOnlyTargets, contextBlock)
: Promise.resolve({ results: [] as AnalysisResult[], raw: null }),
mediaTargets.length > 0
? runMediaBatch(mediaTargets, contextText, attachments)
? runMediaBatch(mediaTargets, contextBlock, attachments)
: Promise.resolve({ results: [] as AnalysisResult[], raw: null }),
]);
@@ -31,11 +31,16 @@ Struktur wajib:
]
}
Instruksi per field:
- "message_id": WAJIB sama persis dengan id di input. Setiap <message> di <messages_to_analyze> menghasilkan SATU hasil. Jangan gabungkan beberapa pesan, jangan lewati, jangan karang id.
- "evidence": kutipan PERSIS frasa yang melanggar (maks 1 baris). Pelanggaran di gambar/sticker kutip deskripsi Media analysis. Pelanggaran lewat balasan/referensi sebut konteks pesan yang dibalas. Boleh tambah label sumber, mis. [media analysis] / [web_search] / [reply]. Kosong jika clean.
## PERSONALITY & MEMORI Profil Pengguna dan Kultur Channel
Data konteks tersedia: <user_profile> (ringkasan kepribadian pengguna) dan <channel_culture> (topik/vibe channel).
Data konteks tersedia: <user_profiles> (peta ringkasan kepribadian, di pesan USER), <user_reputation> (skor trust), dan <channel_culture> (topik/vibe channel). Setiap <message> dapat memuat <user_profile_ref user_id="..."/> yang menunjuk ke entri di peta <user_profiles>.
Gunakan untuk personalisasi analysis, tapi:
- Profil adalah KONTEKS, bukan bukti. Profil mencurigakan flag; profil bersih loloskan pelanggaran.
- Perubahan perilaku mencolok (biasanya teknis tiba-tiba provokatif) layak dicatat di analysis.
- <user_history> (kutipan pesan yang pernah di-flag) = pola pelanggaran lama. Gunakan untuk mendeteksi PENGULANGAN (mis. spam link yang sama, provokasi berulang), tapi JANGAN memflag pesan bersih hanya karena riwayat.
- JANGAN paksa referensi profil jika tidak relevan analysis natural lebih baik.
- Channel culture coding/teknis pesan teknis lebih wajar; channel santai slang lebih wajar. Jangan dipakai mengabaikan pelanggaran nyata.
@@ -54,6 +59,7 @@ Contoh buruk: "Pesan berisi teks dan gambar tanpa pelanggaran." (mengabaikan buk
- **conflict_instigation:** "Pengirim <ajakan memicu konflik>. <konteks>. Diberi peringatan karena berpotensi memicu drama."
- **Username ofensif (pesan bersih):** "Pengirim memiliki username yang <alasan ofensif>. Isi pesan hanya <isi>. Diberi warning ringan." (pesan memperkuat): "<username SARA> + isi pesan memperkuat tone kebencian. Pelanggaran berat."
- **Evasi (zalgo/leetspeak):** "Pengirim menggunakan teknik obfuscation untuk menyembunyikan <makna asli>. <dampak>. <kesimpulan>."
- **Spam (repetitions > 1):** "Pengirim mengirim teks yang sama sebanyak N kali dalam waktu singkat. <isi pesan>. Diberi peringatan karena spam berulang." nilai tetap dari isi; pengulangan saja (mis. "ok" x5 dalam obrolan aktif) bukan pelanggaran.
- **sexual_deviation:** "Pengirim <konten penyimpangan>. <konteks>. Melanggar kebijakan server."
- **SARA/penistaan agama:** "Pengirim <jenis penistaan spesifik: parodi ayat, mengaku Tuhan, mockery ritual, istilah agama sebagai joke, provokasi antar-agama>. <bukti>. Melanggar kebijakan SARA." JANGAN gunakan kata "bercanda" untuk SARA.
@@ -66,7 +72,7 @@ CRITICAL:
- Jika pesan adalah BALASAN (reply) ke pesan lain, jelaskan konteks balasannya: apa yang sedang dibicarakan, siapa yang dibalas (tanpa nama, cukup peran/isi pesan yang dibalas), dan bagaimana tanggapan pengirim terhadapnya.
- Gunakan informasi dari Media analysis untuk mendeskripsikan gambar.
- Analisis harus MEMBERI KONTEKS, bukan hanya menyatakan status.
- GUNAKAN <user_profile> untuk personalisasi analysis jadikan analysis terasa seperti sistem "mengenal" pengguna.
- GUNAKAN <user_profile_ref>/<user_profiles> untuk personalisasi analysis jadikan analysis terasa seperti sistem "mengenal" pengguna.
- Jika perilaku pesan menyimpang dari profil yang diketahui, CATAT dalam analysis sebagai informasi kontekstual yang relevan.
- JANGAN paksa referensi profil jika tidak relevan analysis natural lebih baik dari yang dipaksakan.`;
@@ -39,7 +39,6 @@ Gambar/sticker/embed/preview link sudah DIDESKRIPSIKAN vision model sebelum batc
// ---------------------------------------------------------------------------
export interface BuildSystemPromptOptions {
contextText: string;
/** Prompt mode — determines which sections are included. */
mode: PromptMode;
/** @deprecated Use `mode` instead. */
@@ -59,7 +58,6 @@ export interface BuildSystemPromptOptions {
export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
const {
contextText,
mode,
includeMediaInstructions,
correction,
@@ -105,15 +103,39 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
}
parts.push(
`## Konteks Pengguna\nSetiap pesan mungkin memiliki tag <user_reputation>. Tag ini hanya indikator **referensi**, bukan bukti pelanggaran. Nilai trust_score yang rendah bukan alasan untuk memflag pesan yang bersih. Nilai trust_score yang tinggi bukan alasan untuk mengabaikan pelanggaran nyata. **Setiap pesan harus dinilai berdasarkan isinya sendiri.**`,
`## Blok Data di Pesan USER\n` +
`Semua data dinamis per-batch dikirim di pesan USER — system prompt ini TIDAK memuat data batch:\n` +
`- <location_context .../> = metadata channel/thread (channel_id, channel_name, thread_name, topic, nsfw, age_restricted). topic = deskripsi resmi channel — pakai untuk menilai kesesuaian pesan dengan tujuan channel.\n` +
`- <conversation_context> = obrolan SEBELUM pesan target. Baris "[context]" di dalamnya BUKAN yang dinilai.\n` +
`- <user_profiles> = peta ringkasan kepribadian per user_id (attr as_of = kapan profil terakhir dibuat — profil lama mungkin tidak mencerminkan perilaku terkini); setiap <message> merujuk lewat <user_profile_ref user_id="..."/>.\n` +
`- <web_searches> / <web_content> = bukti web (lihat "Web Sebagai Bukti Utama").\n` +
`- <messages_to_analyze> = pesan-pesan TARGET yang WAJIB dinilai. Atribut <message>: id, user (nama server), time (ISO — kapan pesan dikirim), repetitions (N = teks pendek sama muncul N kali di batch — sinyal spam), bot (true jika dari bot), edited (true jika konten adalah hasil edit setelah posting).`,
);
parts.push(
`## Konteks Pengguna (Referensi, Bukan Bukti)\n` +
`Konteks per pengguna hanya indikator **referensi** untuk personalisasi analisis, BUKAN bukti pelanggaran:\n` +
`- <user_reputation trust_score="..." total_infractions="..." clean_streak="..." last_offense_days_ago="..." repeat_offender="..."> = histori moderasi pengguna. Skor rendah BUKAN alasan memflag pesan bersih; skor tinggi BUKAN alasan mengabaikan pelanggaran nyata. repeat_offender="true" = ada pelanggaran dalam 7 hari terakhir.\n` +
`- <user_history> (di dalam <user_reputation>) = kutipan pesan-pesan pengguna yang PERNAH di-flag. Gunakan untuk mengenali POLA berulang (spam link sama, provokasi), tapi JANGAN memflag pesan bersih hanya karena riwayat.\n` +
`- <user_profiles> (di pesan USER) = peta ringkasan kepribadian per user_id. <user_profile_ref user_id="..."/> dalam sebuah pesan menunjuk ke peta itu. Tanpa ref = tidak ada profil untuk pengguna tersebut.\n` +
`- Profil berguna untuk mengenali penyimpangan perilaku mencolok (mis. pengguna teknis tiba-tiba provokatif), tapi JANGAN memflag atau meloloskan hanya karena profil.\n` +
`**Setiap pesan dinilai berdasarkan isinya sendiri.**`,
);
parts.push(
`## Framing: Konteks vs Target\n` +
`- Baris dalam <conversation_context> berformat "[context] id=... time=<ISO> user=<nama>: isi", diurutkan paling lama → paling baru. Baris pertama biasanya "[conversation_flow] status=... context_msgs=... dropped=..." — metadata sistem tentang status percakapan (ongoing/sparse/cold_start), BUKAN pesan yang dinilai.\n` +
`- <messages_to_analyze> berisi pesan-pesan TARGET yang WAJIB dinilai. Hasilkan SATU hasil per message_id — jangan menggabungkan beberapa pesan, jangan melewati, jangan mengarang id.\n` +
`- Setiap target dinilai berdasarkan isinya sendiri; konteks percakapan memengaruhi interpretasi, bukan menggantikan isi pesan.\n` +
`- Marker "…[pesan dipotong: terlalu panjang]" = konten TARGET sengaja dipotong; marker "…[konteks dipotong: terlalu panjang]" = konten pesan KONTEKS dipotong. Nilai dari bagian yang terlihat; pemotongan BUKAN pelanggaran dan BUKAN teknik evasi.\n` +
`- Atribut time= pada <message> target = kapan pesan dikirim (ISO). Pakai untuk menilai kerelevanan waktu (mis. pesan lama di-bump, spam beruntun dalam menit yang sama).\n` +
`- repetitions="N" pada <message> = teks pendek yang sama muncul N kali dalam batch — pertimbangkan sebagai sinyal spam, tapi nilai tetap dari isi pesan.\n` +
`- bot="true" = pengirim adalah bot (otomatisasi), bukan pengguna manusia — jangan perlakukan sebagai pelanggaran personal, tapi kontennya tetap dinilai.\n` +
`- edited="true" = konten yang ditampilkan adalah hasil edit setelah posting (sinyal potensi evasi), nilai konten saat ini apa adanya.`,
);
parts.push(OUTPUT_INSTRUCTIONS);
// XML-delimited context — prevents prompt injection
const delimitedContext = `<conversation_context>\n${sanitizeAiContent(contextText, 8000)}\n</conversation_context>`;
parts.push(delimitedContext);
let base = parts.join("\n\n");
if (correction) {
@@ -6,7 +6,9 @@
* the LLM for analysis. Extracted from moderationOrchestrator.ts.
*/
import { createChildLogger } from "@/shared/logger/index";
import { delay } from "@/shared/utils/index";
import { config } from "../../shared/config/config.js";
import { resizeImageForVision } from "../attachment-upload/imageResizer.js";
import type {
AnalysisResult,
MessageRecord,
@@ -14,15 +16,21 @@ import type {
import { getChannelCulture } from "./channelCultureStore.js";
import type { ModerationPromptContent, RetryState } from "./llmCaller.js";
import { callModerationLLM } from "./llmCaller.js";
import { analyzeSingleMediaImage } from "./mediaAnalysisClient.js";
import {
buildReferenceXml,
buildUserHistoryXml,
buildUserProfileRef,
buildUserProfilesBlock,
escapeXml,
formatReputationAttrs,
getAnalysisContent,
resolveDisplayName,
resolveIsBot,
resolveIsEdited,
truncateForAi,
} from "./moderationBuilders.js";
import {
buildSystemPrompt as buildSystemPromptModular,
sanitizeAiContent,
} from "./moderationPrompt.js";
import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js";
import { logModerationAnalysis } from "./responseLogger.js";
import {
extractSearchQueries,
@@ -32,7 +40,11 @@ import {
import { getRecentCorrectedModerations } from "./textCacheStore.js";
import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js";
import { getUserProfile } from "./userProfileStore.js";
import { initializeUserReputation } from "./userReputationStore.js";
import {
getUserRecentInfractions,
initializeUserReputation,
} from "./userReputationStore.js";
import type { MessageImagePart } from "./visionAnalyzer.js";
const log = createChildLogger("textBatchProcessor");
@@ -69,7 +81,7 @@ export async function buildCorrectedFewShotExamples(): Promise<string> {
// ---------------------------------------------------------------------------
export async function runTextOnlyBatch(
targets: MessageRecord[],
contextText: string,
contextBlock: string,
): Promise<{ results: AnalysisResult[]; raw: unknown }> {
if (!targets.length) return { results: [], raw: null };
@@ -84,22 +96,33 @@ export async function runTextOnlyBatch(
allUrls.add(url);
}
const urlArr = Array.from(allUrls).slice(0, 10);
if (urlArr.length === 0) return new Map<string, string>();
if (urlArr.length === 0) {
return {
text: new Map<string, string>(),
image: new Map<string, { data: Buffer; mimeType: string }>(),
title: new Map<string, string>(),
};
}
const results = await Promise.allSettled(
urlArr.map((url) => fetchUrlSafely(url)),
);
const map = new Map<string, string>();
const textMap = new Map<string, string>();
const imageMap = new Map<string, { data: Buffer; mimeType: string }>();
const titleMap = new Map<string, string>();
for (let i = 0; i < urlArr.length; i++) {
const r = results[i];
if (
r.status === "fulfilled" &&
r.value.type === "text" &&
r.value.textContent
) {
map.set(urlArr[i], r.value.textContent);
if (r.status !== "fulfilled") continue;
const v = r.value;
if (v.type === "text" && v.textContent) {
textMap.set(urlArr[i], v.textContent);
if (v.title) titleMap.set(urlArr[i], v.title);
} else if (v.type === "image" && v.data && v.mimeType) {
// Direct image link (or og:image followed from an HTML page) —
// kept for vision analysis below.
imageMap.set(urlArr[i], { data: v.data, mimeType: v.mimeType });
}
}
return map;
return { text: textMap, image: imageMap, title: titleMap };
})();
const searxngPromise = (async () => {
@@ -122,10 +145,11 @@ export async function runTextOnlyBatch(
return map;
})();
const [urlFetchMap, searxngResults] = await Promise.all([
const [urlFetchMaps, searxngResults] = await Promise.all([
urlFetchPromise,
searxngPromise,
]);
const urlFetchMap = urlFetchMaps.text;
// Deduplicate identical short messages
const shortContentGroups = new Map<string, MessageRecord[]>();
@@ -171,25 +195,109 @@ export async function runTextOnlyBatch(
const batch = subBatches[i];
const targetIds = batch.map((t) => t.id);
// User reputation + profiles
// User reputation + profiles (raw summary text — deduplicated into a
// single <user_profiles> map per batch; messages only reference it).
const userContexts = new Map<string, string>();
const userProfiles = new Map<string, string>();
const userProfiles = new Map<
string,
{
text: string;
asOf?: number | null;
}
>();
for (const msg of batch) {
if (!userContexts.has(msg.user_id)) {
const rep = await initializeUserReputation(msg.user_id, msg.guild_id);
userContexts.set(
msg.user_id,
`<user_reputation trust_score="${rep.trust_score}" />`,
);
const repAttrs = formatReputationAttrs(rep);
let repXml = `<user_reputation ${repAttrs}/>`;
// Repeat offenders get their last flagged messages as <user_history>
// so the LLM can recognize PATTERNS (same scam link, repeated
// provocation) — history is reference, never proof. Best-effort.
if (rep.total_infractions > 0) {
try {
const history = await getUserRecentInfractions(msg.user_id, 2);
const historyXml = buildUserHistoryXml(
history.map((h) => ({
content: h.content ?? "",
severity: h.severity,
created_at: h.created_at,
})),
);
if (historyXml) {
repXml = `<user_reputation ${repAttrs}>\n${historyXml}\n</user_reputation>`;
}
} catch {
// history is a bonus — fall back to attrs-only reputation
}
}
userContexts.set(msg.user_id, repXml);
}
if (!userProfiles.has(msg.user_id)) {
const profile = await getUserProfile(msg.user_id);
userProfiles.set(
msg.user_id,
profile
? `<user_profile>${sanitizeAiContent(profile.profile_summary)}</user_profile>`
: "",
);
userProfiles.set(msg.user_id, {
text: profile?.profile_summary ?? "",
asOf: profile?.last_analyzed_at ?? null,
});
}
}
const userProfilesBlock = buildUserProfilesBlock(userProfiles);
// ── URL images → multimodal vision evidence ─────────────────────────
// The text batch fetches inline URLs; whenever one resolved to an image
// (direct image link, or og:image followed from an HTML page), run the
// vision model and append its description as media evidence. If any
// message in the sub-batch produced image evidence, the prompt switches
// to "mixed" mode so media-analysis instructions/examples are injected
// — a link to media is analyzed as media, not as bare text.
const batchImageEvidence = new Map<string, string[]>();
let batchHasImageEvidence = false;
const urlImages = urlFetchMaps.image;
const urlTitles = urlFetchMaps.title;
if (urlImages.size > 0) {
const maxDim = config.AI_LLM_IMAGE_MAX_DIMENSION ?? 1024;
const evidenceSets = await Promise.all(
batch.map(async (msg) => {
const content = getAnalysisContent(msg);
const pics = extractUrlsFromText(content)
.slice(0, 3)
.filter((url) => urlImages.has(url));
if (pics.length === 0) return { id: msg.id, lines: [] as string[] };
const lines = await Promise.all(
pics.map(async (url) => {
const img = urlImages.get(url)!;
try {
const { data: resizedBuffer, mimeType: resizedMime } =
await resizeImageForVision(img.data, maxDim);
const part: MessageImagePart = {
type: "image_url",
image_url: {
url: `data:${resizedMime};base64,${resizedBuffer.toString("base64")}`,
},
sourceLabel: `[gambar dari URL ${url} (inline), pesan id=${msg.id}]`,
};
// Bound vision time so a dead vision model can't stall the
// whole text batch — a timeout just skips the evidence.
const timedOut = delay(15000).then(() => null as string | null);
return await Promise.race([
analyzeSingleMediaImage(msg.id, part),
timedOut,
]);
} catch {
return null;
}
}),
);
return {
id: msg.id,
lines: lines.filter((l): l is string => Boolean(l)),
};
}),
);
for (const set of evidenceSets) {
if (set.lines.length > 0) {
batchImageEvidence.set(set.id, set.lines);
batchHasImageEvidence = true;
}
}
}
@@ -204,8 +312,7 @@ export async function runTextOnlyBatch(
: undefined;
const correctedExamples = await buildCorrectedFewShotExamples();
const systemText = buildSystemPromptModular({
contextText,
mode: "text",
mode: batchHasImageEvidence ? "mixed" : "text",
correction,
correctedExamples,
channelCulture,
@@ -214,38 +321,58 @@ export async function runTextOnlyBatch(
const messagesBlock = (
await Promise.all(
batch.map(async (msg) => {
const content = getAnalysisContent(msg);
const content = truncateForAi(getAnalysisContent(msg));
const msgUrls = extractUrlsFromText(content);
const urlContexts = msgUrls
.map((url) => {
const ft = urlFetchMap.get(url);
return ft
? `<web_content url="${escapeXml(url)}">${escapeXml(ft)}</web_content>`
: null;
if (!ft) return null;
const title = urlTitles.get(url);
const titleAttr = title ? ` title="${escapeXml(title)}"` : "";
return `<web_content url="${escapeXml(url)}"${titleAttr}>${escapeXml(ft)}</web_content>`;
})
.filter(Boolean)
.join("\n");
const webContext = urlContexts ? `\n${urlContexts}` : "";
const mediaEvidenceCtx = (batchImageEvidence.get(msg.id) ?? [])
.map((line) => `\n${line}`)
.join("");
const userCtx = userContexts.get(msg.user_id) ?? "";
const userProfileCtx = userProfiles.get(msg.user_id) ?? "";
const userProfileRef = (
userProfiles.get(msg.user_id)?.text ?? ""
).trim()
? buildUserProfileRef(msg.user_id)
: "";
const refXml = await buildReferenceXml(msg);
return `<message id="${msg.id}" user="${msg.username}">\n ${userCtx}${userProfileCtx ? `\n ${userProfileCtx}` : ""}${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(content)}</content>${webContext}\n</message>`;
const repetitionCount = groupMapping.get(msg.id)?.length ?? 1;
const isBot = resolveIsBot(msg);
const isEdited = resolveIsEdited(msg);
return `<message id="${escapeXml(msg.id)}" user="${escapeXml(resolveDisplayName(msg))}" time="${new Date(msg.created_at).toISOString()}"${repetitionCount > 1 ? ` repetitions="${repetitionCount}"` : ""}${isBot ? ` bot="true"` : ""}${isEdited ? ` edited="true"` : ""}>\n ${userCtx}${userProfileRef ? `\n ${userProfileRef}` : ""}${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(content)}</content>${webContext}${mediaEvidenceCtx}\n</message>`;
}),
)
).join("\n");
const searxngBlock =
searxngResults.size > 0
? `\n\n<web_searches>\n${Array.from(searxngResults.entries())
? `<web_searches>\n${Array.from(searxngResults.entries())
.map(
([q, xml]) =>
` <search_query query="${escapeXml(q)}">\n${xml} </search_query>`,
)
.join("\n")}\n</web_searches>`
: "";
// Data/instruction separation: the system prompt is stable per mode —
// all per-batch context (profiles, conversation, web evidence) lives in
// the USER payload, ordered oldest-first so targets come last.
const userBlocks = [
userProfilesBlock?.trimEnd() ?? "",
contextBlock?.trimEnd() ?? "",
searxngBlock,
`<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`,
].filter((b) => b.trim().length > 0);
return {
system: systemText,
user: `${searxngBlock}\n\n<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`,
user: userBlocks.join("\n\n"),
};
};
@@ -11,6 +11,8 @@ export interface FetchedUrlContext {
data?: Buffer;
mimeType?: string;
textContent?: string;
/** Page title from og:title / <title> — strong signal for the LLM. */
title?: string;
error?: string;
}
@@ -86,6 +88,50 @@ function extractOgImage(html: string): string | null {
return null;
}
export interface OgMeta {
title: string | null;
description: string | null;
siteName: string | null;
}
/**
* Extracts OpenGraph / twitter meta + <title> from raw HTML. Both attribute
* orders are accepted (<meta property=... content=...> and reversed).
*/
export function extractOgMeta(html: string): OgMeta {
const metaValue = (name: string): string | null => {
const re = new RegExp(
`<meta[^>]*(?:property|name)=["']${name}["'][^>]*content=["']([^"']+)["']`,
"i",
);
const m = html.match(re);
if (m?.[1]) return m[1].replace(/&amp;/g, "&").replace(/&quot;/g, '"');
const reRev = new RegExp(
`<meta[^>]*content=["']([^"']+)["'][^>]*(?:property|name)=["']${name}["']`,
"i",
);
const mRev = html.match(reRev);
return mRev?.[1]
? mRev[1].replace(/&amp;/g, "&").replace(/&quot;/g, '"')
: null;
};
const title =
metaValue("og:title") ||
metaValue("twitter:title") ||
html.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.trim() ||
null;
const description =
metaValue("og:description") ||
metaValue("twitter:description") ||
metaValue("description") ||
null;
const siteName =
metaValue("og:site_name") || metaValue("application-name") || null;
return { title, description, siteName };
}
function truncateAndCleanHtml(html: string, maxLen = 1000): string {
// Strip <script> and <style> entirely
let text = html.replace(
@@ -176,6 +222,7 @@ export async function fetchUrlSafely(
url,
type: "text",
textContent: cleaned,
title: extractOgMeta(text).title ?? undefined,
};
}
@@ -29,6 +29,35 @@ import {
upsertCachedMediaByPhash,
visionLruCache,
} from "./mediaCache.js";
/**
* Detect vision outputs where the model claims it saw no image at all
* ("Maaf, saya tidak melihat gambar apapun...", "Tidak ada gambar yang
* terlampir...", "I cannot see any image..."). Such text is NOT a valid
* analysis caching it poisons the image cache for 24h (image/phash keys),
* so every re-analysis of the same image returns the "no image" text and the
* moderation LLM writes "lampiran gagal terbaca". These outputs must be
* treated as failures: never cached, and ignored when read back from cache.
*/
export function isNoImageSeenText(text: string | null | undefined): boolean {
if (!text) return false;
const lower = text.toLowerCase();
return (
/tidak (?:melihat|ada|terlihat) (?:gambar|foto|image)/i.test(lower) ||
/tidak (?:ada )?(?:gambar|foto|image) (?:apapun|yang terlampir)/i.test(
lower,
) ||
/gambar apapun/i.test(lower) ||
/tanpa (?:input )?(?:visual|gambar|image)/i.test(lower) ||
/\bno image (?:provided|attached|detected|found|was provided)?/i.test(
lower,
) ||
/(?:cannot|can't) see (?:any |an |the )?image/i.test(lower) ||
/i (?:do not|don't) (?:see|detect) (?:any |an |the )?image/i.test(lower) ||
/there (?:is|are) no image/i.test(lower)
);
}
import {
buildMediaCandidates,
downloadAndExtractFrame,
@@ -37,15 +66,21 @@ import {
} from "./mediaDownloader.js";
import {
buildReferenceXml,
buildUserHistoryXml,
buildUserProfileRef,
escapeXml,
formatReputationAttrs,
getAnalysisContent,
resolveDisplayName,
resolveIsBot,
resolveIsEdited,
truncateForAi,
} from "./moderationBuilders.js";
import {
buildCustomEmojiVisionPrompt,
buildGeneralImageVisionPrompt,
buildStickerTextOnlyWarning,
buildStickerVisionPrompt,
sanitizeAiContent,
} from "./moderationPrompt.js";
import {
extractSearchQueries,
@@ -54,7 +89,10 @@ import {
} from "./searxngSearch.js";
import { extractUrlsFromText } from "./urlFetcher.js";
import { getUserProfile } from "./userProfileStore.js";
import { initializeUserReputation } from "./userReputationStore.js";
import {
getUserRecentInfractions,
initializeUserReputation,
} from "./userReputationStore.js";
// ---------------------------------------------------------------------------
// Types
@@ -110,18 +148,32 @@ export const analyzeSingleMediaImage = async (
// Layer 0: LRU
const lruCached = visionLruCache.get(cacheKey);
if (lruCached) {
if (lruCached && !isNoImageSeenText(lruCached)) {
log.debug({ cacheKey }, "Vision LRU cache HIT (in-memory)");
return `[Media analysis for message ${messageId}] ${image.sourceLabel}: ${lruCached}`;
}
if (lruCached) {
// Poisoned entry ("I see no image") — drop it and re-analyze.
log.warn({ cacheKey }, "Vision LRU cache HIT was no-image-seen — dropping");
visionLruCache.delete(cacheKey);
}
// Layer 1: DB
const cached = await getCachedMediaAnalysis(cacheKey);
if (cached) {
if (cached && !isNoImageSeenText(cached)) {
visionLruCache.set(cacheKey, cached);
log.debug({ cacheKey }, "Media analysis cache HIT (DB → LRU)");
return `[Media analysis for message ${messageId}] ${image.sourceLabel}: ${cached}`;
}
if (cached) {
// Poisoned DB entry — purge it so later messages re-analyze.
log.warn(
{ cacheKey },
"Media analysis cache HIT was no-image-seen — purging",
);
await deleteCachedMediaAnalysis(cacheKey).catch(() => {});
visionLruCache.delete(cacheKey);
}
// In-flight dedupe
const existing = inFlightVisionCalls.get(cacheKey);
@@ -164,7 +216,7 @@ export const analyzeSingleMediaImage = async (
phash = await computeImagePhash(imgBuffer);
if (phash) {
const phashCached = await getCachedMediaByPhash(phash);
if (phashCached) {
if (phashCached && !isNoImageSeenText(phashCached)) {
visionLruCache.set(cacheKey, phashCached);
await upsertCachedMediaAnalysis(
cacheKey,
@@ -174,6 +226,12 @@ export const analyzeSingleMediaImage = async (
).catch(() => {});
return phashCached;
}
if (phashCached) {
log.warn(
{ phash, cacheKey },
"phash cache HIT was no-image-seen — ignoring",
);
}
}
}
} catch {
@@ -186,7 +244,7 @@ export const analyzeSingleMediaImage = async (
for (let attempt = 0; attempt < 3; attempt++) {
try {
const content = await llmVision(promptText, image.image_url);
if (content) {
if (content && !isNoImageSeenText(content)) {
await upsertCachedMediaAnalysis(
cacheKey,
content,
@@ -204,7 +262,17 @@ export const analyzeSingleMediaImage = async (
}
return content;
}
log.warn({ messageId }, "Vision API null response");
if (content) {
// Model claims it saw no image — same as a null response: NOT a
// valid analysis, and caching it would poison the key for every
// re-analysis of the same image (phash TTL is 7 days).
log.warn(
{ messageId, cacheKey },
"Vision returned no-image-seen text — not caching",
);
} else {
log.warn({ messageId }, "Vision API null response");
}
break;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
@@ -231,6 +299,7 @@ export const analyzeSingleMediaImage = async (
"Vision failed after 3 attempts",
);
await deleteCachedMediaAnalysis(cacheKey).catch(() => {});
visionLruCache.delete(cacheKey);
return FAILED_ANALYSIS_PREFIX;
})();
@@ -366,7 +435,37 @@ export async function prepareMediaMessage(
const rep = await initializeUserReputation(target.user_id, target.guild_id);
const profile = await getUserProfile(target.user_id);
const refXml = await buildReferenceXml(target);
// Profile is emitted ONCE per batch in a <user_profiles> map (see
// mediaBatchProcessor); here we only reference it to avoid repeating the
// full summary on every message of the same user.
const profileRef = profile?.profile_summary?.trim()
? buildUserProfileRef(target.user_id)
: "";
const messageBlock = `<message id="${escapeXml(target.id)}" user="${escapeXml(target.username)}">\n <user_reputation trust_score="${rep.trust_score}" />${profile ? `\n <user_profile>${sanitizeAiContent(profile.profile_summary)}</user_profile>` : ""}${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(content)}</content>${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}${searxngXml}\n</message>`;
// Rich reputation — same shape as the text path: attrs + optional
// <user_history> with the last flagged messages for repeat offenders.
const repAttrs = formatReputationAttrs(rep);
let repXml = `<user_reputation ${repAttrs}/>`;
if (rep.total_infractions > 0) {
try {
const history = await getUserRecentInfractions(target.user_id, 2);
const historyXml = buildUserHistoryXml(
history.map((h) => ({
content: h.content ?? "",
severity: h.severity,
created_at: h.created_at,
})),
);
if (historyXml) {
repXml = `<user_reputation ${repAttrs}>\n${historyXml}\n</user_reputation>`;
}
} catch {
// history is a bonus — fall back to attrs-only reputation
}
}
const isBot = resolveIsBot(target);
const isEdited = resolveIsEdited(target);
const messageBlock = `<message id="${escapeXml(target.id)}" user="${escapeXml(resolveDisplayName(target))}" time="${new Date(target.created_at).toISOString()}"${isBot ? ` bot="true"` : ""}${isEdited ? ` edited="true"` : ""}>\n ${repXml}${profileRef ? `\n ${profileRef}` : ""}${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(truncateForAi(content))}</content>${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}${searxngXml}\n</message>`;
return { targetId, messageBlock };
}
@@ -16,6 +16,7 @@ import {
createHandlerRegistry,
} from "./handler-registry.js";
import { MediaHandler } from "./media.handler.js";
import { wireMediaStatusWriter } from "./mediaStatusSink.js";
import { ModerationHandler } from "./moderation.handler.js";
import { VoiceHandler } from "./voice.handler.js";
@@ -85,9 +86,16 @@ export class CommandHandler {
this.mediaHandler = new MediaHandler(client, () =>
voiceController.getStatus(),
);
// Give media handler access to disconnect/reconnect voice around screen
// share (GoLive needs its own WebRTC connection).
this.mediaHandler.setVoiceController(() => voiceController);
this.guildHandler = new GuildHandler(client);
this.moderationHandler = new ModerationHandler(client);
// Wire the media status sink so MediaHandler can persist status on
// queue advances that happen outside a command (natural track end).
wireMediaStatusWriter(this.redisPub);
// Build the command registry
this.registry = createHandlerRegistry(
this.voiceHandler,
@@ -1,6 +1,7 @@
import {
COMMAND_GUILDS_LIST,
COMMAND_GUILDS_TEXT_CHANNELS,
COMMAND_MEDIA_LOOP,
COMMAND_MEDIA_QUEUE,
COMMAND_MEDIA_SKIP,
COMMAND_MEDIA_STOP,
@@ -69,6 +70,7 @@ export function createHandlerRegistry(
registry.set(COMMAND_MEDIA_VOLUME, (cmd) =>
mediaHandler.handleMediaVolume(cmd),
);
registry.set(COMMAND_MEDIA_LOOP, (cmd) => mediaHandler.handleMediaLoop(cmd));
// Guild commands
registry.set(COMMAND_GUILDS_LIST, (cmd) =>
@@ -6,6 +6,7 @@ import { createChildLogger } from "../../shared/logger/index.js";
import {
extractMediaInfo,
resolveMediaUrl,
transcodeToHighQualityOgg,
} from "../voice-recording/mediaSource.js";
import type {
MediaMode,
@@ -16,6 +17,7 @@ import {
ScreenShareController,
type ScreenShareVoiceStatus,
} from "../voice-recording/screenShareController.js";
import { setMediaStatusKey } from "./mediaStatusSink.js";
// ---------------------------------------------------------------------------
// Types
@@ -34,6 +36,7 @@ export interface MediaStatusPayload {
playing: boolean;
activeMode: MediaMode | null;
musicVolume: number;
loop: boolean;
current: MediaStatusItem | null;
queue: MediaStatusItem[];
}
@@ -44,6 +47,9 @@ export interface MediaStatusPayload {
const mediaQueue: MediaQueueItem[] = [];
let currentTrackItem: MediaQueueItem | null = null;
let loopEnabled = false;
/** Active ffmpeg transcode (killed on stop/skip). */
let currentTranscodeCleanup: (() => void) | null = null;
// ---------------------------------------------------------------------------
// Helpers
@@ -66,6 +72,7 @@ function buildStatusPayload(): MediaStatusPayload {
currentTrackItem !== null && discordPlayer.getStatus() === "playing",
activeMode: currentTrackItem?.mode ?? null,
musicVolume: discordPlayer.getMusicVolume(),
loop: loopEnabled,
current: currentTrackItem ? mapToStatusItem(currentTrackItem) : null,
queue: mediaQueue.map(mapToStatusItem),
};
@@ -88,20 +95,63 @@ export class MediaHandler {
activeChannelId: null,
}),
) {
// Register auto-advance on natural track end
// Register auto-advance on natural track end. advanceQueue mutates the
// module-level currentTrackItem/queue, so we must re-publish the status
// key afterward: otherwise the backend's Redis `media:status` cache (and
// the frontend's 10s polling) stays stuck on the finished track.
discordPlayer.onIdle(() => {
this.advanceQueue().catch((err) => {
this.logger.error({ err }, "Auto-advance failed");
});
this.advanceQueue()
.then(() => this.publishStatus())
.catch((err) => {
this.logger.error({ err }, "Auto-advance failed");
});
});
}
/**
* Give MediaHandler access to the VoiceController so screen-share can
* disconnect/reconnect the @discordjs audio connection around a GoLive
* stream (Discord allows only one voice session per user).
*/
private voiceControllerAccessor:
| (() => {
disconnectGuild(guildId: string): Promise<void>;
connect(guildId: string, channelId: string): Promise<unknown>;
getStatus(): {
activeGuildId: string | null;
activeChannelId: string | null;
};
})
| null = null;
setVoiceController(accessor: typeof this.voiceControllerAccessor): void {
this.voiceControllerAccessor = accessor;
}
/**
* Persist the latest media state to Redis so the backend/frontend see queue
* advances that happen outside a command (natural track end, screen-share
* done). CommandHandler owns the Redis status-key writes for command-triggered
* changes; this covers the side-effect-only path.
*/
private publishStatus(): void {
try {
setMediaStatusKey(this.getCurrentMediaStatus());
} catch (err: unknown) {
this.logger.warn(
{ error: err instanceof Error ? err.message : String(err) },
"Failed to publish media status on track end",
);
}
}
getCurrentMediaStatus(): MediaStatusPayload {
return buildStatusPayload();
}
async handleMediaQueue(cmd: CommandMessage): Promise<CommandReply<unknown>> {
const url = String(cmd.payload.url ?? "").trim();
// Accept both `url` (canonical) and `source` (legacy FE) for resilience.
const url = String(cmd.payload.url ?? cmd.payload.source ?? "").trim();
const mode: MediaMode = cmd.payload.mode === "screen" ? "screen" : "music";
const requestedBy = String(cmd.payload.requestedBy ?? "unknown");
@@ -140,6 +190,23 @@ export class MediaHandler {
this.screenController = new ScreenShareController(
this.client,
this.getVoiceStatus,
// releaseVoice — disconnect the @discordjs/voice connection so the
// dank074 Streamer can take over (Discord: one voice session/user).
async (status) => {
const vc = this.voiceControllerAccessor?.();
const guildId = status.activeGuildId ?? null;
if (vc && guildId) {
await vc.disconnectGuild(guildId);
}
},
// restoreVoice — reconnect the @discordjs audio connection after
// the stream ends so mic/listen keep working.
async (status) => {
const vc = this.voiceControllerAccessor?.();
if (vc && status.activeGuildId && status.activeChannelId) {
await vc.connect(status.activeGuildId, status.activeChannelId);
}
},
);
}
const playback = await this.screenController.start(url);
@@ -154,12 +221,19 @@ export class MediaHandler {
addedAt: Date.now(),
status: "playing",
};
playback.done.finally(() => {
this.screenPlayback = null;
if (currentTrackItem?.mode === "screen") {
currentTrackItem = null;
}
});
playback.done
.catch((err) => {
this.logger.error(
{ error: err instanceof Error ? err.message : String(err) },
"Screen playback promise rejected",
);
})
.finally(() => {
this.screenPlayback = null;
if (currentTrackItem?.mode === "screen") {
currentTrackItem = null;
}
});
this.logger.info({ url }, "Screen share started");
return { id: cmd.id, success: true, data: buildStatusPayload() };
} catch (err) {
@@ -268,6 +342,17 @@ export class MediaHandler {
};
}
async handleMediaLoop(cmd: CommandMessage): Promise<CommandReply<unknown>> {
loopEnabled = Boolean(cmd.payload.loop);
this.logger.info({ loop: loopEnabled }, "Media loop toggled");
this.publishStatus();
return {
id: cmd.id,
success: true,
data: buildStatusPayload(),
};
}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
@@ -282,6 +367,8 @@ export class MediaHandler {
discordPlayer.stop("music");
currentTrackItem = null;
}
currentTranscodeCleanup?.();
currentTranscodeCleanup = null;
const next = mediaQueue.shift();
if (!next) {
@@ -304,11 +391,20 @@ export class MediaHandler {
next.title = resolution.title ?? next.title;
next.duration = resolution.duration ?? next.duration;
discordPlayer.playStream(resolution.stream, "music", {
inputType: StreamType.Arbitrary,
inlineVolume: true,
volume: discordPlayer.getMusicVolume(),
// Music playback: transcode once to high-quality OggOpus (48kHz stereo,
// 192kbps) with volume baked into the encode. This avoids the double
// lossy encode that inlineVolume would cause and gives Discord the
// cleanest possible stream. Screen share bypasses this entirely.
const transcoded = transcodeToHighQualityOgg(
resolution.stream,
discordPlayer.getMusicVolume(),
);
discordPlayer.playStream(transcoded.stream, "music", {
inputType: StreamType.OggOpus,
inlineVolume: false,
});
currentTranscodeCleanup = transcoded.cleanup;
this.logger.info({ title: next.title }, "Playback started");
} catch (err) {
@@ -335,10 +431,21 @@ export class MediaHandler {
/**
* Called by the idle callback delegates to playNext since the player is
* already idle and currentTrackItem is already null.
* already idle and currentTrackItem is already null. When loop mode is
* enabled and a music track ended naturally, requeue it so it plays again.
*/
private async advanceQueue(): Promise<void> {
const finished = currentTrackItem;
currentTrackItem = null;
if (loopEnabled && finished && finished.mode === "music") {
mediaQueue.unshift(finished);
this.logger.info(
{ title: finished.title },
"Loop enabled — replaying finished track",
);
}
await this.playNext();
}
}
@@ -0,0 +1,41 @@
import type Redis from "ioredis";
import { createChildLogger } from "@/shared/logger/index";
import { MEDIA_STATUS_KEY } from "../../shared/redis-channels.js";
/**
* Shared sink for writing the media status Redis key.
*
* CommandHandler owns the publisher + status writes for command-triggered
* changes (`publishMediaStatus`). MediaHandler needs to also persist status
* when the queue advances *outside* a command (natural track end / screen-share
* done), so we expose the real publisher here and let CommandHandler wire it
* once at startup.
*/
const logger = createChildLogger("media-status-sink");
let _setMediaStatusKey: ((payload: unknown) => void) | null = null;
export function setMediaStatusWriter(writer: (payload: unknown) => void): void {
_setMediaStatusKey = writer;
}
export function setMediaStatusKey(payload: unknown): void {
if (!_setMediaStatusKey) {
logger.warn("Media status writer not wired — skipping status publish");
return;
}
_setMediaStatusKey(payload);
}
export { MEDIA_STATUS_KEY };
export function wireMediaStatusWriter(redisPub: Redis): void {
setMediaStatusWriter((payload) => {
redisPub
.set(MEDIA_STATUS_KEY, JSON.stringify(payload))
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn({ error: msg }, "Failed to update media status key");
});
});
}
@@ -128,6 +128,9 @@ export class VoiceHandler {
id: c.id,
name: c.name,
type: "voice" as const,
// selfbot exposes joinable (permission check) — let FE filter
// channels the account actually may join.
joinable: (c as { joinable?: boolean }).joinable ?? true,
}));
return { id: cmd.id, success: true, data: voiceChannels };
@@ -343,6 +343,20 @@ export function registerMessageCapture(client: Client): void {
id: newMessage.id,
edited_content: getDisplayContent(newMessage as Message),
edited_at: editedAt,
type: "edited",
// Match the DB update (updateMessageAsEdited resets analysis to
// pending) so the live UI reflects the same state instead of
// lingering on the stale pre-edit verdict.
ai_status: "pending",
ai_moderation_flags: null,
ai_moderation_score: null,
ai_analysis: null,
ai_categories: null,
ai_severity: null,
ai_confidence: null,
ai_recommended_action: null,
ai_analyzed_at: null,
ai_error: null,
});
}
} else if (newMessage.author) {
@@ -9,6 +9,10 @@ export interface MessageLocation {
threadId: string | null;
threadName: string | null;
channelName: string | null;
/** Channel topic (resmi/deskripsi channel) strong context for judging
* whether a message fits the channel's purpose. Guarded: some channel
* types (threads on older API builds) expose no topic. */
topic?: string | null;
nsfw?: boolean;
nsfwLevel?: string | null;
ageRestricted?: boolean;
@@ -107,12 +111,17 @@ export function getMessageLocation(message: Message): MessageLocation {
nsfw?: boolean;
nsfwLevel?: string | null;
};
const topic =
"topic" in channel && typeof channel.topic === "string"
? channel.topic
: null;
if (!channel.isThread?.()) {
return {
channelId: message.channelId,
threadId: null,
threadName: null,
channelName: "name" in channel ? channel.name : null,
topic,
nsfw:
typeof safetyChannel.nsfw === "boolean"
? safetyChannel.nsfw
@@ -133,6 +142,7 @@ export function getMessageLocation(message: Message): MessageLocation {
threadId: channel.id,
threadName: channel.name,
channelName: channel.parent?.name ?? null,
topic,
nsfw:
typeof safetyChannel.nsfw === "boolean" ? safetyChannel.nsfw : undefined,
nsfwLevel:
@@ -33,6 +33,71 @@ export interface ResolveOptions {
quality?: string;
}
export interface TranscodeResult {
stream: Readable;
/** Kill the ffmpeg child (used on stop/skip). */
cleanup: () => void;
}
/**
* Re-encode a source stream to high-quality OggOpus (48kHz stereo, 192kbps).
*
* Discord voice downmixes whatever we feed it to the channel's bitrate, so the
* best we can do is hand it a clean 48kHz stereo Opus stream instead of the
* raw source (which may be mono, low-bitrate, or a non-Opus container). The
* volume is baked into the encode with `-af volume=` so the player does not
* need inlineVolume re-encoding (double lossy encode).
*/
export function transcodeToHighQualityOgg(
input: Readable,
volume: number,
): TranscodeResult {
const proc = spawn(
"ffmpeg",
[
"-hide_banner",
"-loglevel",
"error",
"-i",
"pipe:0",
"-vn",
"-ac",
"2",
"-ar",
"48000",
"-c:a",
"libopus",
"-b:a",
"192k",
"-af",
`volume=${volume}`,
"-f",
"ogg",
"pipe:1",
],
{ stdio: ["pipe", "pipe", "ignore"] },
);
input.pipe(proc.stdin);
activeProcesses.add(proc);
const cleanup = () => {
activeProcesses.delete(proc);
if (proc.exitCode === null) {
proc.kill("SIGKILL");
}
};
proc.once("exit", () => activeProcesses.delete(proc));
// If ffmpeg fails, surface the error to the consumer stream so the
// AudioPlayer's error handler can advance the queue.
const output = proc.stdout;
output.on("error", () => cleanup());
return { stream: output, cleanup };
}
// ---------------------------------------------------------------------------
// Internal state
// ---------------------------------------------------------------------------
@@ -62,90 +127,67 @@ function parseSeconds(value: string): number {
*/
const MAX_HEADER_BUFFER = 65536; // 64KB safety limit for metadata headers
function readFirstTwoLines(
stdout: Readable,
/**
* Read the title + duration header lines from yt-dlp's STDERR.
*
* When yt-dlp streams media to stdout (`-o -`) it redirects its `--print`
* output to STDERR so the media stream on stdout stays clean. The first two
* meaningful lines on stderr are then the title and (before_dl) duration.
*
* Blank lines and `[...]` info prefixes are skipped. An `ERROR:` line is
* reported via `onError` so a failing download surfaces as a resolution error
* instead of a silent empty stream.
*/
function readStderrHeader(
stderr: Readable,
onError: (message: string) => void,
maxBufferSize: number = MAX_HEADER_BUFFER,
): Promise<{
title: string;
duration: number;
remaining: Readable;
}> {
return new Promise((resolve, reject) => {
const passThrough = new PassThrough();
let buffer = Buffer.alloc(0);
): Promise<{ title: string; duration: number }> {
return new Promise((resolve) => {
let buffer = "";
let title = "";
let stage: "title" | "duration" | "done" = "title";
let duration = 0;
let done = false;
function cleanup() {
stdout.removeListener("data", onData);
stdout.removeListener("error", onError);
stdout.removeListener("end", onEnd);
}
const finish = () => {
if (done) return;
done = true;
stderr.removeListener("data", onData);
resolve({ title, duration });
};
function onData(chunk: Buffer) {
if (stage === "done") return;
buffer = Buffer.concat([buffer, chunk]);
const onData = (chunk: Buffer) => {
if (done) return;
buffer += chunk.toString("utf8");
if (buffer.length > maxBufferSize) {
cleanup();
reject(new Error(`Metadata header exceeded ${maxBufferSize} bytes`));
finish();
return;
}
processBuffer();
}
while (!done) {
const nl = buffer.indexOf("\n");
if (nl === -1) break; // need more data
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
function processBuffer() {
while (buffer.length > 0 && stage !== "done") {
const nl = buffer.indexOf(0x0a); // '\n' byte
if (nl === -1) break; // Need more data
const line = buffer.subarray(0, nl).toString("utf8").trim();
buffer = buffer.subarray(nl + 1);
if (stage === "title") {
if (line.length === 0) continue; // blank line
if (line.startsWith("[")) continue; // "[info] ..." — not a header
if (line.startsWith("ERROR")) {
onError(line);
finish();
return;
}
if (!title) {
title = line;
stage = "duration";
} else if (stage === "duration") {
const duration = parseSeconds(line);
stage = "done";
cleanup();
// Write any buffered data that follows the second newline
if (buffer.length > 0) {
passThrough.write(buffer);
}
// Pipe the remainder of stdout into the pass-through
stdout.pipe(passThrough);
resolve({ title, duration, remaining: passThrough });
} else {
duration = parseSeconds(line);
finish();
return;
}
}
}
};
function onError(err: Error) {
if (stage !== "done") {
cleanup();
reject(err);
}
}
function onEnd() {
if (stage !== "done") {
cleanup();
reject(
new Error(
`yt-dlp stdout ended before metadata could be read. ` +
`Stage: ${stage}, partial title: "${title}"`,
),
);
}
}
stdout.on("data", onData);
stdout.on("error", onError);
stdout.on("end", onEnd);
stderr.on("data", onData);
stderr.on("end", finish);
});
}
@@ -163,8 +205,10 @@ function buildNotInstalledError(): Error {
/**
* Resolve a media URL (YouTube, Spotify, etc.) to a playable audio stream.
*
* Spawns `yt-dlp`, extracts the title and duration from the first two stdout
* lines, then pipes the remaining raw audio data into a Readable stream.
* Spawns `yt-dlp` with `-o -` so the raw audio bytes stream on stdout. Since
* stdout is the media sink, yt-dlp emits its `--print before_dl:title` /
* `before_dl:duration` header lines on STDERR the title + duration are read
* from there and the stdout media stream is returned untouched.
*
* The returned stream uses `StreamType.Arbitrary` suitable for
* `DiscordPlayer.playStream()` with `inputType: StreamType.Arbitrary`.
@@ -181,10 +225,10 @@ export function resolveMediaUrl(
const args = [
"-f",
format,
"--audio-format",
"best",
"-o",
"-",
"--no-progress",
"--no-warnings",
"--print",
"before_dl:title",
"--print",
@@ -195,11 +239,17 @@ export function resolveMediaUrl(
logger.info({ url }, "Spawning yt-dlp for media resolution");
const proc = spawn("yt-dlp", args, {
stdio: ["pipe", "pipe", "pipe"],
stdio: ["ignore", "pipe", "pipe"],
});
activeProcesses.add(proc);
// With `-o -` yt-dlp streams the raw audio on stdout and moves its
// `--print` headers to stderr — pipe stdout immediately so the child
// never blocks on a full pipe while we wait for the headers on stderr.
const mediaStream = new PassThrough();
proc.stdout.pipe(mediaStream);
let stderrBuf = "";
let resolved = false;
@@ -209,9 +259,23 @@ export function resolveMediaUrl(
if (resolved) return;
resolved = true;
activeProcesses.delete(proc);
mediaStream.destroy();
reject(err);
};
const resolveOnce = (info: MediaInfo) => {
if (resolved) return;
resolved = true;
activeProcesses.delete(proc);
resolve({
stream: mediaStream,
type: StreamType.Arbitrary,
title: info.title,
duration: info.duration,
info,
});
};
// -- spawn error (ENOENT etc.) ----------------------------------------
proc.on("error", (err: NodeJS.ErrnoException) => {
@@ -222,31 +286,25 @@ export function resolveMediaUrl(
}
});
// -- stderr (capture for diagnostics, capped at 4KB) ----------------------------------
// -- stderr: title + duration headers ---------------------------------
// Capture raw stderr too, for the exit-diagnostics in the close handler.
const _MAX_STDERR = 4096;
if (proc.stderr) {
proc.stderr.on("data", (chunk: Buffer) => {
stderrBuf += chunk.toString("utf8");
if (stderrBuf.length < _MAX_STDERR) {
stderrBuf += chunk
.toString("utf8")
.slice(0, _MAX_STDERR - stderrBuf.length);
}
});
}
// -- stdout: parse header, then stream audio ---------------------------
readFirstTwoLines(proc.stdout)
.then(({ title, duration, remaining }) => {
if (resolved) return;
resolved = true;
activeProcesses.delete(proc);
const info: MediaInfo = { title, duration };
resolve({
stream: remaining,
type: StreamType.Arbitrary,
title,
duration,
info,
});
readStderrHeader(proc.stderr, (message) => {
failOnce(new Error(message));
})
.then(({ title, duration }) => {
resolveOnce({ title: title || url, duration });
})
.catch((err: Error) => {
failOnce(err);
@@ -264,6 +322,10 @@ export function resolveMediaUrl(
failOnce(new Error(`yt-dlp exited with code ${code}${detail}`));
} else if (signal) {
failOnce(new Error(`yt-dlp was killed by signal ${signal}`));
} else {
// Exited cleanly but the header lines never surfaced (e.g. a direct
// file URL with no duration) — keep the media stream alive anyway.
resolveOnce({ title: url, duration: 0 });
}
});
@@ -283,24 +345,42 @@ export function resolveMediaUrl(
}
/**
* Resolve a media URL to a directly playable video URL (for screen share /
* GoLive streaming). Uses yt-dlp `--get-url` with bestvideo+bestaudio.
* Resolve a media URL to a single playable input stream for screen share /
* GoLive streaming.
*
* @throws If yt-dlp is not installed or the process exits with a non-zero code.
* yt-dlp `--get-url` with `bestvideo+bestaudio` prints the video-only and
* audio-only URLs on SEPARATE lines. The old code took only the first line
* (video-only) ffmpeg had no audio track GoLive stream had no sound.
*
* This returns a single input that `prepareStream` (which accepts only ONE
* ffmpeg input) can consume while STILL including audio:
* - If yt-dlp offers a merged progressive URL (one URL, video+audio) it is
* returned directly.
* - Otherwise the video-only + audio-only DASH URLs are fetched in the SAME
* yt-dlp run (signature URLs expire quickly) and merged locally by an
* ffmpeg process into a single NUT stream, which is streamed to the
* consumer over a Readable. NUT over stdin auto-probes cleanly (verified:
* av1+opus merge H264+opus transcode).
*
* @returns a direct video URL (string) or a Readable of the merged NUT stream.
*/
export function getDirectVideoUrl(url: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
export function getDirectScreenInput(url: string): Promise<string | Readable> {
return new Promise<string | Readable>((resolve, reject) => {
const args = [
url,
"--get-url",
"--dump-single-json",
"--format",
"bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best",
"--no-playlist",
"--no-warnings",
"--quiet",
// NOTE: deliberately NOT --no-simulate. Simulate mode still resolves the
// requested format URLs into the JSON (requested_formats[].url), and it
// avoids yt-dlp writing .part files into the process CWD — which is the
// read-only Nix store dir for the deployed gateway (EACCES).
];
logger.info({ url }, "Spawning yt-dlp for direct video URL");
logger.info({ url }, "Spawning yt-dlp for screen share input resolution");
const proc = spawn("yt-dlp", args, {
stdio: ["pipe", "pipe", "pipe"],
@@ -311,7 +391,7 @@ export function getDirectVideoUrl(url: string): Promise<string> {
let stdoutBuf = "";
let stderrBuf = "";
const MAX_STDERR = 4096;
const MAX_STDOUT = 1_048_576;
const MAX_STDOUT = 8 * 1024 * 1024; // JSON metadata + requested format URLs
if (proc.stdout) {
proc.stdout.on("data", (chunk: Buffer) => {
@@ -349,22 +429,160 @@ export function getDirectVideoUrl(url: string): Promise<string> {
const detail = stderrBuf.trim() ? `: ${stderrBuf.trim()}` : "";
reject(
new Error(
`yt-dlp direct URL resolution exited with code ${code}${detail}`,
`yt-dlp screen input resolution exited with code ${code}${detail}`,
),
);
return;
}
const firstLine = stdoutBuf.trim().split("\n")[0];
if (!firstLine) {
reject(new Error("yt-dlp returned no direct video URL"));
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(stdoutBuf.trim()) as Record<string, unknown>;
} catch (parseErr) {
reject(
new Error(
`Failed to parse yt-dlp JSON for screen input: ${(parseErr as Error).message}`,
),
);
return;
}
resolve(firstLine);
resolveScreenInput(parsed).then(resolve, (err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
reject(
new Error(`Failed to build screen input for "${url}": ${message}`),
);
});
});
});
}
/**
* From a parsed yt-dlp JSON info dict, decide how to feed a single ffmpeg
* input with both video and audio.
*/
async function resolveScreenInput(
info: Record<string, unknown>,
): Promise<string | Readable> {
const requested = info.requested_formats as
| Array<Record<string, unknown>>
| undefined;
// Merged/progressive single URL (video+audio in one). Common when yt-dlp
// selects a single format (e.g. format 18 progressive mp4) or when a direct
// muxed URL is available.
const singleUrl = info.url as string | undefined;
const singleHasAudio =
info.acodec !== "none" &&
typeof info.acodec === "string" &&
info.acodec.length > 0;
if (typeof singleUrl === "string" && singleUrl && singleHasAudio) {
logger.debug("Screen share uses merged progressive single URL");
return singleUrl;
}
// Separate video-only + audio-only DASH formats → merge locally via ffmpeg.
if (Array.isArray(requested) && requested.length >= 2) {
const video = requested.find(
(rf) => rf.vcodec && String(rf.vcodec) !== "none",
);
const audio = requested.find(
(rf) => rf.acodec && String(rf.acodec) !== "none",
);
const videoUrl = video?.url as string | undefined;
const audioUrl = audio?.url as string | undefined;
if (
typeof videoUrl === "string" &&
videoUrl.length > 0 &&
typeof audioUrl === "string" &&
audioUrl.length > 0
) {
return mergeScreenStreams(videoUrl, audioUrl);
}
}
throw new Error(
"yt-dlp returned neither a merged progressive URL nor a video+audio format pair",
);
}
/**
* Merge a video-only URL and an audio-only URL into a single NUT stream using
* a child ffmpeg process. Both URLs come from the same yt-dlp run, so they
* share the same signature/expiry and are consumed immediately.
*/
function mergeScreenStreams(videoUrl: string, audioUrl: string): Readable {
logger.info("Merging video+audio DASH streams into a single NUT input");
const ffmpeg = spawn(
"ffmpeg",
[
"-hide_banner",
"-loglevel",
"error",
"-reconnect",
"1",
"-reconnect_streamed",
"1",
"-reconnect_delay_max",
"5",
"-i",
videoUrl,
"-i",
audioUrl,
"-map",
"0:v:0",
"-map",
"1:a:0",
"-c:v",
"copy",
"-c:a",
"copy",
"-f",
"nut",
"pipe:1",
],
{ stdio: ["ignore", "pipe", "pipe"] },
);
// Track so cleanup() can terminate the merge during graceful shutdown.
activeProcesses.add(ffmpeg);
ffmpeg.once("exit", () => {
activeProcesses.delete(ffmpeg);
});
// Prevent the ffmpeg stderr from filling the pipe buffer / leaking.
let stderrBuf = "";
const MAX_STDERR = 4096;
ffmpeg.stderr?.on("data", (chunk: Buffer) => {
if (stderrBuf.length < MAX_STDERR) {
stderrBuf += chunk.toString("utf8");
}
});
ffmpeg.on("error", (err) => {
const msg =
err.message === "spawn ffmpeg ENOENT"
? "FFmpeg not found! Install ffmpeg in the container."
: err.message;
logger.error({ error: msg }, "Screen stream merge ffmpeg error");
});
ffmpeg.on("exit", (code) => {
const stderr = stderrBuf.trim();
logger.warn(
{ code, stderr: stderr.slice(-500) || undefined },
"Screen stream merge ffmpeg exited",
);
});
const stream = ffmpeg.stdout;
stream.setMaxListeners(32);
return stream;
}
/**
* Extract metadata (title, duration, thumbnail) from a media URL
* without downloading the audio stream.
@@ -452,7 +670,7 @@ export async function extractMediaInfo(url: string): Promise<MediaInfo> {
}
/**
* Kill all active yt-dlp child processes.
* Kill all active yt-dlp / screen-share merge ffmpeg child processes.
*
* Call during graceful shutdown to ensure no orphan processes remain.
*/
@@ -32,6 +32,7 @@ export interface MediaState {
playing: boolean;
activeMode: MediaMode | null;
musicVolume: number;
loop: boolean;
current: MediaQueueItem | null;
queue: MediaQueueItem[];
}
@@ -55,6 +56,12 @@ export interface ScreenSharePlayback {
stop(): void;
}
export interface ScreenShareVoiceStatus {
connected: boolean;
activeGuildId: string | null;
activeChannelId: string | null;
}
export interface ScreenShareController {
isActive(): boolean;
start(source: string): Promise<ScreenSharePlayback>;
@@ -18,7 +18,7 @@ export class DiscordPlayer {
private connection: VoiceConnection | null = null;
private owner: DiscordPlayerOwner = "none";
private resource: AudioResource | null = null;
private musicVolume = 1;
private musicVolume = 0.3;
private idleCallback: (() => void) | null = null;
/** Set before manual stop() calls to distinguish from natural track end. */
private manualStop = false;
@@ -1,13 +1,13 @@
import type { Client } from "discord.js-selfbot-v13";
import { createChildLogger } from "@/shared/logger/index";
import {
Encoders,
normalizeVideoCodec,
playStream,
prepareStream,
Streamer,
Utils,
} from "@dank074/discord-video-stream";
import type { Client } from "discord.js-selfbot-v13";
import { createChildLogger } from "@/shared/logger/index";
import { getDirectVideoUrl } from "./mediaSource.js";
} from "../../goLive/index.js";
import { getDirectScreenInput } from "./mediaSource.js";
import type { ScreenSharePlayback } from "./mediaTypes.js";
import { discordPlayer } from "./player.js";
@@ -38,6 +38,16 @@ export class ScreenShareController {
constructor(
private readonly client: Client,
private readonly getVoiceStatus: () => ScreenShareVoiceStatus,
/** Disconnect the @discordjs/voice connection so the Streamer can take
* over the voice channel (Discord allows only ONE voice session per user
* two connections collide and the Streamer never gets VOICE_SERVER_UPDATE). */
private readonly releaseVoice: (
status: ScreenShareVoiceStatus,
) => void | Promise<void>,
/** Reconnect the @discordjs/voice connection after the stream ends. */
private readonly restoreVoice: (
status: ScreenShareVoiceStatus,
) => void | Promise<void>,
) {}
isActive(): boolean {
@@ -55,12 +65,40 @@ export class ScreenShareController {
}
try {
const directUrl = await getDirectVideoUrl(source);
const input = await getDirectScreenInput(source);
if (!this.streamer) {
this.streamer = new Streamer(this.client);
}
const { command, output } = prepareStream(directUrl, {
const guild = this.client.guilds.cache.get(status.activeGuildId);
const channel = guild?.channels.cache.get(status.activeChannelId);
if (
!channel ||
(channel.type !== "GUILD_VOICE" && channel.type !== "GUILD_STAGE_VOICE")
) {
throw new Error(
`Voice channel ${status.activeChannelId} not found for screen share`,
);
}
// Free the @discordjs/voice connection BEFORE the Streamer joins, so
// the user has only one voice session (Discord requirement).
await this.releaseVoice(status);
await Promise.race([
this.streamer.joinVoiceChannel(channel),
new Promise<never>((_, reject) =>
setTimeout(
() =>
reject(
new Error("Timed out joining voice channel for screen share"),
),
15000,
),
),
]);
const prepared = prepareStream(input, {
encoder: Encoders.software({ x264: { preset: "superfast" } }),
width: 1280,
height: 720,
@@ -68,21 +106,75 @@ export class ScreenShareController {
bitrateVideo: 2500,
bitrateVideoMax: 4000,
includeAudio: true,
videoCodec: Utils.normalizeVideoCodec("H264"),
videoCodec: normalizeVideoCodec("H264"),
});
const { command } = prepared;
let stopped = false;
const done = playStream(output, this.streamer, {
// Restore the @discordjs/voice connection after the stream ends (both
// natural end and failure), so the user can keep using audio/mic.
const restoreAfter = () => {
if (!stopped) {
stopped = true;
try {
command.kill("SIGTERM");
} catch {
/* already dead */
}
}
try {
this.streamer?.voiceConnection?.stop();
} catch {
/* already gone */
}
if (this.restoreVoice) {
// Best-effort restore after a short delay. Discord often needs the
// Streamer's session fully torn down before @discordjs/voice can
// re-join; if that races, the reconnect times out — the FE shows
// disconnected and the user just clicks Connect again. This is an
// accepted UX tradeoff for GoLive (single voice session per user).
setTimeout(() => {
Promise.resolve(this.restoreVoice(status)).catch((err) => {
this.logger.warn(
{ error: err instanceof Error ? err.message : String(err) },
"Failed to restore voice connection after screen share (user can reconnect manually)",
);
});
}, 5000);
}
};
const done = playStream(prepared, this.streamer, {
type: "go-live",
}).finally(() => {
this.active = null;
});
})
.catch((err: unknown) => {
// Never let a stream failure become an unhandledRejection — that
// crashed the whole gateway. Log + surface via the done promise.
const message = err instanceof Error ? err.message : String(err);
this.logger.error(
{ error: message, source },
"Screen stream failed during playback",
);
})
.finally(() => {
restoreAfter();
this.active = null;
});
this.active = {
done,
stop: () => {
if (stopped) return;
stopped = true;
command.kill("SIGTERM");
try {
command.kill("SIGTERM");
} catch {
/* already dead */
}
// Leave the voice channel the Streamer joined (its own connection).
try {
this.streamer?.voiceConnection?.stop();
} catch {
/* already gone */
}
this.active = null;
},
};
@@ -189,6 +189,17 @@ export const configSchema = z
.int()
.positive()
.default(20),
// Recency gates for conversation context. A silence longer than GAP_MS
// between context messages = the conversation restarted (older messages
// dropped); MAX_AGE_MS caps how far back context is considered relevant.
AI_ANALYSIS_CONTEXT_GAP_MS: z.coerce
.number()
.positive()
.default(12 * 60 * 1000),
AI_ANALYSIS_CONTEXT_MAX_AGE_MS: z.coerce
.number()
.positive()
.default(45 * 60 * 1000),
AI_ANALYSIS_PROCESSING_TIMEOUT_MS: z.coerce
.number()
.positive()
@@ -242,6 +253,19 @@ export const configSchema = z
.default(false),
AUTO_DELETE_LOG_CHANNEL_ID: z.string().default(""),
// ── Nickname Reset (offensive_username enforcement) ────────────────
// When the only violation is the member's server nickname, reset the
// nickname to the default username instead of deleting the message.
AUTO_NICKNAME_RESET_ENABLED: z
.string()
.optional()
.transform((v) => v === "true")
.default(true),
AUTO_NICKNAME_RESET_COOLDOWN_MS: z.coerce
.number()
.positive()
.default(10 * 60 * 1000),
// ── Retention ───────────────────────────────────────────────────────
RETENTION_MESSAGES_DAYS: z.coerce.number().int().min(0).default(0),
RETENTION_ATTACHMENTS_DAYS: z.coerce.number().int().min(0).default(0),
@@ -615,6 +615,7 @@ export const pgModerationActionsTable = pgTable(
"warn_user",
"kick_user",
"ban_user",
"reset_nickname",
],
}).notNull(),
reason: pgText("reason"),
@@ -198,7 +198,8 @@ export type ModerationActionType =
| "mute_user"
| "warn_user"
| "kick_user"
| "ban_user";
| "ban_user"
| "reset_nickname";
export interface ModerationAction {
id: string;
@@ -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";
@@ -0,0 +1,209 @@
// ═══════════════════════════════════════════════════════════════════════════
// Context enrichment builders — rich <user_reputation> attrs, <user_history>,
// <user_profiles> as_of, bot/edited detection (pure, no DB)
// ═══════════════════════════════════════════════════════════════════════════
import { describe, expect, it } from "vitest";
import {
buildUserHistoryXml,
buildUserProfilesBlock,
formatReputationAttrs,
resolveIsBot,
resolveIsEdited,
} from "../src/modules/ai-moderation/moderationBuilders.js";
import type { MessageRecord } from "../src/modules/message-capture/types.js";
const NOW = 1_800_000_000_000;
function msg(overrides: Partial<MessageRecord> = {}): MessageRecord {
return {
id: "m1",
guild_id: "g1",
channel_id: "c1",
thread_id: null,
user_id: "u1",
username: "user1",
avatar_url: null,
content: "hai",
edited_content: null,
created_at: NOW,
edited_at: null,
deleted_at: null,
type: "text",
is_reply: null,
is_forward: null,
is_crosspost: null,
reference_message_id: null,
reference_channel_id: null,
reference_guild_id: null,
metadata: null,
...overrides,
};
}
const DAY_MS = 24 * 60 * 60 * 1000;
describe("formatReputationAttrs — rich reputation signal", () => {
it("emits trust, infraction count and clean streak", () => {
const attrs = formatReputationAttrs({
trust_score: 62,
total_infractions: 3,
clean_message_streak: 45,
last_infraction_at: null,
});
expect(attrs).toContain('trust_score="62"');
expect(attrs).toContain('total_infractions="3"');
expect(attrs).toContain('clean_streak="45"');
});
it("derives last_offense_days_ago and marks repeat offenders (7-day window)", () => {
const attrs = formatReputationAttrs(
{
trust_score: 50,
total_infractions: 2,
clean_message_streak: 0,
last_infraction_at: NOW - 2 * DAY_MS,
},
NOW,
);
expect(attrs).toContain('last_offense_days_ago="2"');
expect(attrs).toContain('repeat_offender="true"');
});
it("does NOT mark repeat offender when the last offense is older than 7 days", () => {
const attrs = formatReputationAttrs(
{
trust_score: 50,
total_infractions: 2,
clean_message_streak: 10,
last_infraction_at: NOW - 30 * DAY_MS,
},
NOW,
);
expect(attrs).toContain('last_offense_days_ago="30"');
expect(attrs).not.toContain("repeat_offender");
});
it("omits offense-derived attrs when the user has no recorded infraction date", () => {
const attrs = formatReputationAttrs({
trust_score: 85,
total_infractions: 0,
clean_message_streak: 120,
last_infraction_at: null,
});
expect(attrs).not.toContain("last_offense_days_ago");
expect(attrs).not.toContain("repeat_offender");
});
it("clamps a future/skewed timestamp to days_ago=0", () => {
const attrs = formatReputationAttrs(
{
trust_score: 50,
total_infractions: 1,
clean_message_streak: 0,
last_infraction_at: NOW + 5 * DAY_MS,
},
NOW,
);
expect(attrs).toContain('last_offense_days_ago="0"');
});
});
describe("buildUserHistoryXml — last flagged messages for repeat offenders", () => {
it("returns empty when there is no real history", () => {
expect(buildUserHistoryXml([])).toBe("");
expect(
buildUserHistoryXml([{ content: " ", severity: "low", created_at: 1 }]),
).toBe("");
});
it("renders <infraction> rows with severity and recency", () => {
const xml = buildUserHistoryXml(
[
{
content: "beli barang murah disini https://scam.example",
severity: "high",
created_at: NOW - 3 * DAY_MS,
},
],
NOW,
);
expect(xml).toContain("<user_history>");
expect(xml).toContain('severity="high"');
expect(xml).toContain('time_ago_days="3"');
expect(xml).toContain("beli barang murah disini");
});
it("caps long snippets and XML-escapes content", () => {
const xml = buildUserHistoryXml(
[
{
content: "x".repeat(300),
severity: "low",
created_at: NOW - DAY_MS,
},
],
NOW,
);
expect(xml.length).toBeLessThan(250);
});
});
describe("buildUserProfilesBlock — deduplicated map with staleness", () => {
it("emits as_of when the profile has a last-generated timestamp", () => {
const block = buildUserProfilesBlock(
new Map([
[
"u1",
{
text: "Developer teknis, bahasa Indonesia",
asOf: NOW - 3 * DAY_MS,
},
],
]),
);
expect(block).toContain('<user_profile user_id="u1"');
expect(block).toContain(
`as_of="${new Date(NOW - 3 * DAY_MS).toISOString()}"`,
);
expect(block).toContain("Developer teknis");
});
it("omits as_of when absent, and drops empty profiles", () => {
const block = buildUserProfilesBlock(
new Map([
["u1", { text: "profil aktif", asOf: null }],
["u2", { text: " " }],
]),
);
expect(block).toContain('user_id="u1"');
expect(block).not.toContain("as_of");
expect(block).not.toContain("u2");
});
it("returns empty for no profiles", () => {
expect(buildUserProfilesBlock(new Map())).toBe("");
});
});
describe("resolveIsBot / resolveIsEdited — message flags", () => {
it("reads author.bot from captured metadata", () => {
const bot = msg({
metadata: JSON.stringify({
author: { id: "x", username: "bot", bot: true },
}),
});
const human = msg({
metadata: JSON.stringify({
author: { id: "y", username: "user", bot: false },
}),
});
expect(resolveIsBot(bot)).toBe(true);
expect(resolveIsBot(human)).toBe(false);
expect(resolveIsBot(msg())).toBe(false);
});
it("flags edited content only when edited_content is present (the edit path)", () => {
expect(resolveIsEdited(msg({ edited_content: "versi baru" }))).toBe(true);
expect(resolveIsEdited(msg())).toBe(false);
});
});

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