Commit Graph
435 Commits
Author SHA1 Message Date
asepharyana 2b6a1eca19 fix(gateway): re-assert server-undeafen+unmute before every video watch
The bot's own VOICE_STATE_UPDATE showed server-level deaf:true — a
server-deafened member is NOT sent the streamer's audiovisual RTP by Discord,
which is the likely reason no H264 arrives despite the DAVE watch reaching
Ready. The previous fire-and-forget forceSelfServerUnmuteUndeafen() ran once
after the first Ready join and silently reverted on reconnect/restart.

- Export forceSelfServerUnmuteUndeafen from recorder.ts; re-assert it (with
  read-back verification logging stillDeaf) at the START of every
  startStreamWatch() before STREAM_WATCH is sent (dynamic import avoids the
  recorder <-> videoRecorder <-> streamWatchReceiver module cycle).
- Re-assert it again after a successful voice reconnect.
- startStreamWatch() is now async; callers use void.
2026-08-31 19:16:56 +07:00
asepharyana 9606187861 feat(gateway): hexdump+ssrc of watch UDP packets
maxLen stayed 72 across 243 packets (no real H264, which is hundreds+ bytes) —
only 44-72-byte RTP packets on PT 76/72/73 arrive. Add ssrc + first-32-bytes
hex so we can identify exactly what Discord sends to the watch socket (control
packets vs stale video), which determines whether the gap is upstream routing
or whether large H264 packets are missing entirely.
2026-08-31 16:47:27 +07:00
asepharyana b423d21b23 feat(gateway): aggregate VIDEO-PKT diag — distinct PTs + maxLen
Enhance watch-socket diagnostic to report distinct RTP payload types seen and
the max packet length, so we can distinguish 'only small control packets arrive
(no real H264)' from 'H264 arrives but decrypt fails'. Live already confirmed
dave=true ready=true with packets flowing but no burst — need to know if they're
tiny 52-byte control packets (PT 73) or large H264.
2026-08-31 16:40:36 +07:00
asepharyana 266e149233 feat(gateway): add VIDEO-PKT diagnostic logging to stream-watch UDP handler
Instrument handleUdpMessage to log (rate-limited, first 3 then /20s) whether
video RTP packets actually reach the watch socket, and whether the DAVE session
is attached+ready and encryption key material present. Needed to diagnose why
no .h264 is written despite DAVE Ready + MLS: is the packet not arriving, or is
decrypt returning null?
2026-08-31 16:29:17 +07:00
asepharyana f1a7b0c2a1 fix(gateway): resolve DAVE session from net.state.dave, not connectionData
CRITICAL: djs/voice stores the DAVESession wrapper at net.state.dave
(createDaveSession assigns to state.dave on op4 SessionDescription), NOT
inside connectionData. decryptVideoPacket looked up connectionData.dave which
is ALWAYS undefined -> every video packet hit '!dave?.session' guard and was
silently dropped, so no .h264/.mp4 ever got written despite the handshake
reaching Ready.

