Commit Graph
1113 Commits
Author SHA1 Message Date
asepharyana 392bc35a0d fix(gateway): output raw H264+Opus (not NUT) so Demuxer parses NAL keyframes correctly
Root cause: prepareStream muxed video+audio into a NUT container on pipe:1.
The Demuxer scans pipe:1 for AnnexB start codes (00 00 01) to split NAL
units into access units and classify keyframes (nal_type 5). NUT container
framing bytes sat in the stream and were scanned as NALs — NAL type 0
(NUT header) instead of 5 (IDR) → every frame classified key=false →
Discord decoder never got a decodable frame → static/black GoLive tile.

Fix: output raw H264 AnnexB on pipe:1 (demuxer target) and Ogg Opus on
fd3/pipe:3 for audio. NUT is only needed for *input* parsing (single
pipe carries both streams); output is demuxed into separate raw streams.
2026-08-12 15:39:47 +07:00
asepharyana 196cb1d3af fix(gateway): await audio stream line before demux resolve — audio RTP was dropped by metadata race
The demuxer resolved as soon as the VIDEO init line arrived on ffmpeg stderr.
With live NUT input the audio init line ('Stream #0:1: Audio: opus') lands in a
LATER stderr chunk (NUT info-stream packets are read incrementally from the
pipe), so `return { audio: aInfo }` captured undefined → playStream skipped
AudioStream → zero audio RTP on the audio SSRC → Discord showed a static
GoLive tile even though the NUT carried opus audio.

Fix:
- wait for BOTH video and audio init lines (when audio is expected) before
  resolving demux metadata, with a 3s timeout fallback
- default aInfo to opus/48kHz when withAudio instead of undefined, so the
  audio stream is always exposed even if the metadata line races the return
2026-08-12 14:45:06 +07:00
asepharyana 00fc852a32 feat(rtp-capture): add two-peer RTP capture test for H264 frame transmission 2026-08-12 14:26:54 +07:00
asepharyana d9f5592e6e feat(glossary): persist resolved definitions in Postgres + harden live SearXNG lookups
- Add term_glossary_cache table + migration 0014: resolved definitions are
  stored permanently (definitions rarely change); misses stay ephemeral in
  Redis/LRU with 1h TTL so transient failures get retried
- Lookup flow: LRU -> Redis -> Postgres (permanent) -> live SearXNG; DB hits
  re-warm the fast caches; stale Redis miss sentinels no longer shadow DB
- Rate-limit-aware live lookups: concurrency 2 + stagger, retry once on empty
  results, strict definition filter (Wikipedia preferred, rejects
  disambiguation/ads/translate-homepages)
- Make SEARXNG_BASE_URL configurable via env (default unchanged)
2026-08-12 14:22:22 +07:00
asepharyana f1aa08cdf6 fix(gateway): deliver audio + per-IDR SPS/PPS in GoLive screen share
Screen share showed a single frozen frame: the GoLive pipeline sent video
only (-"-an", h264 muxer cannot carry audio) so the audio SSRC never
transmitted and Discord kept the stream in thumbnail state.

- prepareStream: mux NUT when includeAudio (h264 muxer drops audio) and
  return the actual container format
- Demuxer: support NUT input with a second output pipe (fd3) carrying
  Ogg Opus; parse OGG pages into opus frames (20ms, 48kHz) emitted as
  GoLiveFrames; fix metadata parsing that dropped the audio stream line
  when it arrived in a later stderr chunk (early parsedMeta return)
- playStream: pipe audio.stream into AudioStream → RTP on the audio SSRC
- Encoders: -x264-params repeat-headers=1 → SPS/PPS inline before EVERY
  IDR (NUT remux drops container extradata; also enables PLI recovery)
- screenShareController: includeAudio true
- tests: demuxerNut.test.ts — OGG parser unit test + real ffmpeg NUT
  integration (video access units + parsed opus frames)
