Author SHA1 Message Date
asepharyana c8473b0610 build(nix): fix binding link path — LDC_LIB is full .so path
binding.gyp appended '/libdatachannel.so.0.24.0' to LDC_LIB; nixpkgs output
layout is <out>/lib/libdatachannel.so.0.24.1. Make LDC_LIB the complete
library path (env or default) and drop the append.
2026-08-11 18:54:12 +07:00
asepharyana 3deca91ffe build(nix): use nixpkgs libdatachannel (no cmake/fetchFromGitHub)
libdatachannel-src fetchFromGitHub + manual cmake build fails: GitHub tarball
does not include git submodules (deps/plog, libjuice, libsrtp, usrsctp) →
CMake 'source directory does not contain CMakeLists.txt'.

Switch to pkgs.libdatachannel (0.24.1): nixpkgs builds submodules + ships
lib/dev outputs. In the Nix sandbox everything is consistent (store glibc),
so the GLIBC_ABI_GNU2_TLS issue that blocks host-local use of 0.24.1 does
not apply to the Nix build. binding.gyp defaults stay on local 0.24.0 for
dev; Nix sets LDC_INCLUDE/LDC_LIB to the store paths.
2026-08-11 18:45:51 +07:00
asepharyana edec2edf82 build(nix): binding — NAPI_INCLUDE from pnpm store (depth 3), gyp env fallback 2026-08-11 18:35:49 +07:00
asepharyana 17013fe1e5 build(nix): gateway binding — gyp env-var paths, correct cwd, tolerant install
- binding.gyp: resolve libdatachannel include/.so via LDC_INCLUDE/LDC_LIB env
  (node -e expression) instead of hardcoded /tmp/ldc-build paths
- flake buildPhase: run node-gyp from native/libdatachannel-min root (was
  build/ subdir → 'binding.gyp not found'); export LDC_INCLUDE (fetchFromGitHub
  source) + LDC_LIB (cmake build dir)
- flake installPhase: tolerate missing binding (screen share disabled, gateway
  still starts); copy .so real files via -rL
2026-08-11 18:32:09 +07:00
asepharyana 3acb03391a build(nix): gateway flake — build libdatachannel-min binding, drop datachannel/node-av/zeromq
- Replace the per-package rebuild loop (node-datachannel cmake-js, zeromq)
  with: opus build + libdatachannel-min N-API binding build (fetchFromGitHub
  libdatachannel v0.24.0 — pinned because nixpkgs 0.24.1 is glibc-incompatible
  with this host; sha256 1jk53qs…).
- Removes ~760MB of node-datachannel build/cleanup cruft from the build
  phase; node_modules now 423MB (was 1.5GB).
2026-08-11 17:53:47 +07:00
asepharyana 9109d3c898 perf(golive): drop @dank074/discord-video-stream — node_modules 1.5GB → 423MB
Remove the last heavy GoLive dependency now that src/goLive/ replaces it:
- @dank074/discord-video-stream (pulled in @lng2004/node-datachannel
  771MB, node-av 118MB + @seydx/node-av-linux-x64 167MB, zeromq 21MB,
  fluent-ffmpeg 13MB — ~1.09GB total)
- onlyBuiltDependencies: drop node-av/zeromq/@lng2004 (keep opus/esbuild/sharp)
- pnpm.lock regenerated; orphan .pnpm dirs removed locally
- @discordjs/opus prebuild: rebuilt binary copied into
  prebuild/node-v127-napi-v3-linux-x64-glibc-2.39/ (node-pre-gyp find path)

Verified: tsc 0 errors, vitest 8/8, biome clean, opus encode OK.
Fresh CI install now ~423MB instead of ~1.5GB.
2026-08-11 17:49:33 +07:00
asepharyana 9139e225f4 perf(golive): ffmpeg-spawn demuxer (no node-av) + E2E pipeline tests
Phase 2 — replace the 114MB node-av binary with a plain ffmpeg spawn:

Demuxer.ts: spool stream input to temp file → probe via ffmpeg stderr
(ffmpeg-headless ships NO ffprobe — parse 'Stream #0:0: Video: h264...
640x360, 30 fps' from -loglevel info) → ffmpeg -c copy -f h264 pipe:1
→ NAL-split frames. Falls back to h264 defaults when probe fails.

prepareStream.ts: resolve ffmpeg from FFMPEG_PATH env → Nix store
ffmpeg-headless (hash-prefixed entry!) → PATH; split encoder option
strings ('-forced-idr 1' → two argv) — fluent-ffmpeg used to split
automatically, spawn does not.

E2E tests (tsx, need LD_LIBRARY_PATH=/tmp/ldc-build):
- golive-demux-e2e.ts: real H264 file → 33 NAL frames + dims from probe
- golive-pipeline-e2e.ts: prepareStream → demux → 82 frames
- golive-videostream-e2e.ts: local peer pair → demux → VideoStream →
  native setPacketizer/sendFrame/addTimestamp → 33 frames sent connected

Pitfalls captured: setPacketizer before negotiation breaks createOffer
('No DataChannel or Track to negotiate'); track methods are read-only
(no monkeypatching); both peers must declare audio+video tracks or
answer hangs; state() returns 'closed' after close() — snapshot first.
2026-08-11 17:38:06 +07:00
asepharyana 9ae230d047 perf(golive/spike): binding addTrack + TS port of @dank074 media stack
Phase 1 spike: replace @dank074/discord-video-stream + node-datachannel +
node-av (1.3GB) with minimal libdatachannel N-API binding + native RTP
packetizers (H264 FU-A, RTCP SR/NACK, pacer) + pure-TS GoLive stack.

Binding v0.4: addTrack (m=audio/video SDP), TrackWrap w/ setPacketizer +
sendFrame (raw RTP to transport) + addTimestamp — verified by two-peer
handshake emitting SDP with audio(opus 120)+video(H264 101) and 8-frame
RTP roundtrip.

TS layer (src/goLive/, 21 files): CodecPayloadType, VoiceOpCodes,
GatewayOpCodes, utils, BaseMediaConnection (voice WS + DAVE + heartbeat),
VoiceConnection, StreamConnection, Streamer, WebRtcWrapper (SDP mungling,
DAVE encrypt, packetizer chain), BaseMediaStream (pacing/sync), VideoStream,
AudioStream, Demuxer (ffmpeg-spawn NUT/AnnexB, no node-av 114M binary),
Encoders, prepareStream/playStream.

Integration: screenShareController.ts now imports from ../../goLive/index.js —
prepareStream(prepared, ...) + playStream(prepared, streamer, {...}).

Tests: tests/goLive-port.test.ts (8/8 pass). tsc --noEmit clean. biome clean.
2026-08-11 16:16:52 +07:00
asepharyana a1a6d8b418 spike: expose libdatachannel media packetizer chain via Track
Track.setPacketizer(kind, ssrc, pt, clockRate, ...) builds the same
media-handler chain node-datachannel does for @dank074:
  RtpPacketizer (Opus | H264 | H265 | AV1) → RtcpSrReporter →
  RtcpNackResponder → PacingHandler(25Mbps, 1ms) for video
Track.sendFrame(encodedFrame) packetizes into RTP; addTimestamp(delta)
advances the RTP timestamp (node-datachannel contract).

Verified test-packetizer.js: two peers connected over tracks, real opus
frames + AnnexB H264 (SPS/PPS/IDR) flow through the chain without crash.
This removes the need for a JS RTP packetizer entirely — libdatachannel
0.24 has the full media stack built in.
2026-08-11 14:57:04 +07:00
asepharyana 4f06c30c05 spike: add addTrack + TrackWrap to libdatachannel-min binding
Expose rtc::Track with send(binary) for raw RTP — verified:
- SDP from addTrack(audio)+addTrack(video) has m=audio (opus 120)
  and m=video (H264 101 + H265/VP8/VP9/AV1 + RTX)
- libdatachannel Track::send() sends RAW RTP/RTCP when no media
  handler is set (verified in src/track.cpp impl::Track::outgoing) —
  so RTP packetization can live in pure JS, keeping the binding minimal

Also fix: Track class was missing from InitAll exports (crash on
TrackWrap::NewInstance — null FunctionReference).
2026-08-11 14:51:56 +07:00
asepharyana 2203dd5771 spike: minimal N-API libdatachannel binding — WebRTC handshake proven
Phase 0 of GoLive rewrite (drop @dank074/node-datachannel 771MB):
minimal N-API binding exposing PeerConnection/DataChannel/ICE/SDP,
built against libdatachannel 0.24.0 (from node-datachannel _deps source).

Verified: offer/answer/ICE/DataChannel roundtrip between two local
peers (test-handshake.js). Key findings:
- callbacks must be registered in ctor BEFORE createDataChannel
- SDP with candidates comes from localDescription() at gathering Complete
- answer auto-generates on setRemoteDescription(offer); do NOT call
  setLocalDescription() after or role=actpass breaks the peer
2026-08-11 14:23:31 +07:00
asepharyana a53d7b71da fix(voice): screen share GoLive died instantly — neutralize node-av custom ffmpeg filters
prepareStream (from @dank074/discord-video-stream) unconditionally appends
audio filters 'volume@internal_lib' + 'azmq' that exist ONLY in its custom
node-av jellyfin-ffmpeg build. The Nix deployment runs plain ffmpeg-headless
on PATH, so fluent-ffmpeg died instantly with 'Filter not found' (exit 8),
the NUT output stream stayed empty, and playStream's node-av demux failed
with 'Failed to open input from Readable stream: Invalid data found when
processing input' — every screen share failed ~100ms after start.

Fix: pass customFfmpegFlags ['-filter:a','anull'] — ffmpeg applies the LAST
-filter:a for a stream, so the trailing no-op filter overrides the custom
chain (verified: command ends with '-filter:a anull', transcode runs, node-av
demux finds video+audio). Realtime volume control was already removed from
GMW (a690e5b), so dropping the filters is lossless.

Verified end-to-end with the failing URL (youtu.be/fONoh7Pc6VU, AV1+Opus
DASH): getDirectScreenInput → NUT merge → patched prepareStream → node-av
demux finds H264 video + Opus audio streams.
2026-08-11 10:56:49 +07:00
asepharyana c18431bdbf fix(ai-moderation): never cache vision outputs that claim 'no image seen'
Root cause (3rd layer after 50371bd + 4f4c435): a vision model run
(2026-08-10) returned 'Maaf, saya tidak melihat gambar apapun yang terlampir...'
and that text was cached as a VALID vision_llm result (image + phash keys,
24h/7d TTL). Every subsequent analysis of the same image (same hash/phash)
hit the poisoned cache, so image analysis looked broken forever even though
9router responded fine — the moderation LLM wrote 'lampiran yang gagal
terbaca' from a cache hit.

Also: mimo via 9router streams reasoning in delta.reasoning +
delta.reasoning_details[].text (content:"") — extractChunkText only read
delta.reasoning_content, so those runs aggregated empty → 'Vision API null
response' (observed 08:54/09:07/09:38).

Fixes:
- llmClient.extractChunkText: fall back to delta.reasoning and
  reasoning_details[].text (mimo), on top of reasoning_content (gemma).
- visionAnalyzer: isNoImageSeenText() detects 'no image' style outputs;
  such results are NEVER cached, and poisoned entries are purged when hit
  (LRU/DB/phash) so re-analysis actually re-runs vision.
- Tests: reasoning/reasoning_details extraction + isNoImageSeenText
  (Indonesian + English, no false positives on real descriptions).
2026-08-11 09:55:43 +07:00
asepharyana 4f4c43555f fix(ai-moderation): attachment-upload race dropped images before vision
Root cause (2nd layer after 50371bd): the analysis worker could pick up an
image message while its attachment upload was still in flight
(upload_status='pending'). downloadAndExtractFrame then fell back to the
Discord CDN URL (cdn.discordapp.com), which often 404s for old/purged links,
and 'if (!res.ok) return' silently dropped the image — no log, no vision
call, empty image map, and the LLM produced a text-only verdict like
'lampiran yang gagal terbaca oleh sistem'.

Fixes:
- ai-analysis-worker: skip targets whose attachment upload is still pending
  (both batch + individual paths) — they stay ai_status='pending' and the
  next 15s cycle analyzes them after the upload lands.
- mediaDownloader.downloadAndExtractFrame: try uploaded_url first, then
  discord_url as fallback; log non-OK responses (status + host) instead of
  silently returning; log when all candidate URLs fail.
2026-08-11 09:44:34 +07:00
asepharyana 50371bd2d1 fix(ai-moderation): read delta.reasoning_content in stream aggregation — image vision never returned text
Root cause: 9router combo 'multimodal' routes to cloudflare-ai/@cf/google/
gemma-4-26b-a4b-it which streams ALL output in delta.reasoning_content
(content:"") and finishes with 'length' at max_tokens. llmClient only read
delta.content, so llmVision returned empty → every image moderation fell back
to text-only analysis ('Meskipun analisis gambar gagal' in every ai_analysis).

Fix: extractChunkText() prefers delta.content then falls back to
delta.reasoning_content (also handles message/text/response fields), with
unit tests for the exact 9router chunk shape. Verified live against a real
DB image: oc/mimo-v2.5-free (new first model in the multimodal combo) returns
a proper description in delta.content.
2026-08-11 08:06:28 +07:00
asepharyana 0792ff4dc0 perf(nix): prune devDependencies from shipped node_modules
gateway output 1.4G -> 424M (-70%), backend 198M -> 60M (-70%).

- pruneProd: delete every .pnpm dir not in 'pnpm list --prod' graph
  (biome/typescript/esbuild/drizzle-kit/vitest/tsx ~150MB+) then drop
  dangling symlinks (top-level, scoped dirs, hoist, .bin) so stdenv
  noBrokenSymlinks fixup passes.
- NOT using 'pnpm install --prod': it collapses the public-hoist dir
  (.pnpm/node_modules) that peer resolution relies on for
  @lng2004/node-datachannel + @seydx/node-av-linux-x64 (voice breaks).
- node-datachannel: strip build/_deps (cmake FetchContent ~380MB) +
  nested node_modules (nw-gyp/typescript/puppeteer ~380MB) after
  compile; runtime needs only build/Release/node_datachannel.node.
- verified: native binaries (datachannel/opus/zeromq) intact, all 27
  runtime modules resolve, dev tools 0.
2026-08-10 22:17:38 +07:00
asepharyana eb89bb79ed ci(deploy): fix attic push fallbacks - VPS-hop sudo, direct push retry, ssh URL
- VPS-hop attic push now runs via sudo so attic reads root's config
  (~/.config/attic) which has the imrnes-ts server (Tailscale). Without
  it the push ran as the CI user whose config only has pub ->
  'Server imrnes-ts does not exist', silently skipping the cache upload.
- Direct push retried 3x (attic push is idempotent): a transient 502
  (e.g. atticd restart mid-push, Traefik blip) no longer aborts the
  whole closure upload before falling back to VPS-hop.
- Restore $VPS_USER in the 3 ssh:// nix copy fallbacks (was committed
  as masked '***' -> nix copy would ssh as user '***' and fail).
2026-08-10 21:18:25 +07:00
asepharyana 7d6c741bb2 ci(deploy): force narinfo write with --ignore-upstream-cache-filter; Traefik readTimeout=0 on imrnes 2026-08-10 20:18:56 +07:00
asepharyana 4cb4904517 ci(deploy): fix attic fast path - public cache, extra-substituters, self-hosted client bootstrap
Root causes found by reproducing the 2026-08-10 run:
- attic 'gmw' cache was created private -> every narinfo/nix-cache-info
  read returned 401, so the VPS could never actually substitute from
  attic ('Substituted from Attic cache' was a false positive when the
  store path happened to be already present locally).
- VPS nix.conf used extra-trusted-substituters, which Determinate Nix
  never merges for nix-store CLI clients; extra-substituters (all
  users, no trust gate) fixes substitution (verified end-to-end:
  delete path -> nix-store --realise pulls from attic over HTTPS).
- runner bootstrap of the attic client depended on nix copy --from
  ssh:// (fragile, failed on runner); now the prebuilt attic client
  closure lives in the attic cache itself and the runner pulls it over
  HTTPS via extra-substituters configured in the Install Nix step.

Also surfaces bootstrap stderr on fallback for future debugging.
2026-08-10 18:29:00 +07:00
asepharyana 4ee295bd29 ci(deploy): push to attic directly from runner, skip slow SSH closure copy
The old Push-to-Attic step SSH-copied the full closure (~794MB gateway) to
the VPS on every new store path before attic push — at ~500KB/s that took
25+ minutes (observed 40min+ in-flight run). The runner can now push
straight to the public attic endpoint (https://attic.asepharyana.my.id,
token auth validated) after pulling the prebuilt attic client closure
(52MB) from the VPS via nix copy --from. Falls back to the VPS-hop flow
whenever the direct path fails.
2026-08-10 17:28:28 +07:00
asepharyana 65c9c2cd9e feat(ai-moderation): enrich analysis context with recency, repetition, user history and channel topic
- <message> targets now carry time (ISO), repetitions (N identical short texts = spam signal), bot and edited flags; escape id/user XML
- rich <user_reputation>: total_infractions, clean_streak, last_offense_days_ago, repeat_offender (7-day window)
- <user_history> with last flagged messages for repeat offenders (wires dead getUserRecentInfractions)
- <user_profile as_of> staleness signal; <location_context topic> from captured channel topic
- prompt framing + output instructions teach the LLM to use the new signals without treating history as proof
- tests: contextEnrichment.test.ts (13) + topic cases in conversationContext.test.ts
2026-08-10 17:15:33 +07:00
asepharyana 0a5254bf20 feat(ai-moderation): enhance context handling with structured XML blocks and user profiles 2026-08-10 16:46:55 +07:00
asepharyana 4a51f3055c ci(deploy): push builds to attic binary cache (attic.asepharyana.my.id) 2026-08-10 15:27:59 +07:00
asepharyana 185d81f0e0 feat(ai-moderation): reset offensive nickname instead of deleting message
When the ONLY violation is offensive_username (message content clean):
- Message is NOT deleted (nickname-only violation bypasses auto-delete)
- Member's server nickname is reset to default username via
  setNickname(null) (Discord shows the global username again)
- Action 'reset_nickname' logged to moderation_actions; cooldown
  10min per guild:user (LRU) so repeated messages by same member
  don't hammer the Discord PATCH
- Config: AUTO_NICKNAME_RESET_ENABLED / AUTO_NICKNAME_RESET_COOLDOWN_MS
2026-08-10 11:48:27 +07:00
asepharyana ecbb538c9f feat(ai-moderation): use per-server nickname (displayName) in analysis payload
- resolveDisplayName(): member.displayName from captured metadata,
  falls back to global username
- Applied to context lines, target message blocks, and media message
  blocks — LLM sees the name the channel actually sees (nickname can
  carry moderation signal itself)
2026-08-10 11:36:37 +07:00
asepharyana 4049ab4201 feat(ai-moderation): rich context + link media vision analysis
- Conversation context recency gates (GAP_MS/MAX_AGE_MS): drop stale
  messages before silence gaps; cold_start anchor + flow descriptor
  tells LLM whether conversation is ongoing or restarted
- [location] block: channel name, thread name, nsfw/age flags from
  captured metadata (thread names instead of bare IDs)
- Link media -> multimodal: text-batch URL fetches that resolve to
  images now run vision analysis (bounded 15s) and switch prompt to
  mixed mode; <web_content> gains og:title for page context