Fix: pass net.state.dave as a separate arg (the wrapper has .session ->
Davey.DAVESession) so the DAVE-layer decrypt (MediaType.VIDEO) actually runs.
Typecheck + build pass, lint clean (src/), 179/179 tests.
2026-08-31 16:08:55 +07:00
asepharyana 87f1f8be8d feat(gateway): detect pre-existing streamers on bot voice join
If someone is ALREADY sharing screen / camera on when the bot joins the
channel, no voiceStateUpdate with streaming:true fires for them, so the
bot never sent STREAM_WATCH and missed their video entirely. trackChannel
now scans channel.members and starts a watch for anyone already streaming
(ignoring the bot itself and non-streamers). Idempotent: startStreamWatch
no-ops if a watch already exists. +2 tests (9/9 in videoRecorder).
2026-08-31 15:48:20 +07:00
asepharyana 4405647b33 fix(gateway): swap stateChange arg order so Ready actually attaches UDP
djs/voice Networking emits stateChange(oldState, newState), but the watch
handler declared (newState, oldState) -- reversed. So the code-4 Ready
branch (which attaches udp.on('message') + logs 'DAVE watch READY') never
fired when entering Ready; it only fired spuriously when LEAVING Ready.
Result: full DAVE handshake completed on the watch RTC (Ready + DAVE MLA +
video stream 21029 active 1920x1080@60) but no UDP listener -> no video
captured. Swap to (oldState, newState) so enter-Ready wires the socket.
2026-08-31 15:42:12 +07:00
asepharyana 4534b7a17d feat(gateway): log full watch Networking state transitions
Add watch-state N->M log on every djs/voice Networking stateChange (with
hasUdp flag) so the stream-watch connection's exact progression is visible:
OpeningWs(0)->Identifying(1)->UdpHandshaking(2)->SelectingProtocol(3)->
Ready(4). Pins down where the DAVE flow stalls instead of guessing from the
absence of logs. Pairs with debug:true + watch-djs-debug.
2026-08-31 15:36:05 +07:00
asepharyana b3e350d40f feat(gateway): enable djs/voice debug logging on watch Networking
Add debug:true to watch Networking options and wire net.on('debug') to
logger.info so djs/voice internal WS/DAVE state transitions appear in
journal. Without this, the stream-watch connection went silent after
'Streaming DAVE Networking' — no ready/error/close visible. Needed to
diagnose why the WS to stream endpoint 'c-sin14-xxx:2083' produced no
events.
2026-08-31 15:23:57 +07:00
asepharyana 75050bc088 fix(gateway): use rtc_server_id-1 as watch DAVE MLS channelId (WrongGroupId)
Live log (14:52) showed the stream-watch flow reaching STREAM_CREATE +
STREAM_SERVER_UPDATE but then DAVE processProposals threw
'ValidationError(WrongGroupId)' -- the Davey MLS session derived the wrong
group because connectionOptions.channelId was the guild voice channel id.
Per Discord-RE StreamConnection.daveChannelId = BigInt(serverId)-1n, the
stream-watch DAVE MLS group is keyed to rtc_server_id-1, not the vc channel.
Fix: pass BigInt(serverId)-1n as channelId to the watch Networking.

This error also surface as an uncaughtException that crashed the gateway
(systemd restarted it). Correct channel id prevents it at the root.
2026-08-31 14:54:32 +07:00
asepharyana 374fd5a9c2 fix(gateway): use guild voice sessionId for watch RTC identify
The previous code read the sessionId from the selfbot client's voice manager
(client.voice.connection), which is no longer established since ensureSelfbotVoice
was removed — it would have sent sessionId:'none' in the watch Networking
identify and been rejected. Read the active session from the guild
@discordjs/voice connection (getVoiceConnection(guildId).state.networking...
connectionOptions.sessionId) instead.
2026-08-31 14:03:00 +07:00
asepharyana 77c8454bb2 fix(gateway): replicate dual-layer DAVE+LTS decrypt for watch video RTP
The first streamWatchReceiver only did dave.session.decrypt(msg.subarray(12))
which skipped the outer legacy-AES layer Discord wraps around the DAVE payload
on every RTC packet (encrypt = dave.encrypt then aead_aes256_gcm with RTP
header as AAD). Port @discordjs/voice VoiceReceiver.decrypt/parsePacket
faithfully (header strip incl CSRC+extension+padding, AES-GCM auth tag, then
DAVE MediaType.VIDEO). Without this the .h264 would be garbage.
2026-08-31 13:55:58 +07:00
asepharyana 0ea76a8373 feat(gateway): DAVE-capable stream-watch video receive (Phase D)
Replace the dead selfbot-v13 video path (WS 4017 DAVE). videoRecorder
now delegates to a new streamWatchReceiver that:
- sends STREAM_WATCH (op 20) on voiceState.streaming
- opens a @discordjs/voice Networking to the watch RTC (STREAM_CREATE +
  STREAM_SERVER_UPDATE) with DAVE enabled
- decrypts H264 via Davey MediaType.VIDEO, depacketizes + muxes to mp4
- tears down on streaming-stop / leave / untrack