2026-08-12 14:20:29 +07:00
asepharyana f70a92880e feat(glossary): implement term glossary for LLM moderation with caching and extraction logic 2026-08-12 13:44:52 +07:00
asepharyana 88b13225cd fix(gateway): stream screen-share input from yt-dlp stdout — no more raw-URL 403
Second root cause (2026-08-12): even with yt-dlp http_headers forwarded,
YouTube still returns 403 when a signed DASH URL from --dump-single-json is
fetched raw by ffmpeg/curl on some videos (verified on fONoh7Pc6VU: curl
with the EXACT headers got 403; yt-dlp's own downloader succeeded). The
signature is tied to the extracting client context (po_token/visitor), not
just UA/IP.

Fix: getDirectScreenInput now spawns 'yt-dlp -o -' and returns its stdout
as a Readable — the same mechanism resolveMediaUrl already uses for music.
yt-dlp handles auth, cookies and transient retries internally. Merge
fragments go to /tmp/gmw-ytdlp-tmp (Nix store CWD is read-only → EACCES).
Removed resolveScreenInput + mergeScreenStreams (dead code).

Controller resolveInputWithRetry unchanged: tees the stream, waits for the
first byte (12s), retries with a fresh yt-dlp run up to 3x on error/EOF/
timeout, and destroys stuck inputs (EPIPE) so no process leaks.

Tests: rewritten for streaming (yt-dlp emits bytes; fail mode = exit 8
without stdout → stream must terminate with zero bytes).
2026-08-12 13:23:59 +07:00
asepharyana 67ab289caa fix(gateway): fail-fast + retry on screen share merge failure (black tile zombie)
Root cause (2026-08-12 11:50 test): merge ffmpeg hit a transient YouTube
403 and exited code 8 BEFORE prepareStream attached its input listeners
(voice release+join takes ~10s). The input's end/error events fired into
the void, the encoder stdin never received EOF, demux resolved with
fallback 0x0 metadata, setSpeaking fired anyway → stream 'started' with
zero frames for 8+ minutes (black tile, both ffmpeg processes hung).

Fixes:
- mediaSource: pass yt-dlp http_headers (UA/referer) to the merge ffmpeg
  via -headers to suppress transient 403s; destroy the returned stream
  with an error when the merge exits non-zero before producing bytes.
- screenShareController: resolveInputWithRetry — tee the merge stream and
  wait for the first readable byte (12s timeout) before proceeding; on
  error/EOF/timeout retry the whole resolution with a FRESH yt-dlp run
  (signed DASH URLs expire fast) up to 3 attempts. Stuck merges get
  EPIPE via input.destroy() so no process leaks per attempt.
- prepareStream: race guard — if the input already ended/destroyed before
  listeners attach, EOF the encoder stdin immediately; first-frame
  watchdog in playStream rejects 'started but nothing flowing' after 10s
  instead of resolving with a silent black stream.

Tests: +2 (merge-fail zero-byte terminal state, -headers forwarding).
2026-08-12 12:17:43 +07:00
asepharyana ef9e243609 fix(goLive): encode H264 baseline to match SDP profile-level-id (black tile)
SDP offer advertises profile-level-id=42e01f (constrained baseline) but
x264 encoded the default High profile — Discord's receiver configures its
decoder from the negotiated profile, so the High-profile bitstream failed
to decode → black GoLive tile despite valid access units + correct RTP
timestamps (fixed in 42a503c).

- Add -profile:v baseline to H264 encoder options (matches @dank074's
  proven config; SPS now 6742c01e → profile_idc=66 baseline, aligns with
  the 42e01f fmtp).
- Default x264 tune film → zerolatency (no lookahead — correct for live
  GoLive; @dank074 uses it).
- Update goLive-port test to assert baseline + zerolatency.
2026-08-12 11:37:04 +07:00
asepharyana 42a503c206 fix(goLive): demux access-unit grouping + correct RTP timestamps (black tile)
Demuxer emitted each AnnexB NAL as its own WebRTC frame (SPS/PPS/SEI
separate from slices) with a near-zero timestamp delta (duration=1 in a
1/90000 timebase → RTP +1/frame instead of +3000 @30fps). Discord's H264
receiver never receives a complete decodable access unit → black GoLive
tile despite frames flowing.

- Group NALs into access units: buffer param-set/SEI NALs, flush one
  frame per slice with preceding parameter sets (AnnexB start codes kept
  so the H264RtpPacketizer finds NAL boundaries).
- Timestamp each frame at the video frame rate: duration=1, timeBase
  1/fps → BaseMediaStream frametime=1000/fps ms → RTP +clockRate/fps
  (3000 @ 30fps/90kHz) and correct pacing.
- Thread explicit frameRate from playStream options (raw H264 has no
  timing info; ffmpeg guesses 25fps on stderr).
- Strengthen golive-demux-live-e2e: validates every frame has a slice,
  no bare param-set frames, keyframes carry SPS/PPS, timeBase 1/30.
2026-08-12 11:13:00 +07:00
asepharyana 652974e23a fix(goLive): gateway crash on screenshare stop — unhandledRejection during teardown
Test 00:32 confirmed the video pipeline WORKS (1410 frames @ 1280x720 sent,
ready=true, camera off) but the gateway crashed at stream stop:
unhandledRejection → graceful shutdown → systemd restart (bot offline).

Root cause candidates (both were fire-and-forget promises without .catch):
- BaseMediaConnection.setProtocols().then(...) — rejects when the PC is
  closed while setProtocols is in flight (stream teardown)
- void webRtcConn.createOffer().then(...) — rejects when the PC closes
  while the offer is still gathering

Fixes:
- .catch on both promise chains (log + continue; teardown is expected)
- unhandledRejection handler now treats transient stream errors (EPIPE,
  ERR_STREAM_DESTROYED, ERR_STREAM_WRITE_AFTER_END, ECONNRESET) like
  uncaughtException already does — warn + continue instead of shutting
  down the whole gateway. Non-transient rejections still log + shutdown
  (with String(reason) so the detail actually shows).
2026-08-12 00:36:53 +07:00
asepharyana 407e003399 fix(goLive): black screen root cause — h264 muxer can't carry audio; disable self_video camera
ROOT CAUSE of empty GoLive tile (finally): prepareStream ran with
includeAudio: true + output -f h264. The h264 muxer cannot mux audio
('h264 muxer does not support any stream of type audio') → header write
fails -22 → stdout empty → Demuxer ffmpeg 'Invalid data found when
processing input' → 0 frames → black tile. Reproduced locally end-to-end
(13s backpressure delay + prepareStream + demux).

Fixes:
- screenShareController: includeAudio: false (video-only GoLive; demux
  path never delivers audio anyway)
- Demuxer: pin input format -f h264 for stream inputs (raw AnnexB H264
  has no magic header → auto-detect unreliable on delayed pipes)
- Streamer.signalStream: self_video: false — stop flipping on the bot's
  camera in Discord (user request; screen share ≠ camera)

Verified: local repro now emits 644 frames 1280x720 (was 0); tsc/biome/
vitest all green.
2026-08-12 00:25:01 +07:00
asepharyana 968a43b0f4 debug(goLive): instrument frame pipeline — demux spawn/stderr/frames, playStream resolve, sendVideoFrame drop/send
Tile kosong meski STREAM_CREATE handshake penuh (22:18-22:19 retest):
- Demuxer logs spawn args, ffmpeg stderr errors, frame count every 30
- playStream logs createStream resolved + demux done + setPacketizer
- sendVideoFrame logs DROPPED (ready/track) + sent frame count
2026-08-11 22:31:31 +07:00
asepharyana 91c7a67d2f fix(goLive): stream demux directly instead of spool-to-file (empty screen share)
Root cause of 'tile appears but content empty': demux() spooled the live
NUT/H264 input to a temp file and awaited stream 'finish' — but the merge
ffmpeg output never ends during playback, so demux deadlocked, no probe,
no transcode, 0 frames sent.

- Demuxer: pipe input straight into ffmpeg stdin (-i pipe:0), parse NAL
  frames live from stdout; parse video metadata from ffmpeg stderr with a
  1.5s race (fall back to H264 defaults). No spool, no await-end.
- screenShareController: pass width/height/frameRate (1280x720@30) to
  playStream — matches the prepareStream encode settings, so setVideoAttributes
  gets real dimensions even when ffmpeg can't report metadata on an open pipe.
- Add tests/golive-demux-live-e2e.ts: proves frames flow while input is
  still open (regression test for the deadlock).
2026-08-11 21:43:16 +07:00
asepharyana 8615383829 fix(goLive): STREAM_CREATE handshake — self_video voice state + retry + send instrumentation
- signalStream: flip voice state to self_video:true/self_deaf:false before
  STREAM_CREATE (Discord silently ignores the request while video disabled)
- createStream: attach dispatch listeners before first signal (race), clean
  up listeners on timeout, retry STREAM_CREATE every 3s up to 4 attempts
  (upstream issue #217/#219 — Discord randomly drops the request)
- sendOpcode: direct [goLive:Streamer] log bypassing bootstrap debug filter
  (proves op 18 is actually broadcast)
2026-08-11 20:45:00 +07:00
asepharyana 10d7ecd405 fix(gateway): EPIPE crash on media stop — stream error handlers + no shutdown on transient stream errors 2026-08-11 20:10:05 +07:00
asepharyana ff554fcff2 fix(goLive): instrument voice/stream handshake + createStream timeout (12s) 2026-08-11 20:00:33 +07:00
asepharyana f8b253ba5e merge: libdatachannel-min GoLive stack (build -86pct, node_modules -1.09GB) 2026-08-11 19:07:57 +07:00
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