C2 full scope persistence:
- getRecentConversationContext now returns per-turn guildId/channelId
- processMessage merges historical scope when current request is unscoped
- chatbot remembers server context across all 8 history exchanges (not just 3)
A4+Metrics:
- Add incrementCounterBy(name, delta, labels) to gateway-metrics
- Token-usage counters: llm_tokens_total{model, type, label} per batch
- Cache hit counters: moderation_cache_hits{type} exact/semantic-qdrant/semantic-pg
- Cache miss counters: moderation_cache_misses per batch
- Prometheus /metrics now exposes cost + cache hit-rate for dashboards
Performance:
- Parallel tool execution within each chatbot round (Promise.all)
- All tool results collected before sending to model (ordering preserved)
- Tool failure now logged with structured warning (chatbot.tools.ts)
Verified: backend tsc+biome 37/37, discord-gateway tsc+biome 210/210
- Send system prompt as role:system instead of merging into the user message
(models treat system messages as authoritative -> better tool selection)
- Add full 14-tool catalog + multi-hop instruction to the system prompt
- Raise MAX_TOOL_ROUNDS 4->6 so multi-hop queries complete
- Raise conversation history 3->8 exchanges (less repeat questioning)
- Append guild/channel scope hint to each user turn
- Add saturating toolErrors counter + structured warn log per tool failure
so chatbot tool failures are observable instead of silently absorbed
Even with the prompt firewall (username vs content), the LLM can still
occasionally mis-apply a content-level zero-tolerance flag (sara /
conflict_instigation) to a message whose ONLY violation is the username
(e.g. 'matikanetanyahu'). The auto-delete eligibility check only recognized
exact offensive_username flags, so such false positives still deleted the
message.
Add a belt-and-suspenders guard in isNicknameOnlyViolation: if the flag set
is entirely username-attributable (offensive_username/sara/conflict_instigation)
AND the analysis text corroborates that the violation is username-only with
clean message content, route to nickname-reset instead of message deletion.
Adds 6 test cases covering the real matikanetanyahu scenario and the
false-positive/negative boundaries.
Add explicit FIREWALL PENILAIAN rule separating username assessment from
message content assessment. Username containing political/religious terms
(matikanetanyahu etc) is assessed as offensive_username with severity low
— never triggers sara/conflict_instigation zero tolerance for content.
Changes:
- rules.ts: Add FIREWALL section before LARANGAN BERAT; clarify each
zero-tolerance rule applies to ISI PESAN only; update hierarchy #9
- examples.ts: Fix example #11 status flagged→warn for clean username;
add example #11b with matikanetanyahu case
- output.ts: Explicit status/action mapping for username-only violations
result.score was required by zod; the LLM (gemini-3.5-flash-lite via 9router)
occasionally omits it for media batches, hard-failing the whole batch parse
('Zod validation failed: expected number, received undefined' at
results[0].score). Callers already null-coalesce (result.score ?? 0) and the
parser clampScore()s it, so requiring it only caused parse failures.
Adds regression tests: media-batch without score parses (score->0), and
score-present responses still parse with the value.
A selfbot (user token) cannot auto-detect other members' camera/share
(no VOICE_STATE_UPDATE for others, 403 on member fetch). The only
selfbot-viable path to capture another member's SCREEN SHARE is an
operator-initiated STREAM_WATCH (gateway op 20, not gated on bot-vs-user).
Add video:watch / video:unwatch Redis commands routed via the existing
command handler to startStreamWatch/stopStreamWatch, which then does the
DAVE handshake + per-burst MP4 segmentation + DB insert + Tele upload
(already implemented in streamWatchReceiver).
- new VideoHandler (command-handler/video.handler.ts)
- register video:watch / video:unwatch in handler-registry + CommandHandler
- command constants COMMAND_VIDEO_WATCH / COMMAND_VIDEO_UNWATCH
- resolve active voice channel from voice controller + client cache
- 8 unit tests (videoHandler.test.ts)
- biome fixes for pre-existing test import ordering
All green: typecheck, build, lint (174 files), 200 tests.
Dump all WS raw event types after 10s (see if GUILD_CREATE exists at all),
and log broadcaster_user_ids + sessions.voice from READY payload.
Selfbot may RESUME (skip GUILD_CREATE) — voice data may only be in READY.
Temporary diagnostic to see whether GUILD_CREATE.voice_states actually
reaches the selfbot raw listener (selfbot-v13 emits everything via
WebSocketShard Events.RAW). Will remove once root cause confirmed.
Real root cause of 'tidak mendeteksi user yg sudah ada di voice':
The selfbot's discord.js-selfbot-v13 GUILD_CREATE handler only sends
GUILD_SUBSCRIPTIONS_BULK — it DROPS d.voice_states from the payload.
So when the gateway (re)starts, every user who was ALREADY in the voice
channel (with camera or screen-share on) is invisible: channel.members is
empty, and REST fallbacks don't work for user tokens (verified live:
GET /channels/{id}/voice-states -> 404, GET /guilds/{id}/members -> 403).
Fix: register our own raw listener for GUILD_CREATE, capture
d.voice_states per guild (buffer it), and consume it in
scanExistingStreamers when the channel gets tracked (voice join happens
after GUILD_CREATE). This is the ONLY user-token-compatible source of
'who is in voice with video right now'.
- videoRecorder: +pendingGuildCreateVoiceStates buffer, +raw GUILD_CREATE
listener, Path C consumes buffered states (self_video/self_stream,
matching channel, skip self) before the REST fallback
- scanExistingStreamers: Path A cache -> Path C GUILD_CREATE -> Path D
REST members.fetch() best-effort
- +1 test: GUILD_CREATE voice_states buffer -> watch camera+screen-share,
skip self/no-video/other-channel (192/192 pass)
- typecheck + build + biome clean (1 pre-existing warning)
Root cause: scanExistingStreamers only read channel.members, which is
EMPTY after a gateway restart because the selfbot's guild member cache
hasn't been populated yet. So any user who was ALREADY on camera /
screen-sharing when the bot (re)joined was never detected → no video.
Fix: when the member cache is empty, fall back to guild.members.fetch()
(REST GET /guilds/{id}/members — user-token compatible; the bot-only
GET /channels/{id}/voice-states returns 404 for selfbots, verified live)
which populates member.voice states, then re-scan channel.members for
streaming/selfVideo.
- scanExistingStreamers is now async; trackChannel fire-and-forgets it
- logs source=cache vs source=rest-members for observability
- +1 test: cold-start channel.members empty → REST fetch → watch camera user
- 191/191 tests pass, typecheck + build + biome clean
Previously forceSelfServerUnmuteUndeafen only ran on video-watch attempts and
after voice reconnects — so when an admin server-muted or server-deafened the
bot, it stayed muted/deafened for minutes (or forever if no streamer came on).
Add registerSelfVoiceStateGuard: a voiceStateUpdate listener that detects the
bot's own serverMute/serverDeaf transition to true and immediately re-issues
mute:false,deaf:false. Wired at client-ready in bootstrap.ts. Best-effort,
idempotent, never blocks the gateway.
Closes the three highest-value gaps from the FE data-flow audit:
- voice/view.tsx: render VoiceStatus.connections (multi-guild bridge roster)
which was fetched but never shown — channel name, guild/channel id,
connected time, ACTIVE marker for the primary bridge (falls back to the
active channel when connections is empty).
- recordings/view.tsx: show SpeakerSummary.last_at ('active <relative>') on
the speaker leaderboard row (data was fetched but hidden).
- dashboard/view.tsx: render activity.hourly as an 'Hourly Flow · Last 24h'
24-bar volume strip with per-hour tooltip and total msgs/flagged header
(hourly distribution was fetched but only daily was charted).
Verified: tsc clean, biome clean (127 files), pnpm build green.
Follow-up to d0453881: the pgUserReputationsTable definition and the
UserReputation/UserReputationInsert type aliases were the only remaining
references to the removed user reputation feature. Nothing imports them
(verified via grep across src/ + tests/), so they're pure dead code that
could mislead future devs into re-joining a dropped table. Railed out to
close the 42P01 failure class completely.
Root cause: gateway migration 0016 removed the per-user reputation feature
(DROP TABLE user_reputations), but the backend still LEFT JOINed
pgUserReputationsTable in dashboard listUsers/getUserDetail and the chatbot
get_user_reputation tool. Against the live DB these queries raised 42P01
(undefined_table) -> the new /users page could never load (oRPC WS error).
Fix:
- dashboard.repository.ts: remove reputation columns/join from listUsers and
getUserDetail (data sourced only from messages + user_profiles).
- chatbot.tools.ts: get_user_reputation now returns honest 'unavailable'
(feature removed) instead of querying the dropped table.
- chatbot.toolDefs.ts: update tool description so the LLM doesn't advertise
a dead feature.
- frontend types/users view: drop trust_score/clean_message_streak/
total_infractions/last_infraction_at; derive risk label from real flagged%
and add warn_count to the inspector (real available data).
Verified: backend+frontend tsc clean, biome clean, 37 unit tests pass,
query tested against live DB (returns real members), build green.
videoReceiver.getSsrcInternalMap read asAny._map, but @discordjs/voice
0.19.x exposes the SSRC map as the public field 'map'. So inferVideoOwner
(e.g. matching a video RTP SSRC to a user via audio-SSRC proximity) always
returned null -> every video packet was silently dropped after decryption.
Accept both 'map' and '_map'. Unblocks camera/screen-share attribute when
op12 does not carry a videoSSRC stream entry.
- New /users route: member roster (trust score, clean/flagged counts, message volume)
+ inspector detail (trust breakdown, clean streak, infractions, AI profile,
recent messages) with live search
- Backend dashboard.listUsers/getUserDetail now JOIN user_reputations to expose
trust_score, clean_message_streak, total_infractions, last_infraction_at +
warn_count/clean_count breakdowns
- Frontend types updated to match; useUsers refactored to PaginatedUsers shape,
added useUserSearch for the old search behavior
- Nav rail + mobile nav: add Users entry
streamWatchReceiver: DAVE decrypt for screen-share video packets was
returning null every time (VIDEO-PKT diag showed packets arriving but
no 'Video segment opened'). Three possible causes:
1. No VIDEO decryptor in MLS group (audio-only handshake)
2. GoLive stream tags video packets as AUDIO
3. Screen-share payloads are unencrypted above the AES layer
Fix: try MediaType.VIDEO first, then MediaType.AUDIO, then passthrough
(legacy-decrypted payload as-is). This covers all three modes without
breaking audio recording.
Fork @discordjs/voice 0.19.2 into vendor/discord-voice-fork and patch the
voice gateway handshake so Discord sends camera/screen-share RTP video:
- Identify payload now declares video:true + streams:[] (derived from
Discord-RE/Discord-video-stream) - this is what makes Discord deliver
H264 (PT 96-127) to the voice socket. Previously the client never
declared video capability, so Discord omitted all video SSRCs/packets.
- op-12 Speaking handler maps streams[].ssrc -> videoSSRC alongside the
audio SSRC, so ssrcMap carries camera/screen-share stream IDs.
- SSRCMap.get() now also resolves video SSRCs (video RTP arrives on a
different SSRC than audio), enabling attribution for videoReceiver.ts.
- Both dist/index.js (CJS) and dist/index.mjs (ESM) patched; 3 isolated
hunks vs upstream, verified by diff.
- package.json points @discordjs/voice -> file:vendor/discord-voice-fork
(pnpm lockfile updated, CI --frozen-lockfile compatible).
- .gitignore: replace global dist/ with per-service explicit patterns so
the vendored fork's dist/ is committed while build outputs stay ignored.
- tests: +3 SSRCMap videoSSRC cases (190 total, all pass).
The receive-side pipeline (H264Depacketizer -> .h264 -> muxToMp4 -> MP4)
already exists in videoReceiver.ts; this unblocks it by making Discord
actually deliver video packets.
Previously only member.voice.streaming (screen share, self_stream) triggered
video capture. Discord reports camera via self_video:true, so camera-only
users were never watched. Now scanExistingStreamers and handleVoiceStateUpdate
start a watch when streaming OR selfVideo is set, and stop it when both clear.