Remove ensureSelfbotVoice/createVideoStream/joinStreamConnection (dead).
recorder.ts no longer fires the futile eager selfbot join.
2026-08-31 13:46:24 +07:00
asepharyana 6aeebe7826 fix(gateway): establish selfbot voice connection eagerly so video (camera/screen-share) capture works
Video capture (camera + screen share) recorded ZERO frames because the selfbot
ClientVoiceManager.connection was created LAZILY — only when a user started
streaming. At that point the bot is already connected via @discordjs/voice, so
the selfbot re-join never gets a fresh VOICE_SERVER_UPDATE and times out with
VOICE_CONNECTION_TIMEOUT after 15s. joinStreamConnection (STREAM_WATCH) +
receiver.createVideoStream both need that selfbot VoiceConnection CONNECTED.

Fix: establish the selfbot VoiceConnection eagerly in recorder.startRecording,
BEFORE joinVoiceChannel, so it rides the bot's fresh join (Discord emits
VOICE_SERVER_UPDATE → selfbot authenticates). videoRecorder reuses the cached
connection per guild, tears it down on voice stop/destroy. Best-effort — never
blocks audio recording.

Verified: typecheck + build + biome (src/) green; 9/9 videoRecorder tests.
2026-08-31 12:44:36 +07:00
asepharyana 6738279bad feat(gateway): record others' video via native selfbot watch/receive (Phase C)
Root cause of why Phase A/B captured zero video: @discordjs/voice is audio-only
and never sends the gateway STREAM_WATCH signal, so Discord never forwards a
member's video RTP to the bot. Live diagnostic confirmed: while members were
sharing, audio .ogg files flowed for many users but no non-opus RTP ever
arrived.

Correct path: discord.js-selfbot-v13 ships a complete native watch/record stack.
New src/modules/voice-recording/videoRecorder.ts:
- Detects streamers via voiceState.streaming on a single idempotent
  voiceStateUpdate listener.
- client.voice.joinChannel() (reuses the single session alongside
  @discordjs/voice) + joinStreamConnection(userId) -> STREAM_WATCH (op 20).
- receiver.createVideoStream(userId, path) -> PacketHandler -> Recorder
  (ffmpeg over UDP loopback) -> Matroska .mkv, decryption handled internally.

Wired in recorder.ts (trackChannel/untrackChannel) + bootstrap.ts
(setVideoRecorderClient/setVideoRecordingsDir). All best-effort; failures log
and never break existing voice/audio. Unit tests 6/6 (single listener, watch
handshake + mkv path, skip own video, idempotence, teardown). Full suite
20 files / 176 tests green; typecheck + build + biome clean.

UNVERIFIED live yet: needs deploy + a streamer to confirm Recorder ready +
playable .mkv.
2026-08-30 23:26:59 +07:00
asepharyana 260ecabb3c debug(gateway): log non-opus RTP payload types on the voice socket
Temporary diagnostic to answer definitively whether Discord actually delivers
video RTP to the bot when someone screen-shares / turns on camera. Both
videoReceiver and screenShareAudio rely on ssrcMap emitting videoSSRC from
voice-state updates, and NO "Video SSRC appeared"/"Screen-share video started"
lines appear even after a real share+record. This logs any RTP packet whose
payload type is not Opus (120) so we can tell: (a) video RTP IS arriving but
attribution/signaling fails, vs (b) Discord sends no video at all to a
non-signaling receiver. Remove this log once the gap is understood.
2026-08-30 22:44:17 +07:00
asepharyana 5241f9784a feat(gateway): close video burst promptly when a user's SSRC is removed
ssrcMap emits "delete" when a VoiceUserData is removed (user stops sharing /
leaves voice). Hook it to close the user's open video burst ~500ms later so the
ffmpeg mux starts as soon as they stop, instead of waiting up to 5s for the idle
sweep. No-op if no burst exists; safe on normal teardown.
2026-08-30 21:51:14 +07:00
asepharyana 999c054bb2 feat(gateway): auto-mux recorded video to playable MP4 (Phase B)
Phase A captured raw .h264 streams but left them as non-playable elementary
streams. Phase B adds automatic muxing: when a video burst closes, the raw
.h264 is remuxed to a self-contained MP4 via `ffmpeg -c copy` (no re-encode,
fast) with `+faststart`, waits for the write stream to fully flush first so the
mux never reads a truncated tail, and deletes the raw .h264 on success (keeping
it on failure). Output: <RECORDINGS_DIR>/<uid>/video-<ssrc>-<ts>.mp4.