- pnpm-workspace.yaml: approve sharp build script (unblocks install)
2026-08-10 11:26:26 +07:00
asepharyana 5d094829c4 fix(ui): fit select popup to content and wrap long items
A single long guild name was clipped (whitespace-nowrap + narrow min-width),
so the dropdown rendered as a tiny 144px box with truncated text. Size the
popup to fit-content up to the available width and let option text wrap.
2026-08-07 19:29:36 +07:00
asepharyana abbd78f42b fix(ui): theme popover/input tokens and open select below trigger
Select dropdowns (voice tab, guild selector) rendered with a transparent
background because --color-popover/--color-input were undefined, and the
popup overlapped the trigger due to alignItemWithTrigger. Define the missing
theme tokens (popover, popover-foreground, input, secondary) and default the
select popup to open below the trigger.
2026-08-07 18:18:14 +07:00
asepharyana 4f9d4a5c7d refactor: remove unused UI components and replace GlassCard with Card in voice components
- Deleted Item, Kbd, Marker, Message, NativeSelect, Questionnaire, Spinner components.
- Replaced GlassCard with Card in VoiceActivityTimeline, VoiceConnectionCard, ListenControl, MicControl, and SpeakerWaveform components.
- Introduced AppSidebar and ThemeToggle components for improved navigation and theme management.
2026-08-07 17:33:12 +07:00
asepharyana 2c995b41d7 feat: add new UI components including RadioGroup, Resizable, Sidebar, Spinner, Table, Toast, and ToggleGroup
- Implemented RadioGroup and RadioGroupItem for radio button functionality.
- Created ResizablePanelGroup, ResizablePanel, and ResizableHandle for resizable panels.
- Developed Sidebar component with context for state management and various subcomponents (SidebarTrigger, SidebarMenu, etc.).
- Added Spinner component for loading indicators.
- Introduced Table component with TableHeader, TableBody, TableFooter, and related subcomponents for structured data display.
- Built Toast component for notifications with customizable actions and icons.
- Implemented ToggleGroup and ToggleGroupItem for toggle button functionality with context support.
2026-08-07 16:11:33 +07:00
asepharyana 18dd6a56ba feat(media): loop mode + high-quality OggOpus music playback
- Loop: toggle via POST /api/media/loop → COMMAND_MEDIA_LOOP; gateway
  replays finished music track on natural end (queue untouched); status
  payload exposes loop flag; FE tombol Loop di music-player + mini-player.
- Kualitas suara: music playback sekarang di-transcode sekali via ffmpeg ke
  OggOpus 48kHz stereo 192kbps dengan volume di-bake ke encode — menghindari
  double lossy encode (inlineVolume) yang bikin suara buram. Screen share
  tetap pakai jalur lama.
- Backend: MediaState.loop, setLoop service, route + schema validation.
2026-08-07 14:53:37 +07:00
asepharyana a690e5b63e refactor(media): remove volume control from FE & BE
Volume sudah di-set default 0.3 di gateway (suara kecil saat play), dan
user bisa naikin sendiri di Discord (command media:volume) — jadi kontrol
volume lewat dashboard tak perlu. Hapus:
- BE: POST /api/media/volume route, mediaVolumeSchema, setVolume service
- FE: useMediaVolume hook, mediaApi.volume, slider volume di music-player
  + mini-player, field volume/setVolume di MediaPlayerProvider
Pertahankan COMMAND_MEDIA_VOLUME di gateway (masih dipakai command DC)
dan mic volume (terpisah, tetap di voice page).
2026-08-07 14:27:33 +07:00
asepharyana 62ffb676f9 feat(media): default music volume 30% instead of 100%
Volume play music terlalu besar buat user — default sekarang 0.3 (30%)
di semua layer: player gateway (musicVolume=0.3), backend state/schema
(default 0.3), dan UI slider (fallback 0.3). User tetap bisa naikin
manual via slider volume di dashboard.
2026-08-07 13:41:12 +07:00
asepharyana 4797aca20f ci(deploy): poll service readiness instead of fixed 3s sleep
is-active after sleep 3 false-fails when the unit is still activating
(e.g. Next standalone boot >3s) — exit 3 flagged the deploy red even though
the service came up fine. Poll is-active up to 30s and only fail if it never
reaches 'active'.
2026-08-07 11:22:29 +07:00
asepharyana 42b8afd412 fix(flake): purge dangling pnpm symlinks in standalone tree
noBrokenSymlinks fails frontend build: .next/standalone/node_modules/.pnpm/
node_modules/semver -> missing target. The standalone server never resolves
pnpm's hoisted .pnpm dir (it bundles its own node_modules) — delete broken
symlinks before install so stdenv check passes.
2026-08-07 10:57:18 +07:00
asepharyana 7575a701bd style(backend): biome format — organize imports + spacing (live-speaker/redis-bridge/voice.service) 2026-08-07 10:49:16 +07:00
asepharyana 9f91155944 ci(deploy): build+deploy frontend package (SSR standalone server)
Sebelumnya frontend hanya static export yang di-serve nginx di dalam package
proxy. Sekarang frontend = runtime mandiri (Next.js standalone :4017) yang
nginx proxikan ('/' -> Next server, '/api' + '/ws' -> backend :4001). Tambah
'frontend' ke matrix deploy agar dideploy + memulai unit gmw-frontend.
2026-08-07 10:46:02 +07:00
asepharyana f20889868d feat(frontend): rebuild as SSR with server-authoritative shared state
Rombak total alur data frontend: dari static-export CSR (tiap browser
fetch sendiri + akumulasi state voice per-tab) jadi server-side rendering.

Frontend (Next.js):
- next.config: output export -> standalone; halaman jadi server components
- server data layer baru src/lib/api/server.ts (GMW_BACKEND_URL, no window)
- dashboard/media/messages/moderation/recordings/voice page -> RSC yang
  fetch backend di render-time, seed ke client view (SWR fallbackData)
- hook-hook utama terima initialData -> first paint data server, revalidate
  SWR setelahnya, tanpa spinner-blank-load
- messages: guild/channel/tab/selected dibaca dari URL di server, page awal
  di-fetch server-side

Shared realtime state (voice) server-authoritative:
- backend src/modules/voice/live-speaker.ts: agregat voice_active_user dari
  gateway jadi snapshot authoritatif (single source of truth semua browser)
- GET /api/voice/status kini include activeSpeakers
- WS initial states kirim voice_state snapshot saat connect (late join
  langsung dapat state yang sama, bukan daftar kosong)
- useSpeakers seed dari server snapshot + voice_state full-replace +
  voice_active_user delta upsert

Deploy:
- flake.nix: frontend package build SSR standalone (server.js wrapper,
  GMW_FRONTEND_PORT=4017); proxy nginx template proxy / -> Next server,
  /api + /ws tetap ke backend :4001
2026-08-07 10:44:03 +07:00
asepharyana aa440eda69 fix(gateway): music/screen playback heads — read yt-dlp headers from stderr, drop no-simulate
Music playback produced no audio: with `-o -` yt-dlp streams media on
stdout and emits its `--print` title/duration headers on stderr, but
resolveMediaUrl read them from stdout — stripping two binary 'lines' off
the WebM container and corrupting the stream (player 'playing' but silent).
Now headers are read from stderr and the stdout media stream is returned
untouched.

Screenshare was failing with EACCES: getDirectScreenInput used --no-simulate,
making yt-dlp write .f*.part files into the read-only Nix store CWD. Dropped
it — simulate mode still returns requested_formats[].url in the JSON.

