Commit Graph
360 Commits
Author SHA1 Message Date
asepharyana b38616e051 fix(auto-delete): guard nickname reset on role hierarchy + surface LLM parse errors
- resetOffensiveNickname: skip when target role sits above bot
  (member.manageable) instead of hammering a doomed setNickname PATCH
  that Discord rejects with 50013 'Missing Permissions'. Log the
  Discord error code on failure for clear diagnosis.
- llmCaller: include contentPreview (first 200 chars) in the parse-
  failure warning so non-JSON LLM responses are debuggable.
2026-08-17 20:52:09 +07:00
asepharyana 479f4719ba refactor(ai): replace SearXNG with Wikipedia adapter for analysis enrichment
- Add wikipediaClient.ts: native fetch to Wikipedia REST/Action APIs
  (search + summary), no extra npm dependency.
- Extract shared Redis cache into cacheStore.ts (decoupled from search).
- Term glossary now uses wikipediaSummary for direct article lookup.
- Remove searxngSearch.ts entirely; drop SEARXNG_BASE_URL config,
  add WIKIPEDIA_LANG / WIKIPEDIA_TIMEOUT_MS.
- Rename backend searxngCalls metric to webSearchCalls.
2026-08-17 20:07:19 +07:00
asepharyana 2825250804 perf(ai-moderation): remove per-user reputation from analysis context
User: 'jangan ada reputasi juga' — no profile, no reputation in the prompt,
raw messages only.

- textBatchProcessor: drop initializeUserReputation fetch + <user_reputation>
  tag injection (kept the minimal <message> tag + reply/reference context).
- visionAnalyzer (prepareMediaMessage): same removal.
- prompts/system.ts + prompts/output.ts: replace <user_reputation>/<user_history>
  instructions with an explicit 'no per-user profile/reputation context'
  note so the LLM judges purely on message content + conversation/web/location.
- mediaBatchProcessor: fix stale comment.

Trust/infraction state is STILL written to the DB (userReputationsTable) for
enforcement — only the LLM context injection is removed, so moderation
actions (mute/ban via infraction thresholds) keep working.

Net: even smaller prompts (no per-user context at all) → more messages fit
per request, and one fewer DB round-trip per unique user per sub-batch.

tsc, biome, vitest (129) all clean.
2026-08-16 20:44:39 +07:00
asepharyana aa280c48b7 perf(ai-moderation): drop personal user-profile descriptions from context
User insight: personal profile summaries bloat the prompt (less room per
request) and add a per-user DB/Redis round-trip for little moderation signal.
Only the behavioural <user_reputation> history is kept.

- textBatchProcessor: stop fetching getUserProfile; remove <user_profiles>
  block + <user_profile_ref> from message tags. Keep <user_reputation>.
- mediaBatchProcessor + visionAnalyzer: same removal (profile fetch + ref).
- prompts/system.ts + prompts/output.ts: drop stale <user_profiles>/
  <user_profile_ref> instructions; point LLM at <user_reputation> instead.
- aiAnalyzer: gate userProfileLearner behind AI_USER_PROFILE_LEARNING_ENABLED
  (default false) — generates profiles nobody reads, pure LLM/DB waste.
- Add AI_USER_PROFILE_LEARNING_ENABLED config knob.

Net: smaller prompts (more messages fit per request), fewer DB round-trips
per sub-batch, and no background LLM calls learning unused profiles.

tsc, biome, vitest (129) all clean.
2026-08-16 19:56:27 +07:00
asepharyana 4cf5b87f2b perf(ai-moderation): pack more messages per LLM request (fewer API calls when busy)
User insight: rather than many small per-batch API requests, pack many
messages into ONE request so a burst is analyzed with far fewer calls.

- AI_LLM_TEXT_BATCH_SIZE 20 -> 60 (one request now carries ~3x more messages).
- AI_ANALYSIS_MAX_TARGET_TOKENS 4000 -> 14000 (the scheduler's token-budget
  gate was trimming pending messages to ~20 before they reached the sub-batch
  splitter; raising it lets ~60 messages through to a single LLM call).
- AI_LLM_TEXT_ANALYSIS_TIMEOUT_MS 30000 -> 45000 (one larger call needs more
  headroom; gemini-flash-lite has a 1M-token context so 14k+8k is trivial).

Net effect when ramai: a 60-message burst = 1-2 API calls instead of 3+,
less semaphore contention, faster throughput.
2026-08-16 19:00:29 +07:00
asepharyana 0dff7770a1 perf(ai-moderation): speed up analysis queue (ramai + sepi)
- Parallelize per-user reputation/profile fetches in textBatchProcessor
  (was a serial ~2N DB/Redis round-trip loop per sub-batch; now Promise.all
  over unique users). Cuts per-batch latency, biggest win on small/quiet
  batches.
- Make the LLM concurrency semaphore dynamic (cached per config value) instead
  of frozen at import time, so AI_LLM_MAX_CONCURRENT is tunable without code
  change and reflects current config.