muxToMp4 is exported + covered by a real-ffmpeg vitest (tests/videoReceiver.test.ts):
generates a tiny baseline h264, remuxes, asserts mp4 exists/non-empty & raw deleted
(also the 5 depacketizer tests). Full gateway suite 170/170 green, tsc + biome clean.
2026-08-30 21:49:40 +07:00
asepharyana 80e9b913c1 chore(gateway): drop unused H264 SINGLE_NAL constant 2026-08-30 21:21:07 +07:00
asepharyana 419946f38e feat(gateway): record others' video (camera/screen share) — Phase A capture
@discordjs/voice only decrypts/forwards AUDIO (opus) — its onUdpMessage drops
every non-opus RTP packet (dist/index.mjs:2068 `!== RTP_OPUS_PAYLOAD_TYPE`, 120).
Video RTP (H264 camera + screen share, plus VP8/VP9/AV1) arrives on the same UDP
socket but was silently discarded.

New videoReceiver.ts wraps receiver.onUdpMessage (like screenShareAudio.ts):
- detects video payload types (96/98/101/102/106/116/126/127),
- decrypts them with the connection secret key/encryptionMode via the SAME
  receiver.parsePacket path @discordjs/voice uses for audio (so DAVE + voice
  encryption are handled identically),
- depacketizes H264 to AnnexB (single NAL, STAP-A, and FU-A fragmentation),
  waiting for a keyframe (SPS/PPS/IDR) before writing,
- writes a raw .h264 elementary stream per user per burst under
  <RECORDINGS_DIR>/<uid>/video-<ssrc>-<ts>.h264.

Attribution: a videoSSRC→user index is built from ssrcMap updates; a proximity
fallback mirrors screenShareAudio's inferScreenShareOwner. Bot's own video is
skipped.

Unit tests: tests/videoReceiver.test.ts (AnnexB start code, keyframe gating,
FU-A reassembly, orphan-fragment tolerance) — 5/5 green.