Adds tests/mediaResolve.test.ts (stderr-header + untouched-stream regression).
2026-08-06 21:26:50 +07:00
asepharyana f251e69f51 fix(backend): force-exit failsafe so shutdown never hangs
shutdown() awaited httpServer.close(), which waits for ALL open
connections. A lingering WS/keep-alive socket left the process zombie
forever after an uncaughtException (e.g. pg 'Connection terminated
unexpectedly' to imrnes) — no exit, so systemd Restart=always could
never revive it; /api/guilds returned 502 until manual restart.

Add 10s force-exit timer in shutdown(); clear it on clean completion.
2026-08-05 23:58:30 +07:00
asepharyana 9a2fa999bf fix(voice): proper shadcn select dropdowns + top reactors leaderboard
- ui/select: trigger default w-full h-9 (was w-fit h-8 — selects rendered
  tiny/misaligned); callers keep size override via className
- VoiceConnectionCard: labeled full-width h-10 selects (Server/Guild +
  Voice Channel), guild icon + name in options, channel type icon +
  'no akses' tag, empty states, htmlFor/id a11y wiring
- GuildSelector sidebar + messages channel filter bumped to match
- Backend GET /api/dashboard/reactors: top users by net reactions given
  (adds-removes) + messages_reacted + emojis_used
- Reactions tab: second 'Top reaktor' leaderboard panel
2026-08-05 11:29:56 +07:00
asepharyana f999be4fa0 feat(dashboard): add moderation log page + message edit history
- Backend moderation module: GET /api/moderation/stats (per-status + failed rate)
  + GET /api/moderation/actions (filter by status/actionType, cursor paging),
  joins messages for target username + content
- Message GET /api/messages/detail/:id now returns edit_count + edit_history
  (old_content snapshots from message_edits, newest first)
- FE: new /moderation page — summary cards (total/executed/failed/pending +
  failed-rate), status+type filter chips, timeline rows with action icon,
  target user, reason, status badge, timestamps, error text
- FE: message detail shows 'Riwayat edit' panel with previous versions
2026-08-05 11:11:10 +07:00
asepharyana a309570d29 feat(dashboard): expose trust reputation + reactions leaderboard
- GET /api/dashboard/reactions: top reacted messages (net add-remove),
  joined with message content, channel name, top 3 emoji breakdown
- Users tab: colored trust tier badge (Trusted/Netral/At Risk/Kritis)
  in list rows + detail panel (was plain number badge)
- Dashboard: new Reactions sub-tab with leaderboard
  (rank, emoji cluster, message, author, channel, count)
2026-08-05 10:20:12 +07:00
asepharyana 2f51f94610 fix(moderation): remove manual reanalyze triggers — auto-recovery only
Manual per-message and batch reanalyze buttons/endpoints let anyone
re-queue arbitrary messages for LLM analysis, burning AI credits on
spam. Removed:
- FE: Reanalyze buttons in message list, search panel, and messages page
- FE: useReanalyze/useReanalyzeBatch hooks + messagesApi methods
- BE: POST /api/messages/:id/reanalyze and /reanalyze-batch endpoints
- BE: markForReanalysis/reanalyzeErrorBatch service+repository methods

Recovery of failed messages is fully automatic: the discord-gateway
startPendingAIAnalysisWorker retries 'pending' (batch path) and
'error/analysis_incomplete' (individual path) messages on
AI_ANALYSIS_RECOVERY_INTERVAL_MS.
2026-08-04 15:37:56 +07:00
asepharyana 88484f12a9 ci: add Nix GC cleanup job on VPS after deploy 2026-08-04 13:57:44 +07:00
asepharyana a2542493cd fix(voice): screen share now carries audio — merge DASH video+audio into single NUT input
getDirectVideoUrl used yt-dlp --get-url with bestvideo+bestaudio, which
prints the video-only and audio-only URLs on SEPARATE lines. Only the
first (video-only) line was used, so ffmpeg had no audio track and the
GoLive stream had no sound.

Replace with getDirectScreenInput which:
- uses --dump-single-json to fetch both fresh URLs in ONE yt-dlp run
  (signature URLs expire quickly)
- returns the merged progressive URL directly when one exists
- otherwise merges the video-only + audio-only DASH URLs locally via a
  child ffmpeg into a single NUT stream consumed as a Readable
- tracks the merge ffmpeg process in cleanup() so shutdown kills it too

Verified end-to-end with real YouTube URLs: yt-dlp pair → live ffmpeg
merge (NUT) → H264+opus transcode yields both streams. Added
tests/screenShareInput.test.ts covering URL / DASH-pair / error paths.
2026-08-04 13:28:55 +07:00
aseph f84bf723c5 ci: use free GHA Nix cache (disable FlakeHub cache, not subscribed) 2026-08-03 16:44:06 +07:00
asepharyana 24db0f19b1 ci: enable FlakeHub Cache (id-token: write + use-flakehub) 2026-08-03 16:20:15 +07:00
asepharyana ce5db6aa3c fix(media): normalize activeMode in backend MediaState (FE relies on it)
Gateway publishes {playing, activeMode, musicVolume, current, queue} but the
backend MediaState interface + normalizeMediaState dropped activeMode, so the
FE's 'Screen share active' / 'Music playing' badge never rendered. Carry it
through so the FE↔BE media contract stays in sync.
2026-08-03 10:09:19 +07:00
asepharyana 44a0358b0c fix(voice): screen share restore is best-effort — Discord session teardown race
GoLive (dank074 Streamer) needs the single voice session; after the stream
ends, an automatic @discordjs/voice reconnect often races Discord's session
teardown and times out (AbortError). Restore is now best-effort with a 5s
delay; if it fails the FE shows disconnected and the user clicks Connect —
an accepted tradeoff for one-voice-session-per-user.
2026-08-03 09:33:29 +07:00
asepharyana d36c8777fe fix(voice): delay voice restore after screen share — avoid session teardown race
Immediate reconnect after Streamer.stop() races Discord's voice session
teardown → AbortError. Wait 4s so the old session is fully released before
re-joining with @discordjs/voice.
2026-08-03 09:21:10 +07:00
asepharyana 9fd4ded9c8 fix(voice): restore voice after screen share — pass pre-release status to callbacks
The restore callback previously read getVoiceStatus() AFTER disconnectGuild
had already cleared it, so it never knew which guild/channel to reconnect.
Now release/restore receive the status captured BEFORE the audio connection
is released, so reconnect actually happens after the GoLive stream ends.
2026-08-03 09:07:57 +07:00
asepharyana 55d28dc928 style(voice): biome format media handler + screen controller 2026-08-03 08:49:59 +07:00
asepharyana 02e2243a98 fix(voice): screen share releases audio connection so Streamer owns voice session
The dank074 Streamer creates its own WebRTC voice connection, but Discord
allows only ONE voice session per user. When VoiceController (audio) was
already connected, the Streamer join hung forever (never got
VOICE_SERVER_UPDATE). Now:

1. ScreenShareController takes releaseVoice/restoreVoice callbacks.
2. Before joining, it disconnects the @discordjs audio connection via
   VoiceController.disconnectGuild.
3. Streamer joins + streams GoLive.
4. After the stream ends, restoreVoice reconnects the audio connection so
   mic/listen keep working.
5. media.handler wires these via a new setVoiceController accessor from
   commandHandler; VoiceController is the single source of truth.

Also adds caller-bound timeouts & safe .catch() everywhere so a stream
failure can never become an unhandledRejection again.
2026-08-03 08:41:00 +07:00
asepharyana 8528f2c73d fix(voice): screen share join timeout — Streamer join hangs with dual voice conn 2026-08-03 08:19:05 +07:00
asepharyana 53f26185bc fix(voice): screen share crashed gateway — Streamer never joined voice + unhandledRejection
Root cause: ScreenShareController created @dank074 Streamer but never called
streamer.joinVoiceChannel() — playStream threw 'Bot is not connected to a
voice channel', and since the code only used .finally() (no .catch), the
rejection became an unhandledRejection that took down the whole gateway
(graceful shutdown triggered, systemd restarted).

Fixes:
1. Resolve active channel + streamer.joinVoiceChannel(channel) before
   prepareStream/playStream (dank074 needs its OWN WebRTC voice connection).
2. .catch() on the playStream done promise — log + kill ffmpeg instead of
   crashing the process.
3. .catch() on playback.done in media.handler too.
4. stop() now kills ffmpeg AND stops the streamer's voice connection.
2026-08-03 08:05:02 +07:00
asepharyana a57eeb2e22 fix(voice): media queue field mismatch, voice joinable filter, connect error toast
Audit voice (kirim/terima/music/screenshare) menemukan 3 masalah:
1. media:queue SILENT no-op — backend publish {source,mode} tapi gateway
   handler baca payload.url → selalu 'received without a URL'. Backend
   sekarang kirim {url,mode}, gateway terima url ATAU source (robust).
2. Voice connect gagal diam-diam saat user pilih channel tanpa permission
   (joinable=false, contoh Music 32/64/128/256k). Backend+gateway sekarang
   expose joinable; FE disable channel 'no akses' + empty state.
3. FE tidak kasih feedback saat connect gagal — tambah toast.error dengan
   pesan dari backend.

Verified live: @discordjs/voice connect ke Lofi Radio joinable sukses
(VOICE READY, DAVE session OK) — pipeline voice sebenarnya sehat, masalah
utama UX. media:queue fix akan di-verify setelah deploy.
2026-08-03 07:48:50 +07:00
asepharyana 9abb09dd33 feat(frontend): add light mode with runtime theme toggle
- globals.css: split theme tokens into :root (light default) + .dark
  overrides; switch @theme inline → @theme so utilities reference
  var(--color-*) and a runtime class swap actually re-skins the UI
  (inline inlines literal values and ignores .dark overrides)
- layout.tsx: drop the beforeInteractive inline theme script (it caused
  hydration instability); theme is applied client-side only
- top-nav: apply persisted theme on mount, default light, toggle
  updates <html> class + localStorage
- glass-intense: light variant (white card on light canvas); chart grid
  line uses var(--color-border); attachment chip uses bg-glass-bg
- Verified in static export: toggle dark↔light both directions,
  chatbot panel renders clean in both themes
2026-08-03 07:20:09 +07:00
asepharyana 831254bb71 fix(nix): filter build artifacts from frontend source
path: literals in flakes do NOT respect .gitignore, so a dirty local
out/ (stale chunks from previous builds, e.g. 3y39nidcm2n_s.js from
the removed quick-prompt button) leaked into the sandbox and got
served forever. Add filterSource helper that excludes out, .next,
node_modules, pnpm-lock.yaml from the frontend derivation source.
2026-08-03 06:53:46 +07:00
asepharyana 9718940258 fix(chatbot): FAB opens chat directly — remove extra toggle + UX polish
- chatbot-container: remove chatOpen layer — the minimized bubble now
  expands straight into the chat panel (single click, no extra button)
- Remove the redundant 'Tanya soal server...' quick-prompt button and
  the PanelLeft collapse toggle (one less state to fight)
- Bubble fixed h-[460px], chat panel flex-fills remaining space
- chat-panel: suggestion chips on empty state (suasana server, channel
  paling ramai, total pesan, pesan bermasalah) so first-time users can
  start with a single click
- Input: roomier padding, more descriptive placeholder, bigger send
  button, autoComplete off
- Drop chatOpen/setChatOpen from context (dead state)
2026-08-03 06:44:23 +07:00
asepharyana d1c1f3e4a7 feat(chatbot): per-user history via X-User-Id + agentic tools calling
Backend:
- New chatbot.tools.ts: 4 tools (get_server_stats, get_top_channels,
  get_recent_activity, get_top_flagged) with real DB executors
- chatbot.service: agentic loop — stream:true, parse SSE, execute
  tool_calls, feed results back, up to 4 rounds
- controller: resolve userId from X-User-Id header (no-login device
  uuid) with auth middleware precedence; history/clear scoped per user

Frontend:
- use-chatbot-user: mint UUID in localStorage, send as X-User-Id
- chatbotApi.send/getHistory/clearHistory accept userId header
- client.ts: apiRequest supports custom headers per call
- provider: history load + send + clear keyed to device user id
2026-08-03 06:24:19 +07:00
asepharyana 7513681b4b feat(frontend): remove ugly canvas placeholder from chatbot
- Delete chatbot-canvas.tsx (placeholder face canvas) + its export
- Remove canvas area from chatbot container; chat panel gets the freed
  space (248px → 300px) and bubble height drops 440px → 400px
2026-08-03 06:11:06 +07:00
asepharyana 2b815e156c feat(frontend): rebuild chatbot UI to match backend — wider panel, guild context, Indonesian
- Chatbot bubble: 220px → 320px wide, 440px tall; proper header with
  drag handle; quick-prompt row when chat is closed
- ChatPanel: show ALL history (not last 8), Indonesian placeholder/empty
  state/error copy (backend speaks Indonesian), timestamps (id-ID),
  clear-history button, typing indicator bubbles, Enter-to-send
- Send active guildId as context so backend answers reference the real
  server (serverInsights path in chatbot.service)
- GuildId sync from layout → ChatbotProvider via ChatbotGuildSync
- chatbotApi.send(message, guildId) → POST /api/chat {message, context}
  matching BE zod schema (guildId optional)
2026-08-03 06:04:25 +07:00
asepharyana 03d59f0738 feat(frontend): lazy-load images, add lightbox viewer, polish AI panel
- Add loading=lazy + decoding=async to all <img> (message card preview,
  attachments grid, image grid, avatars via ui/avatar)
- New Lightbox component: fullscreen image viewer with keyboard nav
  (←/→/Esc), counter, click-to-close; wired into messages page + detail
- Attachments grid: click image to open, image counter badge, grouped
  non-image attachments
- AI analysis panel: line-clamp-3 with Show more/less toggle
- Message card: preview image is now a clickable button opening detail
2026-08-03 05:08:08 +07:00
asepharyana 5cc0f8a243 docs: frontend README dev note 2026-08-02 16:46:22 +07:00
asepharyana 12c55ef486 docs: sync remaining md (AGENTS, ARCHITECTURE, READMEs to 4001/4009, Nix) 2026-08-02 16:42:57 +07:00
asepharyana 38c27eb5bb chore: ARCHITECTURE.md DB pool example 2026-08-02 16:23:01 +07:00
asepharyana bd044e95c3 chore: env.test.example and ARCHITECTURE pool 6432 2026-08-02 16:22:48 +07:00
asepharyana ec64a078bf chore: fix-missing-tables.sql imrnes IP 2026-08-02 16:22:30 +07:00
asepharyana 37defa5915 chore: Dockerfile.backend expose port 4001 2026-08-02 16:21:48 +07:00
asepharyana 39421c39cb chore: sync port references and docs to 4000s infra 2026-08-02 16:20:27 +07:00
asepharyana 1d27f67788 chore: update gmw-proxy nginx template ports to 4009/4001 2026-08-02 15:16:16 +07:00
asepharyana d1e6f3b47a chore: update ports to 4000-range (4000/4001) 2026-08-02 14:30:54 +07:00
asepharyana dbcf9d68f2 fix(media): publish status when a track ends naturally
The Redis media:status key was only rewritten after a command received via
Redis. When the last track ended naturally (AudioPlayer Idle -> advanceQueue
with an empty queue), currentTrackItem was cleared but the status key was not
persisted — so the backend's cached status and the frontend's 10s polling
stayed stuck showing the finished track as 'playing' forever.

Wire a media-status sink (commandHandler provides the real redisPub to
MediaHandler) and re-publish status after auto-advance, so natural track end
updates the UI.
2026-08-02 10:43:34 +07:00
asepharyana ef4281cd1f fix(voice): activity tab rendered a permanently empty chart
VoiceActivityTimeline was never given a data prop — the Activity tab always
showed an empty Recharts bar chart while the connection tab already had live
speaker state. Replace the dead chart with a live speaker/activity list fed
from the same WebSocket data, so the tab reflects real state instead of
misleading empty bars.
2026-08-02 10:38:02 +07:00
asepharyana 25f6609a9f fix(recordings): stop faking duration from file size + render edited content in search
The voice_recordings table has no duration column, but the backend aliased
duration_bytes = size_bytes (file size in bytes) and RecordingCard divided it
by 60 as if it were seconds — a 3MB MP3 rendered as a nonsensical '55924:3'
fake timestamp. Drop the fabricated field and show real file size instead.

Also render edited_content fallback in the analysis search results for
consistency with message cards/detail.
2026-08-02 10:32:58 +07:00
asepharyana 3f199aa70d fix(messages): merge partial WS updates + display edited content
message_updated broadcasts only {id, edited_content, edited_at} (+ reset
ai_* fields), but the frontend replaced the whole cached record, wiping
username/content/channel_id/created_at -> blank cards and the
'the channel_id of undefined' crash on /messages. Merge partials over the
existing record (list + detail), make list-patching channel-filter aware,
show edited content/badge, and fix the message_updated WS type.

Also broadcast type:'edited' + ai reset in message_updated so the live UI
matches the DB update.
2026-08-02 10:27:41 +07:00
asepharyana a82265f4a9 chore: remove outdated README.md file 2026-08-02 10:18:56 +07:00
asepharyana 78d514b73d feat(dashboard): message activity timeline + moderation donut
Backend:
- GET /api/dashboard/activity?days=1..90 — daily buckets (messages,
  flagged, active_users) + hourly distribution last 24h
- clamped days param, reuses existing indexes (idx_messages_created,
  ai_status_created)

Frontend:
- ActivityChart: area chart messages+flagged per day, 7/14/30d range
- HourlyActivityChart: 24h bars with peak highlight
- ModerationDonut: clean/flagged/warned/error breakdown with live
  summary line (server X% clean)
- Dashboard layout: activity 2/3 + donut 1/3, hourly + top channels

Verified: endpoint returns real data from prod DB (784/1897/8 msgs
per day), tsc clean backend+frontend.
2026-08-01 23:06:00 +07:00
asepharyana 7d2bd75f6c ci: exclude e2e.test.ts from CI unit run (needs live backend API_BASE) 2026-08-01 22:21:55 +07:00
asepharyana b3a2f2ec10 ci: add test+typecheck gate before deploy
Sebelumnya CI hanya build nix -> deploy tanpa verifikasi — placeholder
tests sempat rusak berbulan-bulan tanpa terdeteksi. Job 'test' baru:
- pnpm install + tsc --noEmit + vitest run untuk backend & discord-gateway
- biome check (errors fail, warnings pass)
- build-and-deploy now needs: test
2026-08-01 22:10:28 +07:00
asepharyana 6293d588bc chore(lint): biome cleanup across services — format, sort imports, drop unused
- discord-gateway: 74 lint errors -> 0 (format, import sorting, unused
  imports/vars, dead breath var)
- backend: format + sort imports (11 warnings left: noExplicitAny)
- frontend: remove unused imports, drop dead breathing var, fix
  useExhaustiveDependencies (scroll keyed on messages), a11y biome-ignore
  for drag surface + stopPropagation container (mouse-only gestures)
- remaining warnings are false positives: index keys on static lists,
  <img> in static export (next/image unsupported), noExplicitAny

tsc --noEmit clean on all 3 services; vitest green (60+36).
2026-08-01 22:09:02 +07:00
asepharyana 2357421841 fix(test): repair placeholder tests referencing deleted @bete/shared package
packages/shared dihapus (5802d02), modul pindah ke src/shared/. Update
import placeholder.test.ts (gateway + backend) ke path lokal + aktifkan
tests/ di backend vitest config dengan alias @. Sebelumnya vitest run
gateway selalu gagal; sekarang 60+36 tests pass.
2026-08-01 22:08:54 +07:00
asepharyana 308be9f05a fix(nix): restrict flake to x86_64-linux (nixpkgs 26.11 dropped darwin) 2026-08-01 18:03:41 +07:00
asepharyana a0b3f7e9b2 ci: publish flake to FlakeHub (rolling) 2026-08-01 17:58:19 +07:00
asepharyana 98064d1dd9 feat(fe): recordings — visible playing/loading/paused states
- recording-card: kartu aktif di-highlight (ring primary + glow pulse),
  badge 'Now Playing'/'Loading'/'Paused', tombol play berubah jadi Pause
  saat playing dan spinner saat loading, waveform equalizer beranimasi
  (animate-eq, delay per bar) saat playing / pulse saat loading.
- recording-player: jadi now-playing panel — tombol play/pause + spinner
  loading, progress bar + waktu (current/duration), status 'loading…',
  audio element pindah ke sini + event onPlay/onPause/onWaiting/onCanPlay/
  onPlaying/onError naik ke page.
- recordings/page: state isPlaying/isLoadingAudio + audioRef, togglePlay
  (klik card lain = ganti track, klik card sama = pause/resume).
- globals.css: keyframes eq-bounce + card-glow.

Verified: FE tsc0, next build 10/10 static pages.
2026-08-01 16:54:58 +07:00
asepharyana 0daee56213 ci: migrate CI to GitHub Actions (deploy nix + mirror ke Gitea backup)
Mirror to Gitea / mirror (push) Successful in 26s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Failing after 32m40s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Failing after 18m0s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Failing after 18m0s
2026-08-01 16:40:27 +07:00
asepharyana 762e78d6b6 feat(fe): play Discord voice live + fix recording play/download
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m42s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 4m11s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 11m4s
Voice page (connection tab):
- ListenControl baru: toggle Listen (Headphones) — mulai PcmPlayer dari
  user gesture, subscribe onPcm WS, volume slider, bar level per-user
  REAL dari PCM (bukan random).
- lib/audio/pcm-player.ts (baru): ScriptProcessorNode mixer — ring buffer
  2s per user (hash FNV-1a sama dengan gateway), upsampling 24k→48k
  linear, mix semua user ke mono, gain volume, cleanup ring diam 5s.
- useVoiceListen + hashUserId di hooks; auto-stop saat disconnect.

Recordings:
- recording-player: reset src+load+play() eksplisit (bukan autoPlay doang),
  tampilkan filename + error state 'playback failed' kalau file rusak.
- recording-card: tombol Download fetch blob (CORS tele open) → objectURL
  → force download dengan nama asli; fallback buka tab baru kalau fetch
  gagal; spinner saat mendownload.

Verified: FE tsc 0, next build 10/10 static pages.
2026-08-01 16:30:56 +07:00
asepharyana 6ce784471e fix(gateway): repair Ogg page CRCs + deliver recordings as MP3
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m44s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 4m13s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 11m33s
Root cause rekaman gak bisa dibuka: prism-media OggLogicalBitstream
dipanggil dengan crc:false (node-crc dihapus dari dependency tree) —
semua page Ogg punya checksum 0 — player strict (ffmpeg, iOS) tolak
dengan 'CRC mismatch / End of file'. Rekaman 13:11 terbukti CRC-invalid.

Fix:
1. recorder/oggCrc.ts (baru): recompute CRC-32 (RFC3533, poly 0x04c11db7,
   initial 0, MSB-first) tiap page OggS in-place — pure JS tanpa native dep.
2. segmentFinalizer.ts: panggil fixOggCrc sebelum upload/merge.
3. recorder/uploader.ts: transcode segment ke MP3 (libmp3lame 128k 48k
   stereo) sebelum upload tele — universal playback. filename+size DB
   di-update; source OGG tetap untuk transkripsi.
4. muxer.ts + recorder.ts: merged session file juga .mp3.

Verified: ffprobe baca segmen yang tadinya CRC mismatch, MP3 valid.
2026-08-01 16:05:51 +07:00
asepharyana 0ef2b715c4 fix(gateway): enable stream for all LLM calls — router always streams SSE
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m36s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 4m9s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 11m29s
Audit lanjutan: 6x 'LLM API request failed: Request was aborted' per jam.
Root cause: 9router/omniroute SELALU balas SSE (data: chunks) walau request
tanpa stream:true — SDK OpenAI non-stream menunggu FULL body sebelum parse,
jadi batch moderasi besar yang upstream-nya lambat kena timeout 30-60s dan
di-abort. llmClient sudah punya agregasi streaming (chunks → ChatCompletion).

Fix: stream:true di llmCaller (moderasi batch/individual), llmVision,
cultureLearner, userProfileLearner. Verified: SDK stream test 806ms vs
sebelumnya abort. Caller lain (recovery worker dll) lewat llmCaller sama.
2026-08-01 15:00:59 +07:00
asepharyana dfe689bdec fix(gateway): mediaAnalysis ffprobe path, fallback error-log, generic closer sanitize
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m47s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 4m11s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 11m36s
Audit log produksi (sejak deploy13:38) menemukan 3 isu:
1. mediaDownloader.ts spawn /usr/bin/ffprobe + /usr/bin/ffmpeg (path keras) —
   ENOENT di Nix karena binary cuma di ffmpeg-headless closure. Pakai
   PATH-resolved ('ffprobe'/'ffmpeg') seperti voice-recording module
   (ffmpegProcess.ts/transmitter.ts) — 5 media warning hilang.
2. individualFallbackProcessor log error 'Success' di level50 tiap fallback
   BERHASIL (logModerationError dengan new Error('Success')) — ganti
   logger.info dengan verdict yang sama; error log cuma untuk error asli.
3. moderationResponseParser: strip frasa penutup generik ('Tidak ada
   indikasi pelanggaran.') yang masih sering dikeluarkan LLM walau prompt
   melarang (277/1486 analisis mengandung frasa, termasuk hari ini).
   sanitizeGenericCleanCloser hanya mencocok frasa di AKHIR, teks substantif
   tetap utuh. Unit test: 6/6 pass.
2026-08-01 14:00:43 +07:00
asepharyana ada7a768f8 fix(gateway): bump 0011 voice_transcription journal when above applied max
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 3m15s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m23s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 10m59s
Migration 0011 (add voice_recordings.transcription) when=1781388000000
lebih kecil dari 0010 (1781390000000) yang sudah ter-apply — drizzle
skip diam-diam (folderMillis <= max(created_at)), kolom transcription
tidak pernah dibuat. Recording OGG sukses tapi INSERT voice_recordings
gagal 42703 di produksi.

Fix: when=1785600000000 (> max applied 1785551832190) + apply manual
ALTER TABLE + insert row __drizzle_migrations dengan hash file yang
sama (c368acb0...) supaya gateway restart berikutnya skip (idempotent).
2026-08-01 13:15:03 +07:00
asepharyana 493bca590d fix(infra): build native voice deps (opus, datachannel) in Nix closure
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 4m21s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 4m22s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 11m31s
pnpm 11 rebuild hanya jalanin script package yang di-approve via
pnpm-workspace.yaml (allowBuilds) DAN abort di kegagalan pertama.
Yaml harus tracked (flake source cuma ikut file git). Tapi pnpm rebuild
tetap gagal karena: (1) node-crc MSRV cargo:: error — dead dep, dihapus
dari deps+patch; (2) sharp install script gagal di sandbox — binary-nya
prebuilt @img, script cuma validasi — dikeluarkan dari approval list;
(3) node-datachannel prebuild CLI TypeError + cmake-js butuh cwd benar.

Fix: loop eksplisit di buildPhase gateway yang jalankan install script
tiap native dep (opus/node-datachannel/zeromq) dengan cwd package dir,
+ cmake (dontUseCmakeConfigure biar stdenv nggak auto-configure),
+ opensslDevEnv = symlinkJoin pkgsStatic.openssl.out (libcrypto.a —
CMakeLists set OPENSSL_USE_STATIC_LIBS=TRUE; pkgs.openssl default
output = bin tanpa lib) + openssl.dev (headers),
+ git (libdatachannel FetchContent clone dari GitHub).

Verifikasi: result store punya opus.node (compile source), node_datachannel.node
(compile source), node-av prebuilt, zeromq prebuilt; runtime smoke test:
PeerConnection instantiate+close OK, OpusEncoder encode OK, dank074
Streamer/prepareStream/playStream load OK.
2026-08-01 12:44:01 +07:00
asepharyana 823b484497 chore(gateway): pnpm 11 build-script approvals (allowBuilds) for native voice deps
pnpm 11.17 mengabaikan field pnpm.onlyBuiltDependencies di package.json.
Native deps voice (@discordjs/opus, @lng2004/node-datachannel, zeromq, dll)
tidak pernah kebangun di Nix store karena flake pnpmInstall pakai
--ignore-scripts dan pnpm rebuild tanpa approval. Hasil: receiver/rekaman/
GoLive diam-diam tanpa decoder/encoder native.

pnpm approve-builds --all menulis allowBuilds:true per package di
pnpm-workspace.yaml (harus tracked — flake source cuma ikut file git).
node-crc tetap gagal build (MSRV cargo:: check) tapi tidak pernah
di-import di source — harmless.
2026-08-01 11:47:01 +07:00
asepharyana 891c1305f0 feat(gateway): restore Discord GoLive screenshare (dulu pernah ada, hilang saat split microservices)
User: 'dulu sharescreen juga bisa'. Terbukti: commit d50ce86 (Mei 2026)
punya src/media/screenShareController.ts + vendor @dank074/discord-video-stream,
hilang saat rombak monolith -> microservices. Interface ScreenShareController
masih ada di mediaTypes.ts tapi implementasinya tidak.

Restore:
- dep @dank074/discord-video-stream@6.0.0 (npm, dibangun untuk
  discord.js-selfbot-v13 — cocok dengan stack gateway)
- mediaSource.getDirectVideoUrl (yt-dlp --get-url bestvideo+bestaudio)
- screenShareController.ts (BARU): Streamer(client) + prepareStream H264
  720p30 + playStream go-live; owner check via discordPlayer
- media.handler: mode:'screen' di media:queue -> screen path; status
  expose activeMode; stop matiin screen
- FE: tombol Screen di MusicPlayer + hook useMediaQueue({url, mode})

Verifikasi: gateway tsc PASS, FE tsc PASS, biome 0 error, next build PASS.
Nix build pending (dep native @lng2004/node-datachannel butuh pnpm rebuild).
2026-08-01 11:36:40 +07:00
asepharyana 189ab1c1f6 feat(fe): real mic capture for voice transmit (kirim suara)
Sebelumnya tombol Live/Muted cuma kirim voice:transmit:start/stop ke
gateway — TIDAK ADA audio yang dikirim (0 getUserMedia/AudioContext di
frontend). MicControl cuma toggle state kosong.

- lib/audio/mic-transmit.ts (BARU): getUserMedia → AudioContext 48kHz →
  AudioWorklet (downsample 24kHz mono s16le + volume + chunk 20ms) →
  frame 'PCM\0' + Int16LE → ws.sendBinary. Worklet inline via Blob URL
  (aman untuk static export).
- useMicTransmit(ws): aktif = start capture + voice:transmit:start;
  nonaktif = stop capture + voice:transmit:stop; setVolume untuk slider.
- voice page: volume slider sekarang beneran ngatur gain mic; disconnect
  ikut matiin mic.

Verifikasi: tsc PASS, biome 0 error, next build PASS. Test mic butuh
real device (headless browser tidak punya mic) — protokol: connect voice
→ Live → ngomong → orang di channel denger.
2026-08-01 11:26:18 +07:00
asepharyana 9d60f00934 fix(infra): add ffmpeg + yt-dlp to gmw-discord-gateway runtime
Root cause voice tidak berfungsi di produksi: ffmpeg & yt-dlp cuma ada
di devShell, bukan di package discord-gateway. Bukti dari log gateway:
'FFmpeg/avconv not found!' saat voice:transmit:start (mic -> Discord),
yang juga mematikan music playback (StreamType.Arbitrary butuh ffmpeg)
dan segment muxing rekaman.

- buildInputs: pkgs.ffmpeg-headless + pkgs.yt-dlp
- wrapper export PATH ke keduanya sebelum exec node
Verifikasi: nix build PASS; closure berisi ffmpeg-8.1.2 + yt-dlp-2026.07.04;
wrapper PATH mengarah ke keduanya; ffmpeg/yt-dlp jalan.
2026-08-01 11:22:20 +07:00
331 changed files with 22245 additions and 3206 deletions
+5 -5
View File
@@ -39,7 +39,7 @@ AUDIO_CHANNELS=2 # Number of audio channels (default: 2)
AVATAR_SIZE=64 # User avatar size in pixels (default: 64) AVATAR_SIZE=64 # User avatar size in pixels (default: 64)
# === Webserver === # === Webserver ===
WEBSERVER_PORT=3001 # Backend HTTP/WS server port (default: 3001) WEBSERVER_PORT=4001 # Backend HTTP/WS server port (default: 4001)
# === Connection === # === Connection ===
VOICE_CONNECTION_TIMEOUT_MS=15000 # Voice connection timeout in ms (default: 15000) VOICE_CONNECTION_TIMEOUT_MS=15000 # Voice connection timeout in ms (default: 15000)
@@ -55,7 +55,7 @@ VERBOSE=false # Enable verbose/debug logging (default:
# === Database (PostgreSQL) === # === Database (PostgreSQL) ===
# Option 1: Connection string (overrides individual params) # Option 1: Connection string (overrides individual params)
# DATABASE_URL=postgresql://user:password@localhost:5432/discord_bot DATABASE_URL=postgresql://asephs:***@100.121.180.82:6432/dcbot
# Option 2: Individual connection parameters # Option 2: Individual connection parameters
POSTGRES_HOST=localhost # PostgreSQL host (default: localhost) POSTGRES_HOST=localhost # PostgreSQL host (default: localhost)
@@ -67,11 +67,11 @@ POSTGRES_POOL_MIN=2 # Minimum pool connections (default: 2)
POSTGRES_POOL_MAX=10 # Maximum pool connections (default: 10) POSTGRES_POOL_MAX=10 # Maximum pool connections (default: 10)
# === Redis === # === Redis ===
REDIS_URL=redis://localhost:6379 # Redis connection string (default: redis://localhost:6379) REDIS_URL=redis://100.121.180.82:6379 # Redis connection string (default: redis://localhost:6379)
# === Voice PCM WebSocket (direct gateway→backend, bypasses Redis) === # === Voice PCM WebSocket (direct gateway→backend, bypasses Redis) ===
VOICE_PCM_WS_ENABLED=true # Use direct WS for PCM audio (default: true) VOICE_PCM_WS_ENABLED=true # Use direct WS for PCM audio (default: true)
BACKEND_WS_URL=ws://backend:3000/ws # Backend WebSocket URL for gateway PCM streaming BACKEND_WS_URL=ws://backend:4001/ws # Backend WebSocket URL for gateway PCM streaming
BACKEND_WS_TOKEN= # REQUIRED if VOICE_PCM_WS_ENABLED=true. Internal shared secret BACKEND_WS_TOKEN= # REQUIRED if VOICE_PCM_WS_ENABLED=true. Internal shared secret
# === Attachments === # === Attachments ===
@@ -90,7 +90,7 @@ AI_LLM_MODEL=text # LLM text model name (default: text)
# AI_LLM_VISION_MODEL= # Vision model for image analysis (falls back to AI_LLM_MODEL) # AI_LLM_VISION_MODEL= # Vision model for image analysis (falls back to AI_LLM_MODEL)
# AI_LLM_EMBEDDING_MODEL= # Embedding model for semantic moderation cache (optional; enables near-duplicate text reuse to save LLM calls) # AI_LLM_EMBEDDING_MODEL= # Embedding model for semantic moderation cache (optional; enables near-duplicate text reuse to save LLM calls)
# AI_LLM_EMBEDDING_MIN_SIMILARITY=0.97 # Min cosine similarity to reuse a cached verdict (default: 0.97) # AI_LLM_EMBEDDING_MIN_SIMILARITY=0.97 # Min cosine similarity to reuse a cached verdict (default: 0.97)
# QDRANT_URL=http://100.121.180.82:6333/ # Qdrant vector store for embeddings (semantic cache); when set, vectors are stored/searched in Qdrant instead of Postgres QDRANT_URL=http://100.121.180.82:6333 # Qdrant vector store for embeddings (semantic cache); when set, vectors are stored/searched in Qdrant instead of Postgres
# QDRANT_COLLECTION=gmw_text_moderation # Qdrant collection name (default: gmw_text_moderation) # QDRANT_COLLECTION=gmw_text_moderation # Qdrant collection name (default: gmw_text_moderation)
# QDRANT_API_KEY= # Qdrant API key (optional) # QDRANT_API_KEY= # Qdrant API key (optional)
AI_LLM_MAX_CONCURRENT=5 # Max concurrent LLM API calls (default: 5) AI_LLM_MAX_CONCURRENT=5 # Max concurrent LLM API calls (default: 5)
+2 -2
View File
@@ -2,5 +2,5 @@ NODE_ENV=test
# Use a separate database/data area for tests. It may be on the same PostgreSQL host, # Use a separate database/data area for tests. It may be on the same PostgreSQL host,
# but the database name must clearly be a test database so destructive test setup # but the database name must clearly be a test database so destructive test setup
# cannot touch production data. # cannot touch production data.
TEST_DATABASE_URL=postgres://root:root@100.108.1.124:5432/hub_test TEST_DATABASE_URL=postgres://root:root@100.121.180.82:6432/hub_test
DATABASE_URL=postgres://root:root@100.108.1.124:5432/hub_test DATABASE_URL=postgres://root:root@100.121.180.82:6432/hub_test
-98
View File
@@ -1,98 +0,0 @@
name: Build & Deploy (Nix)
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
service: [backend, discord-gateway, proxy]
steps:
- name: Check out repository
run: |
git clone https://git.imrnes.team/MythEclipse/GMW.git .
git checkout ${{ github.sha }}
- name: Build & Deploy ${{ matrix.service }}
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
# Declarative env: source of truth = Gitea secrets, CI writes the
# service env files on the VPS. Update env by editing the secret,
# never by SSH-ing into the VPS by hand.
BACKEND_ENV: ${{ secrets.BACKEND_ENV }}
GATEWAY_ENV: ${{ secrets.GATEWAY_ENV }}
run: |
set -eu
# --- Install Nix & Build ---
curl -fsSL https://install.determinate.systems/nix \
| sh -s -- install linux --no-confirm --init none 2>&1
mkdir -p /etc/nix
echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
SERVICE="${{ matrix.service }}"
echo "=== Building: $SERVICE ==="
nix build ".#$SERVICE" --impure --option sandbox false 2>&1
STORE_PATH=$(readlink result)
echo "=== Store path: $STORE_PATH"
# --- Deploy ---
NIX_BIN="/nix/var/nix/profiles/default/bin"
PROFILE="/nix/var/nix/profiles/gmw-$SERVICE"
key_file=$(mktemp /tmp/deploy-key.XXXXXX)
printf '%s\n' "$VPS_SSH_KEY" > "$key_file"
chmod 600 "$key_file"
export NIX_SSHOPTS="-i $key_file -o StrictHostKeyChecking=no"
nix copy --to "ssh://${VPS_USER}@${VPS_HOST}" "$STORE_PATH" 2>&1
# --- Deploy env (declarative, from Gitea secrets) ---
ENV_TARGET=""
ENV_VALUE=""
case "$SERVICE" in
backend) ENV_TARGET="/etc/gmw/backend.env"; ENV_VALUE="$BACKEND_ENV" ;;
discord-gateway) ENV_TARGET="/etc/gmw/discord-gateway.env"; ENV_VALUE="$GATEWAY_ENV" ;;
proxy) ;; # nginx proxy has no env file
esac
if [ -n "$ENV_VALUE" ] && [ -n "$ENV_TARGET" ]; then
echo "=== Deploying env: $ENV_TARGET ==="
printf '%s\n' "$ENV_VALUE" | ssh -i "$key_file" -o StrictHostKeyChecking=no \
"${VPS_USER}@${VPS_HOST}" "
set -eu
mkdir -p /etc/gmw
cat > $ENV_TARGET
chown gmw:gmw $ENV_TARGET
chmod 600 $ENV_TARGET
echo \"env file lines: \$(grep -c '=' $ENV_TARGET)\"
"
else
echo "=== No env secret for $SERVICE — skipping env deploy ==="
fi
ssh -i "$key_file" -o StrictHostKeyChecking=no \
"${VPS_USER}@${VPS_HOST}" "
if [ -d $PROFILE ] && [ ! -L $PROFILE ]; then
rm -rf $PROFILE
fi
export PATH=\$PATH:$NIX_BIN
nix-env --profile $PROFILE --set $STORE_PATH
systemctl daemon-reload
systemctl restart gmw-$SERVICE
sleep 3
systemctl status gmw-$SERVICE --no-pager 2>&1 | head -12
" 2>&1
+260
View File
@@ -0,0 +1,260 @@
name: Build & Deploy (Nix)
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: gmw-deploy
cancel-in-progress: false
permissions:
contents: read
id-token: write
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: false
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
run: corepack enable && corepack prepare pnpm@11 --activate
- name: Install deps (backend)
working-directory: services/backend
run: pnpm install --ignore-scripts --no-frozen-lockfile
- name: Typecheck + test (backend)
working-directory: services/backend
run: |
./node_modules/.bin/tsc --noEmit
# e2e.test.ts requires a live backend (API_BASE) — run unit tests only
./node_modules/.bin/vitest run --exclude "src/e2e.test.ts"
- name: Install deps (discord-gateway)
working-directory: services/discord-gateway
run: pnpm install --ignore-scripts --no-frozen-lockfile
- name: Typecheck + test (discord-gateway)
working-directory: services/discord-gateway
run: |
./node_modules/.bin/tsc --noEmit
./node_modules/.bin/vitest run
- name: Biome check (all services)
run: |
cd services/backend && ./node_modules/.bin/biome check src/ tests/
cd ../discord-gateway && ./node_modules/.bin/biome check src/
build-and-deploy:
needs: test
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
service: [backend, discord-gateway, proxy, frontend]
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
submodules: false
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@v22
with:
determinate: false
extra-conf: |
sandbox = false
accept-flake-config = true
# Attic binary cache as substituter on the runner: lets CI pull the
# prebuilt attic client (and any cached deps/builds) over HTTPS,
# no SSH round-trip needed. extra-substituters (NOT
# extra-trusted-substituters) is required — Determinate Nix never
# merges trusted-* substituters for nix-store CLI clients.
extra-substituters = https://attic.asepharyana.my.id/gmw
extra-trusted-public-keys = gmw:Fq2Anzuhkb+T/hftWnPcveHSi21/RzIgIOeG8pCJa88=
# NOTE: nix-installer-action unconditionally injects
# 'build-provenance-tags' into /etc/nix/nix.conf (a Determinate
# Nix-only setting). With determinate:false the runner's upstream
# nix warns 'unknown setting build-provenance-tags' on every
# invocation — benign, cosmetic. Switching determinate:true would
# silence it but changes the runner's nix flavor.
- name: Cache Nix
uses: DeterminateSystems/magic-nix-cache-action@v14
with:
use-flakehub: false
- name: Build ${{ matrix.service }}
id: build
run: |
nix build .#${{ matrix.service }} --impure --option sandbox false --print-build-logs
STORE_PATH=$(readlink result)
echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT"
echo "Build OK ${{ matrix.service }}: $STORE_PATH"
- name: Setup SSH key
env:
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
sed -i 's/\r$//' ~/.ssh/id_ed25519
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; }
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
# Push build result to Attic binary cache (attic.asepharyana.my.id) so
# the VPS can substitute it instead of a single-stream `nix copy ssh://`.
#
# Fast path: push DIRECTLY from the runner to the public attic endpoint
# (validated 2026-08-10: token auth over public HTTPS works without
# Tailscale). This skips the ~794MB closure SSH copy to the VPS that
# used to take 25+ minutes per new store path.
#
# The attic client is NOT in nixpkgs anymore and has no prebuilt
# releases, so we pull the same prebuilt closure the VPS uses
# (/nix/store/fygyy3yk4rqdknxkiwkqambpnhyax0k4-attic-0.1.0, ~52MB).
# The closure itself lives in the attic cache (pushed once from the
# VPS), so the runner bootstraps it over HTTPS via the configured
# extra-substituters — no SSH round-trip. If that fails we fall back
# to `nix copy --from ssh://`, then the old VPS-hop flow (SSH copy to
# VPS, then attic push from the VPS over Tailscale) so the deploy step
# always has a working closure path.
- name: Push to Attic cache
env:
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }}
run: |
if [ -z "$ATTIC_TOKEN" ]; then
echo "ATTIC_TOKEN not set; skipping attic push"
exit 0
fi
STORE_PATH="${{ steps.build.outputs.store-path }}"
ATTIC_DIR="/nix/store/fygyy3yk4rqdknxkiwkqambpnhyax0k4-attic-0.1.0"
ATTIC_BIN="$ATTIC_DIR/bin/attic"
attic_push_vps_hop() {
echo "Fallback: VPS-hop attic push"
# Copy closure to VPS (fast if attic already has it via substitute)
ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-store --realise '$STORE_PATH'" 2>/dev/null \
|| nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH"
# Push from VPS → Attic over Tailscale.
# --ignore-upstream-cache-filter is REQUIRED: without it, attic skips
# writing the narinfo to gmw when chunks exist in the upstream
# cache.nixos.org — leaving the path 404 on gmw so the VPS deploy's
# nix-store --realise can't find it and falls back to ssh copy.
# sudo: attic must read root's config (~/.config/attic), which has
# the imrnes-ts server → Tailscale. Non-root users' configs only
# have the public `pub` server → "Server imrnes-ts does not exist".
ssh "$VPS_USER@$VPS_HOST" "sudo $ATTIC_BIN push imrnes-ts:gmw '$STORE_PATH' --jobs 4 --ignore-upstream-cache-filter" \
|| echo "attic push failed (non-fatal; ssh copy fallback below)"
}
# ── Get an attic client on the runner ────────────────────────────
# Order: PATH → pull the prebuilt closure from the attic cache
# itself (extra-substituters configured in Install Nix step, HTTPS
# only, no SSH) → pull over ssh from the VPS → VPS-hop.
# The attic client closure is stored in the attic cache (pushed
# once from the VPS), so the fast path never depends on SSH.
ATTIC_BIN=""
if command -v attic >/dev/null 2>&1; then
ATTIC_BIN="$(command -v attic)"
elif nix-store --realise "$ATTIC_DIR" 2>/tmp/attic-bootstrap.err; then
echo "✅ Pulled attic client from attic cache (HTTPS substituter)"
ATTIC_BIN="$ATTIC_DIR/bin/attic"
elif nix copy --from "ssh://$VPS_USER@$VPS_HOST" "$ATTIC_DIR" 2>>/tmp/attic-bootstrap.err; then
echo "✅ Pulled attic client from VPS over ssh"
ATTIC_BIN="$ATTIC_DIR/bin/attic"
else
echo "attic client unavailable on runner; using VPS-hop flow"
echo "--- bootstrap errors (stderr) ---"
tail -5 /tmp/attic-bootstrap.err 2>/dev/null || true
attic_push_vps_hop
exit 0
fi
# ── Direct push: runner → attic public endpoint ──────────────────
# --ignore-upstream-cache-filter forces the narinfo write even when
# the path's chunks already exist in upstream cache.nixos.org (which
# attic would otherwise skip, leaving the path 404 on the gmw cache).
mkdir -p "$HOME/.config/attic"
cat > "$HOME/.config/attic/config.toml" <<EOF
default-server = "pub"
[servers.pub]
endpoint = "https://attic.asepharyana.my.id"
token = "$ATTIC_TOKEN"
EOF
# Retry the direct push — a transient 502 (e.g. atticd restart,
# Traefik blip) must not abort the whole closure upload. attic push
# is idempotent, so re-running only uploads what's still missing.
push_ok=""
for attempt in 1 2 3; do
if "$ATTIC_BIN" push pub:gmw "$STORE_PATH" --jobs 4 --ignore-upstream-cache-filter; then
echo "✅ Pushed $STORE_PATH to attic directly from runner"
push_ok=1
break
fi
echo "⚠️ Direct attic push attempt $attempt/3 failed; retrying in 10s..."
sleep 10
done
if [ -z "$push_ok" ]; then
echo "Direct attic push failed after 3 attempts; using VPS-hop flow"
attic_push_vps_hop
fi
# NOTE: env files /etc/gmw/backend.env & /etc/gmw/discord-gateway.env are
# managed MANUALLY on the VPS (source of truth). CI only builds & deploys.
- name: Deploy ${{ matrix.service }} to VPS
run: |
STORE_PATH="${{ steps.build.outputs.store-path }}"
echo "=== Copying ${{ matrix.service }}: $STORE_PATH ==="
if [ -n "${{ secrets.ATTIC_TOKEN }}" ] && ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-store --realise '$STORE_PATH'" 2>/dev/null; then
echo "Substituted ${{ matrix.service }} from Attic cache"
else
echo "Attic substitute failed; falling back to ssh copy"
nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH"
fi
echo "=== Updating profile ==="
ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-env --profile /nix/var/nix/profiles/gmw-${{ matrix.service }} --set '$STORE_PATH'"
echo "=== Restarting service ===\n"
ssh "$VPS_USER@$VPS_HOST" \
"sudo systemctl daemon-reload && sudo systemctl restart gmw-${{ matrix.service }} && for i in \$(seq 1 15); do state=\$(sudo systemctl is-active gmw-${{ matrix.service }} 2>/dev/null || echo inactive); [ \"\$state\" = \"active\" ] && break; sleep 2; done; echo \"final-state=\$state\"; [ \"\$state\" = \"active\" ]"
echo "✅ gmw-${{ matrix.service }} deployed"
cleanup:
# Bersihkan sampah Nix di VPS SETELAH semua deploy selesai: hapus generasi
# profile lama + nix store gc. Profil yang sedang dipakai tidak disentuh.
needs: build-and-deploy
if: always()
runs-on: ubuntu-latest
steps:
- name: Nix GC on VPS
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_USER: ${{ secrets.VPS_USER }}
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null
ssh "$VPS_USER@$VPS_HOST" "sudo /usr/local/bin/nix-gc-vps.sh" || echo "⚠️ Nix GC gagal (non-fatal)"
@@ -0,0 +1,20 @@
name: Publish to FlakeHub
on:
push:
branches: [main, master]
workflow_dispatch:
jobs:
flakehub-publish:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: DeterminateSystems/determinate-nix-action@main
- uses: DeterminateSystems/flakehub-push@main
with:
visibility: public
rolling: true
+26
View File
@@ -0,0 +1,26 @@
name: Mirror to Gitea
on:
push:
branches: [main, master]
workflow_dispatch:
permissions:
contents: write
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Mirror to Gitea
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
git remote add gitea "https://oauth2:${GITEA_TOKEN}@git.imrnes.team/MythEclipse/GMW.git"
git push --mirror gitea
echo "✅ Mirrored to Gitea (MythEclipse/GMW)"
+1 -1
View File
@@ -12,7 +12,7 @@ worktrees/
.worktrees/ .worktrees/
services/frontend/frontend/dist/ services/frontend/frontend/dist/
target/ target/
nix/
# Gitea CI runner logs # Gitea CI runner logs
.gitea/workflows/*.log .gitea/workflows/*.log
-118
View File
@@ -1,118 +0,0 @@
# Bete — Discord Moderation Dashboard
Bot monitoring Discord yang merekam voice channel, menangkap pesan teks, menyimpan attachment, menjalankan analisis AI opsional, dan menyediakan dashboard web real-time.
**Stack utama:** Node.js (Express 5), pnpm, TypeScript, React 19 (Next.js 16), Tailwind v4, shadcn/ui, Drizzle ORM, PostgreSQL, WebSocket, Redis pub/sub.
## Prasyarat
- Node.js 22+
- pnpm 11.x
- FFmpeg di `PATH` (untuk audio muxing dan playback media)
- `yt-dlp` di `PATH` (untuk resolve audio YouTube/Spotify)
- Bun (untuk frontend dev — opsional, bisa pake pnpm)
- PostgreSQL 15+
## Setup
```bash
pnpm install
cp .env.example .env
# Edit .env sesuai konfigurasi server
```
## Menjalankan
```bash
# Backend (port 3001)
pnpm run dev:backend
# Discord Gateway (capture messages, voice, dll)
pnpm run dev:discord-gateway
# Frontend (port 3000)
pnpm run dev:web
```
## Build
```bash
pnpm run build:backend
pnpm run build:discord-gateway
pnpm run build:web # next build — static export ke out/
pnpm run build # build semua service
```
## Deploy
```bash
./deploy.sh # Build + deploy semua service ke VPS
./deploy.sh --frontend # Frontend only
./deploy.sh --backend # Backend only
./deploy.sh --no-build # Skip build, copy files aja
```
## Service Architecture
```
Discord
|
v
discord-gateway ←→ Redis ←→ backend (Express 5) ←→ frontend (Next.js)
| pub/sub | |
| +— REST API (/api/*) |
| +— WebSocket (/ws) |
+— message capture +— AI moderation |
+— voice recording +— dashboard data +— dashboard UI
+— attachment upload +— real-time updates
```
## Fitur
- **Message capture**: Capture pesan baru, edit, dan delete dari Discord
- **Voice recording**: Rekam voice channel ke segmen OGG per user, streaming PCM real-time ke WebSocket
- **Attachment upload**: Download + upload attachment ke external storage
- **AI moderation**: Analisis pesan opsional via LLM, auto-delete, queue management
- **Dashboard**: Messages feed, AI analysis review, voice connection, music player, recordings, user/channel stats
- **Media playback**: Playback dari URL, file lokal, YouTube, Spotify
- **WebSocket**: Real-time event streaming untuk semua aktivitas
- **Public API**: Semua endpoint REST dan WebSocket dapat diakses tanpa autentikasi
## Struktur Proyek
```
services/
├── backend/ # Express 5 REST API + WebSocket server
│ ├── src/modules/ # Feature modules (messages, voice, media, dll)
│ └── src/http/ # Express app setup, middleware
├── discord-gateway/ # Discord client, voice recording, AI analysis
│ ├── src/modules/ # message-capture, voice-recording, ai-moderation
│ └── src/shared/ # Config, database, Discord client
└── frontend/ # Next.js 16 dashboard (static export)
├── src/app/ # Pages (login, dashboard tabs)
├── src/features/ # Feature components (dashboard, live, messages)
└── src/lib/ # API client, WebSocket, types
packages/
└── shared/ # Shared types, errors, logger, utilities
```
## Database
PostgreSQL via Drizzle ORM. Migrasi:
```bash
pnpm run db:generate # Generate migration
pnpm run db:migrate # Apply migration
pnpm run db:studio # Drizzle Studio
```
## WebSocket Events
Backend broadcast event berikut ke frontend via WebSocket:
- `message_created`, `message_updated`, `message_deleted`, `message_analyzed`
- `attachment_created`, `attachment_uploaded`
- `voice_recording_started`, `voice_recording_stopped`, `voice_recording_uploaded`
- `voice_active_user`, `voice_pcm_data`
- `media_state`
- `reaction_*`, `thread_*`, `presence_updated`, `guild_member_*`
+156 -21
View File
@@ -7,10 +7,37 @@
}; };
outputs = { self, nixpkgs, flake-utils }: outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system: flake-utils.lib.eachSystem [ "x86_64-linux" ] (system:
let let
pkgs = import nixpkgs { inherit system; }; pkgs = import nixpkgs { inherit system; };
# libdatachannel for the GoLive N-API binding. nixpkgs 0.24.1 is built
# against this host's glibc and ships both lib + dev headers, so the
# binding links cleanly inside the Nix sandbox (no manual cmake build).
libdatachannel = pkgs.libdatachannel;
# Source filter: `path:` literals do NOT respect .gitignore by default,
# so a dirty local out/ (stale chunks from previous builds) leaks into
# the sandbox. Filter out build artifacts explicitly.
filterSource = { dir, ignore }: builtins.path {
path = dir;
name = "source";
filter = (path: type: let base = baseNameOf path; in !(builtins.elem base ignore));
};
frontendSrc = filterSource {
dir = ./services/frontend;
ignore = [ "out" ".next" "node_modules" "pnpm-lock.yaml" ];
};
# OpenSSL headers (.dev output) + STATIC libs (pkgsStatic.openssl.out —
# node-datachannel's CMakeLists sets OPENSSL_USE_STATIC_LIBS=TRUE, and
# the default `pkgs.openssl` resolves to `bin` which has no lib/) merged
# into one tree so FindOpenSSL resolves both via OPENSSL_ROOT_DIR.
opensslDevEnv = pkgs.symlinkJoin {
name = "openssl-dev-env";
paths = [ pkgs.pkgsStatic.openssl.out pkgs.openssl.dev ];
};
# ---- Shared build tools ---- # ---- Shared build tools ----
nodejs = pkgs.nodejs_22; nodejs = pkgs.nodejs_22;
pnpm = pkgs.pnpm.override { nodejs = nodejs; }; pnpm = pkgs.pnpm.override { nodejs = nodejs; };
@@ -37,6 +64,36 @@
pnpm rebuild 2>&1 || true pnpm rebuild 2>&1 || true
''; '';
# Shrink the shipped node_modules to production deps only. The full
# install's .pnpm virtual store carries dev-only packages (biome,
# typescript, esbuild, drizzle-kit, vitest, ... ~150MB+) that are never
# needed at runtime, so we delete every .pnpm dir that is not part of
# the resolved production graph (`pnpm list --prod`).
#
# NOTE: do NOT use `pnpm install --prod` here — it collapses the
# public-hoist dir (.pnpm/node_modules) that runtime peer resolution
# relies on (e.g. @lng2004/node-datachannel and @seydx/node-av-linux-x64
# are only reachable through it), silently breaking voice/screenshare.
# Instead we keep the full install's symlink layout and only prune
# orphaned package dirs + broken symlinks.
# Must run AFTER tsc (typescript is a devDep) and after native builds.
pruneProd = ''
echo "=== Pruning devDependencies (production-only node_modules) ==="
pnpm list --prod --depth 999 --parseable 2>/dev/null \
| grep -o '\.pnpm/[^/]*' | sort -u > $TMPDIR/prod-pnms.txt
( cd node_modules/.pnpm \
&& for d in */; do \
d="''${d%/}"; \
[ "$d" = "node_modules" ] && continue; \
grep -qF ".pnpm/$d" $TMPDIR/prod-pnms.txt || rm -rf "$d"; \
done ) || true
# Drop symlinks whose .pnpm target was pruned (top-level, scoped dirs,
# hoist, .bin any depth). Mirrors stdenv's noBrokenSymlinks check,
# which would otherwise fail the fixupPhase.
find node_modules -type l ! -exec test -e {} \; -delete 2>/dev/null || true
du -sh node_modules
'';
# ---- Backend ---- # ---- Backend ----
backend = pkgs.stdenv.mkDerivation { backend = pkgs.stdenv.mkDerivation {
pname = "gmw-backend"; pname = "gmw-backend";
@@ -75,7 +132,7 @@
console.log('Fixed ' + count + ' files'); console.log('Fixed ' + count + ' files');
" "
echo "=== Build complete ===" echo "=== Build complete ==="
''; '' + pruneProd;
installPhase = '' installPhase = ''
mkdir -p $out/lib/gmw-backend mkdir -p $out/lib/gmw-backend
@@ -105,15 +162,58 @@ WRAPPER
nativeBuildInputs = [ nativeBuildInputs = [
nodejs pnpm nodejs pnpm
pkgs.python3 pkgs.gnumake pkgs.gcc pkgs.python3 pkgs.gnumake pkgs.gcc pkgs.cmake
pkgs.rustc pkgs.cargo pkgs.rustc pkgs.cargo
pkgs.pkg-config pkgs.pkg-config
pkgs.openssl pkgs.openssl
pkgs.openssl.dev
libdatachannel.dev # rtc/rtc.hpp headers for the GoLive binding
pkgs.git # libdatachannel FetchContent clones from GitHub
pkgs.cacert pkgs.cacert
]; ];
# Runtime tools for the voice pipeline: ffmpeg (mic transmit encode,
# music stream decode, segment muxing) and yt-dlp (YouTube/Spotify/
# search media resolution). Must be on PATH inside the wrapper below.
buildInputs = [ pkgs.ffmpeg-headless pkgs.yt-dlp ];
# cmake is only needed for node-datachannel's postinstall build —
# do NOT let stdenv run its own cmake configure phase on the source.
dontUseCmakeConfigure = true;
buildPhase = pnpmInstall + '' buildPhase = pnpmInstall + ''
echo "=== Compiling TypeScript ===" echo "=== Building native voice deps ==="
# pnpm rebuild aborts on the first failing package and runs scripts
# from the wrong cwd build each native dep explicitly with its own
# install script. Each failure is tolerated (|| true); the packages
# that matter (opus) are verified at runtime.
for pkg in \
node_modules/.pnpm/@discordjs+opus@*/node_modules/@discordjs/opus
do
if [ -d "$pkg" ]; then
echo "--- native build: $pkg ---"
(cd "$pkg" && npm run install 2>&1 || true)
fi
done
echo "=== Building libdatachannel-min N-API binding ==="
# The GoLive screen-share stack uses a minimal N-API binding
# (native/libdatachannel-min) over nixpkgs libdatachannel.
(
cd native/libdatachannel-min
# binding.gyp resolves include/lib from env (LDC_INCLUDE = .dev
# include root, LDC_LIB = lib output dir, NAPI_INCLUDE =
# node-addon-api include root).
NAPI_INCLUDE=$(find ../../node_modules/.pnpm -maxdepth 3 \
-type d -path "*node_modules/node-addon-api" | head -1)
echo "NAPI_INCLUDE=$NAPI_INCLUDE"
LDC_INCLUDE=${libdatachannel.dev} LDC_LIB=${libdatachannel.out}/lib/libdatachannel.so.0.24.1 \
NAPI_INCLUDE=$NAPI_INCLUDE \
npx node-gyp rebuild 2>&1 || true
ls -la build/Release/datachannel_min.node 2>/dev/null \
&& echo "libdatachannel-min binding OK: $(stat -c%s build/Release/datachannel_min.node) bytes" \
|| echo "WARN: libdatachannel-min binding build FAILED (screen share disabled)"
)
echo "=== Compiling TypeScript ===="
npx tsc 2>&1 npx tsc 2>&1
echo "=== Fixing @/ path aliases to relative paths ===" echo "=== Fixing @/ path aliases to relative paths ==="
node -e " node -e "
@@ -141,12 +241,28 @@ WRAPPER
console.log('Fixed ' + count + ' files'); console.log('Fixed ' + count + ' files');
" "
echo "=== Build complete ===" echo "=== Build complete ==="
''; '' + pruneProd;
installPhase = '' installPhase = ''
mkdir -p $out/lib/gmw-discord-gateway mkdir -p $out/lib/gmw-discord-gateway
cp -r dist node_modules package.json tsconfig.json $out/lib/gmw-discord-gateway/ cp -r dist node_modules package.json tsconfig.json $out/lib/gmw-discord-gateway/
# GoLive native binding loadNative resolves it relative to
# dist/goLive/native.js, i.e. <root>/native/libdatachannel-min/
# build/Release/datachannel_min.node; libdatachannel .so must sit
# next to it and be on LD_LIBRARY_PATH at runtime.
mkdir -p $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release
cp native/libdatachannel-min/build/Release/datachannel_min.node \
$out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release/ 2>/dev/null || true
mkdir -p $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/ldc
cp -rL native/libdatachannel-min/build/ldc/libdatachannel.so* \
$out/lib/gmw-discord-gateway/native/libdatachannel-min/build/ldc/ 2>/dev/null || true
# If the binding failed to build, screen share is simply disabled
# the gateway itself must still start.
if [ ! -f $out/lib/gmw-discord-gateway/native/libdatachannel-min/build/Release/datachannel_min.node ]; then
echo "WARN: datachannel_min.node missing GoLive screen share disabled in this build"
fi
# Also include drizzle migrations if they exist # Also include drizzle migrations if they exist
cp -r drizzle $out/lib/gmw-discord-gateway/ 2>/dev/null || true cp -r drizzle $out/lib/gmw-discord-gateway/ 2>/dev/null || true
@@ -154,6 +270,8 @@ WRAPPER
cat > $out/bin/gmw-discord-gateway << WRAPPER cat > $out/bin/gmw-discord-gateway << WRAPPER
#!${pkgs.runtimeShell} #!${pkgs.runtimeShell}
cd $out/lib/gmw-discord-gateway cd $out/lib/gmw-discord-gateway
export PATH=${pkgs.ffmpeg-headless}/bin:${pkgs.yt-dlp}/bin:\$PATH
export LD_LIBRARY_PATH=${libdatachannel.out}/lib:\$LD_LIBRARY_PATH
exec ${nodejs}/bin/node dist/index.js exec ${nodejs}/bin/node dist/index.js
WRAPPER WRAPPER
chmod +x $out/bin/gmw-discord-gateway chmod +x $out/bin/gmw-discord-gateway
@@ -165,39 +283,57 @@ WRAPPER
}; };
}; };
# ---- Frontend (Next.js static export) ---- # ---- Frontend (Next.js SSR standalone) ----
frontend = pkgs.stdenv.mkDerivation { frontend = pkgs.stdenv.mkDerivation {
pname = "gmw-frontend"; pname = "gmw-frontend";
version = "1.0.0"; version = "1.0.0";
src = ./services/frontend; src = frontendSrc;
nativeBuildInputs = [ nodejs pnpm pkgs.gnumake pkgs.gcc pkgs.cacert ]; nativeBuildInputs = [ nodejs pnpm pkgs.gnumake pkgs.gcc pkgs.cacert ];
buildPhase = pnpmInstall + '' buildPhase = pnpmInstall + ''
echo "=== Building Next.js static export ===" echo "=== Building Next.js SSR (standalone) ==="
# Build args are provided as env vars
export NEXT_TELEMETRY_DISABLED=1 export NEXT_TELEMETRY_DISABLED=1
export GMW_BACKEND_URL=http://127.0.0.1:4001
npx next build 2>&1 npx next build 2>&1
''; '';
installPhase = '' installPhase = ''
mkdir -p $out/share/gmw-frontend echo "=== Packaging standalone server ==="
cp -r out $out/share/gmw-frontend/out 2>/dev/null || \ mkdir -p $out/lib/gmw-frontend/standalone
cp -r dist $out/share/gmw-frontend/dist 2>/dev/null || \ # The standalone server bundles its own minimal node_modules but
cp -r .next $out/share/gmw-frontend/.next 2>/dev/null || true # needs the build assets + public copied INSIDE its tree.
cp -r .next/standalone/. $out/lib/gmw-frontend/standalone/
mkdir -p $out/lib/gmw-frontend/standalone/.next
cp -r .next/static $out/lib/gmw-frontend/standalone/.next/static
cp -r public $out/lib/gmw-frontend/standalone/public 2>/dev/null || true
# Copy node_modules for standalone mode if it exists # Remove dangling symlinks left by pnpm's hoisted .pnpm layout
cp -r node_modules $out/share/gmw-frontend/ 2>/dev/null || true # (e.g. node_modules/.pnpm/node_modules/...). The standalone server
# never resolves those at runtime it bundles its own node_modules
# and they trip stdenv's noBrokenSymlinks check.
find $out/lib/gmw-frontend/standalone -type l \
! -exec test -e {} \; -delete 2>/dev/null || true
mkdir -p $out/bin
cat > $out/bin/gmw-frontend << WRAPPER
#!${pkgs.runtimeShell}
cd $out/lib/gmw-frontend/standalone
export PORT=''${GMW_FRONTEND_PORT:-4017}
export HOSTNAME=127.0.0.1
exec ${nodejs}/bin/node server.js
WRAPPER
chmod +x $out/bin/gmw-frontend
''; '';
meta = { meta = {
description = "GMW Frontend Next.js static dashboard"; description = "GMW Frontend Next.js SSR dashboard";
platforms = pkgs.lib.platforms.linux; platforms = pkgs.lib.platforms.linux;
}; };
}; };
# ---- Proxy (nginx serving frontend) ---- # ---- Proxy (nginx: / -> Next SSR, /api + /ws -> backend) ----
proxy = pkgs.stdenv.mkDerivation { proxy = pkgs.stdenv.mkDerivation {
pname = "gmw-proxy"; pname = "gmw-proxy";
version = "1.0.0"; version = "1.0.0";
@@ -212,9 +348,8 @@ WRAPPER
mkdir -p $out/bin $out/etc $out/share mkdir -p $out/bin $out/etc $out/share
# Substitute placeholders in nginx template # Substitute placeholders in nginx template
sed \ sed -e "s|@NGINX_MIME@|${pkgs.nginx}/conf/mime.types|g" \
-e "s|@NGINX_MIME@|${pkgs.nginx}/conf/mime.types|g" \ -e "s|@NEXT_PORT@|4017|g" \
-e "s|@FRONTEND_ROOT@|${frontend}/share/gmw-frontend/out|g" \
${./infra/nix/nginx.conf.template} \ ${./infra/nix/nginx.conf.template} \
> $out/etc/nginx.conf > $out/etc/nginx.conf
@@ -226,7 +361,7 @@ WRAPPER
''; '';
meta = { meta = {
description = "GMW Proxy nginx serving frontend"; description = "GMW Proxy nginx -> Next.js + backend";
platforms = pkgs.lib.platforms.linux; platforms = pkgs.lib.platforms.linux;
}; };
}; };
+2 -2
View File
@@ -33,9 +33,9 @@ COPY --from=builder --chown=node:node /build/node_modules ./node_modules
COPY --from=builder --chown=node:node /build/package.json ./ COPY --from=builder --chown=node:node /build/package.json ./
USER node USER node
EXPOSE 3000 EXPOSE 4001
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/health',r=>process.exit(r.statusCode===200?0:1))" CMD node -e "require('http').get('http://localhost:4001/api/health',r=>process.exit(r.statusCode===200?0:1))"
CMD ["node", "dist/index.js"] CMD ["node", "dist/index.js"]
+2 -2
View File
@@ -33,9 +33,9 @@ services:
- .env - .env
environment: environment:
NODE_ENV: production NODE_ENV: production
WEBSERVER_PORT: 3000 WEBSERVER_PORT: 4001
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"] test: ["CMD", "wget", "-qO-", "http://localhost:4001/api/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
start_period: 15s start_period: 15s
+39 -9
View File
@@ -11,28 +11,44 @@ http {
'' close; '' close;
} }
# Next.js standalone SSR server (backend-fetching on every render).
# Not for hand-editing: @NEXT_PORT@ is substituted at build time.
upstream gmw_next {
server 127.0.0.1:@NEXT_PORT@;
keepalive 16;
}
upstream gmw_backend {
server 127.0.0.1:4001;
keepalive 16;
}
server { server {
listen 8080; listen 4009;
server_name _; server_name _;
# Use relative redirects (Location: /dashboard/) instead of absolute # Use relative redirects (Location: /dashboard/) instead of absolute
# URLs that leak the internal listen port (8080) through Traefik. # URLs that leak the internal listen port (4009) through the reverse proxy.
absolute_redirect off; absolute_redirect off;
gzip on; gzip on;
gzip_types text/plain text/css application/json application/javascript application/wasm image/svg+xml; gzip_types text/plain text/css application/json application/javascript application/wasm image/svg+xml;
gzip_min_length 256; gzip_min_length 256;
# ── Backend REST ───────────────────────────────────────────────
location ^~ /api { location ^~ /api {
proxy_pass http://127.0.0.1:3001$uri$is_args$args; proxy_pass http://gmw_backend$uri$is_args$args;
proxy_http_version 1.1;
proxy_set_header Connection ""; # keepalive to backend
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# ── Backend WebSocket (realtime shared state + voice PCM) ──────
location ^~ /ws { location ^~ /ws {
proxy_pass http://127.0.0.1:3001$uri$is_args$args; proxy_pass http://gmw_backend$uri$is_args$args;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade; proxy_set_header Connection $connection_upgrade;
@@ -45,16 +61,30 @@ http {
proxy_send_timeout 86400s; proxy_send_timeout 86400s;
} }
location /assets/ { # ── Next.js build assets — immutable, edge/shareable ───────────
root @FRONTEND_ROOT@; location ^~ /_next/static/ {
proxy_pass http://gmw_next$uri$is_args$args;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
} }
# ── Everything else → Next.js server (SSR) ──
location / { location / {
root @FRONTEND_ROOT@; proxy_pass http://gmw_next$uri$is_args$args;
index index.html; proxy_http_version 1.1;
try_files $uri $uri/ /index.html; proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Next-Prefetch $http_x_next_prefetch;
proxy_buffering off;
proxy_read_timeout 30s;
} }
} }
} }
+1 -1
View File
@@ -1,5 +1,5 @@
-- Fix: missing messages and attachments tables on VPS -- Fix: missing messages and attachments tables on VPS
-- Run: PGPASSWORD=hunterz psql -h 100.108.1.124 -U asephs -d hub -f scripts/fix-missing-tables.sql -- Run: PGPASSWORD=hunterz psql -h 100.121.180.82 -U asephs -d hub -f scripts/fix-missing-tables.sql
BEGIN; BEGIN;
+6 -6
View File
@@ -225,21 +225,21 @@ All config via environment variables (`.env`), validated with Zod in `shared/con
```env ```env
# Server # Server
WEBSERVER_PORT=3001 WEBSERVER_PORT=4001
NODE_ENV=development NODE_ENV=development
LOG_LEVEL=info LOG_LEVEL=info
# Database # Database
DATABASE_URL=postgresql://user:pass@localhost:5432/discord_moderation DATABASE_URL=postgresql://asephs:***@100.121.180.82:6432/discord_moderation
# OR # OR
DATABASE_HOST=localhost DATABASE_HOST=100.121.180.82
DATABASE_PORT=5432 DATABASE_PORT=6432
DATABASE_NAME=discord_moderation DATABASE_NAME=discord_moderation
DATABASE_USER=postgres DATABASE_USER=postgres
DATABASE_PASSWORD=secret DATABASE_PASSWORD=secret
# Redis (optional, for pub/sub) # Redis (optional, for pub/sub)
REDIS_URL=redis://localhost:6379 REDIS_URL=redis://100.121.180.82:6379
# Discord # Discord
MONITOR_GUILD_ID=123456789 MONITOR_GUILD_ID=123456789
@@ -263,7 +263,7 @@ Use Vitest with mocked database and services.
2. **Implement repository queries** for each module using Drizzle ORM 2. **Implement repository queries** for each module using Drizzle ORM
3. **Add WebSocket server** in `src/ws/server.ts` with Redis pub/sub listener 3. **Add WebSocket server** in `src/ws/server.ts` with Redis pub/sub listener
4. **Create Discord Gateway service** in `services/discord-gateway/` (separate microservice) 4. **Create Discord Gateway service** in `services/discord-gateway/` (separate microservice)
5. **Add Docker & CI/CD** for multi-service deployment 5. **Add Nix & CI/CD** for multi-service deployment (flake.nix + GitHub Actions → nix copy → systemd)
6. **Write integration tests** for full request flow 6. **Write integration tests** for full request flow
## Circular Dependency Check ## Circular Dependency Check
+2 -2
View File
@@ -1,10 +1,10 @@
/** /**
* E2E API tests — runs against a running backend instance. * E2E API tests — runs against a running backend instance.
* Usage: API_BASE=http://localhost:3001 vitest run * Usage: API_BASE=http://localhost:4001 vitest run
*/ */
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
const BASE = process.env.API_BASE ?? "http://localhost:3001/api"; const BASE = process.env.API_BASE ?? "http://localhost:4001/api";
async function api(path: string, init?: RequestInit) { async function api(path: string, init?: RequestInit) {
const res = await fetch(`${BASE}${path}`, { const res = await fetch(`${BASE}${path}`, {
+4 -2
View File
@@ -1,4 +1,3 @@
import { createChildLogger } from "@/shared/logger/index";
import express, { import express, {
type Express, type Express,
type NextFunction, type NextFunction,
@@ -6,13 +5,15 @@ import express, {
type Response, type Response,
} from "express"; } from "express";
import helmet from "helmet"; import helmet from "helmet";
import { createChildLogger } from "@/shared/logger/index";
import { createAnalysisRouter } from "../modules/analysis/index.js"; import { createAnalysisRouter } from "../modules/analysis/index.js";
import { createChatbotRouter } from "../modules/chatbot/index.js";
import { createConfigRouter } from "../modules/config/index.js"; import { createConfigRouter } from "../modules/config/index.js";
import { createDashboardRouter } from "../modules/dashboard/index.js"; import { createDashboardRouter } from "../modules/dashboard/index.js";
import { createHealthRouter } from "../modules/health/index.js"; import { createHealthRouter } from "../modules/health/index.js";
import { createChatbotRouter } from "../modules/chatbot/index.js";
import { createMediaRouter } from "../modules/media/index.js"; import { createMediaRouter } from "../modules/media/index.js";
import { createMessagesRouter } from "../modules/messages/index.js"; import { createMessagesRouter } from "../modules/messages/index.js";
import { createModerationRouter } from "../modules/moderation/index.js";
import { createRecordingsRouter } from "../modules/recordings/index.js"; import { createRecordingsRouter } from "../modules/recordings/index.js";
import { createUiStateRouter } from "../modules/ui-state/index.js"; import { createUiStateRouter } from "../modules/ui-state/index.js";
import { createVoiceRouter } from "../modules/voice/index.js"; import { createVoiceRouter } from "../modules/voice/index.js";
@@ -69,6 +70,7 @@ export function createHttpApp(): Express {
app.use("/api", createUiStateRouter()); app.use("/api", createUiStateRouter());
app.use("/api", createMediaRouter()); app.use("/api", createMediaRouter());
app.use("/api", createVoiceRouter()); app.use("/api", createVoiceRouter());
app.use("/api", createModerationRouter());
// 404 handler // 404 handler
app.use((_req: Request, res: Response) => { app.use((_req: Request, res: Response) => {
+11
View File
@@ -24,6 +24,15 @@ async function main() {
async function shutdown(signal: string) { async function shutdown(signal: string) {
logger.info({ signal }, "Shutting down gracefully"); logger.info({ signal }, "Shutting down gracefully");
// Failsafe: graceful shutdown must never hang the process forever.
// httpServer.close() waits for ALL open connections (including lingering
// WebSocket/keep-alive sockets), so on a stuck connection the process would
// otherwise sit zombie and systemd (Restart=always) can never revive it.
const forceExitTimer = setTimeout(() => {
logger.error({ signal }, "Graceful shutdown timed out; forcing exit");
process.exit(1);
}, 10_000);
try { try {
// 1. Stop accepting new HTTP connections // 1. Stop accepting new HTTP connections
if (httpServer) { if (httpServer) {
@@ -54,9 +63,11 @@ async function shutdown(signal: string) {
); );
logger.info("Graceful shutdown completed"); logger.info("Graceful shutdown completed");
clearTimeout(forceExitTimer);
process.exit(0); process.exit(0);
} catch (err) { } catch (err) {
logger.error({ err }, "Error during graceful shutdown"); logger.error({ err }, "Error during graceful shutdown");
clearTimeout(forceExitTimer);
process.exit(1); process.exit(1);
} }
} }
@@ -1,7 +1,7 @@
import { pgMessagesTable } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { and, desc, eq, ilike, type SQL } from "drizzle-orm"; import { and, desc, eq, ilike, type SQL } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js"; import { getDatabase } from "../../shared/database/index.js";
import { pgMessagesTable } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { import {
type MappedMessage, type MappedMessage,
mapMessageRow, mapMessageRow,
@@ -1,6 +1,6 @@
import { createChildLogger } from "@/shared/logger/index";
import type { Request, Response, Router } from "express"; import type { Request, Response, Router } from "express";
import express from "express"; import express from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js"; import { asyncHandler } from "../../shared/middlewares/index.js";
import { analysisService } from "./analysis.service.js"; import { analysisService } from "./analysis.service.js";
@@ -1,5 +1,5 @@
import { createChildLogger } from "@/shared/logger/index";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js"; import { asyncHandler } from "../../shared/middlewares/index.js";
import { chatbotService } from "./chatbot.service.js"; import { chatbotService } from "./chatbot.service.js";
@@ -9,6 +9,18 @@ interface AuthenticatedRequest extends Request {
userId?: string; userId?: string;
} }
/**
* Resolve the actor id for a request. Frontend (no-login) sends a per-device
* UUID via X-User-Id so chat history stays isolated per visitor; a registered
* auth middleware userId takes precedence when present.
*/
function resolveUserId(req: Request): string {
const authId = (req as AuthenticatedRequest).userId;
if (authId) return authId;
const header = (req.headers["x-user-id"] as string | undefined)?.trim();
return header || "anonymous";
}
export const handleChatbotChat = asyncHandler( export const handleChatbotChat = asyncHandler(
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const { message, context } = req.body as { const { message, context } = req.body as {
@@ -24,8 +36,8 @@ export const handleChatbotChat = asyncHandler(
}); });
} }
// Get user ID from auth middleware (if available) // Get user ID from X-User-Id header (no-login device uuid) or auth
const userId = (req as AuthenticatedRequest).userId || "anonymous"; const userId = resolveUserId(req);
logger.debug( logger.debug(
{ userId, messageLength: message.length, context }, { userId, messageLength: message.length, context },
@@ -59,7 +71,7 @@ export const handleChatbotChat = asyncHandler(
export const getChatbotHistory = asyncHandler( export const getChatbotHistory = asyncHandler(
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId || "anonymous"; const userId = resolveUserId(req);
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100); const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
const history = await chatbotService.getChatHistory(userId, limit); const history = await chatbotService.getChatHistory(userId, limit);
@@ -73,7 +85,7 @@ export const getChatbotHistory = asyncHandler(
export const clearChatbotHistory = asyncHandler( export const clearChatbotHistory = asyncHandler(
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId || "anonymous"; const userId = resolveUserId(req);
await chatbotService.clearChatHistory(userId); await chatbotService.clearChatHistory(userId);
@@ -1,7 +1,7 @@
import { pgChatbotMessagesTable, pgMessagesTable } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { and, desc, eq, type SQL, sql } from "drizzle-orm"; import { and, desc, eq, type SQL, sql } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js"; import { getDatabase } from "../../shared/database/index.js";
import { pgChatbotMessagesTable, pgMessagesTable } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
const logger = createChildLogger("chatbot.repository"); const logger = createChildLogger("chatbot.repository");
@@ -10,11 +10,7 @@ import { chatRequestSchema } from "./chatbot.schema.js";
export function createChatbotRouter(): Router { export function createChatbotRouter(): Router {
const router = express.Router(); const router = express.Router();
router.post( router.post("/chat", validateBody(chatRequestSchema), handleChatbotChat);
"/chat",
validateBody(chatRequestSchema),
handleChatbotChat,
);
router.get("/chat/history", getChatbotHistory); router.get("/chat/history", getChatbotHistory);
router.delete("/chat/history", clearChatbotHistory); router.delete("/chat/history", clearChatbotHistory);
@@ -6,6 +6,7 @@ import type {
SaveConversationInput, SaveConversationInput,
} from "./chatbot.repository.js"; } from "./chatbot.repository.js";
import { chatbotRepository } from "./chatbot.repository.js"; import { chatbotRepository } from "./chatbot.repository.js";
import { executeTool, tools } from "./chatbot.tools.js";
const logger = createChildLogger("chatbot.service"); const logger = createChildLogger("chatbot.service");
@@ -118,41 +119,104 @@ Gaya ngobrol:
try { try {
const { default: axios } = await import("axios"); const { default: axios } = await import("axios");
// Gateway tidak handle role system — gabung konteks ke user message // Gateway tidak handle role system — gabung konteks ke user message.
// The system section stays visible to the model as the first user turn.
const contextPrefixed = `${systemPrompt}\n\nPertanyaan user: ${userMessage}`; const contextPrefixed = `${systemPrompt}\n\nPertanyaan user: ${userMessage}`;
const messages: Array<{ role: "user" | "assistant"; content: string }> = [ // Seed conversation: prior turns + current question.
...history, const messages: Array<
{ role: "user", content: contextPrefixed }, | { role: "user" | "assistant"; content: string }
]; | {
role: "assistant";
content: string | null;
tool_calls: Array<{
id: string;
type: "function";
function: { name: string; arguments: string };
}>;
}
| { role: "tool"; tool_call_id: string; content: string }
> = [...history, { role: "user", content: contextPrefixed }];
// ── Agentic tool loop ─────────────────────────────────────────
const MAX_TOOL_ROUNDS = 4;
for (let round = 0; round <= MAX_TOOL_ROUNDS; round += 1) {
const response = await axios.post( const response = await axios.post(
`${baseUrl}/chat/completions`, `${baseUrl}/chat/completions`,
{ {
model, model,
messages, messages,
max_tokens: 500, tools,
tool_choice: "auto",
max_tokens: 600,
temperature: 0.4, temperature: 0.4,
stream: true,
}, },
{ {
headers: { headers: {
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
timeout: 30_000, timeout: 45_000,
// 9router returns SSE even without stream:true; force stream:true
// in the body and read the raw SSE text.
responseType: "text",
}, },
); );
const result = response.data as { // Parse SSE `data:` lines → content + tool_calls.
choices?: Array<{ message?: { content?: string } }>; const { content, toolCalls } = this.parseSse(response.data as string);
};
const content = result?.choices?.[0]?.message?.content?.trim();
if (content) { logger.debug(
return content; {
round,
hasToolCalls: toolCalls.length > 0,
toolNames: toolCalls.map((t) => t.name),
},
"LLM round parsed",
);
if (toolCalls.length > 0) {
// Execute each tool, append tool results, continue loop.
for (const tc of toolCalls) {
messages.push({
role: "assistant",
content: null,
tool_calls: [
{
id: tc.id,
type: "function",
function: { name: tc.name, arguments: tc.arguments },
},
],
});
let result = "";
try {
result = await executeTool(tc.name, tc.args);
} catch (e) {
result = `Tool error: ${(e as Error).message}`;
}
messages.push({
role: "tool",
tool_call_id: tc.id,
content: result,
});
}
if (round === MAX_TOOL_ROUNDS) {
logger.warn("Hit max tool rounds; returning what we have");
}
continue;
} }
logger.warn({ response: result }, "LLM returned empty response"); if (content?.trim()) {
return content.trim();
}
logger.warn("LLM returned empty response (no tools, no content)");
return this.fallbackResponse(userMessage);
}
logger.warn("Tool loop exhausted without final content");
return this.fallbackResponse(userMessage); return this.fallbackResponse(userMessage);
} catch (error) { } catch (error) {
logger.warn({ error }, "LLM call failed, using fallback response"); logger.warn({ error }, "LLM call failed, using fallback response");
@@ -160,6 +224,93 @@ Gaya ngobrol:
} }
} }
/**
* Parse an SSE stream body into accumulated content + any tool_calls.
* 9router (and most OpenAI-compatible routers) emit `data: {json}` lines
* even when stream is only implied; we must collect deltas manually.
*/
private parseSse(body: string): {
content: string;
toolCalls: Array<{
id: string;
name: string;
arguments: string;
args: Record<string, unknown>;
}>;
} {
const contentParts: string[] = [];
const toolById = new Map<
string,
{ id: string; name: string; arguments: string }
>();
const lines = body.split("\n");
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
try {
const json = JSON.parse(payload) as {
choices?: Array<{
delta?: {
content?: string;
tool_calls?: Array<{
id?: string;
index?: number;
type?: string;
function?: { name?: string; arguments?: string };
}>;
};
finish_reason?: string | null;
}>;
};
const delta = json.choices?.[0]?.delta;
if (!delta) continue;
if (delta.content) contentParts.push(delta.content);
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const idx = String(tc.index ?? 0);
const cur = toolById.get(idx) ?? {
id: tc.id ?? "",
name: "",
arguments: "",
};
// Keep the first non-empty id for this call index.
if (tc.id && !cur.id) cur.id = tc.id;
if (tc.function?.name) cur.name += tc.function.name;
if (tc.function?.arguments) cur.arguments += tc.function.arguments;
toolById.set(idx, cur);
}
}
} catch {
// Skip malformed lines (keepalives, etc.)
}
}
// Build a de-duplicated id for any call the stream never assigned one.
let fallbackId = 0;
const toolCalls = Array.from(toolById.values()).map((tc) => {
const id = tc.id || `tool_${fallbackId++}_${Date.now()}`;
return {
id,
name: tc.name,
arguments: tc.arguments,
args: this.safeJsonParse(tc.arguments),
};
});
return { content: contentParts.join(""), toolCalls };
}
private safeJsonParse(s: string): Record<string, unknown> {
try {
return JSON.parse(s) as Record<string, unknown>;
} catch {
return {};
}
}
private fallbackResponse(input: string): string { private fallbackResponse(input: string): string {
const lower = input.toLowerCase(); const lower = input.toLowerCase();
@@ -0,0 +1,232 @@
import { sql } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
/**
* Tools the chatbot LLM can call. Definitions describe the schema to the
* model; the executor implements each one against the real database.
* This turns the chatbot from "blind stats guesser" into an agent that
* pulls real, current server data on demand.
*/
export type ToolResult = string;
/** JSON schema for a tool definition (OpenAI function-calling format). */
export interface ToolDef {
type: "function";
function: {
name: string;
description: string;
parameters: {
type: "object";
properties: Record<string, unknown>;
required?: string[];
};
};
}
export const tools: ToolDef[] = [
{
type: "function",
function: {
name: "get_server_stats",
description:
"Ambil statistik ringkas server/guild saat ini: total pesan, user aktif, jumlah pesan flagged, dan jumlah warning. Panggil ini untuk menjawab pertanyaan umum tentang kondisi server. Opsional fill guild_id untuk scope ke guild tertentu, channel_id untuk scope ke channel.",
parameters: {
type: "object",
properties: {
guildId: {
type: "string",
description: "ID guild/server (opsional). Kosongkan = semua data.",
},
channelId: {
type: "string",
description: "ID channel (opsional).",
},
},
},
},
},
{
type: "function",
function: {
name: "get_top_channels",
description:
"Ambil daftar channel paling aktif (jumlah pesan terbanyak) di server. Panggil buat jawab 'channel mana paling ramai' atau aktivitas per-channel.",
parameters: {
type: "object",
properties: {
guildId: {
type: "string",
description: "ID server (opsional).",
},
limit: {
type: "number",
description: "Jumlah channel teratas (default 5, max 10).",
},
},
},
},
},
{
type: "function",
function: {
name: "get_recent_activity",
description:
"Ambil aktivitas/pesan terbaru di server: siapa yang baru ngomong, di channel mana, jam berapa. Panggil buat jawaban soal 'lagi ngapain' / aktivitas terbaru di server.",
parameters: {
type: "object",
properties: {
guildId: {
type: "string",
description: "ID server (opsional).",
},
limit: {
type: "number",
description: "Jumlah pesan terakhir (default 5).",
},
},
},
},
},
{
type: "function",
function: {
name: "get_top_flagged",
description:
"Ambil pesan yang paling sering di-flag atau kena warning. Panggil buat jawab soal pesan bermasalah / moderator.",
parameters: {
type: "object",
properties: {
guildId: {
type: "string",
description: "ID server (opsional).",
},
limit: {
type: "number",
description: "Jumlah pesan (default 5).",
},
},
},
},
},
];
/** Executes a tool call against the real DB and returns a readable result. */
export async function executeTool(
name: string,
args: Record<string, unknown>,
): Promise<string> {
const guildId =
typeof args.guildId === "string" && args.guildId ? args.guildId : undefined;
const channelId =
typeof args.channelId === "string" && args.channelId
? args.channelId
: undefined;
const limitRaw =
typeof args.limit === "number" ? args.limit : Number(args.limit) || 5;
const limit = Math.min(Math.max(1, Math.round(limitRaw)), 10);
try {
switch (name) {
case "get_server_stats":
return await serverStats(guildId, channelId);
case "get_top_channels":
return await topChannels(guildId, limit);
case "get_recent_activity":
return await recentActivity(guildId, limit);
case "get_top_flagged":
return await topFlagged(guildId, limit);
default:
return `Unknown tool: ${name}`;
}
} catch (error) {
// Best-effort: if a tool fails, return readable error instead of crashing
return `Terjadi kesalahan saat ambil data: ${(error as Error).message ?? "unknown"}`;
}
}
// ── Tool executors ──────────────────────────────────────────
async function serverStats(
guildId?: string,
channelId?: string,
): Promise<string> {
const db = getDatabase();
const conditions: string[] = [];
if (guildId) conditions.push(`guild_id = '${guildId}'`);
if (channelId) conditions.push(`channel_id = '${channelId}'`);
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const result = await db.execute(
sql.raw(
`SELECT COUNT(*)::int AS total_messages,
COUNT(DISTINCT user_id)::int AS active_users,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned
FROM messages ${cond}`,
),
);
const rows =
(result as unknown as { rows: Record<string, unknown>[] }).rows ?? [];
const r = rows[0] ?? {};
return JSON.stringify({
total_messages: r.total_messages ?? 0,
active_users: r.active_users ?? 0,
flagged: r.flagged ?? 0,
warned: r.warned ?? 0,
});
}
async function topChannels(guildId?: string, limit = 5): Promise<string> {
const db = getDatabase();
const conditions: string[] = [];
if (guildId) conditions.push(`guild_id = '${guildId}'`);
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const result = await db.execute(
sql.raw(
`SELECT channel_id,
COUNT(*)::int AS count
FROM messages ${cond}
GROUP BY channel_id
ORDER BY count DESC
LIMIT ${limit}`,
),
);
const rows = (result as unknown as { rows: unknown[] }).rows ?? [];
return JSON.stringify(rows.slice(0, limit));
}
async function recentActivity(guildId?: string, limit = 5): Promise<string> {
const db = getDatabase();
const conditions: string[] = [];
if (guildId) conditions.push(`guild_id = '${guildId}'`);
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const result = await db.execute(
sql.raw(
`SELECT username, content, channel_id, created_at
FROM messages ${cond}
ORDER BY created_at DESC
LIMIT ${limit}`,
),
);
return JSON.stringify((result as unknown as { rows: unknown[] }).rows ?? []);
}
async function topFlagged(guildId?: string, limit = 5): Promise<string> {
const db = getDatabase();
const conditions = ["ai_status IN ('flagged', 'warn')"];
if (guildId) conditions.push(`guild_id = '${guildId}'`);
const cond = `WHERE ${conditions.join(" AND ")}`;
const result = await db.execute(
sql.raw(
`SELECT username, content, channel_id, ai_status, created_at
FROM messages ${cond}
ORDER BY created_at DESC
LIMIT ${limit}`,
),
);
return JSON.stringify((result as unknown as { rows: unknown[] }).rows ?? []);
}
@@ -1,3 +1,6 @@
import type { SQL } from "drizzle-orm";
import { sql } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
import { import {
pgChannelCulturesTable, pgChannelCulturesTable,
pgMessagesTable, pgMessagesTable,
@@ -5,9 +8,6 @@ import {
pgUserReputationsTable, pgUserReputationsTable,
pgVoiceRecordingsTable, pgVoiceRecordingsTable,
} from "../../shared/index.js"; } from "../../shared/index.js";
import type { SQL } from "drizzle-orm";
import { sql } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
import type { ListUsersQuery } from "./dashboard.service.js"; import type { ListUsersQuery } from "./dashboard.service.js";
export class DashboardRepository { export class DashboardRepository {
@@ -82,6 +82,52 @@ export class DashboardRepository {
}; };
} }
async getActivity(days: number) {
const db = getDatabase();
const sinceMs = Date.now() - days * 86400000;
const dayAgoMs = Date.now() - 86400000;
// Daily buckets (last N days)
const daily = await db.execute(sql`
SELECT
to_char(to_timestamp(created_at / 1000), 'YYYY-MM-DD') AS day,
COUNT(*)::int AS messages,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
COUNT(DISTINCT user_id)::int AS active_users
FROM ${pgMessagesTable}
WHERE created_at >= ${sinceMs}
GROUP BY day
ORDER BY day
`);
// Hourly distribution (last 24h)
const hourly = await db.execute(sql`
SELECT
EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour,
COUNT(*)::int AS messages,
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged
FROM ${pgMessagesTable}
WHERE created_at >= ${dayAgoMs}
GROUP BY hour
ORDER BY hour
`);
return {
days,
daily: (daily.rows as Record<string, unknown>[]).map((r) => ({
day: String(r.day),
messages: Number(r.messages),
flagged: Number(r.flagged),
active_users: Number(r.active_users),
})),
hourly: (hourly.rows as Record<string, unknown>[]).map((r) => ({
hour: Number(r.hour),
messages: Number(r.messages),
flagged: Number(r.flagged),
})),
};
}
async listUsers(query: ListUsersQuery) { async listUsers(query: ListUsersQuery) {
const db = getDatabase(); const db = getDatabase();
const limit = query.limit ?? 20; const limit = query.limit ?? 20;
@@ -285,6 +331,100 @@ export class DashboardRepository {
}; };
} }
async getTopReactions(limit: number) {
const db = getDatabase();
const cap = Math.min(Math.max(limit || 20, 1), 50);
// Top messages by net reactions (adds minus removes), joined to message content
const result = await db.execute(sql`
SELECT
m.id AS message_id,
m.content,
m.username,
m.channel_id,
m.created_at,
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
r.reaction_count::int
FROM (
SELECT message_id,
(COUNT(*) FILTER (WHERE reaction_type = 'add')
- COUNT(*) FILTER (WHERE reaction_type = 'remove'))::int AS reaction_count
FROM message_reactions
GROUP BY message_id
) r
JOIN messages m ON m.id = r.message_id
WHERE r.reaction_count > 0
ORDER BY r.reaction_count DESC
LIMIT ${cap}
`);
const rows = (result.rows as Record<string, unknown>[]) || [];
if (rows.length === 0) return [];
// Top emoji per message (adds only) for the breakdown
const ids = rows.map((r) => String(r.message_id));
const emojiResult = await db.execute(sql`
SELECT message_id, emoji, COUNT(*)::int AS c
FROM message_reactions
WHERE reaction_type = 'add' AND message_id IN (${sql.join(ids, sql`, `)})
GROUP BY message_id, emoji
ORDER BY message_id, c DESC
`);
const emojiByMessage = new Map<
string,
Array<{ emoji: string; count: number }>
>();
for (const e of emojiResult.rows as Record<string, unknown>[]) {
const mid = String(e.message_id);
const list = emojiByMessage.get(mid) ?? [];
list.push({ emoji: String(e.emoji), count: Number(e.c) });
emojiByMessage.set(mid, list);
}
return rows.map((r) => ({
message_id: String(r.message_id),
content: r.content ? String(r.content) : "",
username: r.username ? String(r.username) : null,
channel_id: String(r.channel_id),
channel_name: r.channel_name ? String(r.channel_name) : null,
created_at: r.created_at ? Number(r.created_at) : null,
reaction_count: Number(r.reaction_count),
top_emojis: (emojiByMessage.get(String(r.message_id)) ?? []).slice(0, 3),
}));
}
async getTopReactors(limit: number) {
const db = getDatabase();
const cap = Math.min(Math.max(limit || 20, 1), 50);
// Top users by net reactions given (adds minus removes)
const result = await db.execute(sql`
SELECT
user_id,
username,
(COUNT(*) FILTER (WHERE reaction_type = 'add')
- COUNT(*) FILTER (WHERE reaction_type = 'remove'))::int AS net_count,
COUNT(*) FILTER (WHERE reaction_type = 'add')::int AS adds_count,
COUNT(DISTINCT message_id)::int AS messages_reacted,
COUNT(DISTINCT emoji)::int AS emojis_used
FROM message_reactions
GROUP BY user_id, username
ORDER BY net_count DESC
LIMIT ${cap}
`);
return ((result.rows as Record<string, unknown>[]) || []).map((r) => ({
user_id: String(r.user_id),
username: String(r.username ?? "unknown"),
net_count: Number(r.net_count),
adds_count: Number(r.adds_count),
messages_reacted: Number(r.messages_reacted),
emojis_used: Number(r.emojis_used),
}));
}
async getUserDetail(userId: string) { async getUserDetail(userId: string) {
const db = getDatabase(); const db = getDatabase();
@@ -1,6 +1,6 @@
import { createChildLogger } from "@/shared/logger/index";
import type { Request, Response, Router } from "express"; import type { Request, Response, Router } from "express";
import express from "express"; import express from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js"; import { asyncHandler } from "../../shared/middlewares/index.js";
import { dashboardService } from "./dashboard.service.js"; import { dashboardService } from "./dashboard.service.js";
@@ -19,6 +19,16 @@ export function createDashboardRouter(): Router {
}), }),
); );
// GET /api/dashboard/activity?days=14 — message volume over time
router.get(
"/dashboard/activity",
asyncHandler(async (req: Request, res: Response) => {
const days = Math.min(Math.max(Number(req.query.days) || 14, 1), 90);
const activity = await dashboardService.getActivity(days);
res.json(activity);
}),
);
// GET /api/dashboard/users — paginated user list with profiles // GET /api/dashboard/users — paginated user list with profiles
router.get( router.get(
"/dashboard/users", "/dashboard/users",
@@ -77,5 +87,25 @@ export function createDashboardRouter(): Router {
}), }),
); );
// GET /api/dashboard/reactions — top reacted messages
router.get(
"/dashboard/reactions",
asyncHandler(async (req: Request, res: Response) => {
const limit = Number(req.query.limit) || 20;
const reactions = await dashboardService.getTopReactions(limit);
res.json(reactions);
}),
);
// GET /api/dashboard/reactors — top users by reactions given
router.get(
"/dashboard/reactors",
asyncHandler(async (req: Request, res: Response) => {
const limit = Number(req.query.limit) || 20;
const reactors = await dashboardService.getTopReactors(limit);
res.json(reactors);
}),
);
return router; return router;
} }
@@ -15,6 +15,11 @@ export class DashboardService {
return dashboardRepository.getStats(); return dashboardRepository.getStats();
} }
async getActivity(days: number) {
logger.debug({ days }, "Fetching dashboard activity");
return dashboardRepository.getActivity(days);
}
async listUsers(query: ListUsersQuery) { async listUsers(query: ListUsersQuery) {
logger.debug({ query }, "Listing dashboard users"); logger.debug({ query }, "Listing dashboard users");
return dashboardRepository.listUsers(query); return dashboardRepository.listUsers(query);
@@ -38,6 +43,16 @@ export class DashboardService {
logger.debug({ channelId }, "Fetching channel detail"); logger.debug({ channelId }, "Fetching channel detail");
return dashboardRepository.getChannelDetail(channelId); return dashboardRepository.getChannelDetail(channelId);
} }
async getTopReactions(limit: number) {
logger.debug({ limit }, "Fetching top reactions");
return dashboardRepository.getTopReactions(limit);
}
async getTopReactors(limit: number) {
logger.debug({ limit }, "Fetching top reactors");
return dashboardRepository.getTopReactors(limit);
}
} }
export const dashboardService = new DashboardService(); export const dashboardService = new DashboardService();
@@ -1,5 +1,5 @@
import { createChildLogger } from "@/shared/logger/index";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { createChildLogger } from "@/shared/logger/index";
import { getDatabase } from "../../shared/database/index.js"; import { getDatabase } from "../../shared/database/index.js";
const logger = createChildLogger("health.repository"); const logger = createChildLogger("health.repository");
@@ -1,9 +1,9 @@
import { createChildLogger } from "@/shared/logger/index";
import type { Request, Response, Router } from "express"; import type { Request, Response, Router } from "express";
import express from "express"; import express from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js"; import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
import { mediaQueueSchema, mediaVolumeSchema } from "./media.schema.js"; import { mediaLoopSchema, mediaQueueSchema } from "./media.schema.js";
import { getStatus, queue, setVolume, skip, stop } from "./media.service.js"; import { getStatus, queue, setLoop, skip, stop } from "./media.service.js";
const logger = createChildLogger("media.routes"); const logger = createChildLogger("media.routes");
@@ -55,14 +55,14 @@ export function createMediaRouter(): Router {
}), }),
); );
// POST /api/media/volume // POST /api/media/loop
router.post( router.post(
"/media/volume", "/media/loop",
validateBody(mediaVolumeSchema), validateBody(mediaLoopSchema),
asyncHandler(async (req: Request, res: Response) => { asyncHandler(async (req: Request, res: Response) => {
const { volume } = req.body as { volume: number }; const { loop } = req.body as { loop: boolean };
logger.debug({ volume }, "Media volume requested"); logger.debug({ loop }, "Media loop requested");
const state = await setVolume(volume); const state = await setLoop(loop);
res.json(state); res.json(state);
}), }),
); );
@@ -5,9 +5,9 @@ export const mediaQueueSchema = z.object({
mode: z.enum(["music", "screen"]).default("music"), mode: z.enum(["music", "screen"]).default("music"),
}); });
export const mediaVolumeSchema = z.object({ export const mediaLoopSchema = z.object({
volume: z.number().min(0).max(1).default(1.0), loop: z.boolean().default(false),
}); });
export type MediaQueueInput = z.infer<typeof mediaQueueSchema>; export type MediaQueueInput = z.infer<typeof mediaQueueSchema>;
export type MediaVolumeInput = z.infer<typeof mediaVolumeSchema>; export type MediaLoopInput = z.infer<typeof mediaLoopSchema>;
@@ -1,14 +1,14 @@
import {
COMMAND_MEDIA_QUEUE,
COMMAND_MEDIA_SKIP,
COMMAND_MEDIA_STOP,
COMMAND_MEDIA_VOLUME,
MEDIA_STATUS_KEY,
} from "../../shared/index.js";
import { import {
createChildLogger, createChildLogger,
tryCommandThenFallback, tryCommandThenFallback,
} from "../../shared/commandHelper.js"; } from "../../shared/commandHelper.js";
import {
COMMAND_MEDIA_LOOP,
COMMAND_MEDIA_QUEUE,
COMMAND_MEDIA_SKIP,
COMMAND_MEDIA_STOP,
MEDIA_STATUS_KEY,
} from "../../shared/index.js";
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js"; import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
const logger = createChildLogger("media.service"); const logger = createChildLogger("media.service");
@@ -28,7 +28,10 @@ export interface MediaItem {
export interface MediaState { export interface MediaState {
playing: boolean; playing: boolean;
/** null/absent when idle; "music" | "screen" while a track is active. */
activeMode?: "music" | "screen" | null;
musicVolume: number; musicVolume: number;
loop: boolean;
current: MediaItem | null; current: MediaItem | null;
queue: MediaItem[]; queue: MediaItem[];
} }
@@ -41,7 +44,9 @@ const DEFAULT_COMMAND_TIMEOUT_MS = 5000;
const DEFAULT_STATE: MediaState = { const DEFAULT_STATE: MediaState = {
playing: false, playing: false,
musicVolume: 1.0, activeMode: null,
musicVolume: 0.3,
loop: false,
current: null, current: null,
queue: [], queue: [],
}; };
@@ -56,9 +61,14 @@ function normalizeMediaState(raw: Record<string, unknown>): MediaState {
rawPlaying === true || rawPlaying === true ||
rawPlaying === "playing" || rawPlaying === "playing" ||
rawPlaying === "buffering"; rawPlaying === "buffering";
const mode = raw.activeMode;
const activeMode: "music" | "screen" | null =
mode === "music" || mode === "screen" ? mode : null;
return { return {
playing, playing,
musicVolume: Number(raw.musicVolume ?? 1.0), activeMode,
musicVolume: Number(raw.musicVolume ?? 0.3),
loop: Boolean(raw.loop ?? false),
current: (raw.current as MediaItem | null) ?? null, current: (raw.current as MediaItem | null) ?? null,
queue: (raw.queue as MediaItem[]) ?? [], queue: (raw.queue as MediaItem[]) ?? [],
}; };
@@ -99,7 +109,9 @@ export async function queue(
() => () =>
publishCommand<MediaState>( publishCommand<MediaState>(
COMMAND_MEDIA_QUEUE, COMMAND_MEDIA_QUEUE,
{ source, mode }, // NOTE: gateway MediaHandler reads `payload.url` (not `source`) —
// keep the field name aligned or playback silently no-ops.
{ url: source, mode },
DEFAULT_COMMAND_TIMEOUT_MS, DEFAULT_COMMAND_TIMEOUT_MS,
), ),
() => readStatusFallback(), () => readStatusFallback(),
@@ -142,18 +154,18 @@ export async function stop(): Promise<MediaState> {
} }
/** /**
* Set volume via Redis command to discord-gateway. * Toggle loop mode (replay current track on natural end) via Redis command.
*/ */
export async function setVolume(volume: number): Promise<MediaState> { export async function setLoop(loop: boolean): Promise<MediaState> {
logger.info({ volume }, "setVolume called"); logger.info({ loop }, "setLoop called");
return tryCommandThenFallback( return tryCommandThenFallback(
() => () =>
publishCommand<MediaState>( publishCommand<MediaState>(
COMMAND_MEDIA_VOLUME, COMMAND_MEDIA_LOOP,
{ volume }, { loop },
DEFAULT_COMMAND_TIMEOUT_MS, DEFAULT_COMMAND_TIMEOUT_MS,
), ),
() => readStatusFallback(), () => readStatusFallback(),
"setVolume", "setLoop",
); );
} }
@@ -1,5 +1,5 @@
import { createChildLogger } from "@/shared/logger/index";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js"; import { asyncHandler } from "../../shared/middlewares/index.js";
import { messageQuerySchema } from "./messages.schema.js"; import { messageQuerySchema } from "./messages.schema.js";
import { messagesService } from "./messages.service.js"; import { messagesService } from "./messages.service.js";
@@ -1,6 +1,3 @@
import type { PageResult } from "../../shared/index.js";
import { pgAttachmentsTable, pgMessagesTable } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { import {
and, and,
desc, desc,
@@ -9,13 +6,16 @@ import {
isNull, isNull,
like, like,
lt, lt,
ne,
notInArray, notInArray,
or, or,
type SQL, type SQL,
sql,
} from "drizzle-orm"; } from "drizzle-orm";
import { config } from "../../shared/config/index.js"; import { config } from "../../shared/config/index.js";
import { getDatabase } from "../../shared/database/index.js"; import { getDatabase } from "../../shared/database/index.js";
import type { PageResult } from "../../shared/index.js";
import { pgAttachmentsTable, pgMessagesTable } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { mapMessageRow } from "../../shared/utils/messageMapper.js"; import { mapMessageRow } from "../../shared/utils/messageMapper.js";
import type { import type {
MessageCreate, MessageCreate,
@@ -115,6 +115,27 @@ export class MessagesRepository {
return mapMessageRow(row as Record<string, unknown>); return mapMessageRow(row as Record<string, unknown>);
} }
/**
* Edit history for a message: previous content snapshots (newest first).
* Stored in message_edits by the gateway's message-capture module.
*/
async getEditHistory(
messageId: string,
): Promise<Array<{ old_content: string; edited_at: number }>> {
const db = getDatabase();
const result = await db.execute(sql`
SELECT old_content, edited_at
FROM message_edits
WHERE message_id = ${messageId}
ORDER BY edited_at DESC
LIMIT 50
`);
return ((result.rows as Record<string, unknown>[]) || []).map((r) => ({
old_content: String(r.old_content ?? ""),
edited_at: Number(r.edited_at ?? 0),
}));
}
async findByChannel( async findByChannel(
channelId: string, channelId: string,
query: MessageQuery, query: MessageQuery,
@@ -223,58 +244,6 @@ export class MessagesRepository {
return mapMessageRow(row as Record<string, unknown>); return mapMessageRow(row as Record<string, unknown>);
} }
/**
* Bulk-reset ai_status from 'error' to 'pending' so the DG recovery worker
* picks them up on its next poll cycle.
*
* Accepts optional scope filters (guildId, channelId) or a list of explicit
* message IDs. Returns the count of rows that were actually updated.
*/
async reanalyzeErrorBatch(opts: {
guildId?: string;
channelId?: string;
messageIds?: string[];
}): Promise<number> {
const db = getDatabase();
const conditions: SQL[] = [eq(pgMessagesTable.ai_status, "error")];
if (opts.messageIds && opts.messageIds.length > 0) {
conditions.push(inArray(pgMessagesTable.id, opts.messageIds));
}
if (opts.guildId) {
conditions.push(eq(pgMessagesTable.guild_id, opts.guildId));
}
if (opts.channelId) {
conditions.push(eq(pgMessagesTable.channel_id, opts.channelId));
}
const result = await db
.update(pgMessagesTable)
.set({ ai_status: "pending" })
.where(and(...conditions));
const count = result.rowCount ?? 0;
logger.info({ count, ...opts }, "Batch reanalyze triggered");
return count;
}
/**
* Mark a single message for re-analysis by resetting ai_status to 'pending'.
* Skips messages already in 'pending' state to avoid write amplification.
*/
async markForReanalysis(id: string): Promise<void> {
const db = getDatabase();
await db
.update(pgMessagesTable)
.set({ ai_status: "pending" })
.where(
and(
eq(pgMessagesTable.id, id),
ne(pgMessagesTable.ai_status, "pending"),
),
);
}
/** /**
* Retrieve messages flagged for review (ai_status IN ('warn', 'flagged')). * Retrieve messages flagged for review (ai_status IN ('warn', 'flagged')).
* Optionally filtered by channelId, with configurable limit. * Optionally filtered by channelId, with configurable limit.
@@ -1,7 +1,7 @@
import { createChildLogger } from "@/shared/logger/index";
import type { Request, Response, Router } from "express"; import type { Request, Response, Router } from "express";
import express from "express"; import express from "express";
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js"; import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { import {
handleGetAttachmentsByChannel, handleGetAttachmentsByChannel,
handleGetImageMessages, handleGetImageMessages,
@@ -9,27 +9,10 @@ import {
handleGetMessagesByChannel, handleGetMessagesByChannel,
handleListMessages, handleListMessages,
} from "./messages.controller.js"; } from "./messages.controller.js";
import { reanalyzeBatchSchema } from "./messages.schema.js";
import { messagesService } from "./messages.service.js"; import { messagesService } from "./messages.service.js";
const logger = createChildLogger("messages.routes"); const logger = createChildLogger("messages.routes");
/**
* Per-message in-flight guard for the single reanalyze endpoint.
* Prevents concurrent spam-clicks from issuing duplicate UPDATE + recovery
* worker triggers for the same message.
*/
const reanalyzeInFlight = new Set<string>();
/**
* Per-scope in-flight guard for the batch reanalyze endpoint.
* Scope key = "guildId:channelId" (empty string used for undefined parts).
* Two concurrent batch-reanalyze requests for the same scope are rejected
* with 409 so the recovery worker is not triggered multiple times for the
* same set of error messages.
*/
const reanalyzeBatchInFlight = new Set<string>();
export function createMessagesRouter(): Router { export function createMessagesRouter(): Router {
const router = express.Router(); const router = express.Router();
@@ -51,74 +34,6 @@ export function createMessagesRouter(): Router {
// (uses /detail/ prefix to avoid collision with :channelId route above) // (uses /detail/ prefix to avoid collision with :channelId route above)
router.get("/messages/detail/:id", handleGetMessageById); router.get("/messages/detail/:id", handleGetMessageById);
// POST /api/messages/reanalyze-batch — Bulk retry all errored messages
// MUST be registered BEFORE /messages/:id/reanalyze so "reanalyze-batch"
// is not captured as an :id param.
router.post(
"/messages/reanalyze-batch",
validateBody(reanalyzeBatchSchema),
asyncHandler(async (req: Request, res: Response) => {
const { guildId, channelId, messageIds } = req.body as {
guildId?: string;
channelId?: string;
messageIds?: string[];
};
// Idempotency guard: one concurrent batch-reanalyze per scope.
// Prevents two admin sessions clicking simultaneously from each
// triggering the recovery worker for the same set of messages.
const scopeKey = `${guildId ?? ""}:${channelId ?? ""}`;
if (reanalyzeBatchInFlight.has(scopeKey)) {
res
.status(409)
.json({ error: "REANALYZE_BATCH_IN_PROGRESS", scope: scopeKey });
return;
}
reanalyzeBatchInFlight.add(scopeKey);
let count = 0;
try {
count = await messagesService.reanalyzeErrorBatch({
guildId,
channelId,
messageIds,
});
} finally {
reanalyzeBatchInFlight.delete(scopeKey);
}
logger.info({ count, guildId, channelId }, "Batch reanalyze completed");
res.status(200).json({ ok: true, count });
}),
);
// POST /api/messages/:id/reanalyze - Mark single message for re-analysis
router.post(
"/messages/:id/reanalyze",
asyncHandler(async (req: Request, res: Response) => {
const id = String(req.params.id ?? "");
if (!id) {
res.status(400).json({ error: "MISSING_ID" });
return;
}
// Idempotency guard: reject concurrent duplicate requests for the same ID.
if (reanalyzeInFlight.has(id)) {
res.status(409).json({ error: "REANALYZE_IN_PROGRESS", messageId: id });
return;
}
reanalyzeInFlight.add(id);
try {
await messagesService.markForReanalysis(id);
} finally {
reanalyzeInFlight.delete(id);
}
res.status(200).json({ ok: true });
}),
);
// GET /api/review - Get flagged/warned messages for review // GET /api/review - Get flagged/warned messages for review
router.get( router.get(
"/review", "/review",
@@ -38,13 +38,6 @@ export const messageUpdateSchema = z.object({
aiConfidence: z.number().optional(), aiConfidence: z.number().optional(),
}); });
export const reanalyzeBatchSchema = z.object({
guildId: z.string().optional(),
channelId: z.string().optional(),
messageIds: z.array(z.string()).optional(),
});
export type MessageQuery = z.infer<typeof messageQuerySchema>; export type MessageQuery = z.infer<typeof messageQuerySchema>;
export type MessageCreate = z.infer<typeof messageCreateSchema>; export type MessageCreate = z.infer<typeof messageCreateSchema>;
export type MessageUpdate = z.infer<typeof messageUpdateSchema>; export type MessageUpdate = z.infer<typeof messageUpdateSchema>;
export type ReanalyzeBatchInput = z.infer<typeof reanalyzeBatchSchema>;
@@ -34,7 +34,12 @@ export class MessagesService {
throw new NotFoundError(`Message with ID ${id} not found`); throw new NotFoundError(`Message with ID ${id} not found`);
} }
return message; const editHistory = await messagesRepository.getEditHistory(id);
return {
...message,
edit_count: editHistory.length,
edit_history: editHistory,
};
} }
async getAttachmentsByChannel(channelId: string, query: MessageQuery) { async getAttachmentsByChannel(channelId: string, query: MessageQuery) {
@@ -58,15 +63,6 @@ export class MessagesService {
return messagesRepository.getImageMessages(guildId, limit); return messagesRepository.getImageMessages(guildId, limit);
} }
async markForReanalysis(id: string): Promise<void> {
if (!id) {
throw new ValidationError("message ID is required");
}
logger.debug({ id }, "Marking message for re-analysis");
await messagesRepository.markForReanalysis(id);
}
async getReviewMessages( async getReviewMessages(
channelId?: string, channelId?: string,
limit?: number, limit?: number,
@@ -74,25 +70,6 @@ export class MessagesService {
logger.debug({ channelId, limit }, "Getting review messages"); logger.debug({ channelId, limit }, "Getting review messages");
return messagesRepository.getReviewMessages(channelId, limit); return messagesRepository.getReviewMessages(channelId, limit);
} }
async reanalyzeErrorBatch(opts: {
guildId?: string;
channelId?: string;
messageIds?: string[];
}) {
if (
!opts.guildId &&
!opts.channelId &&
(!opts.messageIds || opts.messageIds.length === 0)
) {
throw new ValidationError(
"At least one of guildId, channelId, or messageIds[] is required",
);
}
logger.info(opts, "Batch reanalyzing errored messages");
return messagesRepository.reanalyzeErrorBatch(opts);
}
} }
export const messagesService = new MessagesService(); export const messagesService = new MessagesService();
@@ -0,0 +1 @@
export { createModerationRouter } from "./moderation.routes.js";
@@ -0,0 +1,141 @@
import { sql } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
export interface ListModerationQuery {
status?: string;
actionType?: string;
limit?: number;
cursor?: number;
}
const ACTION_TYPES = [
"delete_message",
"mute_user",
"warn_user",
"kick_user",
"ban_user",
] as const;
const STATUSES = ["pending", "executed", "failed"] as const;
export class ModerationRepository {
async getStats() {
const db = getDatabase();
const result = await db.execute(sql`
SELECT action_type, status, COUNT(*)::int AS c
FROM moderation_actions
GROUP BY action_type, status
`);
const rows = (result.rows as Record<string, unknown>[]) || [];
let executed = 0;
let failed = 0;
let pending = 0;
const byAction: Record<
string,
{ executed: number; failed: number; pending: number }
> = {};
for (const r of rows) {
const actionType = String(r.action_type ?? "unknown");
const status = String(r.status ?? "unknown");
const count = Number(r.c ?? 0);
byAction[actionType] ??= { executed: 0, failed: 0, pending: 0 };
if (status === "executed") {
executed += count;
byAction[actionType].executed += count;
} else if (status === "failed") {
failed += count;
byAction[actionType].failed += count;
} else {
pending += count;
byAction[actionType].pending += count;
}
}
const total = executed + failed + pending;
return {
total,
executed,
failed,
pending,
failed_rate: total > 0 ? Number(((failed / total) * 100).toFixed(1)) : 0,
by_action: byAction,
};
}
async listActions(query: ListModerationQuery) {
const db = getDatabase();
const limit = Math.min(Math.max(query.limit ?? 50, 1), 200);
const conditions: string[] = [];
if (
query.status &&
(STATUSES as readonly string[]).includes(query.status)
) {
conditions.push(`a.status = '${query.status}'`);
}
if (
query.actionType &&
(ACTION_TYPES as readonly string[]).includes(query.actionType)
) {
conditions.push(`a.action_type = '${query.actionType}'`);
}
if (query.cursor) {
conditions.push(`a.created_at < ${Number(query.cursor)}`);
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const result = await db.execute(
sql.raw(`
SELECT
a.id,
a.message_id,
a.user_id,
a.guild_id,
a.action_type,
a.reason,
a.executed_by,
a.status,
a.error,
a.created_at,
a.executed_at,
m.username,
LEFT(m.content, 300) AS content
FROM moderation_actions a
LEFT JOIN messages m ON m.id = a.message_id
${whereClause}
ORDER BY a.created_at DESC
LIMIT ${limit + 1}
`),
);
const rows = (result.rows as Record<string, unknown>[]) || [];
const data = rows.slice(0, limit).map((r) => ({
id: String(r.id ?? ""),
message_id: r.message_id ? String(r.message_id) : null,
user_id: r.user_id ? String(r.user_id) : null,
guild_id: String(r.guild_id ?? ""),
action_type: String(r.action_type ?? "unknown"),
reason: r.reason ? String(r.reason) : null,
executed_by: r.executed_by ? String(r.executed_by) : null,
status: String(r.status ?? "unknown"),
error: r.error ? String(r.error) : null,
created_at: r.created_at ? Number(r.created_at) : null,
executed_at: r.executed_at ? Number(r.executed_at) : null,
username: r.username ? String(r.username) : null,
content: r.content ? String(r.content) : null,
}));
const lastRow = rows[limit - 1] as Record<string, unknown> | undefined;
const nextCursor =
rows.length > limit ? String(lastRow?.created_at ?? "") : null;
return { data, nextCursor };
}
}
export const moderationRepository = new ModerationRepository();
@@ -0,0 +1,43 @@
import type { Request, Response, Router } from "express";
import express from "express";
import { createChildLogger } from "../../shared/logger/index.js";
import { asyncHandler } from "../../shared/middlewares/index.js";
import { moderationService } from "./moderation.service.js";
const logger = createChildLogger("moderation.routes");
export function createModerationRouter(): Router {
const router = express.Router();
// GET /api/moderation/stats — moderation action summary
router.get(
"/moderation/stats",
asyncHandler(async (_req: Request, res: Response) => {
const stats = await moderationService.getStats();
res.json(stats);
}),
);
// GET /api/moderation/actions — paginated moderation action log
router.get(
"/moderation/actions",
asyncHandler(async (req: Request, res: Response) => {
const limit = Number(req.query.limit) || 50;
const status = req.query.status as string | undefined;
const actionType = req.query.actionType as string | undefined;
const cursor = req.query.cursor as string | undefined;
const result = await moderationService.listActions({
limit,
status,
actionType,
cursor: cursor ? Number(cursor) : undefined,
});
logger.debug({ count: result.data.length }, "Moderation actions listed");
res.json(result);
}),
);
return router;
}
@@ -0,0 +1,21 @@
import { createChildLogger } from "../../shared/logger/index.js";
import {
type ListModerationQuery,
moderationRepository,
} from "./moderation.repository.js";
const logger = createChildLogger("moderation.service");
export class ModerationService {
async getStats() {
logger.debug("Fetching moderation stats");
return moderationRepository.getStats();
}
async listActions(query: ListModerationQuery) {
logger.debug({ query }, "Listing moderation actions");
return moderationRepository.listActions(query);
}
}
export const moderationService = new ModerationService();
@@ -1,6 +1,6 @@
import { createChildLogger } from "@/shared/logger/index";
import type { Request, Response, Router } from "express"; import type { Request, Response, Router } from "express";
import express from "express"; import express from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js"; import { asyncHandler } from "../../shared/middlewares/index.js";
import { recordingsService } from "./recordings.service.js"; import { recordingsService } from "./recordings.service.js";
@@ -1,7 +1,7 @@
import { pgVoiceRecordingsTable } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { and, desc, eq, lt, type SQL } from "drizzle-orm"; import { and, desc, eq, lt, type SQL } from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js"; import { getDatabase } from "../../shared/database/index.js";
import { pgVoiceRecordingsTable } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
const logger = createChildLogger("recordings.service"); const logger = createChildLogger("recordings.service");
@@ -20,7 +20,6 @@ export interface RecordingRow {
upload_error: string | null; upload_error: string | null;
created_at: number; created_at: number;
uploaded_at: number | null; uploaded_at: number | null;
duration_bytes: number;
} }
export interface PaginatedRecordings { export interface PaginatedRecordings {
@@ -69,7 +68,6 @@ export class RecordingsService {
upload_error: pgVoiceRecordingsTable.upload_error, upload_error: pgVoiceRecordingsTable.upload_error,
created_at: pgVoiceRecordingsTable.created_at, created_at: pgVoiceRecordingsTable.created_at,
uploaded_at: pgVoiceRecordingsTable.uploaded_at, uploaded_at: pgVoiceRecordingsTable.uploaded_at,
duration_bytes: pgVoiceRecordingsTable.size_bytes,
}) })
.from(pgVoiceRecordingsTable) .from(pgVoiceRecordingsTable)
.where(where) .where(where)
@@ -1,6 +1,6 @@
import { createChildLogger } from "@/shared/logger/index";
import type { Request, Response, Router } from "express"; import type { Request, Response, Router } from "express";
import express from "express"; import express from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js"; import { asyncHandler } from "../../shared/middlewares/index.js";
import { uiStateService } from "./ui-state.service.js"; import { uiStateService } from "./ui-state.service.js";
@@ -1,5 +1,5 @@
import { createChildLogger } from "@/shared/logger/index";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { createChildLogger } from "@/shared/logger/index";
import { getDatabase } from "../../shared/database/index.js"; import { getDatabase } from "../../shared/database/index.js";
const logger = createChildLogger("ui-state.service"); const logger = createChildLogger("ui-state.service");
@@ -0,0 +1,83 @@
/**
* Authoritative live-voice store.
*
* Single source of truth for who is present / speaking in voice. The backend
* WebSocket server is the one relay every frontend client connects to, so it
* is the correct place to aggregate the gateway's `voice_active_user` deltas
* into a shared snapshot. A late-joining browser must be able to see the same
* state as everyone else — this store makes that possible (seeded into the WS
* initial states and served via GET /api/voice/status).
*/
export interface LiveSpeaker {
userId: string;
username: string;
avatar?: string | null;
speaking: boolean;
/** Epoch ms of the most recent activity (start OR end of speech). */
lastActiveAt: number;
}
const speakers = new Map<string, LiveSpeaker>();
const MAX_SPEAKERS = 200;
/**
* Record a voice_active_user event. `speaking: true` upserts the speaker as
* active; `speaking: false` marks them inactive while keeping them for the
* activity timeline.
*/
/**
* recordSpeaker(data) — apply a `voice_active_user` event. `speaking: true`
* upserts the speaker as ACTIVE; `speaking: false` marks them inactive while
* keeping them for the activity timeline.
*/
export function recordSpeaker(data: {
userId: string;
username?: string;
avatar?: string | null;
speaking: boolean;
}): void {
const { userId, speaking } = data;
const existing = speakers.get(userId);
const speaker: LiveSpeaker = {
userId,
username: data.username ?? existing?.username ?? "Unknown",
avatar: data.avatar ?? existing?.avatar ?? null,
speaking,
lastActiveAt: Date.now(),
};
if (speakers.size >= MAX_SPEAKERS && !existing) {
// Drop the least-recently-active non-speaking speaker to stay bounded.
let oldestId: string | null = null;
let oldestTs = Infinity;
for (const [id, s] of speakers) {
if (!s.speaking && s.lastActiveAt < oldestTs) {
oldestTs = s.lastActiveAt;
oldestId = id;
}
}
if (oldestId) speakers.delete(oldestId);
else return;
}
speakers.set(userId, speaker);
}
/** All known speakers, most recently active first. */
export function getActiveSpeakers(): LiveSpeaker[] {
return [...speakers.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt);
}
/** Only speakers currently flagged as speaking. */
export function getSpeakingSpeakers(): LiveSpeaker[] {
return [...speakers.values()]
.filter((s) => s.speaking)
.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
}
/** Drop all tracked speakers (used on backend restart). */
export function resetLiveSpeakers(): void {
speakers.clear();
}
@@ -1,5 +1,5 @@
import { createChildLogger } from "@/shared/logger/index";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler } from "../../shared/middlewares/index.js"; import { asyncHandler } from "../../shared/middlewares/index.js";
import { publishCommandNoReply } from "../../shared/redis/index.js"; import { publishCommandNoReply } from "../../shared/redis/index.js";
import type { ConnectVoiceInput, VoiceCommandInput } from "./voice.schema.js"; import type { ConnectVoiceInput, VoiceCommandInput } from "./voice.schema.js";
@@ -1,6 +1,6 @@
import { createChildLogger } from "@/shared/logger/index";
import type { Request, Response, Router } from "express"; import type { Request, Response, Router } from "express";
import express from "express"; import express from "express";
import { createChildLogger } from "@/shared/logger/index";
import { asyncHandler, validateBody } from "../../shared/middlewares/index.js"; import { asyncHandler, validateBody } from "../../shared/middlewares/index.js";
import { import {
handleConnectVoice, handleConnectVoice,
@@ -1,3 +1,9 @@
import { eq } from "drizzle-orm";
import {
createChildLogger,
tryCommandThenFallback,
} from "../../shared/commandHelper.js";
import { getDatabase } from "../../shared/database/index.js";
import { import {
COMMAND_GUILDS_LIST, COMMAND_GUILDS_LIST,
COMMAND_GUILDS_TEXT_CHANNELS, COMMAND_GUILDS_TEXT_CHANNELS,
@@ -8,13 +14,8 @@ import {
pgMessagesTable, pgMessagesTable,
VOICE_STATUS_KEY, VOICE_STATUS_KEY,
} from "../../shared/index.js"; } from "../../shared/index.js";
import { eq } from "drizzle-orm";
import {
createChildLogger,
tryCommandThenFallback,
} from "../../shared/commandHelper.js";
import { getDatabase } from "../../shared/database/index.js";
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js"; import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
import { getActiveSpeakers, type LiveSpeaker } from "./live-speaker.js";
const logger = createChildLogger("voice.service"); const logger = createChildLogger("voice.service");
@@ -28,6 +29,8 @@ export interface Channel {
id: string; id: string;
name: string; name: string;
type: "voice" | "text"; type: "voice" | "text";
/** Whether the selfbot account can actually join this voice channel. */
joinable?: boolean;
} }
export interface GuildVoiceEntry { export interface GuildVoiceEntry {
@@ -43,6 +46,12 @@ export interface VoiceStatus {
activeChannelId: string | null; activeChannelId: string | null;
activeChannelName: string | null; activeChannelName: string | null;
connections: GuildVoiceEntry[]; connections: GuildVoiceEntry[];
/**
* Authoritative shared voice snapshot — who is present / speaking right
* now, aggregated server-side from the gateway's `voice_active_user`
* deltas. All browsers converge on this same list.
*/
activeSpeakers: LiveSpeaker[];
} }
export const DEFAULT_VOICE_STATUS: VoiceStatus = { export const DEFAULT_VOICE_STATUS: VoiceStatus = {
@@ -51,8 +60,16 @@ export const DEFAULT_VOICE_STATUS: VoiceStatus = {
activeChannelId: null, activeChannelId: null,
activeChannelName: null, activeChannelName: null,
connections: [], connections: [],
activeSpeakers: [],
}; };
/** Attach the live speaker snapshot to any voice status payload. */
function withActiveSpeakers<T extends Partial<VoiceStatus>>(
status: T,
): T & { activeSpeakers: LiveSpeaker[] } {
return { ...status, activeSpeakers: getActiveSpeakers() };
}
/** /**
* Wraps tryCommandThenFallback with a cleaner signature for use within this module. * Wraps tryCommandThenFallback with a cleaner signature for use within this module.
* Attempts a Redis command first; on failure, falls back to the provided function. * Attempts a Redis command first; on failure, falls back to the provided function.
@@ -66,8 +83,10 @@ async function withFallback<T>(
} }
function readVoiceStatusFallback(): Promise<VoiceStatus> { function readVoiceStatusFallback(): Promise<VoiceStatus> {
return readRedisStatus(VOICE_STATUS_KEY).then( return readRedisStatus(VOICE_STATUS_KEY).then((cached) =>
(cached) => (cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS, withActiveSpeakers(
(cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
),
); );
} }
@@ -137,7 +156,9 @@ export async function getVoiceChannels(guildId: string): Promise<Channel[]> {
export async function getVoiceStatus(): Promise<VoiceStatus> { export async function getVoiceStatus(): Promise<VoiceStatus> {
logger.debug("getVoiceStatus called"); logger.debug("getVoiceStatus called");
const cached = await readRedisStatus(VOICE_STATUS_KEY); const cached = await readRedisStatus(VOICE_STATUS_KEY);
return (cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS; return withActiveSpeakers(
(cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
);
} }
/** /**
@@ -160,8 +160,6 @@ export const configSchema = z
.default(30000) .default(30000)
.describe("Timeout for individual LLM moderation calls"), .describe("Timeout for individual LLM moderation calls"),
// ── AI Analysis Timing ────────────────────────────────────────────── // ── AI Analysis Timing ──────────────────────────────────────────────
AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500), AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500),
AI_ANALYSIS_RECOVERY_INTERVAL_MS: z.coerce AI_ANALYSIS_RECOVERY_INTERVAL_MS: z.coerce
+1 -1
View File
@@ -1,6 +1,6 @@
import { createChildLogger } from "../logger/index.js";
import { drizzle } from "drizzle-orm/node-postgres"; import { drizzle } from "drizzle-orm/node-postgres";
import type { Pool, PoolClient } from "pg"; import type { Pool, PoolClient } from "pg";
import { createChildLogger } from "../logger/index.js";
import { closePool, createPoolFromConfig } from "./pool.js"; import { closePool, createPoolFromConfig } from "./pool.js";
const logger = createChildLogger("database.init"); const logger = createChildLogger("database.init");

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