- Bump AI_LLM_MAX_CONCURRENT default 5 -> 8 (gemini-flash-lite is cheap; helps
  throughput when busy).
- Lower AI_ANALYSIS_DEBOUNCE_MS 500 -> 250 (snappier first-message analysis
  when quiet).
- Lower AI_ANALYSIS_RECOVERY_INTERVAL_MS 15000 -> 10000 (stuck/errored
  messages re-analyze sooner).

tsc, biome, vitest (129) all clean.
2026-08-16 18:51:13 +07:00
asepharyana 726a8e116b fix(build): make oRPC/tRPC dist runnable under node ESM (deploy crashloop)
flake.nix only rewrote @/ aliases but left extensionless relative imports
(./router) in compiled dist/. node dist/index.js (how prod runs) cannot
resolve extensionless ESM specifiers -> ERR_MODULE_NOT_FOUND -> backend
crashlooped (444 restarts, port 4001 dead). Extract the fixer into a shared
scripts/fix-imports.mjs that appends .js to extensionless relative imports and
rewrites @/ aliases, and wire it into backend + discord-gateway build phases.

Verified: fresh tsc + fixer -> node dist/index.js boots; oRPC over /trpc
serves both HTTP POST and WebSocket (config/dashboard/voice/moderation/
media/chatbot/analysis) end-to-end against Postgres + Redis. next build
passes with the oRPC client + partysocket.
2026-08-16 14:45:02 +07:00
asepharyanaandClaude Opus 5 (Nous Research) d8552a9fb8 feat(ai): make standalone image/vision analysis timeout explicit (1 min)
The standalone image analysis path (analyzeSingleMediaImage → llmVision →
llmChat) previously had no request-level timeout of its own — it silently
inherited the shared OpenAI client default (60s), and AI_LLM_MEDIA_ANALYSIS_
TIMEOUT_MS only governed the text+media *batch*, not a single vision call.

- Add AI_LLM_VISION_ANALYSIS_TIMEOUT_MS (default 60000) to config.
- llmChat now accepts an optional per-request `timeout` in LlmCallOpts,
  forwarded to the OpenAI request options (falls back to the 60s client
  default when omitted).
- llmVision passes config.AI_LLM_VISION_ANALYSIS_TIMEOUT_MS, so a single
  image/sticker/emoji analysis gets a guaranteed 1-minute budget and is
  independently tunable from the text path.

Verified: tsc + biome green, 129 gateway tests pass.

Co-Authored-By: Claude Opus 5 (Nous Research)
2026-08-16 10:47:24 +07:00
asepharyanaandClaude Opus 5 (Nous Research) a3e5a8c1b9 perf(gateway): hoist correctedExamples query out of retry closure
buildCorrectedFewShotExamples() (a getRecentCorrectedModerations(5)
DB hit) was called inside the per-sub-batch buildContent closure in
textBatchProcessor.ts — re-queried for every sub-batch (≈10× for a
200-msg burst) AND re-fired on each parse-error retry. mediaBatchProcessor
already hoisted it once. Mirror that: fetch once per runTextOnlyBatch,
reuse the cached string inside the closure.

No behavior change — identical content, fewer identical DB reads.
tsc + 129 tests + biome green.

Co-Authored-By: Claude Opus 5 (Nous Research)
2026-08-16 09:06:17 +07:00
asepharyanaandClaude Opus 5 (Nous Research) f82b5caae4 refactor(gateway): strip boilerplate fields from few-shot examples
The 32 few-shot examples each re-echoed score/confidence/
recommended_action/categories/policy_version inline (~150 chars ×
32). Those fields carry zero moderation-decision signal — the schema
and their ??-default coercion already live in OUTPUT_INSTRUCTIONS +
moderationResponseParser.ts. Removed 96 redundant key/value pairs.

Kept per-example: message_id, status, flags, severity, evidence,
analysis — the fields that actually teach decisions. Parser derives
the rest via ?? fallback, so real output shape is unchanged.

examples.ts: 21.7K→18.5K chars; FEW_SHOT(mixed) 15.3K→13.4K.
Total mixed system prompt now 33.9K (was 39.3K at audit start,
~14% leaner). tsc + 129 tests + biome green.

Co-Authored-By: Claude Opus 5 (Nous Research)
2026-08-16 09:00:55 +07:00
asepharyanaandClaude Opus 5 (Nous Research) 9e2b107fcd refactor(gateway): compact AI analysis system prompt, preserve all rules
- prompts/system.ts: merge 3 overlapping framing blocks (Blok Data /
  Konteks Pengguna / Framing Konteks vs Target) into 1 tight block —
  same coverage, no duplicated "standalone judgment / profile-is-
  reference-not-evidence" prose.
- prompts/output.ts: trim duplicated user_history/standalone paragraph
  in PERSONALITY & MEMORI (keep concrete per-case lessons).
- prompts/examples.ts: drop 2 exact-duplicate-lesson few-shots (LGBT id=19
  dup of id=30; weapons-tech id=33 dup of id=32). All teaching signals
  retained via the surviving example of each lesson.