Phase A only (capture raw h264). Phase B (ffmpeg decode+mux to MP4/WebM +
persist) and Phase C (frontend playback) are follow-ups.
2026-08-30 21:20:32 +07:00
asepharyana 462f643d6f feat(gateway): auto server-undeafen + unmute the bot itself on voice join
A server-muted/server-deafened bot can't reliably receive/record members' audio
(and definitely can't receive video/screen share). After the voice connection
is Ready, force a REST guild-members PATCH (mute:false, deaf:false) on the self
member so the bot is auto-unmuted & undeafened on every join/reconnect.

Requires MUTE_MEMBERS + DEAFEN_MEMBERS permissions (user granted). Failure is
logged as a warning and never breaks the voice join.
2026-08-30 21:15:04 +07:00
asepharyana 50967f6481 feat(gateway): capture NSFW/age-restricted messages but skip AI analysis + exclude from public archive
Previously isAgeRestrictedMessage() early-returned in messageCreate/messageUpdate,
so NSFW-channel messages were never stored at all. Now they are captured like
any message (visible in dashboard), while the existing age-restricted skip path
(queueMessageAnalysis -> buildAgeRestrictedSkipResult) marks them clean with flag
age_restricted WITHOUT calling the LLM.

NSFW content is also deliberately kept OUT of the Qdrant public semantic-search
archive (archiveMessageEmbedded skips when isAgeRestricted), so it can't be found
via public web search. No schema change needed (metadata already carries channel.nsfw).
2026-08-30 20:13:35 +07:00
asepharyana 7dfb4035b7 feat(gateway): persistent voice auto-reconnect — rejoin same channel after restart/reboot or unexpected drop 2026-08-30 14:03:12 +07:00
asepharyana a3a91aa2ce feat(voice): auto-detect speech language for transcription (drop forced 'en')
Whisper previously hardcoded language:en, mis-transcribing id/en-mixed
speech. Omitting 'language' makes Whisper auto-detect. Paired with enabling
AI_VOICE_TRANSCRIPTION_ENABLED (BWS secret gmw_ai_voice_transcription_enabled
= true) so new recordings are transcribed.

Spec: .hermes/plans/2026-08-30_recordings-v2-features-spec.md
2026-08-30 13:28:17 +07:00
asepharyana e3016a858a fix(voice-recording): stop missing start-of-burst audio & mid-burst splits
Root-cause fixes for 'banyak miss & terpotong' in the voice->recording flow:

- subscribe BEFORE collecting user metadata. receiver.speaking 'start' fires
  on the FIRST opus packet, and onUdpMessage forwards frames to the
  subscription only when one exists — every frame during the old
  await collectUserMetadata (a Discord REST roundtrip on cache miss) was
  dropped, cutting off the start of every burst. Now subscribe synchronously
  (guard first, no await in between), then fetch metadata in the background
  and discard the burst if the speaker turns out to be a bot.
- one segment per burst: drop the fixed 5s RECORDING_SEGMENT_MS rotation on
  the OGG path, which split continuous speech mid-word/sentence. Only the
  web-PCM decoder still rotates (bounds memory).
- finalize only once the underlying file has flushed to disk (wait on the
  write stream 'finish'), so upload/transcode reads a complete file.
- raise AfterSilence 3000->4000ms so natural pauses (thinking, interruptions)
  don't split one utterance into several recordings.
- lower the 'too short to keep' threshold 1000->300ms so brief replies
  ("ya", "siap") are kept instead of dropped.

All typecheck / biome(src/) / vitest (164) green.
2026-08-30 12:23:03 +07:00
asepharyana 8d6b48fb4c Merge remote-tracking branch 'origin/main' 2026-08-28 22:08:06 +07:00
asepharyana 9c9cd8917e feat(discord-gateway): use Discord CDN for image analysis, uploader archive-only
- mediaDownloader: flip URL candidate order so discord_url is tried
  before uploaded_url (uploaded_url is archive-only fallback)
- ai-analysis-worker: remove upload-pending race guard that blocked
  analysis until Tele upload completed; analysis now runs immediately
  on the Discord CDN URL
- batchProcessor: remove upload-pending defer/poll-backoff logic
- individualFallbackProcessor: remove upload_pending requeue loop
- batchOutcomeClassifier/fallbackResultClassifier: drop upload_pending
  classification (no longer needed)
- tests: update batchOutcomeClassifier + fallbackResultClassifier tests
  to reflect removed upload_pending signal
2026-08-28 22:07:37 +07:00
dependabot[bot] 430e3d137c build(deps-dev): bump @types/node
Bumps the development group in /services/discord-gateway with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@types/node` from 26.2.0 to 26.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 14:25:31 +00:00
asepharyana ffbe9959ab chore: migrate AI LLM router from 9router to omniroute
Switch GMW's AI LLM base URL from 9router (https://9router.asepharyana.my.id/v1)
to omniroute on imrnes (http://100.121.180.82:20128/api/v1).

- Update default AI_LLM_BASE_URL in discord-gateway + backend config schemas
- Update .env.example documentation
- Update all 9router references in comments/docs/tests to omniroute
- Production BWS secret gmw_ai_llm_base_url already updated

Omniroute uses /api/v1 prefix (not /v1 like 9router), so the base URL
now correctly points at the right API path for the OpenAI SDK.
2026-08-28 20:18:48 +07:00
asepharyana 631b5e1027 fix: make gateway migrations idempotent + self-heal drizzle history
Prevent recurring infinite restart loop (389x crash) caused by drizzle
re-applying already-applied migrations when public.__drizzle_migrations
tracking is empty/partial.

- 0017/0018: ADD COLUMN IF NOT EXISTS (re-run safe)
- 0019: DO-block rename that handles all prior states (server_name-only,
  both columns, or server_nick-only) so it never errors or double-renames
- seedDrizzleHistory: reconcile tracked created_at to the journal's latest
  'when' when the schema already reflects the latest migration, instead of
  early-returning on an existing-but-empty/partial tracking table
2026-08-27 15:05:30 +07:00
asepharyana ea23c405fa fix: capture server nickname (member displayName) per action
Rename server_name (guild name) to server_nick and populate it from
the member's server-specific display name (metadata.member.displayName)
at write time. This is what the moderation dashboard should show as
TARGET — e.g. server nick 'Bandar Togel「✔ ᵛᵉʳᶦᶠᶦᵉᵈ 」' for global
username '.nichiyobi'. Backfilled 210 existing actions from messages
metadata (reset_nickname rows now show 'Sarjana .jav', 'Penindas
Minoritas', etc). Frontend TARGET shows server nick with global
username as secondary context.
2026-08-27 13:50:37 +07:00
asepharyana 4991164591 fix: use AI for global username check instead of keyword list
Replace static OFFENSIVE_USERNAME_KEYWORDS substring matching with a
lightweight LLM call that evaluates whether a global username violates
server rules (gambling, scam, NSFW, SARA, etc). Fail-open design:
if the LLM call fails/times out, the nickname reset still completes.
2026-08-27 13:20:20 +07:00
asepharyana 1590479f58 fix: replace global username if also offensive after nickname reset
After resetting an offensive server nickname to the global username,
check the global username against gambling/scam keyword list. If it
also violates, generate a random 'UserXXXXX' nickname to prevent
circumvention via offensive global usernames.
2026-08-27 12:46:33 +07:00
asepharyana 0de393625f fix: add server_name to moderation_actions for full context retention
Denormalize guild name alongside username so the moderation dashboard
shows both TARGET and server even after message table purges.
Migration 0018. Frontend displays 'username · server_name' in TARGET.
2026-08-27 12:32:12 +07:00
asepharyana cccfd89266 fix: store username on moderation_actions for retention safety
Add denormalized  column to moderation_actions so the
dashboard TARGET field survives message table purges. Backfills all
existing 217 rows. Changes: schema + autoDeleteManager + backend
repository query + migration 0017.
2026-08-27 12:15:06 +07:00
asepharyana a6e2c1fa4f voice: deep stability audit — FFmpeg crash recovery, activity timeout, reconnect refresh
Gateway (transmitter.ts):
- Auto-stop on FFmpeg crash: non-zero exit triggers stop() to prevent
  silent audio loss and resource leaks
- Voice activity timeout (10s): auto-stops transmitter when no PCM
  received, preventing dead-air CPU waste on backgrounded tabs
- Stderr cap (4KB): prevents unbounded memory growth in long sessions

Gateway (voice.handler.ts):
- Double-check voiceController.getStatus().connected before starting
  transmitter — detects stale player state after gateway disconnect

Frontend (context.tsx):
- Force-refetch voice status on WS reconnect — UI converges in <1s
  instead of waiting up to 4s for SWR poll interval

All: tsc clean, biome clean
2026-08-26 23:48:36 +07:00
asepharyana 7e0d0d5123 voice: audit + noise suppression toggle + stability fixes
Gateway:
- transmitter.ts: cap backpressure queue at 500 chunks (prevents memory leak)
- transmitter.ts: fix Redis race — assign redisSub AFTER subscribe completes
- voice.handler.ts: static import Redis instead of dynamic (cleaner, no eval)

Frontend:
- mic-transmit.ts: MicAccessError with specific reasons (permission-denied, no-mic, timeout)
- mic-transmit.ts: noiseSuppression option in getUserMedia constraints
- mic-transmit.ts: proper DOMException handling for all getUserMedia failure modes
- use-voice.ts: noiseSuppression state + toggleNoiseSuppression exposed
- use-voice.ts: cleanup on unmount (stops transmitter, clears refs)
- voice/view.tsx: noise suppression toggle button (ShieldCheck/ShieldOff icons)
- voice/view.tsx: improved mic error toasts (permission denied / no mic specific)
- voice/view.tsx: NS status in codec footer (NS_ACTIVE when enabled)
2026-08-26 23:32:37 +07:00
asepharyana 4f4f92706c feat(voice): implement stale speaker management and clear functionality 2026-08-26 23:16:57 +07:00
asepharyana 709074935f style: fix biome formatting after audit fixes 2026-08-26 18:12:02 +07:00
asepharyana 9f02edd646 perf+fix(ai-moderation): 11 pipeline optimizations from audit
Audit of the full AI analysis flow found 14 issues; 11 fixed, 3 deferred:

Fixed:
1. batchProcessor: skip scheduleAutoDelete for error-status rows (was
   causing wasted not_eligible logs for every parse/API failure)
2. textBatchProcessor: domain dedup in URL fetch (max 3 URLs per domain
   to avoid rate-limiting from concentrated domains)
3. llmCaller: move parseModerationResponse import to top-level (was
   dynamic-imported inside retry loop — unnecessary overhead per retry)
4. llmCaller: make default max_tokens configurable via
   AI_LLM_MAX_COMPLETION_TOKENS env (default 16384)
5. moderationOrchestrator: log cache write errors instead of silent
   .catch(() => {}) — surface intermittent Redis failures
6. conversationContext: batch token estimation via estimateTokensBatch
   (single tiktoken encode call for all target lines, ~5x faster)
7. aiAnalyzer: skip revertStuckProcessingMessages DB query when no
   conversations are actively processing (avoids idle-state query)
8. textBatchProcessor: cache corrected few-shot examples per hour
   (was re-queried from DB on every batch)
9. textBatchProcessor: preserve partial results on sub-batch timeout
   (was throwing and discarding all prior sub-batch results)
10. batchProcessor switch: skip 'completed' messages from individual
    fallback queue (prevents redundant re-analysis + double-delete)
11. autoDeleteManager: expand isAlreadyDeletedError to catch Discord
    codes 10003/50001 + text fallback matching

Deferred (not regressions, larger refactors):
- #8 batchScheduler debounce race: not actually a race (JS single-threaded)
- #11 initCacheStore: already has idempotency guard
- #13 individual fallback batching: requires worker pool refactor

7 files changed, 73 insertions(+), 32 deletions(-)
2026-08-26 18:08:24 +07:00
asepharyana 54e7220d06 fix(auto-delete): prevent double-processing + improve error classification
Two bugs causing 23 spurious 'error' logs after successful deletions:

1. batchProcessor switch missing 'completed' case: partitionBatchOutcome
   returns 'completed' for successful messages, but the switch only handled
   'upload_pending' and 'api_failed'. Successful messages fell through to
   default → re-enqueued to individual fallback → re-analyzed → re-delete
   attempt → error (message already gone from Discord). Now explicitly
   skips 'completed' messages.

2. isAlreadyDeletedError only caught codes 10008/404. Discord also returns
   10003 (Unknown Channel) and 50001 (Missing Access) when a message or
   channel is gone. Added these codes plus text-based fallback matching
   'Unknown Message'/'Unknown Channel'.

Impact: eliminates ~23 redundant error logs per day + stops wasted LLM
calls re-analyzing already-processed messages.
2026-08-26 17:31:37 +07:00
asepharyana 46d889271e fix(auto-delete): accept 'warn' recommendedAction + delete flagged+medium
isEligibleForAutoDelete was rejecting messages where recommendedAction
was 'warn' or 'review' — only 'delete' and 'escalate' were accepted.
This caused 28+ medium-severity flagged messages to be logged as
'not_eligible' instead of being auto-deleted.

Changes:
- deriveRecommendedAction: return 'delete' for flagged+medium severity
  (previously only critical/high triggered delete; medium got 'review')
- isEligibleForAutoDelete: accept 'warn' as valid recommendedAction
  alongside 'delete' and 'escalate'

Impact: ~28 pending medium-severity flagged messages + all future
'warn'-action flagged messages will now be eligible for auto-deletion.
2026-08-26 17:16:30 +07:00
asepharyana 3b688e0d0f Refactor code structure for improved readability and maintainability 2026-08-26 10:13:40 +07:00
Asep Hariyana 613840e2d1 chore(discord-gateway): update and sync lockfile for bun 2026-08-26 09:51:26 +08:00
dependabot[bot] cffa72c21f build(deps-dev): bump tsx in /services/discord-gateway
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.23.1 to 4.23.12.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.1...v4.23.12)

---
updated-dependencies:
- dependency-name: tsx
  dependency-version: 4.23.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 14:26:03 +00:00
asepharyana 91de43a6c6 fix: switch GMW AI source from omniroute to 9router (model alias revert)
- Switch AI_LLM_BASE_URL from omniroute.imrnes.team to 9router.asepharyana.my.id
- Keep AI_LLM_MODEL as 'text' (9router uses alias-based routing, not bare names)
- Update .env.example comments to document 9router
- Per user: multimodal stays 'multimodal' alias

API verified: curl to 9router/v1/chat/completions with model 'text'
returns HTTP 200 (OpenAI-compatible format)
2026-08-25 20:36:24 +07:00
asepharyana 0bd4b4075e Merge pull request #17 from asepharyana/feat/fe-error-handling-state
fix: switch GMW AI source from omniroute to 9router
2026-08-25 20:27:10 +07:00
asepharyana 588e750ede fix: switch GMW AI source from omniroute to 9router
- Change AI_LLM_BASE_URL default from omniroute.imrnes.team to 9router.asepharyana.my.id
- Update AI_LLM_MODEL default from 'text' to 'claude-opus-5' (bare model name
  compatible with 9router/OpenAI-compatible router)
- Update .env.example and inline comments to reflect 9router
- discord-gateway config now matches backend (which already uses 9router)
2026-08-25 20:22:40 +07:00
mytheclipsebotreview[bot] 9b78abaf3d Auto-merge PR #7
build(deps): bump the production group across 1 directory with 8 updates
2026-08-25 11:29:54 +00:00
dependabot[bot] d06caa7e58 build(deps): bump the production group across 1 directory with 8 updates
Bumps the production group with 8 updates in the /services/discord-gateway directory:

| Package | From | To |
| --- | --- | --- |
| [ioredis](https://github.com/redis/ioredis) | `5.11.1` | `6.0.0` |
| [openai](https://github.com/openai/openai-node) | `6.49.0` | `7.5.0` |
| [opusscript](https://github.com/abalabahaha/opusscript) | `0.0.8` | `0.1.1` |
| [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) | `8.22.0` | `8.23.0` |
| [pino](https://github.com/pinojs/pino) | `9.14.0` | `10.3.1` |
| [piscina](https://github.com/piscinajs/piscina) | `5.3.0` | `5.3.1` |
| [sharp](https://github.com/lovell/sharp) | `0.34.5` | `0.35.3` |
| [ws](https://github.com/websockets/ws) | `8.21.1` | `8.21.3` |



Updates `ioredis` from 5.11.1 to 6.0.0
- [Release notes](https://github.com/redis/ioredis/releases)
- [Changelog](https://github.com/redis/ioredis/blob/main/CHANGELOG.md)
- [Commits](https://github.com/redis/ioredis/compare/v5.11.1...v6.0.0)

Updates `openai` from 6.49.0 to 7.5.0
- [Release notes](https://github.com/openai/openai-node/releases)
- [Changelog](https://github.com/openai/openai-node/blob/main/CHANGELOG.md)
- [Commits](https://github.com/openai/openai-node/compare/v6.49.0...v7.5.0)

Updates `opusscript` from 0.0.8 to 0.1.1
- [Release notes](https://github.com/abalabahaha/opusscript/releases)
- [Commits](https://github.com/abalabahaha/opusscript/compare/0.0.8...0.1.1)

Updates `pg` from 8.22.0 to 8.23.0
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.23.0/packages/pg)

Updates `pino` from 9.14.0 to 10.3.1
- [Release notes](https://github.com/pinojs/pino/releases)
- [Commits](https://github.com/pinojs/pino/compare/v9.14.0...v10.3.1)

Updates `piscina` from 5.3.0 to 5.3.1
- [Release notes](https://github.com/piscinajs/piscina/releases)
- [Changelog](https://github.com/piscinajs/piscina/blob/v5.3.1/CHANGELOG.md)
- [Commits](https://github.com/piscinajs/piscina/compare/v5.3.0...v5.3.1)

Updates `sharp` from 0.34.5 to 0.35.3
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.34.5...v0.35.3)

Updates `ws` from 8.21.1 to 8.21.3
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.21.1...8.21.3)

---
updated-dependencies:
- dependency-name: ioredis
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production
- dependency-name: openai
  dependency-version: 7.5.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production
- dependency-name: opusscript
  dependency-version: 0.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: pg
  dependency-version: 8.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: pino
  dependency-version: 10.3.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production
- dependency-name: piscina
  dependency-version: 5.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: sharp
  dependency-version: 0.35.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: ws
  dependency-version: 8.21.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:24:34 +00:00