Static system prompt: text 32.7K→29.2K, mixed 39.3K→35.8K chars
(~10% smaller). No moderation rule, zero-tolerance category, or decision
tree altered — accuracy-controlling content untouched. tsc + 129 tests +
biome green.

Co-Authored-By: Claude Opus 5 (Nous Research)
2026-08-16 08:52:11 +07:00
asepharyanaandClaude Opus 5 d2e97ae11d audit(gateway): fix dead /metrics endpoint, raise OOM-prone MemoryMax, trim DB pool
- gateway-metrics: collectors now run per scrape so Prometheus sees real
  data (process memory/uptime + live AI-analysis pipeline gauges) instead
  of an always-empty stub. bootstrap registers the pipeline collectors.
- systemd: MemoryMax 512M -> 1G (live RSS ~500MiB, peak 508MiB; 512M left
  ~2% headroom and risked an OOM-kill restart; host has 8GB free).
- config: POSTGRES_POOL_MIN 2 -> 0 so main + 4 Piscina worker threads don't
  hold ~10 permanently-open idle pg connections against PgBouncer.
- docs: rewrite stale ARCHITECTURE.md / MODULE_STRUCTURE.md (winston ->
  pino, removed mock-crc/indonesianTextNormalizer, renamed
  aiAnalysisWorker/llmModerationClient).

Verified: tsc clean, 129 vitest pass, biome clean on changed files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 08:41:54 +07:00
asepharyana 6244e307a3 feat: surface AI analysis duration across gateway, backend, and FE
Adds per-message AI moderation analysis time (ai_analysis_duration_ms)
so operators can see how long the LLM took to moderate each message.

Gateway:
- messagesTable: new ai_analysis_duration_ms (bigint) column.
- AIAnalysisUpdate + buildAIAnalysisSet: carry analysisDurationMs through
  both single and bulk update paths.
- ai-analysis-worker: measure wall-clock time around runModerationAnalysis
  and attach it to every result in the batch.

Backend:
- Mirror schema column; messageMapper maps ai_analysis_duration_ms;
  moderation-types + MappedMessage expose it.

Frontend:
- message.ts type gains ai_analysis_duration_ms.
- AiBadge (messages view) shows 'status · 1.2s' when duration is present;
  analysis view badge mirrors the same formatting.

DB:
- scripts/add-ai-analysis-duration.sql (idempotent ADD COLUMN IF NOT EXISTS).

No behavior change for moderation logic; null until new gateway build
records values.
2026-08-16 00:11:00 +07:00
asepharyana 2d7c7f2c35 fix(gateway): stop Qdrant upsert aborts (semantic cache was being skipped)
Qdrant upserts were failing with 'This operation was aborted' ~32x/2h,
so semantic moderation cache entries were silently dropped. Root cause:
upsertQdrantPoint ran ensureQdrantCollection() on EVERY call — a GET
(and sometimes DELETE+PUT) round-trip — while the request AbortController
had only a 10s timeout. Under moderation load Qdrant is busy (the
gmw_text_moderation collection is not yet HNSW-indexed, so searches are
full-scans), the extra round-trips pushed the upsert past 10s, and the
client aborted it.

- Memoise ensureQdrantCollection() at module scope so the collection is
  verified exactly once per process (resetQdrantCollectionCache() for
  tests / config reload).
- Bump the upsert request timeout 10s -> 30s so a transiently busy
  Qdrant no longer aborts the write.

Qdrant server itself is healthy (<100ms for direct upsert; collection is
green), so no server-side change is needed. Semantic cache should now
populate reliably.
2026-08-15 23:40:19 +07:00
asepharyana 416c690ebc style(gateway,backend): clear all biome warnings (no warnings left behind)
Address every remaining biome lint/format warning across both services
so the codebase ships warning-free:

- textCacheStore: drop unused deleteExpiredQdrantPoints import; hash
  image cache key (sha256[:32]) so long/base64 URLs no longer blow the
  text_analysis_cache PK B-tree 8191-byte index (was aborting the media
  analysis lock INSERT).
- bootstrap: drop unused unhandledRejection promise param.
- moderationOrchestrator: drop unused  destructure at L197.
- mediaDownloader / textBatchProcessor / transmitter: replace non-null
  assertions with proper null guards (stickerName ?? '', urlImages.get
  guard, backpressureQueue.shift guard).
- backend utils: throw lastError ?? fallback instead of lastError!.
- message-capture: remove unused  (retentionDb),  (moderationActionsDb,
  reviewsDb); simplify renderDiscordMentions guard to optional chain.
- transmitter: remove dead write-only  field + its assignments.

No behavior change beyond the cache-key hashing (now deterministic
fixed-length) and the intentional null-safety guards.
2026-08-15 23:18:33 +07:00
asepharyana c590a8be27 style(gateway): biome format fix for imageResizer (unblock CI gate)
imageResizer.ts had a line exceeding the print width that biome flagged
as a formatter error, failing the Build & Deploy biome check. Re-format
the file. No logic change.
2026-08-15 23:11:31 +07:00
asepharyana 9c83ec86cc fix(gateway): image vision analysis + media cache lock failures
Two root causes behind 'all image analysis failing':

1. imageResizer still emitted lossless PNG for vision input. A 1024px
   Facebook photo balloons to multi-MB PNG base64 that the vision model
   silently rejects ('Vision API null response'). Switch to JPEG q85
   (no upscaling) — same photo drops to ~100-400KB, model processes fine.
   Re-encodes even already-small images so raw originals never bloat the
   data URL. Added tests/imageResizer.test.ts covering both cases.

2. acquireMediaAnalysisLock INSERT aborted with 'index row requires N
   bytes, maximum size is 8191'. text_analysis_cache.text is the PK in a
   B-tree index (8191-byte/row cap); callers pass the raw image URL as the
   key, and base64 data URLs / very long URLs blow past the limit, so the
   lock INSERT fails and every media analysis is skipped. Hash the URL in
   makeImageCacheKey (image:<sha256[:32]>) — fixed-length, deterministic,
   well under the limit. All store/get/lock/delete callers already route
   through this function so lookup stays consistent.
2026-08-15 23:04:52 +07:00
asepharyana e2013988ff ci: fix biome format gate so Build & Deploy passes
Auto-format llmClient.ts (Object.assign indent) — the only biome
error blocking the Build & Deploy workflow. Logic unchanged; gateway
biome check now exits 0 (11 pre-existing warnings remain, non-blocking).
2026-08-15 21:39:44 +07:00
asepharyana 0164444dd7 refactor(gateway): remove screen-share / GoLive feature entirely
Drop the Discord Go Live (screen share) stack across the discord-gateway:
- delete src/goLive/ (19 modules: Streamer, Demuxer, encoders, WebRTC wrapper, native loader, etc.)
- delete native/libdatachannel-min/ N-API binding + flake native build + LD_LIBRARY_PATH wiring
- delete screenShareController.ts and screen-share tests (goLive-port, golive-*, demuxerNut, screenShareInput)
- mediaSource.ts: remove Invidious helpers + downloadScreenInput (YouTube full-file download)
- mediaTypes.ts: drop ScreenShare* types, narrow MediaMode to 'music' and DiscordPlayerOwner to non-screen
- media.handler.ts: remove screen branch, screenController/screenPlayback, voice-disconnect/reconnect accessor
- commandHandler.ts: stop passing getVoiceStatus / setVoiceController into MediaHandler
- media handler now only handles music; music queue/playback/status untouched

Verification: tsc --noEmit clean, biome clean on touched files, no lingering goLive/screenShare refs in BE/FE/gateway.
2026-08-15 21:20:20 +07:00
asepharyana 9ae26b8ec9 refactor(llm): unify vision routing with text moderation and remove dedicated endpoint 2026-08-15 21:05:06 +07:00
asepharyana 7ebee7559d feat(llm): add disableThinking option for faster LLM analysis and update config 2026-08-15 20:52:53 +07:00
asepharyana 66c33a2657 feat(message-capture): add bot exclusion logic for message capture 2026-08-15 20:35:51 +07:00
asepharyana 1c4f28c5f2 fix(message-capture): remove bot message filtering from capture logic 2026-08-15 20:21:06 +07:00
asepharyana 6c9a91dad4 style(vision): biome format llmClient.ts (wrap long const line) 2026-08-15 14:38:44 +07:00
asepharyana bcb563ea7f feat(vision): route multimodal analysis to dedicated NVIDIA direct endpoint
- config: add AI_LLM_VISION_BASE_URL + AI_LLM_VISION_API_KEY (separate from text router)
- llmClient: llmVision() now calls dedicated vision endpoint when configured
  (axios POST to integrate.api.nvidia.com, model nvidia/nemotron-3-nano-omni-30b-a3b-reasoning,
  reasoning_budget 16384, non-stream), falls back to router combo otherwise
- keeps text/moderation on omniroute, vision on NVIDIA direct
2026-08-15 14:31:53 +07:00
asepharyana 5816e94a63 fix(goLive): remove syncStream — synthetic PTS timebases make A/V sync deadlock
Symptom: video plays ~1s then freezes. BaseMediaStream sync logic:
- video _pts advances 33.3ms/frame (timeBase 1/fps), audio _pts advances
  20ms/packet (timeBase 1/48000) — two synthetic frame-index timebases that
  never share a clock.
- If audio starts late (ffmpeg audio init / Ogg header), ptsDelta = video-audio
  stays positive → isAhead() true → video loops 'await sleep(frametime) while
  isAhead()' → video freezes. Downchain: vPipe fills → proc.stdout paused →
  demuxer emits ~15fps (log: 30 frames per 2s).

Upstream dank sets syncStream because node-av provides REAL PTS from NUT in a
consistent timebase. Our raw-h264 demuxer has no real PTS; per-stream sleep-PTS
pacing alone keeps both at 1000ms/s, which is correct without a shared clock.
Re-enable sync only if real PTS is added.
2026-08-13 19:06:36 +07:00
asepharyana 11f2ad5f23 fix(goLive): kill 4.3s backlog — HWM2 pipes + wire A/V sync (dank-faithful)
Lag root cause: vPipe/aPipe were objectMode PassThrough HWM 128 → the pipe
held up to 128 frames ≈ 4.3s of video before backpressure reached the encoder.
The viewer was watching a 4+ second stale backlog.

Fixes (both faithful to @dank074/discord-video-stream):
1. vPipe/aPipe HWM 2 — at most ~1-2 frames in flight (~66ms @ 30fps), so the
   writeFrame() backpressure pauses ffmpeg stdout almost immediately and the
   whole chain (encoder → NUT → demuxer → vPipe → BaseMediaStream → WebRTC)
   runs at the sender's real pace, exactly like dank's 'resume &&= vPipe.write'.
2. Wire vStream.syncStream = aStream — audio is the master clock; video
   sleeps/wakes on ptsDelta like upstream newApi.js. Prevents A/V drift under
   variable encoder throughput.
2026-08-13 18:34:28 +07:00
asepharyana 6e188f81d6 refactor(goLive): revert to dank-faithful demuxer — no custom pacing clock
Per user direction ('pakai dank sebagai referensi karena itu yg berhasil'):
drop the custom setInterval/tail-drop emission clock entirely. The demuxer
now writes each access unit straight to vPipe with a monotonic PTS and lets
BaseMediaStream (ported 1:1 from @dank074) handle pacing via sleep-PTS + A/V
sync, exactly like the upstream library. The custom clocks were the source of
the blank tile (IDR delivery race) and the lag (head-drop watching 10s-old
frames).

Adds proper backpressure: pause ffmpeg stdout when vPipe.write() returns
false, resume on drain — mirrors dank's 'resume &&= vPipe.write(packet)' so the
encoder self-throttles to the WebRTC sender's real pace instead of bursting.
2026-08-13 18:12:34 +07:00
asepharyana 7c376ea66a fix(goLive): keep IDR in own slot so decoder always has a reference (was blank)
The tail-drop rewrite let a P-frame supersede a pending keyframe before the
emit tick fired, so the decoder never received an IDR → blank GoLive tile.
Give keyframes their own slot (pendingKey) that P-frames cannot steal, and
only emit a P-frame once at least one IDR has been shown (haveReference).
IDR is always emitted first when present so the reference re-establishes.
2026-08-13 17:43:16 +07:00
asepharyana 8ee32b8df8 fix(goLive): tail-drop emitter clock — always show the freshest frame, never lag
The Node token-bucket pacer used HEAD-drop (emit frames in arrival order,
drop newer ones when over budget). Under the encoder's ~330fps burst (ffmpeg
-re does not reliably throttle YouTube-DASH webm), the viewer was watching
frames ~10s behind live → frozen / 'patah-patah' video while audio (not
rate-limited) played current = desync.

Replace it with a steady setInterval emission clock at videoFps: each tick
emits exactly ONE frame — the NEWEST buffered one — and discards everything
older (tail-drop). At most one frame is ever held, so no backlog and no lag;
the emit clock (not the encoder rate) defines playback speed. Keyframes are
never superseded so the decoder keeps getting IDRs. Audio stays in sync.
2026-08-13 17:32:18 +07:00
asepharyana c285a4c813 fix(voice): copy cookies to temp before yt-dlp + fall back to Invidious on cookie/permission errors
yt-dlp 2026.07.04 rewrites the --cookies file on close. Handing it the
root-owned /etc/.../ytcookies.txt (not writable by the gmw service user)
caused PermissionError -> exit 1 on every screen-share download attempt.

- buildCookieArgs on-disk branch now copies the system cookie file into a
  per-run temp file (like the env branch) so write-back lands somewhere we
  own; unreadable -> anonymous.
- resolveInputWithRetry Invidious fallback regex now also matches
  permission|EACCES|cookie, so a cookie failure triggers the link-alternative
  (no-auth Invidious mirror) path instead of failing all retries.
- adds regression test asserting the original cookie path is never passed to yt-dlp
2026-08-13 17:16:05 +07:00
asepharyana f156fc0c9e fix(goLive): download screen-share media to file before play (not live pipe)
The live pipe (yt-dlp -o - -> ffmpeg) delivers data at network speed with
unreliable PTS, which defeats ffmpeg -re and made x264 -r 30 force-duplicate
held frames -> ~1fps video (the patah-patah symptom). Per user suggestion,
download the FULL clip to a temp file first (downloadScreenInput), then feed
that FILE PATH to prepareStream. String inputs already get -re, so the
encoder now paces cleanly at 1x against a monotonic-PTS file — proven
reliable in local tests (vs the live pipe which always bursted). Temp file
is removed on stream end / stop.

- getDirectScreenInput -> downloadScreenInput (returns file path)
- resolveInputWithRetry now awaits a completed file + retries on failure
- screenShareController.stops/cleanup removes the per-run tmpdir
- screenShareInput.test.ts updated to the file-download contract
2026-08-13 16:42:41 +07:00
asepharyana 89f1097729 fix(goLive): add -re throttle at encoder for screen-share pipe input
Previous code only added ffmpeg -re when input was a string URL. Screen
share passes a Readable pipe (yt-dlp merge -> stdout) delivered at network
speed (bursts + stalls). Without -re the encoder slurps it instantly and,
when the merge stalls, x264 -r 30 force-duplicates the last held frame
~30x -> viewer sees ~1fps while WebRTC still paces 30fps. Add -re for all
inputs so the encoder paces at the stream's native PTS rate and emits a
fresh picture every frame.
2026-08-13 16:18:51 +07:00
asepharyana df24c756a0 fix(goLive): token-bucket pacing + pin biome rules so CI passes
- Demuxer.ts: deterministic token-bucket video pacing (replace unreliable ffmpeg -re which did not throttle the live multi-stage pipe — demuxer emitted ~240fps vs 30fps sender, 100k+ frame backlog, frozen video). Surplus non-key frames dropped; keyframes forced through; audio on fd3 unaffected.
- biome.json: pin noExplicitAny/noUnused* to off/warn. Biome 2.5.x (drifted via --no-frozen-lockfile) promotes these to errors and was failing the CI gate on pre-existing backend code unrelated to this change. Restores the warn-level behavior the config schema 2.2.0 expects.
2026-08-13 14:25:02 +07:00
asepharyana add31d3561 fix(goLive): deterministic token-bucket video pacing at demuxer (replace unreliable -re)
Root cause (3rd iteration): ffmpeg '-re' on the demuxer does NOT reliably
throttle a multi-stage live pipe (merge ffmpeg -> encoder x264 -> NUT ->
demuxer). In production the demuxer still emitted ~240fps while the WebRTC
sender consumed 30fps, building a 100k+ frame backlog (observed: frames=197490
vs sent #24600, ~8.4 min in). The sender always emitted the OLDEST buffered
frame -> video frozen ~10 min behind live, while audio (tiny, jitter-buffer
recovered) stayed smooth. Local file/pipe tests showed -re working (30fps)
but the live YouTube/WebM pipeline did not — -re is not trustworthy here.

Fix: enforce 1x video output with a token-bucket limiter in the demuxer
(Node side), independent of ffmpeg. Capacity = 1s of frames, refill 1 token
per 1000/fps ms. Surplus non-key frames are DROPPED (never buffered) so the
sender always emits the newest frame; keyframes are forced through even over
budget so the decoder keeps a fresh IDR. The limiter does NOT stall the ffmpeg
process (unlike the earlier proc.stdout pause), so audio on fd3 keeps flowing.

Verified: tsc --noEmit clean.
2026-08-13 14:17:35 +07:00
asepharyana 6e7c4901c9 fix(goLive): pace demuxer with -re + bounded frame-drop (was: audio patah, 8s lag)
Root cause (revisited): the previous gate paused proc.stdout when vPipe was
full. That stalled the SAME ffmpeg process that also writes audio on fd3, so
audio stuttered; and the ~8s backlog already built never drained → permanent
lag. Symptom: 'video still lags bad, now audio also choppy'.

Fix:
- spawn demuxer ffmpeg with -re for stream (pipe) input. Verified locally:
  a 5s NUT clip demuxes in 0.088s without -re (57x burst) vs 4.539s with -re
  (real-time). -re throttles the input read, which back-pressures the whole
  upstream chain (encoder x264 -> merge ffmpeg -> yt-dlp) through OS pipes,
  pinning production at 1x. No unbounded backlog.
- drop oldest queued frame when vPipe readableLength >= 30 (transient sender
  stall guard) instead of pausing stdout — keeps video fresh and audio intact.
- removed gateSource/sourcePaused entirely.

Audio and video now pace together at 1x; video is the newest frame, not an
8-second-old one.
2026-08-13 12:41:49 +07:00
asepharyana 60faaa9304 fix(goLive): backpressure-throttle screen-share pipeline to 1x (video freezes while audio plays)
Root cause: prepareStream's ffmpeg consumed a YouTube VOD at download/CPU
speed (~10x real-time), so the demuxer buffered a huge frame backlog.
The sender paces at 30fps but always emitted the OLDEST buffered frames, so
the viewer saw frozen/laggy video while audio (tiny, jitter-buffer
recoverable) stayed smooth. That is exactly the 'video stuck, voice normal'
symptom reported live.

Fix: propagate vPipe backpressure UP to the demuxer's ffmpeg stdout — when
the sender can't keep up, pause the source, which stalls the demuxer and
back-pressures the encoder, pinning the whole pipeline to 1x. Also add a
realtime (-re) option for file/URL inputs (no-op for the streaming path,
which is what screen share uses).

Verified: 10s test clip encodes in 1.8s without -re vs 9.5s with it; tsc --noEmit clean.
2026-08-13 11:59:33 +07:00
asepharyana 5505983dbd fix(goLive): retry VIDEO(op12)/SPEAKING(op5) opcodes until ws OPEN — broken shared-screen video
Root cause: BaseMediaConnection.sendOpcode is a silent no-op when
ws.readyState !== OPEN. In GoLive, playStream() calls setVideoAttributes(true)
+ setSpeaking(true) the instant createStream() resolves (right after
SELECT_PROTOCOL_ACK), but the StreamConnection WebSocket can still be in
CONNECTING for a few ms — so op 12 (VIDEO, activating the video SSRC) was
silently DROPPED every session. Empirically verified: 0 ops 12/5 ever logged
across the entire journal, yet 10k+ video frames were sent and audio played
(audio SSRC is activated via the VoiceConnection handshake, independent of
GoLive op 12). Discord's media server thus received video RTP on video_ssrc
but was never told to forward it → black/broken shared-screen video with
working voice.

sendOpcodeWhenOpen retries up to ~2s for ws OPEN instead of dropping. Also
emits a=fmtp:101 packetization-mode=1;profile-level-id=42e01f in the answer
SDP (H264 FU-A fragments require packetization-mode=1 to reassemble).

Also removes pre-existing noNonNullAssertion lint (biome 2.5.8 now errors)
that was blocking the deploy CI.
2026-08-13 01:44:40 +07:00
asepharyana 3d57e9c102 fix(goLive): retry VIDEO(op12)/SPEAKING(op5) opcodes until ws OPEN — broken shared screen video
Root cause: BaseMediaConnection.sendOpcode is a silent no-op when
ws.readyState !== OPEN. In GoLive, playStream() calls
setVideoAttributes(true) + setSpeaking(true) the instant createStream()
resolves (right after SELECT_PROTOCOL_ACK), but the StreamConnection WebSocket
can still be in CONNECTING for a few ms — so op 12 (VIDEO, enabling the video
SSRC) was silently DROPPED every session. Empirically verified: 0 ops 12/5 ever
logged across the entire journal, yet 10k+ video frames were sent and audio
played (audio SSRC is activated via the VoiceConnection handshake, independent
of GoLive op 12). Discord's media server thus received video RTP on video_ssrc
but was never told to forward it → black/broken shared-screen video with
working voice.

sendOpcodeWhenOpen retries up to ~2s for ws OPEN instead of dropping. Also
keeps the H264 packetization-mode=1 answer-SVP (defensive SDP correctness).

Also fix: emit a=fmtp:101 packetization-mode=1;profile-level-id=42e01f in the
answer SDP — H264 FU-A fragments require packetization-mode=1 to reassemble.
2026-08-13 00:24:53 +07:00
asepharyana d3cb5f6756 refactor: rombak cache AI analisis image — pakai CDN URL langsung, hapus phash+sha
- Cache key image = CDN URL (query params stripped), bukan SHA data URL
  → re-analysis SAME attachment selalu cache-hit, berbeda attachment tidak kolisi
- Hapus perceptual hash (imghash dep + phash get/upsert/compute) sepenuhnya
- Hapus makeImageCacheKey hashing, ganti makeImageCacheKey yang return CDN URL
- textCacheStore, visionAnalyzer, mediaCache, mediaAnalysisClient updated
- imghash dependency removed from package.json
- Purge 82 stale cache rows (image: + phash:) dari DB
2026-08-12 22:31:08 +07:00
asepharyana 37787cc4f0 fix: prevent false positive moderation on physics/tech discussions
- Add examples for technical discussions (kinetic energy, drone weapon
  engineering, physics simulations) that should be marked clean
- System rule: physics/engineering topics (kinetik, gravitasi, energi,
  drone, senjata, drone warfare, CAD, CNC, 3D printing, robotics, aerospace)
  are safe when in technical context — flag only if explicit threat
- Riwayat pengguna dengan pelanggaran sebelumnya tidak memengaruhi
  penilaian pesan bersih yang terpisah dan tidak mengandung pelanggaran
2026-08-12 22:12:11 +07:00
asepharyana f849a87f2f fix: remove user history injection to prevent false positive moderation
- Removed getUserRecentInfractions usage in textBatchProcessor.ts and visionAnalyzer.ts
- Removed buildUserHistoryXml import and calls
- Messages are now evaluated standalone, not influenced by past violations in other channels
- Updated moderation prompts with clearer instructions about user_history usage
- Fixes issue where benign messages like 'tubuh manusia vs gravitasi' were incorrectly flagged due to carryover from previous drone weapons discussion

The user history context was causing the LLM to interpret unrelated current messages
as threats because it conflated them with past violations. Now each message is judged
on its own merit with only channel-specific context.
2026-08-12 20:48:26 +07:00
asepharyana deb5dedf2c test(gateway): add regression test untuk makeImageCacheKey collision
Verifies that two data URLs sharing the first 128 chars (same MIME prefix
+ identical base64 header — the real-world scenario that caused ALL images
to reuse the same cached vision analysis) produce DIFFERENT cache keys
under the fixed full-dataURL hashing, whereas the old 128-char-prefix
approach would collide. Also includes consistency + prefix tests.
2026-08-12 19:34:15 +07:00
asepharyana 3b221823e7 feat(gateway): add observability logging for vision cache hits/misses
Add debug logging to trace cacheKey + messageId + content length on
every vision cache HIT and MISS, so we can detect if the vision model
returns duplicate analysis for different images (provider issue vs
cache collision). Includes the phash on cache miss (new analysis cached).

Follow-up to 9f7ce7d which fixed makeImageCacheKey to hash full data
URL instead of just first 128 chars (root cause of all images sharing
the same cached 'konten judi' verdict due to hash collision).
2026-08-12 19:22:56 +07:00
asepharyana 9f7ce7dbd5 fix(gateway): hash full image data URL for cache key to prevent collision
Root cause: makeImageCacheKey() only hashed the first 128 chars of the
data URL. Since all resized images use the same MIME prefix
('data:image/png;base64,') + identical base64 header bytes, nearly every
image got the same 16-char hash → 'image:<same-hash>' → all images reused
the first cached vision analysis (often a gambling-detection verdict).

Fix: hash the entire data URL instead of just the prefix. Verified
114 stale 'image:' entries + 745 stale 'phash:' entries purged from prod
DB. tsc --noEmit clean, 133 tests pass.
2026-08-12 18:28:19 +07:00
asepharyana bd292fdf3d feat(gateway): Invidious fallback for YouTube 403 in screen share
YouTube blocks anon + cookies terbind ke IP browser (403 download).
Auto-rewrite youtube.com -> yewtu.be/invidious mirror saat cookies gagal.
- mediaSource: export isYoutubeWatchUrl/toInvidiousUrl/INVIDIOUS_INSTANCES
- screenShareController: resolveInputWithRetry tries Invidious instances on 403
2026-08-12 18:19:04 +07:00
asepharyana 7a7f433988 feat(gateway): read YouTube cookies from BWS env (gmw_yt_downloader_cookies) fallback to on-disk file
bws-exec exposes the BWS secret as env GMW_YT_DOWNLOADER_COOKIES.
Materialize to temp Netscape file (yt-dlp --cookies needs a path).
Falls back to /etc/gmw-discord-gateway/ytcookies.txt written by deploy.
2026-08-12 17:50:07 +07:00
asepharyana 84c5c36672 feat(gateway): YouTube cookies support for yt-dlp screen share + music
YouTube now blocks anonymous embeds (403 'Sign in to confirm you're not a
bot'). Resolve with account cookies via --cookies.

- mediaSource: buildCookieArgs() reads GMW_YT_COOKIES_PATH (default
  /etc/gmw-discord-gateway/ytcookies.txt) and injects --cookies into
  resolveMediaUrl + getDirectScreenInput + extractMediaInfo. Falls back
  to anon if file missing (graceful 403, not crash).
- bws-exec now writes cookies file from BWS secret gmw_yt_downloader_cookies
  on service start (systemd ConfigFile).
2026-08-12 17:46:05 +07:00
asepharyana c5898f7cf0 fix(gateway): crash safety on screen-share input timeout + proper error serialization
Root cause of "langsung left": YouTube bot-block/403 on u_c1tRmj7E4 (live
stream, LOGIN_REQUIRED) made yt-dlp timeout in resolveInputWithRetry (12s).
The timeout handler did cleanup() (removing once() listeners) THEN
tee.destroy(new Error(...)) — the PassThrough emitted 'error' with NO
listener left → unhandled stream 'error' event → uncaughtException →
gracefulShutdown → bot left voice.

Fix:
- resolveInputWithRetry: tee.destroy() silently after cleanup (error carried
  in the rejection only); add permanent no-op tee.on('error') safety.
- prepareStream: output.on('error') no-op so ffmpeg spawn failure before
  playStream attaches a demux listener never crashes the gateway.
- bootstrap: serialize uncaughtException/ClientError/DB errors with
  {err, errorMsg, stack} (pino only serializes the 'err' magic key — the old
  {error: err} key printed {} so crashes were invisible).
2026-08-12 16:59:41 +07:00
asepharyana 354e378e74 fix(gateway): output NUT (not raw h264) so Demuxer re-splits video+audio correctly
Revert 392bc35: streaming raw h264 video + opus on separate pipes broke
because prepareStream.output (pipe:1) feeds the Demuxer, but the opus
pipe:3 was never attached to the Demuxer's input — so for audio-capable
streams the Demuxer saw format=h264 (video-only) and emitted -an,
dropping audio RTP.

Correct design (from f1aa08c): prepareStream muxes video+audio into NUT
on a SINGLE pipe:1. The Demuxer then spawns a child ffmpeg that
demuxes NUT → -f h264 pipe:1 (pure AnnexB, start-code scan sees real
IDR type 5) + -f opus pipe:3 (Ogg Opus via createOggOpusDemux). The
start-code parser never touches NUT framing — it runs on the child
ffmpeg's clean h264 stdout.
2026-08-12 16:09:34 +07:00