Compare commits
117
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
401155b501 | ||
|
|
771783d991 | ||
|
|
7ad6487050 | ||
|
|
a13884d80f | ||
|
|
2c69c51fba | ||
|
|
ce784f8305 | ||
|
|
33013697e0 | ||
|
|
b5b370e6eb | ||
|
|
b9bba643bd | ||
|
|
5ba891ecfb | ||
|
|
efca86c50e | ||
|
|
23a7bde4a5 | ||
|
|
c2d6e37ac4 | ||
|
|
e93d826199 | ||
|
|
404ad2231d | ||
|
|
ef476f3ce7 | ||
|
|
6ee1af41c0 | ||
|
|
e6cb514c3a | ||
|
|
08e5a161f2 | ||
|
|
bbf40774ef | ||
|
|
f6d9cd2cc6 | ||
|
|
9104235f28 | ||
|
|
22d896c7eb | ||
|
|
2ccb5c07ce | ||
|
|
8c7751b96b | ||
|
|
c9ac155145 | ||
|
|
2a839e7b09 | ||
|
|
d47221eb2a | ||
|
|
ff7fbf3ad6 | ||
|
|
47d49e3395 | ||
|
|
f0f5100253 | ||
|
|
09e4f9aa84 | ||
|
|
c6d5b93d77 | ||
|
|
d7aa034661 | ||
|
|
ae3b22df93 | ||
|
|
4f7e586ec3 | ||
|
|
fc3c80d73d | ||
|
|
43e35a71dd | ||
|
|
c18b2c42c2 | ||
|
|
bfed04f449 | ||
|
|
9509eaa65c | ||
|
|
ef41898a60 | ||
|
|
8f9f5932b5 | ||
|
|
f6d7b02a8a | ||
|
|
c8382d3930 | ||
|
|
62c531adb5 | ||
|
|
172f8ede43 | ||
|
|
b69da05003 | ||
|
|
1e56c9d716 | ||
|
|
13d140f82a | ||
|
|
687e0566ac | ||
|
|
3472cc072d | ||
|
|
75b9605982 | ||
|
|
ea97dcea79 | ||
|
|
8e96caa075 | ||
|
|
cfd11848a0 | ||
|
|
4c0c76089d | ||
|
|
517441546d | ||
|
|
182b62da4f | ||
|
|
4686b86d1e | ||
|
|
db7814cd0c | ||
|
|
25a9e11945 | ||
|
|
104875e20e | ||
|
|
cc7a914f14 | ||
|
|
44578b0569 | ||
|
|
45f2598937 | ||
|
|
3324553871 | ||
|
|
4369bec527 | ||
|
|
73f13ecac1 | ||
|
|
14c5a50f3d | ||
|
|
341befc43b | ||
|
|
87ccbbce8e | ||
|
|
b76ae57cf6 | ||
|
|
4e442f70e8 | ||
|
|
2ef28cc5c7 | ||
|
|
5abd25c1a8 | ||
|
|
e746c0c2d2 | ||
|
|
1c8f75f034 | ||
|
|
aace81c2d4 | ||
|
|
ff0f888887 | ||
|
|
0c6ff651d1 | ||
|
|
6ecfd559b9 | ||
|
|
3c92cc406a | ||
|
|
dc4347ef9f | ||
|
|
a74a9ceb2e | ||
|
|
5c133f7302 | ||
|
|
9685fa02be | ||
|
|
90d4c2d0b1 | ||
|
|
72fe2a3ad0 | ||
|
|
a30df8b526 | ||
|
|
e0a383c5b3 | ||
|
|
43f2f8449d | ||
|
|
b3418ad799 | ||
|
|
0eb089d9dc | ||
|
|
22be86d1c2 | ||
|
|
922b225b47 | ||
|
|
30977d2e2b | ||
|
|
78d8730785 | ||
|
|
250f043e4a | ||
|
|
2c3d485af2 | ||
|
|
023217b260 | ||
|
|
db4e84f057 | ||
|
|
f85af3952a | ||
|
|
7d4009da9f | ||
|
|
75c419e0ca | ||
|
|
ce6aa72052 | ||
|
|
071aab72ed | ||
|
|
2cff6edf38 | ||
|
|
423568018e | ||
|
|
fbee943eba | ||
|
|
2f9a87ca24 | ||
|
|
b39bfc8e82 | ||
|
|
9f894b1dea | ||
|
|
67c01cdef3 | ||
|
|
68f6fdef57 | ||
|
|
de0d0bb85e | ||
|
|
bf2d6c396f |
No files matched your search
+6
-25
@@ -18,31 +18,12 @@ MONITOR_GUILD_ID=your_guild_id_here # Target guild for text monitoring
|
||||
TEXT_GUILD_ID=optional_text_guild_id # Override text capture guild (falls back to MONITOR_GUILD_ID)
|
||||
TEXT_CHANNEL_ID=optional_text_channel_id # Restrict text capture to a single channel
|
||||
|
||||
# === Voice Channels ===
|
||||
# VOICE_GUILD_ID= # Guild for voice connection (optional)
|
||||
# VOICE_CHANNEL_ID= # Channel for voice connection (optional)
|
||||
|
||||
# === Recording ===
|
||||
RECORDINGS_DIR=./recordings # Audio file output directory (default: ./recordings)
|
||||
RECORDING_SEGMENT_MS=5000 # OGG segment duration in ms (default: 5000)
|
||||
|
||||
# === Decoder ===
|
||||
DECODER_ROTATE_MS=5000 # Opus decoder rotation interval in ms (default: 5000)
|
||||
DECODER_COOLDOWN_MS=30000 # Decoder error cooldown in ms (default: 30000)
|
||||
|
||||
# === Audio ===
|
||||
AUDIO_STREAM_SILENCE_DURATION_MS=3000 # Silence threshold in ms before stopping stream (default: 3000)
|
||||
PACKET_FILTER_MIN_SIZE=8 # Minimum Opus packet size in bytes (default: 8)
|
||||
OPUS_FRAME_SIZE=960 # Opus frame size in samples (default: 960)
|
||||
AUDIO_SAMPLE_RATE=48000 # Audio sample rate in Hz (default: 48000)
|
||||
AUDIO_CHANNELS=2 # Number of audio channels (default: 2)
|
||||
AVATAR_SIZE=64 # User avatar size in pixels (default: 64)
|
||||
|
||||
# === Webserver ===
|
||||
WEBSERVER_PORT=4001 # Backend HTTP/WS server port (default: 4001)
|
||||
|
||||
# === Connection ===
|
||||
VOICE_CONNECTION_TIMEOUT_MS=15000 # Voice connection timeout in ms (default: 15000)
|
||||
RECONNECT_TIMEOUT_MS=5000 # Reconnect timeout in ms (default: 5000)
|
||||
|
||||
# === Logging ===
|
||||
@@ -69,11 +50,6 @@ POSTGRES_POOL_MAX=10 # Maximum pool connections (default: 10)
|
||||
# === Redis ===
|
||||
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_WS_ENABLED=true # Use direct WS for PCM audio (default: true)
|
||||
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
|
||||
|
||||
# === Attachments ===
|
||||
TELE_UPLOAD_URL=https://upload.asepharyana.my.id/api/upload # Attachment upload endpoint (default)
|
||||
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000 # Upload timeout in ms (default: 30000)
|
||||
@@ -98,6 +74,12 @@ AI_LLM_IMAGE_MAX_DIMENSION=1024 # Max image dimension in pixels before r
|
||||
AI_LLM_TEXT_BATCH_SIZE=20 # Max messages per text-only moderation batch (default: 20)
|
||||
AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS=60000 # Timeout in ms for media analysis calls (default: 60000)
|
||||
AI_LLM_TEXT_ANALYSIS_TIMEOUT_MS=30000 # Timeout in ms for text-only analysis calls (default: 30000)
|
||||
AI_LLM_JEV_ENABLED=false # Use TypeSafe Jev (System One) as PRIMARY text analyzer; LLM is fallback
|
||||
AI_LLM_JEV_API_KEY= # REQUIRED if AI_LLM_JEV_ENABLED=true. 9router/TypeSafe API key for /v1/systemone
|
||||
AI_LLM_JEV_BASE_URL=http://127.0.0.1:4014 # 9router base URL (default: local 9router; prod: https://9router.asepharyana.my.id)
|
||||
AI_LLM_JEV_MODEL=oc/jev-1.13-free # Jev model id (default: oc/jev-1.13-free)
|
||||
AI_LLM_JEV_TIMEOUT_MS=45000 # Timeout in ms for a Jev systemone batch call (default: 45000)
|
||||
AI_LLM_JEV_MIN_CONFIDENCE=0.9 # Min status-choice confidence to accept a Jev verdict (default: 0.9)
|
||||
|
||||
# === AI Analysis Tuning ===
|
||||
AI_ANALYSIS_DEBOUNCE_MS=500 # Debounce window for batching messages in ms (default: 500)
|
||||
@@ -126,7 +108,6 @@ AUTO_DELETE_LOG_CHANNEL_ID= # Channel ID to log auto-delete actions
|
||||
# === Retention (0 = disabled) ===
|
||||
RETENTION_MESSAGES_DAYS=0 # Message retention in days (default: 0 = off)
|
||||
RETENTION_ATTACHMENTS_DAYS=0 # Attachment retention in days (default: 0 = off)
|
||||
RETENTION_VOICE_DAYS=0 # Voice recording retention in days (default: 0 = off)
|
||||
RETENTION_CLEANUP_INTERVAL_MS=86400000 # Cleanup interval in ms (default: 24h)
|
||||
RETENTION_DRY_RUN=true # Dry-run: log but do not delete (default: true)
|
||||
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@ services/frontend/frontend/dist/
|
||||
public/app/
|
||||
.muxer-queue.**
|
||||
.claude/
|
||||
.hermes/
|
||||
.env.test
|
||||
logs/
|
||||
.codegraph/
|
||||
@@ -16,7 +17,7 @@ target/
|
||||
nix/
|
||||
# Gitea CI runner logs
|
||||
.gitea/workflows/*.log
|
||||
|
||||
.planning/
|
||||
# Nix build result symlink
|
||||
result
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# Spec: Perbagus fitur Voice + Audio Playback (GMW frontend)
|
||||
|
||||
Tanggal: 2026-08-22 · Scope: **frontend only** (backend/gateway API sudah cukup)
|
||||
|
||||
## Masalah (audit)
|
||||
1. Recordings: semua kartu pakai `<audio controls>` native — tampilan identik,
|
||||
tidak ada indikasi which-clip-playing / loading / paused, dan N audio bisa
|
||||
play bareng (overlap).
|
||||
2. Media view: `thumbnailUrl` dari gateway tidak dipakai; tidak ada visual
|
||||
"sedang playing" selain disc spin; queue item semua sama tanpa badge up-next.
|
||||
3. Mini-player (`lib/hooks/use-media-player.tsx`) ada tapi TIDAK PERNAH
|
||||
dimount → dead code, user tidak lihat status musik di halaman lain.
|
||||
4. Voice page: `useMicTransmit.setVolume` + `useVoiceListen.setVolume`
|
||||
tersedia tapi tak ada UI-nya; mic live tidak punya level feedback.
|
||||
|
||||
## Desain
|
||||
|
||||
### A. RecordingAudioPlayer (baru, `components/voice/recording-audio-player.tsx`)
|
||||
Custom player menggantikan `<audio controls>`:
|
||||
- Play/pause button (ikon berubah), spinner saat buffering (`waiting` event).
|
||||
- Progress bar seekable (click-to-seek) + time label `m:ss / m:ss`.
|
||||
- Waveform-ish equalizer bars saat playing (CSS animation, reduced-motion safe).
|
||||
- **Single-playback**: module-level registry `activePlayers` — memainkan satu
|
||||
clip otomatis pause yang lain.
|
||||
- Kartu pemilik player aktif dapat highlight border signal + "Now playing" chip.
|
||||
|
||||
### B. Recordings view — pasang player baru
|
||||
- Ganti `<audio>` → `<RecordingAudioPlayer src download_url>`.
|
||||
- Highlight kartu via state lifted: `playingId` di view, callback `onPlay`.
|
||||
|
||||
### C. Media view polish
|
||||
- Hero: thumbnail (jika `current.thumbnailUrl`) sebagai disc center image;
|
||||
fallback ListMusic icon. Equalizer bars animasi CSS saat `playing`.
|
||||
- Queue row pertama: badge "up next"; baris current track diberi ring signal.
|
||||
- Volume read-only tetap.
|
||||
|
||||
### D. MiniPlayer global
|
||||
- Hapus `lib/hooks/use-media-player.tsx` (dead) — ganti dengan komponen
|
||||
`components/media/mini-player.tsx` yang subscribe `useMediaState` +
|
||||
`useMediaWsSync` langsung (SWR cache shared antar route), mounted di
|
||||
`AppFrame` bawah layar (fixed bottom, hidden di route `/media`).
|
||||
- Menampilkan: thumbnail kecil/judul, tombol skip/stop, link ke /media.
|
||||
|
||||
### E. Voice UI
|
||||
- Mic live: level meter (Equalizer bars) — mic-transmitter sudah punya worklet;
|
||||
tambah `getLevel()` via AnalyserNode pada stream (simple RMS) di hook.
|
||||
- Listen: volume slider (input range) wired ke `listen.setVolume`.
|
||||
- Mic volume slider wired ke `mic.setVolume`.
|
||||
|
||||
## File touched
|
||||
| File | Aksi |
|
||||
|---|---|
|
||||
| services/frontend/src/components/voice/recording-audio-player.tsx | new |
|
||||
| services/frontend/src/app/(dashboard)/recordings/view.tsx | edit |
|
||||
| services/frontend/src/app/(dashboard)/media/view.tsx | edit |
|
||||
| services/frontend/src/components/media/mini-player.tsx | new |
|
||||
| services/frontend/src/components/shell/ambient-app.tsx | mount MiniPlayer |
|
||||
| services/frontend/src/lib/hooks/use-media-player.tsx | delete |
|
||||
| services/frontend/src/hooks/use-voice.ts | tambah micLevel |
|
||||
| services/frontend/src/lib/audio/mic-transmit.ts | expose analyser level |
|
||||
| services/frontend/src/app/(dashboard)/voice/view.tsx | sliders + meter |
|
||||
|
||||
## Verifikasi
|
||||
1. `pnpm lint` (biome) + `pnpm build` clean.
|
||||
2. Smoke di port **4024** (BUKAN 4017) → curl 200 semua route.
|
||||
3. Commit (tanpa trailer) → push → `gh run watch` → live check
|
||||
https://imphnen.asepharyana.my.id/{media,recordings,voice}/ = 200.
|
||||
@@ -1,82 +0,0 @@
|
||||
# Spec: Recordings — filter per user + export WAV (Audacity)
|
||||
|
||||
## Konteks / Gejala
|
||||
Halaman `services/frontend/src/app/(dashboard)/recordings` menampilkan semua
|
||||
rekaman voice (deck). User ingin:
|
||||
1. **Filter per orang** (tampil rekaman satu user saja).
|
||||
2. **Export ke format untuk Audacity** (buka & edit rekaman di Audacity).
|
||||
|
||||
## Fakta saat ini (verified)
|
||||
- Backend `recordings.list` (services/backend/src/orpc/router.ts:291) SUDAH
|
||||
menerima `userId`/`channelId` filter → `RecordingsService.getRecent`.
|
||||
- Frontend `recordingsApi.list(limit, channelId, userId, cursor)` (lib/api/recordings.ts)
|
||||
sudah meneruskan `userId`. `useLoadMoreRecordings` juga sudah bawa userId.
|
||||
- Tapi UI `RecordingsView` (app/(dashboard)/recordings/view.tsx) TIDAK punya
|
||||
filter UI, dan `useRecordingsPage` dipanggil tanpa userId → semua tampil.
|
||||
- Setiap rekaman punya `download_url` (MP3 di TeleUploader), `user_id`, `username`.
|
||||
- Audacity membuka MP3/OGG tapi editing paling bersih dari WAV (uncompressed)
|
||||
/ FLAC (lossless). Backend TIDAK punya ffmpeg & Nix flake backend tak include
|
||||
ffmpeg → transcode server-side bukan pilihan. Browser punya codec MP3 → export
|
||||
WAV via Web Audio API (client-side) adalah solusi self-contained terbaik.
|
||||
|
||||
## Keputusan desain
|
||||
1. **Filter per user**: UI dropdown (Semua User + per user) di header halaman.
|
||||
Memilih user → re-fetch `recordingsApi.list(50, undefined, userId)` (server
|
||||
filter, benar untuk dataset besar + pagination). Dropdown dibangun dari
|
||||
distinct `user_id`/`username` pada items yang sedang tampil.
|
||||
2. **Export WAV (Audacity)**: client-side via Web Audio API.
|
||||
- Per kartu: tombol "WAV" → decode `download_url` → WAV 16-bit PCM → download.
|
||||
- Header: tombol "EXPORT WAV (N)" → gabung (concat) semua rekaman yang
|
||||
sedang tampil (ter-filter) jadi 1 file WAV → download. Ideal untuk analisis
|
||||
/ mixdown per orang.
|
||||
- Implementasi di `lib/audio/wav.ts` (decode + encode + concat), tanpa dep baru.
|
||||
|
||||
## Perubahan
|
||||
|
||||
### Frontend
|
||||
- **`src/lib/audio/wav.ts`** (baru):
|
||||
- `decodeAudio(url: string): Promise<AudioBuffer>` — fetch arrayBuffer →
|
||||
`new AudioContext().decodeAudioData`.
|
||||
- `audioBufferToWav(buf: AudioBuffer, sampleRate=48000): Blob` — PCM 16-bit
|
||||
interleaved, mono→stereo handling, RIFF/WAVE writer. Audacity-importable.
|
||||
- `concatBuffers(buffers: AudioBuffer[]): AudioBuffer` — gabung di channel 0
|
||||
(mono) dengan sample-rate max; untuk export gabungan.
|
||||
- `downloadWav(blob: Blob, filename: string): void` — obj URL + <a download>.
|
||||
- **`src/app/(dashboard)/recordings/view.tsx`**:
|
||||
- Toolbar filter: dropdown user (built from distinct items) + tombol reset.
|
||||
- State `filterUserId`; saat berubah → `recordingsApi.list(50, undefined, id)`
|
||||
→ set ke SWR (key includes filter), reset pagination.
|
||||
- Tombol "WAV" per kartu (disabled jika `!r.download_url`).
|
||||
- Tombol "EXPORT WAV (N)" di header (disabled jika 0 item punya download_url);
|
||||
concat semua items ter-filter yang punya download_url.
|
||||
- Status loading saat export (spinner/disable).
|
||||
- **`src/hooks/use-recordings.ts`**: `useRecordingsPage` menerima `userId?` dan
|
||||
memasukkan ke key + call, supaya filter re-fetch bersih (per-user cache key).
|
||||
`useRecordings`/`useLoadMoreRecordings` propagate `userId`.
|
||||
- **`src/lib/types/recording.ts`**: tidak berubah (userId dari items).
|
||||
|
||||
### Backend / gateway
|
||||
- Tidak ada perubahan. Filter & export sepenuhnya frontend.
|
||||
|
||||
## File yang disentuh (frontend only)
|
||||
- `src/lib/audio/wav.ts` (baru)
|
||||
- `src/app/(dashboard)/recordings/view.tsx`
|
||||
- `src/hooks/use-recordings.ts`
|
||||
|
||||
## Verification
|
||||
1. `cd services/frontend && pnpm typecheck` (tsc --noEmit) — 0 error.
|
||||
2. `pnpm lint` (biome check src/) — exit 0.
|
||||
3. `pnpm build` (next build) — hijau.
|
||||
4. Manual (user): buka /recordings; pilih user di dropdown → hanya rekaman user
|
||||
itu; klik WAV di kartu → file .wav ter-download & terbuka di Audacity; klik
|
||||
EXPORT WAV (filtered) → satu .wav gabungan.
|
||||
5. Push → CI `Build & Deploy (Nix)` (frontend job) hijau → deploy landing.
|
||||
|
||||
## Risiko / Trade-off
|
||||
- Web Audio decode MP3 di client: butuh CORS pada download_url (TeleUploader
|
||||
asepharyana.my.id — sudah same-serve/proxied, CORS ikut origin). Jika 403/CORS
|
||||
gagal, error toaster + fallback manual (RAW MP3 tetap ada).
|
||||
- concat gabungan = mono 48k; Audacity bisa edit per-channel nanti. Acceptable.
|
||||
- Filter client (dropdown dari items yang dimuat) hanya menawarkan user yang
|
||||
sudah tampil; dataset besar bisa pakai search nanti. Server filter benar untuk
|
||||
yang dipilih.
|
||||
@@ -1,81 +0,0 @@
|
||||
# Spec: Recordings v2 — transcription, search, filters, leaderboard, sessions
|
||||
|
||||
## Konteks
|
||||
4 fitur lanjutan untuk halaman /recordings (dipilih user):
|
||||
1. Tampilkan transkripsi + search by kata kunci
|
||||
2. Filter lanjutan: by channel + rentang tanggal
|
||||
3. Kelompokkan klip jadi "sesi rapat" + autoplay berurutan + export satu sesi
|
||||
4. Leaderboard bicara per user + ringkasan
|
||||
|
||||
## Fakta terverifikasi (2026-08-30)
|
||||
- `voice_recordings` kolom: id, user_id, username, avatar_url, guild_id,
|
||||
channel_id, channel_name, filename, size_bytes, download_url, upload_status,
|
||||
upload_error, created_at, uploaded_at, **transcription** (schema
|
||||
`shared/database/schema.ts:246`). Index user_id/channel_id/created_at.
|
||||
- **Transcription 0/10.640** terisi prod: `AI_VOICE_TRANSCRIPTION_ENABLED`
|
||||
default **false** (config/index.ts:333) & tidak diset di BWS env →
|
||||
`transcribeRecording` (voiceTranscriber.ts) langsung return null.
|
||||
`AI_LLM_BASE_URL` + `AI_LLM_API_KEY` SUDAH dikonfig (Whisper via router GMW).
|
||||
- Transcriber hardcode `language: "en"` (voiceTranscriber.ts:34) — salah utk
|
||||
ucapan campur id/en. Utk auto-detect: hapus param `language` (Whisper
|
||||
auto-detect jika tidak diberikan).
|
||||
- Backend `RecordingsService.getRecent` (recordings.service.ts) TIDAK select
|
||||
`transcription`; SUDAH dukung filter `channelId`+`userId`+`cursor`; belum
|
||||
dukung date-range & keyword search. `RecordingRow` interface juga tak punya
|
||||
`transcription`.
|
||||
- Tidak ada kolom `session_id` / `duration_ms` → sesi grouping = heuristik
|
||||
(channel sama + gap created_at), durasi leaderboard = estimasi dari
|
||||
size_bytes (MP3 128kbps: durasi_s ≈ size_bytes*8/128000).
|
||||
|
||||
## Keputusan desain
|
||||
1. **Aktifkan transkripsi (fondasi)**: transcriber auto-detect (hapus
|
||||
`language:"en"`), set secret BWS `AI_VOICE_TRANSCRIPTION_ENABLED=true`
|
||||
(dibaca runtime oleh bws-exec saat service start). Rekaman BARU dapat
|
||||
transkripsi. Backfill rekaman lama TIDAK dilakukan (pilih user: fokus baru;
|
||||
file OGG lama kemungkinan besar sudah tidak dipakai).
|
||||
2. **Backend** — perluas `getRecent`:
|
||||
- select `transcription` (+ interface RecordingRow + FE type)
|
||||
- filter baru: `q` (ILIKE on transcription + username), `startDate`/`endDate`
|
||||
(created_at range, bigint ms)
|
||||
- endpoint baru `recordings.summary`: agregasi per user → {user_id,
|
||||
username, avatar_url, clips, est_duration_s, words, last_at}. `words`
|
||||
dihitung dari transcription (tokenisasi spasi). Return sorted by clips.
|
||||
3. **Frontend**:
|
||||
- Kartu: tampilkan transkripsi (collapse/expand line-clamp) + durasi estimasi.
|
||||
- Toolbar: search box (q), Select channel, date range (start/end), speaker
|
||||
(sudah ada), reset filter.
|
||||
- Tab/segment "Tape Deck" vs "Leaderboard": leaderboard render summary per
|
||||
user + klik → filter deck by user itu.
|
||||
- Sesi grouping (deck view): klip di-group jadi sesi bila channel sama &
|
||||
gap antar klip < SESSION_GAP_MS (default 120s). Header sesi (channel,
|
||||
waktu mulai, jumlah klip, total durasi). Autoplay tombol "Play session" &
|
||||
"Export session WAV" (concat klip sesi via lib/audio/wav.ts yg sudah ada).
|
||||
|
||||
## Perubahan file
|
||||
|
||||
### Gateway (Tahap 1)
|
||||
- `voiceTranscriber.ts`: hapus baris `language: "en"`.
|
||||
- (secret) set `AI_VOICE_TRANSCRIPTION_ENABLED=true` via bws.
|
||||
|
||||
### Backend (Tahap 2)
|
||||
- `recordings.service.ts`: interface + select + getRecent tambah transcription;
|
||||
tambah filter q/startDate/endDate; method getSummary() untuk leaderboard.
|
||||
- `orpc/router.ts`: procedur `recordings.list` schema tambah fields; prosedur
|
||||
baru `recordings.summary`.
|
||||
|
||||
### Frontend (Tahap 3-5)
|
||||
- `lib/types/recording.ts`: tambah transcription, est_duration_s opsional,
|
||||
Summary type.
|
||||
- `lib/api/recordings.ts`: list tambah q/startDate/endDate; + summary().
|
||||
- `hooks/use-recordings.ts`: propagate filter baru ke key+call; hook
|
||||
useRecordingsSummary.
|
||||
- `app/(dashboard)/recordings/view.tsx`: toolbar search+channel+date, kartu
|
||||
transkripsi, tab leaderboard, grouping sesi + autoplay + export sesi.
|
||||
|
||||
## Verification
|
||||
- tiap tahap: gateway `pnpm build` + `biome check src/`; backend `pnpm build`
|
||||
+ `biome check src/ tests/`; FE `pnpm build` + `biome check src/`.
|
||||
- CI Build & Deploy hijau tiap tahap; deploy landing dicek via
|
||||
`systemctl show gmw-<svc>.service --property=ActiveEnterTimestamp`.
|
||||
- Tahap 1c: setelah deploy + rekaman baru, cek
|
||||
`SELECT COUNT(*) FROM voice_recordings WHERE transcription IS NOT NULL`.
|
||||
@@ -1,71 +0,0 @@
|
||||
# Spec: Record video (kamera/screenshare) orang lain — WebRTC receive (Request 2)
|
||||
|
||||
Date: 2026-08-30. Status: Phase A + B DONE (capture → playable MP4); Phase C (UI) open.
|
||||
|
||||
## Why this is hard (ground truth, verified from @discordjs/voice 0.19.2 source)
|
||||
`VoiceReceiver.onUdpMessage` (dist/index.mjs:2059) drops EVERY non-opus RTP
|
||||
packet at line 2068: `if ((msg[1] & 127) !== RTP_OPUS_PAYLOAD_TYPE) return;`.
|
||||
So video (kamera H264? actually Discord uses VP8/H264; screenshare combines with
|
||||
video SSRC) is decrypted-capable but never forwarded. `receiver.parsePacket`
|
||||
(2033) DOES decrypt any payload type generically (audio + video) using
|
||||
`connectionData.{encryptionMode, nonceBuffer, secretKey}` — the only audio gate
|
||||
is the opus check inside onUdpMessage.
|
||||
|
||||
=> FIX: wrap `receiver.onUdpMessage` (like screenShareAudio.ts already does for
|
||||
screen-share AUDIO SSRCs): for packets whose payload type is a VIDEO type
|
||||
(payload 96 VP8, 101/102 H264, 106/116/126/127 AV1, VP9 98...), call
|
||||
`receiver.parsePacket(...)` myself to decrypt, then depacketize + write frames.
|
||||
Delegate opus (120) to the original handler. Delegate audio to original.
|
||||
|
||||
## Audio already works (screenShareAudio.ts). We add VIDEO.
|
||||
|
||||
## Science-of-the-changes below.
|
||||
|
||||
## Phase A — capture + decrypt + depacketize to AnnexB h264 (THIS change)
|
||||
Files (new): `src/modules/voice-recording/videoReceiver.ts`
|
||||
- Hook into `recorder.startRecording` alongside `hookScreenShareAudio`.
|
||||
- Wrap `receiver.onUdpMessage`:
|
||||
- read ssrc = msg.readUInt32BE(8); userData = receiver.ssrcMap.get(ssrc)
|
||||
- if payload type is video AND we have a "watching" subscription for that user
|
||||
(videoSSRC present), decrypt via receiver.parsePacket(...), then:
|
||||
- H264 (101/102 + payload 120 not): strip RTP header, reassemble FU-A
|
||||
fragments into AnnexB NALs (start-code prefixed), buffer until we have
|
||||
a full access unit (keyframe SPS/PPS/IDR or slices), append to a per-
|
||||
user-per-burst `.h264` file.
|
||||
- else delegate to original onUdpMessage.
|
||||
- Watch `receiver.ssrcMap` "create"/"update" for `videoSSRC !== undefined` →
|
||||
signal a video burst started for that user (like screenShareAudio does).
|
||||
- Per-user video files written to `config.RECORDINGS_DIR/<uid>/video-<ts>.h264`.
|
||||
- Guard: skip bot's own video (client.user.id).
|
||||
|
||||
Dependencies: NO new npm deps for Phase A (only crypto already in
|
||||
@discordjs/voice via parsePacket + Buffer). ffmpeg-headless (already in Nix
|
||||
buildInputs) used in Phase B for decode+mux.
|
||||
|
||||
## Phase B — decode + mux to playable MP4/WebM (DONE, commit 999c054b)
|
||||
- `closeBurst` waits for the WriteStream `finish` (full flush/fd close), then
|
||||
`muxToMp4(rawPath)`: `ffmpeg -f h264 -i raw.h264 -c copy -movflags +faststart
|
||||
out.mp4`, deletes raw on success (>=1B mp4), keeps it on failure.
|
||||
- Output: `<RECORDINGS_DIR>/<uid>/video-<ssrc>-<ts>.mp4`.
|
||||
- ffmpeg is on the gateway runtime PATH (pkgs.ffmpeg-headless, already in the
|
||||
Nix buildInputs for the music/GoLive players).
|
||||
- Test: `tests/videoReceiver.test.ts` muxToMp4 case (real ffmpeg, generates a
|
||||
tiny baseline h264, asserts mp4 non-empty + raw deleted; skipped if no ffmpeg).
|
||||
|
||||
## Phase C — frontend playback + session grouping (follow-up)
|
||||
- Backend oRPC list video files; FE video player, group by call session like audio.
|
||||
|
||||
## Verification
|
||||
- Phase A: join voice, have a member screen-share/camera, confirm `.h264` file
|
||||
grows with NAL frames + keyframes; journal shows "video burst" logs.
|
||||
- Run vitest unit: RTP header strip + FU-A reassembly gives correct bytes.
|
||||
|
||||
## Open questions / risks
|
||||
- Discord codec for camera = H264(101/102); screenshare uses H264 (101/103?)
|
||||
and can also be VP8/VP9. Handle H264 first (depacketize proven), VP8/VP9 in
|
||||
Phase B via ffmpeg RTP input.
|
||||
- Encryption: DAVE (dave_protocol_version) adds a session layer; parsePacket
|
||||
already applies daveSession.decrypt for audio — we must call the SAME
|
||||
parsePacket path so DAVE/encryption is handled identically.
|
||||
- ssrc↔user mapping during a broadcast: videoSSRC is in ssrcMap after the
|
||||
voice state; may need the STREAM_CREATE network events to key reliably.
|
||||
@@ -1,66 +0,0 @@
|
||||
# Spec: Voice auto-reconnect (persistent state + rejoin on drop)
|
||||
|
||||
## Goal
|
||||
Setelah `VoiceController.connect()` berhasil, state "sedang merekam di <guild>/<channel>"
|
||||
disimpan di Postgres. Kalau gateway restart/reboot, atau koneksi voice drop tidak
|
||||
disengaja (dikeluarkan/moved/server restart), gateway otomatis join ulang ke channel
|
||||
yang sama.
|
||||
|
||||
## Requirement mapping (user's ask)
|
||||
- "autoreconnect ke channel yg sama jika server restart atau reboot" → reconnect on
|
||||
startup (ready handler) + keep DB record across graceful shutdown.
|
||||
- "state nya persistent di db" → `voice_auto_reconnect` table.
|
||||
- "rejoin jika tidak sengaja dikeluarkan" → watchdog on `Disconnected`/`Destroyed`
|
||||
(kick / moved / voice server restart) → full rejoin with backoff.
|
||||
- Manual leave (`/voice disconnect`, dashboard disconnect) MUST NOT rejoin.
|
||||
|
||||
## Design decisions
|
||||
1. **New table** `voice_auto_reconnect` (dedicated, not `ui_state`):
|
||||
- `guild_id` text PK
|
||||
- `channel_id` text NOT NULL
|
||||
- `channel_name` text
|
||||
- `connected_at` bigint epoch-ms
|
||||
- `updated_at` bigint epoch-ms
|
||||
DAO: `voiceAutoReconnectRepo.ts` — `upsert(record)`, `list()`, `delete(guildId)`.
|
||||
2. **Write on connect**: `VoiceController.connect()` → after `startRecording` success →
|
||||
`upsert`. IDEMPOTENT (upsert per guild).
|
||||
3. **Clear on manual leave**: `handleVoiceDisconnect` (all) + `handleVoiceDisconnectGuild`
|
||||
pass `clearPersisted: true`. Graceful shutdown `disconnect()` keeps the record.
|
||||
4. **Rejoin on startup**: bootstrap `ready` → `await voiceController.autoReconnect()`
|
||||
(list persisted → connect each, non-fatal on failure).
|
||||
5. **Rejoin on unexpected drop**: monitor per-connection; on `Disconnected`/`Destroyed`
|
||||
with `!intentional` → schedule rejoin `connect(guildId, persistedChannelId)` with
|
||||
backoff (min 2s, max 30s, max 5 attempts). Track `rejoinAttempts`, reset on success.
|
||||
6. **Intentional flag**: `disconnectGuild(guildId, { clearPersisted?, intentional? })`.
|
||||
- shutdown `disconnect()` → `{ intentional: true, clearPersisted: false }`.
|
||||
- manual `disconnect()` (dashboard) → `{ clearPersisted: true }`, sets intentional.
|
||||
- manual `disconnectGuild` → `{ clearPersisted: true }`, sets intentional.
|
||||
The monitor checks `intentional` before rejoin; `clearPersisted` only deletes the row.
|
||||
|
||||
## Files touched
|
||||
- `services/discord-gateway/src/shared/database/schema.ts` — add `pgVoiceAutoReconnectTable`
|
||||
+ types.
|
||||
- `services/discord-gateway/src/shared/database/voiceAutoReconnectRepo.ts` (NEW) — DAO.
|
||||
- `services/discord-gateway/src/modules/voice-recording/voiceController.ts` — upsert on
|
||||
connect; monitor + rejoin; `autoReconnect()`; `disconnect/disconnectGuild` opts.
|
||||
- `services/discord-gateway/src/modules/command-handler/voice.handler.ts` — manual
|
||||
disconnect/disconnectGuild pass `clearPersisted: true`.
|
||||
- `services/discord-gateway/src/app/bootstrap.ts` — call `voiceController.autoReconnect()`
|
||||
in `ready`.
|
||||
- `services/discord-gateway/drizzle/migrations/0020_add_voice_auto_reconnect.sql` +
|
||||
`meta/_journal.json` entry (apply manually per gmw-ops).
|
||||
|
||||
## Edge cases
|
||||
- Channel deleted / guild lost while persisted → `connect()` throws (channel not found)
|
||||
→ log + delete persisted row (don't retry forever).
|
||||
- Rejoin attempts exhausted → keep row (so next restart retries) + log.
|
||||
- Multiple guilds: per-guild monitor, per-guild persisted row.
|
||||
- Graceful shutdown order: shutdown sets intentional=true (so no rejoin during teardown)
|
||||
but keeps row.
|
||||
|
||||
## Verification
|
||||
- `pnpm typecheck && pnpm build && pnpm lint` in `services/discord-gateway`.
|
||||
- Apply migration `0020` manually; verify table exists.
|
||||
- CI `Build & Deploy (Nix)` green; gateway deploy lands.
|
||||
- Manual: connect via dashboard → check `voice_auto_reconnect` row; simulate drop →
|
||||
confirm rejoin; manual disconnect → row cleared.
|
||||
@@ -1,166 +0,0 @@
|
||||
# Spec: Perbaiki alur voice → recording (miss & terpotong)
|
||||
|
||||
## Konteks & Gejala
|
||||
User melaporkan alur voice sampai recording **banyak miss** (audio tidak tercatat)
|
||||
dan **terpotong** (satu alur bicara kebelah jadi beberapa segmen / audio putus di
|
||||
tengah). Ini domain `services/discord-gateway/src/modules/voice-recording/`.
|
||||
|
||||
Pipeline per user yang mulai bicara (speaking "start"):
|
||||
```
|
||||
receiver.speaking "start" → speakingHandler(userId)
|
||||
├─ await collectUserMetadata(...) ← roundtrip API, subscribe tertunda
|
||||
├─ receiver.subscribe(userId, {end: AfterSilence, duration: 3000ms}) → audioStream
|
||||
├─ attach data/end/error handlers → audioStream.pipe(PacketFilter) → oggPacketStream
|
||||
├─ SegmentManager.open() → OggLogicalBitstream → file .ogg
|
||||
├─ data: SegmentManager.rotateIfNeeded (rotasi 5s) + decoder.write (web PCM tho
|
||||
└─ end: SegmentManager.close() → segmen finish → finalizeSegment upload + transkrip
|
||||
```
|
||||
|
||||
## Root cause (dari pembacaan kode — justifikasi di bawah)
|
||||
|
||||
### A. MISS bagian awal bicara — subscribe tertunda (utama)
|
||||
`speakingHandler.ts:53` melakukan `await collectUserMetadata(...)` SEBELUM
|
||||
`receiver.subscribe`. `collectUserMetadata` (metadata.ts:30) pada cold path
|
||||
(cache miss) melakukan `client.users.fetch` + `guild.members.fetch` roundtrip
|
||||
Discord API (ratusan ms–detik). Selama await, seluruh opus awal bocor → awal
|
||||
kalimat hilang. Cache menghilangkan ini untuk user yang pernah ter-record, tapi
|
||||
user baru/evict (cache max 200) kena setiap kali.
|
||||
|
||||
### B. Double-subscribe race
|
||||
Guard `receiver.subscriptions.has(userId)` di `speakingHandler.ts:63` diletakkan
|
||||
SETELAH `await collectUserMetadata`. Dua event "start" cepat keduanya melewati
|
||||
guard (belum subscribe) → dua subscription → audio terbelah/ganda per user.
|
||||
|
||||
### C. TERPOTONG di jeda bicara — AfterSilence 3000ms
|
||||
`AUDIO_STREAM_SILENCE_DURATION_MS=3000`. Setelah 3s diam, stream auto-`end` →
|
||||
`SegmentManager.close` → segmen baru saat bicara lagi. Jeda normal (berpikir,
|
||||
interupsi) memecah 1 alur bicara jadi beberapa segmen/file. Ini source "terpotong".
|
||||
Segmen pendek hasil jeda <1s juga DIBUANG di `finalizeSegment` (MIN_DURATION_MS=1000)
|
||||
→ miss kata singkat ("ya", "siap").
|
||||
|
||||
### D. Rotasi segmen 5s di tengah bicara
|
||||
`RECORDING_SEGMENT_MS=5000`: `rotateIfNeeded` menutup bitstream & membuka baru
|
||||
setiap 5s walau bicara kontinu. Pipenya di-re-wire di dalam handler data →
|
||||
window drop kecil + continuity file pecah (bukan masalah besar, tapi berkontribusi).
|
||||
|
||||
### E. Tidak ada sinkronisasi "stop" speaking & stream "end" flaky
|
||||
Handler hanya listen "start"; mengandalkan `AfterSilence` untuk emit "end".
|
||||
Bug @discordjs/voice yang dikenal: `AfterSilence` bisa TIDAK emit "end" saat
|
||||
koneksi gagal/teardown → segmen menggantung & tidak pernah finalize/upload
|
||||
(recording "hilang"). Tidak ada watchdog.
|
||||
|
||||
## Scope
|
||||
Hanya `services/discord-gateway/src/modules/voice-recording/` (+ config index bila
|
||||
perlu default baru). Tidak menyentuh playback (player.ts), transmitter (voice dari
|
||||
browser → Discord, arah berlawanan), muxer (konsolidasi akhir), atau screen-share
|
||||
video (hanya audio SSRC via `hookScreenShareAudio` sudah ada & dibiarkan).
|
||||
|
||||
## Perubahan
|
||||
|
||||
### 1. Speak-before-metadata: subscribe LEBIH DULU, metadata paralel
|
||||
`recorder/speakingHandler.ts`:
|
||||
- Pindahkan `receiver.subscribe` + pipeline setup ke ATAS, SEGERA di handler,
|
||||
sebelum `collectUserMetadata`.
|
||||
- Jalankan `collectUserMetadata` secara paralel non-blocking; gunakan metadata
|
||||
cache untuk registrasi segmen saat finalize.
|
||||
- Pertahankan guard skip bot/user (bot bisa dicek dari `client.users.cache` /
|
||||
`client.user.id` tanpa await) SEBELUM subscribe — jangan tunggu fetch user.
|
||||
|
||||
Detail konkret:
|
||||
```
|
||||
async handler(userId):
|
||||
if (userId === client.user?.id) return;
|
||||
if (receiver.subscriptions.has(userId)) return; // guard kini di DEPAN, tanpa await
|
||||
// (belum tahu bot? gunakan cache user; subscribe dulu biar nggak miss)
|
||||
clone = subscribe(userId, {AfterSilence, duration}) // TANPA await metadata
|
||||
setup pipeline (data/end/error, pipe, open segment)
|
||||
collectUserMetadata(...).then(meta => {
|
||||
if (meta.bot) { drain & close subscription (jangan simpan) }
|
||||
else { registrasi ulang metadata utk segmen aktif }
|
||||
})
|
||||
```
|
||||
Karena listener `start` dibuang untuk bot, harus tutup subscription bot tanpa
|
||||
menyimpan segmen (buang hasil). Pakai `receiver.subscriptions.get(userId)?.destroy()`.
|
||||
|
||||
### 2. Selesaikan race double-subscribe (bagian dari #1)
|
||||
Guard `receiver.subscriptions.has(userId)` diletakkan SINCRON di awal (sebelum
|
||||
await). Karena `subscribe` sinkron dan `subscriptions` terisi sinkron saat
|
||||
dipanggil, event "start" kedua yang tiba setelah subscribe akan melihat
|
||||
subscription aktif → di-skip. Tidak ada await antara guard & subscribe.
|
||||
|
||||
### 3. Naikkan AfterSilence + tail-length → kurangi "terpotong"
|
||||
`shared/config/index.ts`:
|
||||
- `AUDIO_STREAM_SILENCE_DURATION_MS` default 3000 → 4000 (beri ruang jeda
|
||||
alami; Discord packet 20ms, 4s masih wajar, tidak membengkak file).
|
||||
Opsional via env override di production (tidak wajib komit env).
|
||||
|
||||
### 4. Segmen berorientasi "burst bicara" daripada rotasi jam
|
||||
`recorder/segment.ts` + `recorder/speakingHandler.ts`:
|
||||
- Hapus/lepas rotasi SEGMEN berbasis waktu (RECORDING_SEGMENT_MS). Alih-alih,
|
||||
satu segmen = satu burst bicara (buka di "start", tutup di "end"/AfterSilence
|
||||
end). Ini menghilangkan pemecahan di tengah kalimat.
|
||||
- `RECORDING_SEGMENT_MS` tetap dipakai untuk rotasi decoder web-PCM (broadcast
|
||||
live), di mana segmen besar bisa menunda frame — biarkan seperti ada.
|
||||
JADI: `SegmentManager.rotateIfNeeded` TIDAK lagi dipanggil pada jalur OGG
|
||||
recording; decoder rotate tetap dijalankan.
|
||||
|
||||
Catatan: dengan satu segmen per burst, ukuran file ~ durasi bicara. File panjang
|
||||
dibutuhkan transkrip & transcode; tidak ada batas keras yang perlu di-override.
|
||||
Watchdog di #5 membatasi durasi menggantung.
|
||||
|
||||
### 5. Watchdog end-of-burst & teardown recovery
|
||||
`recorder/speakingHandler.ts`:
|
||||
- Setelah subscribe, arm timer watchdog (mis. `config twin`/hitung) yang menutup
|
||||
segmen jika `AfterSilence` tidak emit "end" dalam X detik setelah "stop"
|
||||
speaking — atau, lebih sederhana & robust: dengarkan BOTH stream "end" DAN
|
||||
timer dari `receiver.speaking` "stop" (hingga @discordjs/voice meng-klaim
|
||||
AfterSilence). Bila "stop" fire, mulai countdown kecil (mis. 500ms) lalu
|
||||
`segmentManager.close` + `decoder.destroy` + destroy subscription jika stream
|
||||
belum "end".
|
||||
- Ini menutup A: segmen menggantung → jadi pasti finalize & upload.
|
||||
|
||||
Implementasi: subscriptionStream (audioStream) + track milik per-user di
|
||||
Map<userId, {audioStream, segmentManager, decoder, timer}>; handler "stop"
|
||||
menjadwalkan finalize.
|
||||
|
||||
### 6. Naikkan/ambil MIN segmen duration lebih rendah
|
||||
`recorder/segmentFinalizer.ts`: MIN_DURATION_MS 1000 → 300ms. Kata pendek
|
||||
("ya", "siap") tetap tersimpan. GUI biarkan.
|
||||
|
||||
## File yang disentuh
|
||||
- `src/modules/voice-recording/recorder/speakingHandler.ts` (utama: subscribe
|
||||
first, guard depan, watchdog stop, hapus rotasi segmen dari jalur OGG)
|
||||
- `src/modules/voice-recording/recorder/segment.ts` (opsional: API close/open,
|
||||
pertahankan rotate untuk decoder tapi tak dipakai jalur OGG)
|
||||
- `src/modules/voice-recording/recorder/streamSetup.ts` (kecil: backfill subscribe
|
||||
supaya return subscription utk cleanup/destroy bot)
|
||||
- `src/modules/voice-recording/recorder/segmentFinalizer.ts` (MIN_DURATION)
|
||||
- `src/shared/config/index.ts` (default AfterSilence 4000)
|
||||
|
||||
## Yang TIDAK disentuh
|
||||
- `transmitter.ts` (arah browser→Discord, bukan recording)
|
||||
- `player.ts`, `mediaSource.ts`, `screenShareAudio.ts` (hook SSRC sudah benar)
|
||||
- `muxer.ts` (konsolidasi akhir tetap jalan)
|
||||
- Flake/deps/bundle
|
||||
|
||||
## Verification
|
||||
1. `cd services/discord-gateway && pnpm typecheck` (tsc --noEmit) — 0 error.
|
||||
2. `pnpm lint` (biome check src/) — exit 0.
|
||||
3. `pnpm build` (tsc → dist/).
|
||||
4. Unit test baru (vitest, kalau infra tes ada):
|
||||
- subscribe terjadi tanpa await metadata (spy urutan panggilan)
|
||||
- double-start skip lewat guard sinkron
|
||||
- "stop" → watchdog finalize segmen walau stream tidak "end"
|
||||
- MIN_DURATION 300ms menyimpan kata pendek
|
||||
5. Smoke/CI: `nix flake check` (eval). Push → CI `Build & Deploy (Nix)` hijau →
|
||||
deploy landing (`systemctl show gmw-discord-gateway.service --property=ActiveEnterTimestamp`).
|
||||
6. Runtime manual (user): join voice, bicara dengan jeda >3s, pastikan 1 alur
|
||||
kontinu = 1 segmen utuh (bukan 3), dan awal kata tidak hilang.
|
||||
|
||||
## Risiko / Trade-off
|
||||
- Subscribe-before-metadata: burst bot akan dikumpulkan sesaat lalu dibuang
|
||||
(cost kecil: buang segmen). Lebih baik miss bot daripada miss user.
|
||||
- AfterSilence naik: file lebih panjang sedikit saat jeda; upload/transkrip
|
||||
timeout (transcodeToMp3 30s) tetap aman.
|
||||
- Satu segmen per burst: tidak ada rotasi paksa → durasi segmen = durasi bicara
|
||||
(bisa menit). Transkrip & transcode tetap ok. Watchdog batasi menggantung.
|
||||
@@ -1,177 +0,0 @@
|
||||
# Spec: Receive Others' Screen-Share/Camera Video Under DAVE — Build a DAVE-capable Stream-Watch Connection
|
||||
|
||||
Status: **P1–P3 DONE + 4th CRITICAL FIX deployed (f1a7b0c2); DAVE Ready + MLS handshake CONFIRMED live; P4 = waiting on active streamer to confirm video-burst→mp4**
|
||||
Date: 2026-08-31
|
||||
Author: Hermes
|
||||
Related: `.hermes/plans/2026-08-31_video-receive-eager-selfbot-connection-spec.md` (superseded by this)
|
||||
`.hermes/plans/2026-08-31_video-receive-phaseC-spec.md` (Phase C build, selfbot path — dead)
|
||||
|
||||
## Problem / Ground truth (established from live logs 2026-08-31)
|
||||
GMW must record OTHER members' screen-share + camera video in a voice channel it
|
||||
records. Audio works (via `@discordjs/voice` 0.19.2 negotiating DAVE). Video does
|
||||
not. Verified live: the selfbot path (`discord.js-selfbot-v13` eager `joinChannel`
|
||||
→ `joinStreamConnection` → `receiver.createVideoStream`) authenticates but Discord
|
||||
closes the connection with WS code **4017 "E2EE/DAVE protocol required"** (5x →
|
||||
`VOICE_CONNECTION_ATTEMPTS_EXCEEDED`). Root cause: **Discord now REQUIRES DAVE
|
||||
(E2EE) on every voice RTC, and `discord.js-selfbot-v13`'s voice stack predates
|
||||
DAVE** (identify has no `max_dave_protocol_version`, no MLS handshake). The selfbot
|
||||
path is dead, cannot be repaired. Full details: skill `gmw-ops` →
|
||||
`references/video-receive-and-unmute.md` §4.
|
||||
|
||||
Facts:
|
||||
- Watching a stream = a **SEPARATE RTC connection**, not the guild audio socket:
|
||||
gateway `STREAM_WATCH` (op 20) → Discord replies `STREAM_CREATE` (rtc_server_id)
|
||||
+ `STREAM_SERVER_UPDATE` (separate token+endpoint) → client opens its own voice
|
||||
WS+UDP to that endpoint (`StreamConnectionReadonly` in selfbot). The watched
|
||||
video never rides the @discordjs/voice guild socket.
|
||||
- The stream-watch RTC ALSO requires DAVE (same 4017 mechanism).
|
||||
- `@snazzah/davey` (bundled with @discordjs/voice 0.19.2) supports
|
||||
`MediaType.VIDEO` + `Codec.H264` decrypt — DAVE machinery CAN decrypt H264 video.
|
||||
- No off-the-shelf DAVE-capable video-RECEIVE path exists. Closing references:
|
||||
- **Discord-RE/Discord-video-stream** (fork of `@dank074/discord-video-stream`,
|
||||
master 2026-08-28): full DAVE in `src/client/voice/BaseMediaConnection.ts`
|
||||
(Davey `DAVESession` init via `initDave`, MLS key-package / proposals /
|
||||
commit / welcome / transitions; `WebRtcConnWrapper` encrypts audio/video via
|
||||
`daveSession.encrypt(MediaType.VIDEO, codec, …)`). BUT it is STREAMING only
|
||||
(send). No STREAM_WATCH / receive.
|
||||
- `@discordjs/voice`: full DAVE receive for AUDIO only; `DAVESession.decrypt`
|
||||
hardcodes `MediaType.AUDIO` (dist ~line 892); `onUdpMessage` drops non-opus;
|
||||
no STREAM_WATCH.
|
||||
- `discord.js-selfbot-v13`: video receive but no DAVE.
|
||||
|
||||
## Goal
|
||||
Replace the dead selfbot receive path with a **DAVE-capable stream-watch voice
|
||||
connection**: on detecting a member `voiceState.streaming`, send `STREAM_WATCH`,
|
||||
connect a DAVE-authenticated RTC to the stream endpoint, decrypt incoming H264
|
||||
RTP (`MediaType.VIDEO`), reassemble via the existing `H264Depacketizer`, mux to a
|
||||
playable container. Reuse every tested building block already in the repo.
|
||||
|
||||
## Strategy decision (default A; B as fallback) — de-risk in Phase 2
|
||||
Two implementation routes. Decide by Phase 2 prototype result.
|
||||
|
||||
### Strategy A — extend @discordjs/voice's tested native stack (PREFERRED, lighter)
|
||||
Reuse @discordjs/voice 0.19.2 internals (already a runtime dep, already DAVE-tested
|
||||
for audio):
|
||||
- Drive a connection to the stream endpoint using djs/voice's `VoiceWebSocket` +
|
||||
`VoiceUDPSocket` + `DAVESession` (the same classes that work for the guild
|
||||
connection — they take arbitrary endpoint/token/session).
|
||||
- Send the voice identify with `max_dave_protocol_version`, complete the DAVE
|
||||
handshake (Davey), then on receipt of a video RTP packet call
|
||||
`daveSession.decrypt(userId, MediaType.VIDEO, packet)` (Davey exposes
|
||||
`MediaType.VIDEO` + `Codec.H264`) — djs/voice's hardcoded `AUDIO` is the only
|
||||
blocker, fix by invoking Davey directly with `MediaType.VIDEO` for video SSRCs.
|
||||
- STREAM_WATCH sent via the existing selfbot `client.ws.broadcast` (cheap, works —
|
||||
it needs no selfbot voice connection).
|
||||
- Feed decrypted H264 → `H264Depacketizer` → `.h264` → `muxToMp4` (both already
|
||||
in `videoReceiver.ts`, unit-tested).
|
||||
- No new runtime deps. Risk: relies on non-exported djs/voice internals (reachable
|
||||
via `as any`, as the existing `parsePacket` usage shows).
|
||||
|
||||
### Strategy B — port Discord-RE's BaseMediaConnection (heavier, more self-contained)
|
||||
Port `BaseMediaConnection.ts` DAVE handling + `WebRtcConnWrapper` into a
|
||||
receive/watch connection in the gateway. Deps: requires `@lng2004/node-datachannel`
|
||||
(new native WebRTC dep) + `@snazzah/davey` (already available). More code, more
|
||||
risk (native dep in Nix store), but a clean-room receive path decoupled from
|
||||
djs/voice internals. Use only if A proves infeasible.
|
||||
|
||||
## Files touched (Strategy A shape)
|
||||
- `services/discord-gateway/src/modules/voice-recording/streamWatchReceiver.ts`
|
||||
(NEW): DAVE stream-watch connection wrapper. Owns, per watched user:
|
||||
- `sendStreamWatch(client, streamKey)` (via `client.ws.broadcast({op:20,
|
||||
d:{stream_key}})`), stream_key = `guild:<gid>:<chid>:<uid>`.
|
||||
- collects STREAM_CREATE (rtc_server_id) + STREAM_SERVER_UPDATE (token+
|
||||
endpoint) via `client.on('raw')` match on stream_key.
|
||||
- builds a djs/voice-style connection to `<endpoint>` with the received token/
|
||||
session; completes DAVE handshake.
|
||||
- `onUdpMessage` wrapper: for video payload types, decrypt with
|
||||
`daveSession.decrypt(userId, MediaType.VIDEO, buf)`, depacketize, write.
|
||||
- teardown on STREAM_DELETE / user stops streaming / leave / channel untrack.
|
||||
- `recorder.ts`: wire `trackChannel`/`untrackChannel` already exist; ensure the
|
||||
selfbot *eager voice connection* attempt is REMOVED (it only 4017-spams logs) —
|
||||
but KEEP `client.ws.broadcast` availability for STREAM_WATCH.
|
||||
- `videoRecorder.ts`: remove the dead selfbot `joinChannel`/`joinStreamConnection`
|
||||
calls; keep the `voiceStateUpdate` streaming detection + teardown bookkeeping as
|
||||
the entry point; delegate the actual receive to `streamWatchReceiver`.
|
||||
- `videoReceiver.ts`: keep `H264Depacketizer` + `muxToMp4` (reused). The
|
||||
guild-socket `hookVideoReceiver` can be removed or left inert.
|
||||
- Tests: `tests/streamWatchReceiver.test.ts` (DAVE-handshake stub, RTP decrypt path
|
||||
with a mocked Davey, STREAM_WATCH packet shape); keep `tests/videoReceiver.test.ts`.
|
||||
|
||||
## Phases (each independently verifiable)
|
||||
1. **Phase 1 (this session): spec + source reconnaissance.** Confirm djs/voice
|
||||
internals are reachable (VoiceWebSocket/VoiceUDPSocket/DAVESession exports &
|
||||
shapes), confirm Davey `MediaType.VIDEO` decrypt signature, confirm how a raw
|
||||
stream-watch connection's identify/select-protocol flows. Verify the selfbot
|
||||
`streamKey` format + `raw` STREAM_CREATE/SERVER_UPDATE payload. GATE: accurate
|
||||
spec + no unknowns blocking A.
|
||||
2. **Phase 2: de-risk prototype.** Standalone script (not in the gateway) that:
|
||||
logs into the same selfbot token, joins a real voice channel, sends STREAM_WATCH
|
||||
for a live streamer, receives STREAM_CREATE/SERVER_UPDATE, and attempts a
|
||||
DAVE-authenticated connect + receive of ≥1 H264 packet to prove the path before
|
||||
any gateway integration. GATE: at least one decrypted H264 NAL captured in the
|
||||
lab.
|
||||
3. **Phase 3: gateway integration** per files-touched. GATE: typecheck + build +
|
||||
biome + unit tests green; CI deploy ok.
|
||||
4. **Phase 4: live verify.** With a real streamer in a recorded channel: journal
|
||||
shows `STREAM_WATCH sent`, `DAVE ready`, `Video burst opened`, and a playable
|
||||
`.h264`/`.mp4`/`.mkv` on disk. GATE: playable file with real video content.
|
||||
|
||||
## Risks / open questions
|
||||
- Does Discord require the stream-watch connection to use the SAME session_id as
|
||||
the bot's active voice session, or a fresh one? (Affects identify.) Resolve in P2.
|
||||
- Which video codec does Discord actually send for camera vs GoLive (H264 likely,
|
||||
but VP9/AV1 possible) — the depacketizer only handles H264. P2 measures the
|
||||
payload type live; add depacketizers for other codecs only if observed.
|
||||
- djs/voice `DAVESession`/`VoiceUDPSocket` reachability via `as any` must be
|
||||
confirmed against the installed 0.19.2 build (P1).
|
||||
- The separate stream RTC may need `selectProtocol`/SDP even for receive-only; the
|
||||
Discord-RE SDP shows a `m=video ... inactive` section. Follow the same shape.
|
||||
|
||||
## Verification (overall)
|
||||
- Per-phase gates above.
|
||||
- No regression: audio recording + message capture still work after changes.
|
||||
- `pnpm typecheck && pnpm build && pnpm lint` green in discord-gateway.
|
||||
- Commit + push; CI `Build & Deploy (Nix)` green; live streamer produces a file.
|
||||
|
||||
## Phase 1 findings (CONFIRMED 2026-08-31, Strategy A feasible)
|
||||
- `@discordjs/voice` 0.19.2 dist/index.mjs PUBLICLY exports exactly the primitives
|
||||
needed: `DAVESession`, `Networking`, `NetworkingStatusCode`, `VoiceConnection`,
|
||||
`VoiceReceiver`, `VoiceUDPSocket`, `VoiceWebSocket`, `SSRCMap`,
|
||||
`RTP_OPUS_PAYLOAD_TYPE` (export block ~3143). So a stream-watch connection can be
|
||||
built OUTSIDE the lib using these constructors — no `as any` needed for the heavy
|
||||
lifting.
|
||||
- `Networking` child wiring (~line 1364-1484): `new VoiceWebSocket('wss://' +
|
||||
endpoint + '?v=8', debug)`; on WS open send Identify `{op, d:{server_id,
|
||||
user_id, session_id, token, max_dave_protocol_version: getMaxProtocolVersion()}}`;
|
||||
`createDaveSession(protocolVersion)` → `new DAVESession(protocolVersion, userId,
|
||||
channelId, {decryptionFailureTolerance})` then `.reinit()`; UDP via
|
||||
`new VoiceUDPSocket({ip, port})` after Ready gives modes + ssrc.
|
||||
- `DAVESession` wraps `@snazzah/davey` `Davey.DAVESession(protocolVersion, userId,
|
||||
channelId)`; on network packets it calls `this.session.decrypt(userId,
|
||||
Davey.MediaType.AUDIO, packet)` — hardcoded AUDIO (line ~892). For video we call
|
||||
Davey directly with `MediaType.VIDEO` + `Codec.H264`.
|
||||
- `@snazzah/davey` MediaType enum: AUDIO=0, VIDEO=1; Codec H264=4; methods
|
||||
`decrypt(mediaType, codec, packet): Buffer` + `encrypt(...)`. Confirmed in davey
|
||||
index.d.ts.
|
||||
- Stream key format (selfbot VoiceConnection.js ~1240): `guild:<gid>:<chid>:<uid>`
|
||||
for guild channels; `STREAM_WATCH` = gateway op 20, `d:{stream_key}`;
|
||||
`sendSignalScreenshare` = `client.ws.broadcast({op:20,d:{stream_key}})`. Replies
|
||||
come as gateway `raw` events `STREAM_CREATE` (`d.rtc_server_id`) +
|
||||
`STREAM_SERVER_UPDATE` (`d.token`, `d.endpoint`); selfbot routes them to the
|
||||
stream connection via `client.on('raw')` matching `d.stream_key`, setting
|
||||
`setSessionId(sessionId)` + `setTokenAndEndpoint(token, endpoint)` (Watch case in
|
||||
`StreamConnectionReadonly`).
|
||||
- Discord-RE reference for the identify SDP: stream connections send a `m=video`
|
||||
section with `a=inactive` (receive-only-ish) + standard DAVE/VoiceOpCodes
|
||||
(op 0 identify, op 2 select protocol incl. `max_dave_protocol_version`). See
|
||||
`BaseMediaConnection.handleProtocolAck` + `initDave`.
|
||||
- Decision: proceed with **Strategy A**. Selfbot code to REMOVE: the eager
|
||||
`ensureSelfbotVoice` join + `joinStreamConnection`/`receiver.createVideoStream`
|
||||
in `videoRecorder.ts` (proven dead — 4017). Keep the `voiceStateUpdate` streaming
|
||||
detection + bookkeeping; swap the receive plumbing to a new `streamWatchReceiver`
|
||||
driven by a djs/voice-style connection. STREAM_WATCH itself still sent via the
|
||||
selfbot `client.ws.broadcast` (needs only the WS, not a selfbot voice conn).
|
||||
- OPEN (resolve in Phase 2 lab): (a) whether the stream connection's identify must
|
||||
use the bot's ACTIVE voice session_id or a fresh one; (b) actual video codec Discord
|
||||
sends for camera vs GoLive (measure payload type live; H264 assumed, VP9/AV1 possible
|
||||
→ add depacketizers only if observed).
|
||||
@@ -1,93 +0,0 @@
|
||||
# Spec: Fix Video Capture — Eagerly Establish the Selfbot Voice Connection at Join Time
|
||||
|
||||
Status: PLANNED
|
||||
Date: 2026-08-31
|
||||
Author: Hermes
|
||||
Related: `.hermes/plans/2026-08-31_video-receive-phaseC-spec.md` (Phase C build, made Option A this fix)
|
||||
|
||||
## Symptom (from live logs, 2026-08-31 ~12:34)
|
||||
A user was actively screen-sharing + on camera in the recorded voice channel.
|
||||
The gateway recorded MANY users' audio (.ogg) fine, but video capture produced
|
||||
nothing. The only video signal in `journalctl -u gmw-discord-gateway` was:
|
||||
|
||||
```
|
||||
[VOICE (guild:2)]: Sending voice state update: {"self_mute":false,...,"flags":2}
|
||||
[VOICE] received voice state update: {member hunterz ...} # OTHER user, not bot
|
||||
[VOICE] connection? true, guild session channel
|
||||
[VOICE (guild:2)]: Setting sessionId <S> (stored as "undefined")
|
||||
[VOICE (guild:2)]: Authenticated with sessionId <S> # debug print only
|
||||
[VOICE (guild:2)]: Authenticate failed - VOICE_CONNECTION_TIMEOUT # +15s
|
||||
video-recorder: userId=..., "Connection not established within 15 seconds."
|
||||
```
|
||||
|
||||
## Root cause (verified against discord.js-selfbot-v13 3.7.1 source)
|
||||
The gateway records audio via `@discordjs/voice` (`joinVoiceChannel` + adapter).
|
||||
Video receive lives on the SEPARATE selfbot `ClientVoiceManager.connection`
|
||||
(a singleton `VoiceConnection`). `videoRecorder.ts` currently calls
|
||||
`client.voice.joinChannel(channel)` LAZILY — only when a `voiceStateUpdate`
|
||||
shows `newState.streaming === true`.
|
||||
|
||||
At that moment the bot is ALREADY connected to the channel via @discordjs/voice.
|
||||
A selfbot `joinChannel` then does `VoiceConnection.authenticate()` →
|
||||
`sendVoiceStateUpdate()`, and waits for a fresh `VOICE_SERVER_UPDATE`
|
||||
(`setTokenAndEndpoint`) + `VOICE_STATE_UPDATE` (`setSessionId`) to reach
|
||||
`checkAuthenticated()` (needs token+endpoint+sessionId). Because the bot is
|
||||
already in an established voice session, Discord does NOT emit a new
|
||||
`VOICE_SERVER_UPDATE` for the lazy selfbot re-join → token/endpoint never set →
|
||||
15s `VOICE_CONNECTION_TIMEOUT`.
|
||||
|
||||
This is fatal to video: `joinStreamConnection(userId)` (STREAM_WATCH op 20) and
|
||||
`receiver.createVideoStream(userId, out)` (Recorder/ffmpeg) BOTH live on the
|
||||
parent selfbot `VoiceConnection` and require it `CONNECTED` (its own voice
|
||||
WS+UDP socket feeds `PacketHandler.push`, authenticated with
|
||||
`authentication.secret_key`).
|
||||
|
||||
## Fix — establish the selfbot connection eagerly, at voice-join time
|
||||
The selfbot `VoiceConnection` must exist and be `CONNECTED` before any streamer
|
||||
appears. Establish it once, synchronously alongside the @discordjs/voice join in
|
||||
`recorder.startRecording`, so it rides the bot's FRESH voice join — when Discord
|
||||
DOES emit VOICE_SERVER_UPDATE. Then cache it and let `videoRecorder` reuse it.
|
||||
|
||||
Ordering: in `startRecording`, after the @discordjs/voice `joinVoiceChannel`
|
||||
returns (and retries) — fire `ensureSelfbotVoice(channel)` best-effort:
|
||||
1. `await client.voice.joinChannel(channel, { selfMute:false, selfDeaf:false,
|
||||
selfVideo:false })` (rejects ~VOICE_CONNECTION_TIMEOUT on failure → log +
|
||||
return null; do NOT block audio).
|
||||
2. Cache the returned selfbot `VoiceConnection` keyed by guildId.
|
||||
3. Wire teardown: on `recorder` voice stop / destroyed → `untrackChannel` +
|
||||
destroy the cached selfbot connection (`disconnect()`).
|
||||
|
||||
`videoRecorder.startVideoRecording` then uses the cached selfbot connection:
|
||||
- If cached & `status === CONNECTED` → use it.
|
||||
- Else → fall back to a lazy `joinChannel` (still best-effort).
|
||||
|
||||
## The two-connection coexistence risk (must verify live)
|
||||
@discordjs/voice (audio) and the selfbot `VoiceConnection` (video) each open
|
||||
their OWN low-level voice WS+UDP on the same session. The spec's original
|
||||
open-question flagged this. Mitigations:
|
||||
- Clear logging: `Selfbot voice connected (guild=...)`, plus a periodic
|
||||
`djs/voice status` log so we can confirm audio stays `READY` while the selfbot
|
||||
connection is up.
|
||||
- If Discord kicks/breaks the audio connection, logs will show
|
||||
@discordjs/voice `Disconnected`/reconnect churn — we detect and pivot.
|
||||
|
||||
## Files touched
|
||||
- `services/discord-gateway/src/modules/voice-recording/videoRecorder.ts`:
|
||||
add `ensureSelfbotVoice(channel)` (return cached/connected), use it in
|
||||
`startVideoRecording`, add `destroyGuildSelfbotVoice(guildId)`,
|
||||
richer status logging.
|
||||
- `services/discord-gateway/src/modules/voice-recording/recorder.ts`: call
|
||||
`ensureSelfbotVoice(channel)` after `joinVoiceChannel` (best-effort);
|
||||
call `destroyGuildSelfbotVoice` on voice stop/destroy.
|
||||
- Tests: `tests/videoRecorder.test.ts` (update to assert eager-connection reuse
|
||||
+ status gating).
|
||||
|
||||
## Verification
|
||||
1. `pnpm typecheck` + `pnpm build` + biome clean (discord-gateway).
|
||||
2. Tests green.
|
||||
3. Commit + push; CI `Build & Deploy (Nix)` green, service restarts.
|
||||
4. LIVE (deploy): join a channel with the bot → journal shows
|
||||
`Selfbot voice connected` (parent CONNECTED). When a member streams →
|
||||
`Sender signal screenshare` / `Video recorder ready` + a `.mkv` under
|
||||
`<RECORDINGS_DIR>/<uid>/video-*.mkv`; playable via ffmpeg. Confirm audio
|
||||
recording still flows (no djs/voice reconnect churn).
|
||||
@@ -1,97 +0,0 @@
|
||||
# Spec: Record Other Users' Video (Camera / Screen Share) — Phase C
|
||||
|
||||
Status: PLANNED (not built)
|
||||
Date: 2026-08-31
|
||||
Author: Hermes
|
||||
Related: `.hermes/plans/2026-08-30_video-record-receive-spec.md` (Phase A/B — raw UDP hook, superseded for receive)
|
||||
|
||||
## TL;DR — what changed vs Phase A/B
|
||||
|
||||
Phase A/B (commit `999c054b` etc.) hooked `@discordjs/voice`'s UDP socket to capture non-opus RTP and
|
||||
depacketize H264 → mp4. **It captured ZERO video** because `@discordjs/voice` never authorizes the bot to
|
||||
receive others' video (no STREAM_WATCH). This spec replaces that approach with the **native, selfbot-lib
|
||||
receive path**, which is battle-tested and does the authorization + decryption + ffmpeg muxing for us.
|
||||
|
||||
## Ground truth (verified in discord.js-selfbot-v13 3.7.1 source)
|
||||
|
||||
1. `ClientVoiceManager.joinChannel(channel, config)` → a **selfbot `VoiceConnection`** with
|
||||
`.receiver` (`VoiceReceiver` → `PacketHandler`). [ClientVoiceManager.js:102-118]
|
||||
2. `VoiceConnection.receiver` is created in the constructor. [VoiceConnection.js:140]
|
||||
3. `VoiceReceiver.createVideoStream(user, output)` → `PacketHandler.makeVideoStream` → **`Recorder`**
|
||||
(ffmpeg that muxes H264+Opus RTP over UDP → **Matroska (.mkv)**). [Receiver.js, Recorder.js]
|
||||
4. `PacketHandler` routes: video RTP → Recorder UDP 65506, opus RTP → UDP 65510; decodes all via
|
||||
`connection.authentication.{secret_key, mode}` (supports `aead_aes256_gcm_rtpsize` and
|
||||
`aead_xchacha20_poly1305_rtpsize` = DAVE-compatible). [PacketHandler.js:115-155, 195-240]
|
||||
5. `StreamConnectionReadonly.joinStreamConnection(userId)` + `sendSignalScreenshare()` sends
|
||||
gateway op `STREAM_WATCH` so Discord actually forwards the streamer's RTP to us. [VoiceConnection.js:1100-1240]
|
||||
6. `VoiceState.streaming` = `data.self_stream ?? false` — lets us detect a streamer on voice state update. [VoiceState.js:94]
|
||||
|
||||
## Problem / the crux
|
||||
|
||||
The gateway's voice today is **`@discordjs/voice`** (audio + music + GoLive-send). The selfbot-lib
|
||||
video-receive path lives on the **selfbot-lib `VoiceConnection`** — a separate voice stack. Two options:
|
||||
|
||||
### Option A (RECOMMENDED): Parallel selfbot video-watch connection
|
||||
Keep `@discordjs/voice` for everything it does today. Add a **second, selfbot-lib voice connection**
|
||||
to the same channel whose ONLY job is to watch + record others' video.
|
||||
|
||||
- Pros: zero regression risk to audio/music/screenshare-send; uses native `createVideoStream` → mk4.
|
||||
- Cons: two voice connections for the same bot user in one channel. Need to verify Discord tolerates it
|
||||
(real selfbots like Discord-RE do exactly this for multi-stream). The selfbot lib's `joinChannel`
|
||||
reuses `ClientVoiceManager.connection` (it's a singleton) — see caveat below.
|
||||
|
||||
### Option B: Migrate primary voice to selfbot lib
|
||||
Make the selfbot `VoiceConnection` THE voice layer (it also does audio via `receiver.createStream`).
|
||||
- Pros: one connection; video+audio unified.
|
||||
- Cons: large refactor; high regression risk to the entire existing audio/music/GoLive stack. NOT chosen now.
|
||||
|
||||
## CAVEAT — ClientVoiceManager.connection is a singleton
|
||||
`ClientVoiceManager.connection` is a single `VoiceConnection`. The gateway's `@discordjs/voice` adapter and
|
||||
the selfbot lib both drive the same client voice state. Need to verify whether `client.voice.joinChannel()`
|
||||
can coexist with the active `@discordjs/voice` session, or whether we must create the selfbot VoiceConnection
|
||||
manually / re-use the existing voice state. This is the #1 technical risk to validate in the spike before
|
||||
committing to Option A.
|
||||
|
||||
## Implementation plan (Option A)
|
||||
|
||||
### 1. Streamer detector (new: `modules/voice-recording/videoRecorder.ts`)
|
||||
- Listen to voice state updates (`client.on('voiceStateUpdate')` or the existing voice-state hook).
|
||||
- When `voiceState.streaming === true` for a member in the bot's channel → candidate to record.
|
||||
- Skip bot's own user id (unless we also want self-video; default skip).
|
||||
|
||||
### 2. Watch + record wiring
|
||||
- Ensure a selfbot-lib `VoiceConnection` exists for the channel (spike: `client.voice.joinChannel(channel)`,
|
||||
fallback: build a `VoiceConnection` directly from the existing voice auth).
|
||||
- `await selfbotVoiceConn.joinStreamConnection(userId)` → STREAM_WATCH op 20.
|
||||
- `const recorder = selfbotVoiceConn.receiver.createVideoStream(userId, outPath)` where outPath points under
|
||||
`<RECORDINGS_DIR>/<uid>/video-<streamKey>-<ts>.mkv` (Recorder outputs MKV natively).
|
||||
- On `recorder.on('ready')` → mark recording; `recorder.on('closed')` → finalize.
|
||||
- Transcript later: MKV → mp4 via ffmpeg (Phase B `muxToMp4` can accept mkv) for dashboard playback.
|
||||
|
||||
### 3. Teardown
|
||||
- When `voiceState.streaming === false` / user leaves / channel emptied → `recorder.destroy()`,
|
||||
`selfbotVoiceConn.streamWatchConnection.delete(userId)` / `sendStopScreenshare()`.
|
||||
|
||||
### 4. Frontend (Phase UI, later)
|
||||
- oRPC/backend list `.mkv` per call session + FE `<video>` player (mirror audio recordings UI).
|
||||
|
||||
## Files touched
|
||||
- `services/discord-gateway/src/modules/voice-recording/videoRecorder.ts` (new)
|
||||
- `services/discord-gateway/src/modules/voice-recording/recorder.ts` (wire streamer detector on voice join)
|
||||
- Possibly `voiceController.ts` (voice state update subscription)
|
||||
- Tests: `tests/videoRecorder.test.ts` (mock selfbot VoiceConnection + Recorder)
|
||||
|
||||
## Verification
|
||||
1. `pnpm typecheck` + `pnpm build` + biome clean in discord-gateway.
|
||||
2. Unit: Recorder wiring + streamer detection with mocked VoiceConnection.
|
||||
3. Live (deploy): user shares screen → journal shows `STREAM_WATCH` sent + `Recorder ready` + `.mkv` file
|
||||
appears under recordings dir; playable via ffmpeg.
|
||||
4. CI Build & Deploy (Nix) green.
|
||||
|
||||
## Open questions for spike (before full build)
|
||||
- [ ] Can `client.voice.joinChannel()` run alongside the active `@discordjs/voice` session, or does the
|
||||
singleton `ClientVoiceManager.connection` collide / tear down the existing audio connection?
|
||||
- [ ] Does the selfbot `VoiceConnection` need the bot's `video: true` flag in IDENTIFY to receive video
|
||||
(it advertises `streams` in IDENTIFY — see BaseMediaConnection/identify vs selfbot VoiceConnection)?
|
||||
- [ ] Does `Recorder` (spawns system ffmpeg, UDP loopback on 65506/65510) work in the Nix store runtime
|
||||
(ffmpeg-headless on PATH confirmed; UDP loopback fine)?
|
||||
@@ -1,33 +0,0 @@
|
||||
# Video Recording Splitting — Like Voice Recording
|
||||
|
||||
## Goal
|
||||
Camera + screen share (stream watch) recording should split into per-burst
|
||||
segments just like voice recording does — each time a streamer pauses/stops
|
||||
and resumes, a new MP4 segment is created and registered in the DB + uploaded.
|
||||
|
||||
## Voice Recording Model (to replicate)
|
||||
1. `receiver.speaking.start` → new OGG segment per burst
|
||||
2. AfterSilence (4000ms) → stream "end" → segment finalized + uploaded
|
||||
3. Each segment → DB insert → OGG→MP3 transcode → upload → update DB
|
||||
4. File stored as `<userId>/<startTime>.ogg` + `.json`
|
||||
|
||||
## Video Recording Splitting
|
||||
1. DAVE video RTP → depacketize H264 → write to current segment .h264
|
||||
2. Silence detection: no H264 packets for 4000ms → close segment → flush →
|
||||
mux to MP4 → insert DB record → upload → start new segment on next packet
|
||||
3. Each segment: `<userId>/video-<channelId>-<startTime>.h264` → `.mp4`
|
||||
4. DB: reuse `voice_recordings` table (filename indicates video, e.g. `video-XXX-1234.mp4`)
|
||||
5. Upload: MP4 to TeleUploader (no transcode needed — MP4 plays everywhere)
|
||||
|
||||
## Files Modified
|
||||
- `services/discord-gateway/src/modules/voice-recording/streamWatchReceiver.ts`
|
||||
— Main change: silence-based splitting + DB registration + upload
|
||||
|
||||
## Constants
|
||||
- `VIDEO_SILENCE_MS = 4000` (matches voice AfterSilence)
|
||||
- `VIDEO_MIN_SEGMENT_MS = 1000` (skip segments <1s — avoid noise)
|
||||
|
||||
## Verification
|
||||
- `pnpm typecheck` in `services/discord-gateway`
|
||||
- `pnpm build` (dist/ is the deployed artifact)
|
||||
- Push → CI deploy → live test with a streamer
|
||||
@@ -1,81 +0,0 @@
|
||||
# Spec: Selfbot-Viable Video Capture — manual screen-share watch command (Phase D)
|
||||
|
||||
Status: PLANNED (not yet built)
|
||||
Date: 2026-09-02
|
||||
Author: Hermes
|
||||
Related: `.hermes/plans/2026-08-31_video-receive-phaseC-spec.md` (auto-receive, superseded
|
||||
for selfbot), `gmw-ops/references/selfbot-presence-detection-limits.md`,
|
||||
`gmw-ops/references/discord-voice-fork-video-receive.md`
|
||||
|
||||
## TL;DR — the decisive finding (verified live 2026-09-02)
|
||||
|
||||
User insists on keeping the **selfbot** (no bot-token migration). Live diagnostics prove
|
||||
a selfbot CANNOT auto-detect other members' camera/share because:
|
||||
- It never receives `VOICE_STATE_UPDATE` for other members (only its own).
|
||||
- `guild.members.fetch()` → 403, `GET /channels/{id}/voice-states` → 404.
|
||||
- No `GUILD_CREATE`, no `READY.broadcaster_user_ids` presence.
|
||||
- `scanExistingStreamers` + `handleVoiceStateUpdate` (the only two `startStreamWatch`
|
||||
triggers) are therefore both **dead on a selfbot**.
|
||||
- No manual watch command exists today, so even on-demand capture is impossible.
|
||||
|
||||
→ The ONE selfbot-viable path is a **manual, operator-initiated STREAM_WATCH** on a
|
||||
member known to be screen-sharing. Gateway op 20 (STREAM_WATCH) is **NOT gated on
|
||||
bot-vs-user**; the DAVE handshake to Ready+MLS was already verified live in earlier
|
||||
sessions. The receive/mux/segment/upload pipeline (`streamWatchReceiver.ts`) is already
|
||||
built and only lacks a real streamer to produce its first `.mp4`.
|
||||
|
||||
Camera-of-others is NOT viable on a selfbot even with `unknown-ssrc` fallback:
|
||||
`@discordjs/voice` `parsePacket` calls `daveSession.decrypt(packet, userId)` keyed per
|
||||
REAL userId (vendor fork dist/index.js:2143), so a fake id selects no MLS decryptor →
|
||||
garbage, not H264. (The uncommitted `unknown-ssrc` change was reverted this session.)
|
||||
|
||||
Selfbot CAN capture the OWNER's own video (its own VOICE_STATE_UPDATE + fork op12
|
||||
videoSSRC are attributable), but `videoRecorder.ts` hard-skips its own id — parameterized
|
||||
self-capture is a follow-up, not the default.
|
||||
|
||||
## Goal
|
||||
|
||||
Add a **manual watch command** so an operator can say "record <member>'s screen share"
|
||||
and the gateway `startStreamWatch`s that member → DAVE watch → per-burst `.mp4` segments
|
||||
(mirroring voice silence split) → upload → DB `voice_recordings` → dashboard `<video>`.
|
||||
|
||||
This is the only form of OTHER-member video capture a selfbot can deliver, and it is
|
||||
genuinely buildable with the existing receive pipeline.
|
||||
|
||||
## Scope / files
|
||||
|
||||
Gateway (`services/discord-gateway`):
|
||||
- New command type `VIDEO_WATCH` + handler in `command-handler/` (dedicated
|
||||
`video.handler.ts`), routed via `createHandlerRegistry`.
|
||||
- Handler resolves a VoiceChannel (from persisted `voice_auto_reconnect` / active
|
||||
connections) + target memberId from the command payload, calls
|
||||
`startStreamWatch(channel, memberId)` (already exported).
|
||||
- Idempotent (startStreamWatch early-returns if a watch exists); a `VIDEO_UNWATCH`
|
||||
command calls `stopStreamWatch(guildId, userId)`.
|
||||
- Reply: success/failure via the standard `CommandReply` publish.
|
||||
|
||||
Backend (`services/backend`):
|
||||
- oRPC procedure (or the existing command bridge) that publishes a `VIDEO_WATCH`
|
||||
command to `backend:command` with `{ guildId, channelId, userId }`. Reuse the same
|
||||
bridge the FE already uses for voice commands.
|
||||
|
||||
Frontend (`services/frontend`):
|
||||
- A "Video Watch" control: pick a voice member + a "Record screen" button → calls the
|
||||
backend procedure. Shows live status (watching / recording / segments uploaded).
|
||||
|
||||
(Each layer optional independently; gateway alone gives a Redis-testable path.)
|
||||
|
||||
## Verification
|
||||
1. `pnpm typecheck` + `pnpm build` + `biome check src/` green in discord-gateway.
|
||||
2. Unit test: handler publishes reply + calls startStreamWatch with the right args
|
||||
(mock the module).
|
||||
3. Live: operator invokes `!videorec <member>` while that member screen-shares →
|
||||
journal shows `Sending STREAM_WATCH` → `STREAM_CREATE` → `DAVE watch READY` → `Video
|
||||
burst opened` → `Video muxed to mp4` → a `video-*.mp4` appears under
|
||||
`<recordingsDir>/<uid>/` and a `video-%` row lands in `voice_recordings`.
|
||||
4. `Build & Deploy (Nix)` CI green.
|
||||
|
||||
## Out of scope (documented dead ends on selfbot)
|
||||
- Auto camera/share capture of OTHER members (impossible at detection layer).
|
||||
- Camera-of-others via `unknown-ssrc` (DAVE decrypt needs real userId).
|
||||
- Bot-token migration (user declined).
|
||||
@@ -0,0 +1,100 @@
|
||||
# GMW — Agent Guide
|
||||
|
||||
GMW (Guild Moderation Watcher) is a Discord bot + web dashboard for AI-powered moderation. A monorepo with three services: a selfbot gateway that captures Discord events and runs LLM moderation, an Express/oRPC backend that serves the dashboard API, and a Next.js 16 SSR frontend. They communicate via Redis pub/sub (gateway→backend) and WebSocket (backend→browser).
|
||||
|
||||
## Quick reference
|
||||
|
||||
```bash
|
||||
# Per-service — cd into the service first
|
||||
pnpm install # install deps (pnpm 11, not npm or bun)
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm lint # biome check
|
||||
pnpm format # biome format --write
|
||||
pnpm build # gateway/backend: tsc + fix-imports.mjs; frontend: next build
|
||||
pnpm test # vitest run (gateway & backend only — frontend has no tests)
|
||||
```
|
||||
|
||||
No monorepo-level scripts exist. Run each command from inside the service directory.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
services/
|
||||
├── discord-gateway/ Event-driven selfbot. No HTTP (except :4016 metrics).
|
||||
│ ├── src/
|
||||
│ │ ├── app/ Bootstrap, shutdown, retention
|
||||
│ │ ├── modules/ Feature modules — each self-contained
|
||||
│ │ └── shared/ Config, DB (Drizzle), logger, errors, utils
|
||||
│ ├── tests/ Vitest tests (colocated, not inside src/)
|
||||
│ ├── drizzle/migrations/ DB migrations
|
||||
│ └── scripts/fix-imports.mjs Post-build: rewrites @/ aliases → relative .js
|
||||
│
|
||||
├── backend/ Express HTTP + WebSocket + oRPC server (:4001).
|
||||
│ ├── src/
|
||||
│ │ ├── modules/ Feature modules (schema→repo→service→controller→routes)
|
||||
│ │ ├── http/ Express app setup
|
||||
│ │ ├── ws/ WebSocket server + Redis bridge
|
||||
│ │ ├── orpc/ oRPC router definition
|
||||
│ │ └── shared/ Config, DB, errors, Redis, logger
|
||||
│ └── tests/ Vitest tests
|
||||
│
|
||||
└── frontend/ Next.js 16 App Router, React 19, Tailwind v4 (:4017).
|
||||
├── src/
|
||||
│ ├── app/ Pages — route groups under (dashboard)/
|
||||
│ ├── components/ UI components (primitives, shell, charts, etc.)
|
||||
│ ├── hooks/ React hooks
|
||||
│ └── lib/ API clients, types, utils, WebSocket, audio
|
||||
└── pnpm-workspace.yaml Build-script approvals (sharp only)
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
### Package manager & runtime
|
||||
|
||||
- **pnpm** (v11), not npm or bun. Lockfiles are committed. Node ≥ 22.
|
||||
- ESM throughout (`"type": "module"` in all package.json files).
|
||||
|
||||
### Import style
|
||||
|
||||
Source files use `@/*` path aliases (mapped in tsconfig to `./src/*`). Relative imports **must include the `.js` extension** (e.g., `from "./embed.js"`). The `moduleResolution: "bundler"` tsconfig setting allows bare specifiers during dev, but `tsc` emits them as-is. A post-build script (`scripts/fix-imports.mjs`) rewrites both `@/` aliases and extensionless imports in `dist/` so Node ESM can resolve them at runtime.
|
||||
|
||||
### Error handling
|
||||
|
||||
Both gateway and backend define an `AppError` base class in `@/shared/errors/index` with subclasses: `ValidationError` (400), `NotFoundError` (404), `UnauthorizedError` (401), `DatabaseError` (500), `ConfigError` (500). Services throw these; callers or middleware map them to HTTP status codes.
|
||||
|
||||
### Logging
|
||||
|
||||
Use `createChildLogger('module-name')` from `@/shared/logger/index`. Never use raw `console`. It wraps pino; in development it pretty-prints via `pino-pretty`.
|
||||
|
||||
### Config
|
||||
|
||||
Environment variables are validated with Zod at startup in `shared/config/index.ts` of both gateway and backend. Do not read `process.env` directly outside config modules.
|
||||
|
||||
### Module boundaries
|
||||
|
||||
- Gateway: each feature lives in `src/modules/<name>/` with its own `index.ts` barrel. Modules register event listeners and are composed in `src/app/bootstrap.ts`.
|
||||
- Backend: `modules/<name>/` follows schema → repository → service → controller → routes. Data flows up only. No cross-module repository imports.
|
||||
- Frontend: `page.tsx` is a server component that fetches data via `src/lib/api/server.ts` (oRPC over HTTP, server-side only) and passes it to a `view.tsx` client component. Browser code uses `src/lib/orpc/client.ts` (oRPC over WebSocket via partysocket). Never import the server API client from a client component.
|
||||
|
||||
### API layer
|
||||
|
||||
The backend exposes an oRPC router mounted at `/trpc` (both HTTP and WebSocket). The frontend does **not** use a REST `/api/*` layer — all data goes through oRPC procedures. The server-side client (`src/lib/api/server.ts`) uses a fetch-based RPCLink; the browser client uses a WebSocket-based RPCLink backed by partysocket for auto-reconnection. Results are asserted to the frontend's local types at each call site (the backend's router type is not imported into the frontend).
|
||||
|
||||
### Testing
|
||||
|
||||
- **Gateway**: Vitest, tests in `tests/` at the service root. Config sets env vars (`DISCORD_TOKEN`, `DATABASE_URL`, etc.) so tests run without real services.
|
||||
- **Backend**: Vitest, tests in `tests/` and `src/`. Includes an `e2e.test.ts` excluded from CI (needs a live backend).
|
||||
- **Frontend**: No test runner configured.
|
||||
- Tests are pure-function / unit-level. Mock external dependencies; do not start real DB/Redis in tests.
|
||||
|
||||
### Formatting
|
||||
|
||||
Biome, 2-space indent, spaces. Config at repo root `biome.json`. `lint` uses `--diagnostic-level=error`; `format` auto-writes.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
1. **Never commit without running `fix-imports.mjs` after `tsc`** — gateway and backend builds will produce ESM that crashes at startup (`ERR_MODULE_NOT_FOUND`).
|
||||
2. **Don't add `@discordjs/opus` build-from-source flags** — it ships prebuilt binaries for Node 22. Forcing source builds in CI/Nix will fail or add minutes of compile time.
|
||||
3. **Frontend SSR fetches are always `cache: "no-store"`** — the server API client bypasses Next.js fetch cache. Do not add caching without understanding the live dashboard requirement.
|
||||
4. **oRPC types are loosely coupled** — the frontend casts oRPC results to its own types with `as unknown`. Adding a field to the backend schema does not automatically update the frontend type. Update both sides.
|
||||
5. **Gateway is a selfbot** (`discord.js-selfbot-v13`) — it uses a user token, not a bot token. It must not be deployed as a standard Discord bot.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Roadmap
|
||||
|
||||
What is built, what is next, and what has been deliberately declined.
|
||||
|
||||
---
|
||||
|
||||
## Next
|
||||
|
||||
_Empty._
|
||||
@@ -0,0 +1,23 @@
|
||||
# TODO
|
||||
|
||||
Next up. One item, one outcome, verifiable when done.
|
||||
|
||||
Longer-term direction lives in [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
---
|
||||
|
||||
## Now
|
||||
|
||||
_Empty._
|
||||
|
||||
---
|
||||
|
||||
## Next
|
||||
|
||||
_Empty._
|
||||
|
||||
---
|
||||
|
||||
## Maintenance
|
||||
|
||||
_Empty._
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
version: "neuform-top-creators-featured"
|
||||
name: "AeroNet Visualization"
|
||||
description: "Aeronet Visualization Dashboard Section is designed for demonstrating application workflows and interface hierarchy. Key features include clear information density, modular panels, and interface rhythm. It is suitable for product showcases, admin panels, and analytics experiences."
|
||||
colors:
|
||||
primary: "#FFFFFF"
|
||||
secondary: "#000000"
|
||||
accent: "#FFFFFF"
|
||||
background: "#FFFFFF"
|
||||
surface: "#18181B"
|
||||
text-primary: "#111827"
|
||||
text-secondary: "#4B5563"
|
||||
border: "#E5E7EB"
|
||||
typography:
|
||||
display-lg:
|
||||
fontFamily: "Inter"
|
||||
fontSize: "64px"
|
||||
fontWeight: 500
|
||||
lineHeight: "1.04"
|
||||
letterSpacing: "0"
|
||||
body-md:
|
||||
fontFamily: "Inter"
|
||||
fontSize: "16px"
|
||||
fontWeight: 400
|
||||
lineHeight: "1.6"
|
||||
label-md:
|
||||
fontFamily: "JetBrains Mono"
|
||||
fontSize: "12px"
|
||||
fontWeight: 600
|
||||
lineHeight: "1.2"
|
||||
spacing:
|
||||
base: "8px"
|
||||
gap: "16px"
|
||||
card-padding: "24px"
|
||||
section-padding: "80px"
|
||||
rounded:
|
||||
card: "16px"
|
||||
control: "8px"
|
||||
pill: "9999px"
|
||||
components:
|
||||
card:
|
||||
background: "Use the surface token with subtle borders and HTML-matched shadow depth"
|
||||
radius: "Match the declared card radius token"
|
||||
button:
|
||||
background: "Use primary or accent colors for the main action"
|
||||
radius: "Use the control or pill radius based on the source HTML"
|
||||
---
|
||||
# AeroNet Visualization
|
||||
Source: Neuform Featured templates from top creators. Author: Sourasith Phomhome (@madebysourasith). Views: 419; favorites: 14; remixes: 4.
|
||||
Tags: dashboard, animated, threejs, charts, navigation, links, support, effect.
|
||||
## Overview
|
||||
Aeronet Visualization Dashboard Section is designed for demonstrating application workflows and interface hierarchy. Key features include clear information density, modular panels, and interface rhythm. It is suitable for product showcases, admin panels, and analytics experiences.
|
||||
|
||||
AeroNet Mapping Framework Metrics Docs Log In Deploy Node Planet Core Uninterrupted worldwide relay networks driving next-generation infrastructure. 19.5 TB/s +924 820 GB/s +418 Relay established successfully. 2.41 PB/s…
|
||||
## Composition
|
||||
Use the attached HTML reference as the source of truth. Preserve the visible hierarchy, first-screen composition, section rhythm, density, and interaction tone before adapting copy or content.
|
||||
Key visible headings include: Planet Core.
|
||||
## Colors
|
||||
Anchor the palette in primary #FFFFFF, secondary #000000, accent #FFFFFF, background #FFFFFF, surface #18181B, text-primary #111827. Keep background, surface, text, and border roles distinct so generated layouts retain the same contrast pattern as the source.
|
||||
## Typography
|
||||
Use Inter for display moments and Inter for body copy unless the HTML clearly demands a compatible fallback. Labels and technical metadata should use JetBrains Mono or an equivalent mono face.
|
||||
## Layout
|
||||
Keep spacing deliberate and stable. Favor the same grid direction, max-width behavior, card density, and responsive stacking seen in the HTML. Do not replace distinctive source structures with generic SaaS sections.
|
||||
## Components
|
||||
Dashboard, chart, and data panels should preserve their compact operational hierarchy, nested surfaces, and metric emphasis.
|
||||
## Motion
|
||||
Preserve existing motion cues such as masked reveals, staggered entrance, hover lift, scroll-triggered transitions, and ambient movement. Keep easing smooth and restrained.
|
||||
## WebGL & Effects
|
||||
|
||||
If the source includes canvas, WebGL, Three.js, gradients, particles, or atmospheric effects, rebuild them as supporting layers behind the content. Keep effects performant, responsive, and secondary to the interface.
|
||||
|
||||
## Guardrails
|
||||
- Do not flatten the source into a generic card grid.
|
||||
- Do not swap the color mode unless the source clearly supports it.
|
||||
- Preserve the first viewport signal, focal object, and visual density.
|
||||
- Keep buttons, cards, and badges aligned to the same radius and border language.
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
version: "neuform-top-creators-featured"
|
||||
name: "Nexus Capital Render"
|
||||
description: "Nexus Capital Feature Section is designed for highlighting product capabilities and value points. Key features include reusable structure, responsive behavior, and production-ready presentation. It is suitable for component libraries and responsive product interfaces."
|
||||
colors:
|
||||
primary: "#F2EAD3"
|
||||
secondary: "#000000"
|
||||
accent: "#F2EAD3"
|
||||
background: "#000000"
|
||||
surface: "#F2EAD3"
|
||||
text-primary: "#FFFFFF"
|
||||
text-secondary: "#A1A1AA"
|
||||
border: "#27272A"
|
||||
typography:
|
||||
display-lg:
|
||||
fontFamily: "Inter"
|
||||
fontSize: "64px"
|
||||
fontWeight: 500
|
||||
lineHeight: "1.04"
|
||||
letterSpacing: "0"
|
||||
body-md:
|
||||
fontFamily: "Playfair Display"
|
||||
fontSize: "16px"
|
||||
fontWeight: 400
|
||||
lineHeight: "1.6"
|
||||
label-md:
|
||||
fontFamily: "JetBrains Mono"
|
||||
fontSize: "12px"
|
||||
fontWeight: 600
|
||||
lineHeight: "1.2"
|
||||
spacing:
|
||||
base: "8px"
|
||||
gap: "16px"
|
||||
card-padding: "24px"
|
||||
section-padding: "80px"
|
||||
rounded:
|
||||
card: "16px"
|
||||
control: "8px"
|
||||
pill: "9999px"
|
||||
components:
|
||||
card:
|
||||
background: "Use the surface token with subtle borders and HTML-matched shadow depth"
|
||||
radius: "Match the declared card radius token"
|
||||
button:
|
||||
background: "Use primary or accent colors for the main action"
|
||||
radius: "Use the control or pill radius based on the source HTML"
|
||||
---
|
||||
# Nexus Capital Render
|
||||
Source: Neuform Featured templates from top creators. Author: Meng To (@mengto). Views: 311; favorites: 22; remixes: 15.
|
||||
Tags: feature, section, animated, bento, charts.
|
||||
## Overview
|
||||
Nexus Capital Feature Section is designed for highlighting product capabilities and value points. Key features include reusable structure, responsive behavior, and production-ready presentation. It is suitable for component libraries and responsive product interfaces.
|
||||
|
||||
NEXUS WEALTH CORP. Beyond traditional equity. The multi-asset strategy. A dynamic portfolio framework where capital transitions seamlessly from baseline reserves to high-yield synthetic instruments. Drag the timeline to…
|
||||
## Composition
|
||||
Use the attached HTML reference as the source of truth. Preserve the visible hierarchy, first-screen composition, section rhythm, density, and interaction tone before adapting copy or content.
|
||||
Key visible headings include: Beyond traditional equity. The multi-asset strategy..
|
||||
## Colors
|
||||
Anchor the palette in primary #F2EAD3, secondary #000000, accent #F2EAD3, background #000000, surface #F2EAD3, text-primary #FFFFFF. Keep background, surface, text, and border roles distinct so generated layouts retain the same contrast pattern as the source.
|
||||
## Typography
|
||||
Use Inter for display moments and Playfair Display for body copy unless the HTML clearly demands a compatible fallback. Labels and technical metadata should use JetBrains Mono or an equivalent mono face.
|
||||
## Layout
|
||||
Keep spacing deliberate and stable. Favor the same grid direction, max-width behavior, card density, and responsive stacking seen in the HTML. Do not replace distinctive source structures with generic SaaS sections.
|
||||
## Components
|
||||
Dashboard, chart, and data panels should preserve their compact operational hierarchy, nested surfaces, and metric emphasis.
|
||||
## Motion
|
||||
Preserve existing motion cues such as masked reveals, staggered entrance, hover lift, scroll-triggered transitions, and ambient movement. Keep easing smooth and restrained.
|
||||
## WebGL & Effects
|
||||
|
||||
If the source includes canvas, WebGL, Three.js, gradients, particles, or atmospheric effects, rebuild them as supporting layers behind the content. Keep effects performant, responsive, and secondary to the interface.
|
||||
|
||||
## Guardrails
|
||||
- Do not flatten the source into a generic card grid.
|
||||
- Do not swap the color mode unless the source clearly supports it.
|
||||
- Preserve the first viewport signal, focal object, and visual density.
|
||||
- Keep buttons, cards, and badges aligned to the same radius and border language.
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
version: "neuform-staff-featured-2026-05-22"
|
||||
name: "Summit — Cloud Migration Platform"
|
||||
description: "Summit Cloud Pricing Section is designed for comparing plans and supporting conversion decisions. Key features include plan comparison blocks and conversion-oriented actions. It is suitable for subscription pricing pages and plan comparison experiences."
|
||||
colors:
|
||||
primary: "#2563EB"
|
||||
secondary: "#050510"
|
||||
accent: "#3B82F6"
|
||||
background: "#050510"
|
||||
surface: "#18181B"
|
||||
text-primary: "#FFFFFF"
|
||||
text-secondary: "#A1A1AA"
|
||||
border: "#27272A"
|
||||
typography:
|
||||
display-lg:
|
||||
fontFamily: "Inter"
|
||||
fontSize: "64px"
|
||||
fontWeight: 500
|
||||
lineHeight: "1.04"
|
||||
letterSpacing: "0"
|
||||
body-md:
|
||||
fontFamily: "Inter"
|
||||
fontSize: "16px"
|
||||
fontWeight: 400
|
||||
lineHeight: "1.6"
|
||||
label-md:
|
||||
fontFamily: "JetBrains Mono"
|
||||
fontSize: "12px"
|
||||
fontWeight: 600
|
||||
lineHeight: "1.2"
|
||||
spacing:
|
||||
base: "8px"
|
||||
gap: "16px"
|
||||
card-padding: "24px"
|
||||
section-padding: "80px"
|
||||
rounded:
|
||||
card: "23px"
|
||||
control: "18px"
|
||||
pill: "9999px"
|
||||
components:
|
||||
card:
|
||||
background: "Use the surface token with subtle borders and HTML-matched shadow depth"
|
||||
radius: "Match the declared card radius token"
|
||||
button:
|
||||
background: "Use primary or accent colors for the main action"
|
||||
radius: "Use the control or pill radius based on the source HTML"
|
||||
---
|
||||
# Summit — Cloud Migration Platform
|
||||
Source: Neuform staff featured templates. Author: Vanh DesignCode (@vanh). Views: 161; favorites: 11; remixes: 3.
|
||||
Tags: pricing, section, animated, cta, charts, billing.
|
||||
## Overview
|
||||
Summit Cloud Pricing Section is designed for comparing plans and supporting conversion decisions. Key features include plan comparison blocks and conversion-oriented actions. It is suitable for subscription pricing pages and plan comparison experiences.
|
||||
|
||||
Summit Platform Solutions Pricing Docs Start Migration Now in General Availability Migrate to the cloud with confidence Automated discovery, dependency mapping, and zero-downtime migration for enterprise workloads. Move…
|
||||
## Composition
|
||||
Use the attached HTML reference as the source of truth. Preserve the visible hierarchy, first-screen composition, section rhythm, density, and interaction tone before adapting copy or content.
|
||||
Key visible headings include: Migrate to the cloud with confidence; Discover untethered scalability. The platform built to dynamically map, migrate, and optimize your entire infrastructure without lifting a finger.; Architectural Clarity; Dynamic Scaling.
|
||||
## Colors
|
||||
Anchor the palette in primary #2563EB, secondary #050510, accent #3B82F6, background #050510, surface #18181B, text-primary #FFFFFF. Keep background, surface, text, and border roles distinct so generated layouts retain the same contrast pattern as the source.
|
||||
## Typography
|
||||
Use Inter for display moments and Inter for body copy unless the HTML clearly demands a compatible fallback. Labels and technical metadata should use JetBrains Mono or an equivalent mono face.
|
||||
## Layout
|
||||
Keep spacing deliberate and stable. Favor the same grid direction, max-width behavior, card density, and responsive stacking seen in the HTML. Do not replace distinctive source structures with generic SaaS sections.
|
||||
## Components
|
||||
Dashboard, chart, and data panels should preserve their compact operational hierarchy, nested surfaces, and metric emphasis.
|
||||
## Motion
|
||||
Preserve existing motion cues such as masked reveals, staggered entrance, hover lift, scroll-triggered transitions, and ambient movement. Keep easing smooth and restrained.
|
||||
## WebGL & Effects
|
||||
|
||||
If the source includes canvas, WebGL, Three.js, gradients, particles, or atmospheric effects, rebuild them as supporting layers behind the content. Keep effects performant, responsive, and secondary to the interface.
|
||||
|
||||
## Guardrails
|
||||
- Do not flatten the source into a generic card grid.
|
||||
- Do not swap the color mode unless the source clearly supports it.
|
||||
- Preserve the first viewport signal, focal object, and visual density.
|
||||
- Keep buttons, cards, and badges aligned to the same radius and border language.
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
# docs/ — Spec-Driven Development Hub
|
||||
|
||||
Direktori ini adalah pusat dari workflow **spec-driven development** di GMW.
|
||||
Semua perubahan signifikan dimulai dari satu spec, **sebelum kode ditulis**,
|
||||
dan setiap tugas yang berjalan dilacak lewat `todo.md`.
|
||||
|
||||
## Struktur
|
||||
|
||||
```
|
||||
docs/
|
||||
├── README.md # Dokumen ini
|
||||
├── spec-template.md # Template standar untuk semua spec
|
||||
├── todo-template.md # Template todo list
|
||||
└── specs/ # Semua spec dan implementation plan
|
||||
├── YYYY-MM-DD_<slug>-spec.md # Spec (apa & mengapa)
|
||||
└── YYYY-MM-DD_<slug>.md # Plan/fix (bisa langsung spec+plan)
|
||||
```
|
||||
|
||||
### Naming convention
|
||||
|
||||
```
|
||||
YYYY-MM-DD_<slug>-spec.md # Spec baru untuk fitur/fix
|
||||
YYYY-MM-DD_<slug>.md # Plan yang sudah include spec di dalamnya
|
||||
```
|
||||
|
||||
Contoh:
|
||||
- `2026-08-30_recordings-v2-features-spec.md` — spec untuk Recordings v2
|
||||
- `2026-08-24-attachment-delay-fix.md` — spec + plan untuk attachment delay fix
|
||||
|
||||
### Service-specific docs
|
||||
|
||||
```
|
||||
services/<service>/docs/specs/ # Spec yang spesifik untuk 1 service
|
||||
```
|
||||
|
||||
## Workflow: Spec-Driven Development
|
||||
|
||||
### Prinsip utama
|
||||
|
||||
> **No code without a spec.** Semua perubahan signifikan harus punya spec
|
||||
> terlebih dahulu. Spec adalah kontrak: apa yang akan dibangun, mengapa,
|
||||
> dan bagaimana memverifikasinya.
|
||||
|
||||
### Todo list (`todo.md`) — WAJIB
|
||||
|
||||
Setiap tugas berjalan (implementasi fitur/fix) harus punya `todo.md`:
|
||||
|
||||
```markdown
|
||||
# Todo — <judul tugas>
|
||||
|
||||
## Task
|
||||
- [ ] Tulis spec
|
||||
- [ ] Verifikasi facts (baca kode: file:line)
|
||||
- [ ] Implementasi tahap 1: ...
|
||||
- [ ] Implementasi tahap 2: ...
|
||||
- [ ] Verifikasi: pnpm typecheck / lint / build / test
|
||||
- [ ] Commit + push
|
||||
```
|
||||
|
||||
Aturan `todo.md`:
|
||||
- Task **konkret & verifiable** — "ubah X di file Y", bukan "fix bug".
|
||||
- Satu task `[in_progress]` pada satu waktu; ceklis `[x]` segera setelah selesai.
|
||||
- Gunakan `docs/todo-template.md` sebagai template.
|
||||
- Simpan `todo.md` di `docs/` (tugas lintas-service) atau di
|
||||
`services/<service>/docs/` (tugas satu service).
|
||||
|
||||
### Kapan perlu spec + todo
|
||||
|
||||
| Perlu spec + todo | Tidak perlu |
|
||||
|---|---|
|
||||
| Fitur baru | Typo fix |
|
||||
| Bug fix non-trivial | Dependency bump (dependabot) |
|
||||
| Refactor yang mengubah behavior | Format/lint auto-fix |
|
||||
| Perubahan DB schema | Test-only change |
|
||||
| Perubahan API contract | README/doc update |
|
||||
| Perubahan arsitektur | Variable rename |
|
||||
|
||||
### Workflow step-by-step
|
||||
|
||||
1. **Tulis spec** — Salin `docs/spec-template.md`, isi semua section yang relevan.
|
||||
- **Verified facts**: baca kode yang terpengaruh, catat temuan dengan file:line.
|
||||
- **Root cause**: analisis sebab (jangan hanya describe symptom).
|
||||
- **Decisions**: pilih pendekatan, jelaskan alternatif yang ditolak.
|
||||
- **Verification**: command executable, bukan "should work".
|
||||
- Simpan sebagai `docs/specs/YYYY-MM-DD_<slug>-spec.md`.
|
||||
|
||||
2. **Tulis `todo.md`** — Pecah spec jadi task konkret yang bisa diceklis
|
||||
(gunakan `docs/todo-template.md`), dengan urutan implementasi yang logis.
|
||||
|
||||
3. **Review spec** — Baca ulang spec sendiri. Cek:
|
||||
- Apakah verified facts benar-benar verified (bukan asumsi)?
|
||||
- Apakah file changes lengkap (tidak ada yang terlewat)?
|
||||
- Apakah verification steps executable?
|
||||
- Jika spec untuk user request: pastikan user setuju dengan approach.
|
||||
|
||||
4. **Implement** — Ikuti spec + todo step-by-step. Ceklis `[x]` setiap task
|
||||
yang selesai. Jika menemukan sesuatu yang berubah dari asumsi spec,
|
||||
**update spec dulu**, baru implement.
|
||||
|
||||
5. **Verify** — Jalankan semua verification steps di spec. Catat hasilnya.
|
||||
|
||||
6. **Commit** — Reference spec di commit message:
|
||||
```
|
||||
feat(module): deskripsi singkat
|
||||
|
||||
Ref: docs/specs/YYYY-MM-DD_<slug>-spec.md
|
||||
```
|
||||
|
||||
### Tips menulis spec yang baik
|
||||
|
||||
- **Evidence-based**: setiap klaim harus ada sumbernya (file:line, log, error).
|
||||
- **Actionable**: orang lain (atau AI agent) harus bisa implementasi dari spec saja.
|
||||
- **Verifiable**: setiap requirement harus bisa di-test secara eksplisit.
|
||||
- **Scoped**: satu spec = satu perubahan terfokus. Jangan campur 3 fitur dalam 1 spec.
|
||||
- **Bahasa campuran**: narrative boleh Indonesia/English, istilah teknis pakai English.
|
||||
|
||||
### Role of AI agents
|
||||
|
||||
AI coding agents (Shiro Neko / Hermes) **harus**:
|
||||
- Membaca spec sebelum menulis kode
|
||||
- Membuat & meng-update `todo.md` untuk setiap tugas berjalan
|
||||
- Verifikasi fakta dari kode aktual (bukan dari asumsi training data)
|
||||
- Update spec jika temuan baru mengubah approach
|
||||
- Jalankan verification steps setelah implementasi
|
||||
- Reference spec di commit message
|
||||
|
||||
AI agents **tidak boleh**:
|
||||
- Langsung implement tanpa membaca/menulis spec
|
||||
- Mengabaikan verified facts yang bertentangan dengan asumsi
|
||||
- Skip verification steps
|
||||
- Mengerjakan tugas tanpa todo list yang jelas
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "architecture",
|
||||
"meta": {
|
||||
"title": "GMW — Guild Moderation Watcher",
|
||||
"output": "gmw-architecture.html",
|
||||
"quality_profile": "showcase",
|
||||
"viewBox": [1140, 620]
|
||||
},
|
||||
"components": [
|
||||
{ "id": "redis", "type": "messagebus", "label": "Redis", "sublabel": "pub/sub :6379", "pos": [300, 40], "size": [140, 60], "tag": "events + PCM" },
|
||||
{ "id": "pg", "type": "database", "label": "PostgreSQL", "sublabel": "shared state :6432", "pos": [640, 40], "size": [140, 60] },
|
||||
{ "id": "discord", "type": "external", "label": "Discord Guilds", "sublabel": "channels · voice · reactions", "pos": [40, 240], "size": [140, 60] },
|
||||
{ "id": "gateway", "type": "backend", "label": "discord-gateway", "sublabel": "selfbot :4016", "pos": [260, 240], "size": [140, 60], "tag": "discord.js-selfbot-v13" },
|
||||
{ "id": "backend", "type": "backend", "label": "gmw-backend", "sublabel": "Express · oRPC · WS :4001", "pos": [640, 240], "size": [140, 60], "tag": "Drizzle ORM" },
|
||||
{ "id": "proxy", "type": "cloud", "label": "nginx proxy", "sublabel": "reverse proxy :4009", "pos": [960, 240], "size": [140, 60], "tag": "nginx" },
|
||||
{ "id": "ai", "type": "backend", "label": "AI Moderation", "sublabel": "LLM caller · embeddings", "pos": [260, 400], "size": [140, 60] },
|
||||
{ "id": "frontend", "type": "frontend", "label": "gmw-frontend", "sublabel": "Next.js 16 SSR :4017", "pos": [960, 400], "size": [140, 60], "tag": "React 19 · Tailwind v4" },
|
||||
{ "id": "llm", "type": "cloud", "label": "9router LLM", "sublabel": "text + vision API", "pos": [260, 540], "size": [140, 60], "tag": "AI_LLM_BASE_URL" },
|
||||
{ "id": "browser", "type": "external", "label": "Dashboard Users", "sublabel": "browser · partysocket", "pos": [960, 540], "size": [140, 60] }
|
||||
],
|
||||
"boundaries": [
|
||||
{ "kind": "region", "label": "imrnes — Tailscale shared state", "wraps": ["redis", "pg"] },
|
||||
{ "kind": "region", "label": "orangevps — Nix/systemd", "wraps": ["gateway", "ai", "backend", "proxy", "frontend"] }
|
||||
],
|
||||
"connections": [
|
||||
{ "id": "c1", "from": "discord", "to": "gateway", "label": "selfbot events", "variant": "emphasis" },
|
||||
{ "id": "c2", "from": "gateway", "to": "ai", "label": "capture → analyze", "labelDy": 24 },
|
||||
{ "id": "c3", "from": "gateway", "to": "redis", "label": "publish events", "fromSide": "top", "toSide": "bottom" },
|
||||
{ "id": "c4", "from": "redis", "to": "backend", "label": "redis-bridge → WS" },
|
||||
{ "id": "c5", "from": "ai", "to": "llm", "label": "text + vision", "labelDy": 24 },
|
||||
{ "id": "c6", "from": "backend", "to": "llm", "label": "agentic chatbot", "variant": "dashed" },
|
||||
{ "id": "c7", "from": "backend", "to": "pg", "label": "Drizzle SQL" },
|
||||
{ "id": "c8", "from": "proxy", "to": "backend", "label": "/api · /ws · /trpc" },
|
||||
{ "id": "c9", "from": "proxy", "to": "frontend", "label": "SSR + static", "labelDy": 24 },
|
||||
{ "id": "c10", "from": "browser", "to": "frontend", "label": "HTTPS dashboard", "variant": "emphasis" }
|
||||
],
|
||||
"cards": [
|
||||
{ "dot": "cyan", "title": "Capture", "items": ["Selfbot gateway listens to guild events + voice streams", "AI moderation runs LLM on messages, attachments, transcripts"] },
|
||||
{ "dot": "emerald", "title": "Application", "items": ["oRPC over HTTP + WS (partysocket auto-reconnect)", "Next.js 16 SSR dashboard · nginx :4009 reverse proxy"] },
|
||||
{ "dot": "rose", "title": "Shared State", "items": ["Redis pub/sub relays events + live PCM audio", "PostgreSQL via Drizzle persists messages & assessments"] }
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Archify automated browser evidence · gmw-architecture.html</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}body{margin:0;padding:24px;background:#e9eef5;color:#172033;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}header{max-width:1500px;margin:0 auto 18px}h1{margin:0 0 6px;font-size:20px}p{margin:0;color:#526176}.grid{max-width:1500px;margin:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}figure{margin:0;padding:10px;background:white;border:1px solid #c9d4e3;border-radius:12px;box-shadow:0 10px 30px rgba(15,23,42,.08)}img{display:Line truncated
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>Automated browser evidence</h1><p>gmw-architecture.html · visual-check containment pass · perceptual visual review pending</p></header>
|
||||
<main class="grid">
|
||||
<figure>
|
||||
<img src="gmw-architecture.visual-check.1440x900.light.png" alt="light 1440 by 900">
|
||||
<figcaption><strong>LIGHT</strong> · 1440×900 · containment pass</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<img src="gmw-architecture.visual-check.1440x900.dark.png" alt="dark 1440 by 900">
|
||||
<figcaption><strong>DARK</strong> · 1440×900 · containment pass</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<img src="gmw-architecture.visual-check.2048x1320.light.png" alt="light 2048 by 1320">
|
||||
<figcaption><strong>LIGHT</strong> · 2048×1320 · containment pass</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<img src="gmw-architecture.visual-check.2048x1320.dark.png" alt="dark 2048 by 1320">
|
||||
<figcaption><strong>DARK</strong> · 2048×1320 · containment pass</figcaption>
|
||||
</figure>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,548 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"ok": true,
|
||||
"command": "visual-check",
|
||||
"evidenceKind": "automated-browser",
|
||||
"status": "pass",
|
||||
"visualReview": "pending",
|
||||
"artifact": {
|
||||
"path": "/home/code/GMW/docs/diagrams/gmw-architecture.html",
|
||||
"sha256": "8a9646053eb562bd79f674764521f96856d74da603476459c0aeedce53ab7573",
|
||||
"bytes": 811554
|
||||
},
|
||||
"state": {
|
||||
"detail": "read",
|
||||
"motion": "still"
|
||||
},
|
||||
"chrome": {
|
||||
"status": "available",
|
||||
"executable": "/bin/google-chrome"
|
||||
},
|
||||
"diagnostics": [],
|
||||
"containment": {
|
||||
"status": "pass",
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"theme": "light",
|
||||
"innerWidth": 1440,
|
||||
"innerHeight": 900,
|
||||
"scrollWidth": 1440,
|
||||
"scrollHeight": 900,
|
||||
"overflowX": false,
|
||||
"overflowY": false,
|
||||
"ok": true,
|
||||
"readerWidth": 1163,
|
||||
"diagramWidth": 1133,
|
||||
"viewBoxWidth": 1140,
|
||||
"minimumProjectedNodeTextPx": 7.752105263157895,
|
||||
"minimumProjectedNodeText": "channels · voice · reactions",
|
||||
"minimumProjectedNodeTextDetail": "context",
|
||||
"minimumRequiredNodeTextPx": 6,
|
||||
"readabilityOk": true,
|
||||
"hasLegend": false,
|
||||
"hasNavigationDock": true,
|
||||
"legendDockIntersectionArea": 0,
|
||||
"dockStageIntersectionArea": 0,
|
||||
"dockStageGap": 10.21875,
|
||||
"requiredDockStageGap": 10,
|
||||
"viewerChromeStageOk": true,
|
||||
"viewerChromeReserve": 51,
|
||||
"viewerChromeActive": true,
|
||||
"viewerChromeOk": true,
|
||||
"resolvedTheme": "light"
|
||||
},
|
||||
{
|
||||
"width": 1600,
|
||||
"height": 1000,
|
||||
"theme": "light",
|
||||
"innerWidth": 1600,
|
||||
"innerHeight": 1000,
|
||||
"scrollWidth": 1600,
|
||||
"scrollHeight": 1000,
|
||||
"overflowX": false,
|
||||
"overflowY": false,
|
||||
"ok": true,
|
||||
"readerWidth": 1207,
|
||||
"diagramWidth": 1177,
|
||||
"viewBoxWidth": 1140,
|
||||
"minimumProjectedNodeTextPx": 7.8,
|
||||
"minimumProjectedNodeText": "channels · voice · reactions",
|
||||
"minimumProjectedNodeTextDetail": "context",
|
||||
"minimumRequiredNodeTextPx": 6,
|
||||
"readabilityOk": true,
|
||||
"hasLegend": false,
|
||||
"hasNavigationDock": true,
|
||||
"legendDockIntersectionArea": 0,
|
||||
"dockStageIntersectionArea": 0,
|
||||
"dockStageGap": 10.21875,
|
||||
"requiredDockStageGap": 10,
|
||||
"viewerChromeStageOk": true,
|
||||
"viewerChromeReserve": 51,
|
||||
"viewerChromeActive": true,
|
||||
"viewerChromeOk": true,
|
||||
"resolvedTheme": "light"
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"theme": "light",
|
||||
"innerWidth": 1920,
|
||||
"innerHeight": 1080,
|
||||
"scrollWidth": 1920,
|
||||
"scrollHeight": 1080,
|
||||
"overflowX": false,
|
||||
"overflowY": false,
|
||||
"ok": true,
|
||||
"readerWidth": 1383,
|
||||
"diagramWidth": 1353,
|
||||
"viewBoxWidth": 1140,
|
||||
"minimumProjectedNodeTextPx": 7.8,
|
||||
"minimumProjectedNodeText": "channels · voice · reactions",
|
||||
"minimumProjectedNodeTextDetail": "context",
|
||||
"minimumRequiredNodeTextPx": 6,
|
||||
"readabilityOk": true,
|
||||
"hasLegend": false,
|
||||
"hasNavigationDock": true,
|
||||
"legendDockIntersectionArea": 0,
|
||||
"dockStageIntersectionArea": 0,
|
||||
"dockStageGap": 10.21875,
|
||||
"requiredDockStageGap": 10,
|
||||
"viewerChromeStageOk": true,
|
||||
"viewerChromeReserve": 51,
|
||||
"viewerChromeActive": true,
|
||||
"viewerChromeOk": true,
|
||||
"resolvedTheme": "light"
|
||||
},
|
||||
{
|
||||
"width": 2048,
|
||||
"height": 1320,
|
||||
"theme": "light",
|
||||
"innerWidth": 2048,
|
||||
"innerHeight": 1320,
|
||||
"scrollWidth": 2048,
|
||||
"scrollHeight": 1320,
|
||||
"overflowX": false,
|
||||
"overflowY": false,
|
||||
"ok": true,
|
||||
"readerWidth": 1797,
|
||||
"diagramWidth": 1747,
|
||||
"viewBoxWidth": 1140,
|
||||
"minimumProjectedNodeTextPx": 7.8,
|
||||
"minimumProjectedNodeText": "channels · voice · reactions",
|
||||
"minimumProjectedNodeTextDetail": "context",
|
||||
"minimumRequiredNodeTextPx": 6,
|
||||
"readabilityOk": true,
|
||||
"hasLegend": false,
|
||||
"hasNavigationDock": true,
|
||||
"legendDockIntersectionArea": 0,
|
||||
"dockStageIntersectionArea": 0,
|
||||
"dockStageGap": 10.21875,
|
||||
"requiredDockStageGap": 10,
|
||||
"viewerChromeStageOk": true,
|
||||
"viewerChromeReserve": 41,
|
||||
"viewerChromeActive": true,
|
||||
"viewerChromeOk": true,
|
||||
"resolvedTheme": "light"
|
||||
}
|
||||
]
|
||||
},
|
||||
"readability": {
|
||||
"status": "pass",
|
||||
"minimumProjectedNodeTextPx": 6,
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"theme": "light",
|
||||
"innerWidth": 1440,
|
||||
"innerHeight": 900,
|
||||
"scrollWidth": 1440,
|
||||
"scrollHeight": 900,
|
||||
"overflowX": false,
|
||||
"overflowY": false,
|
||||
"ok": true,
|
||||
"readerWidth": 1163,
|
||||
"diagramWidth": 1133,
|
||||
"viewBoxWidth": 1140,
|
||||
"minimumProjectedNodeTextPx": 7.752105263157895,
|
||||
"minimumProjectedNodeText": "channels · voice · reactions",
|
||||
"minimumProjectedNodeTextDetail": "context",
|
||||
"minimumRequiredNodeTextPx": 6,
|
||||
"readabilityOk": true,
|
||||
"hasLegend": false,
|
||||
"hasNavigationDock": true,
|
||||
"legendDockIntersectionArea": 0,
|
||||
"dockStageIntersectionArea": 0,
|
||||
"dockStageGap": 10.21875,
|
||||
"requiredDockStageGap": 10,
|
||||
"viewerChromeStageOk": true,
|
||||
"viewerChromeReserve": 51,
|
||||
"viewerChromeActive": true,
|
||||
"viewerChromeOk": true,
|
||||
"resolvedTheme": "light"
|
||||
},
|
||||
{
|
||||
"width": 1600,
|
||||
"height": 1000,
|
||||
"theme": "light",
|
||||
"innerWidth": 1600,
|
||||
"innerHeight": 1000,
|
||||
"scrollWidth": 1600,
|
||||
"scrollHeight": 1000,
|
||||
"overflowX": false,
|
||||
"overflowY": false,
|
||||
"ok": true,
|
||||
"readerWidth": 1207,
|
||||
"diagramWidth": 1177,
|
||||
"viewBoxWidth": 1140,
|
||||
"minimumProjectedNodeTextPx": 7.8,
|
||||
"minimumProjectedNodeText": "channels · voice · reactions",
|
||||
"minimumProjectedNodeTextDetail": "context",
|
||||
"minimumRequiredNodeTextPx": 6,
|
||||
"readabilityOk": true,
|
||||
"hasLegend": false,
|
||||
"hasNavigationDock": true,
|
||||
"legendDockIntersectionArea": 0,
|
||||
"dockStageIntersectionArea": 0,
|
||||
"dockStageGap": 10.21875,
|
||||
"requiredDockStageGap": 10,
|
||||
"viewerChromeStageOk": true,
|
||||
"viewerChromeReserve": 51,
|
||||
"viewerChromeActive": true,
|
||||
"viewerChromeOk": true,
|
||||
"resolvedTheme": "light"
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"theme": "light",
|
||||
"innerWidth": 1920,
|
||||
"innerHeight": 1080,
|
||||
"scrollWidth": 1920,
|
||||
"scrollHeight": 1080,
|
||||
"overflowX": false,
|
||||
"overflowY": false,
|
||||
"ok": true,
|
||||
"readerWidth": 1383,
|
||||
"diagramWidth": 1353,
|
||||
"viewBoxWidth": 1140,
|
||||
"minimumProjectedNodeTextPx": 7.8,
|
||||
"minimumProjectedNodeText": "channels · voice · reactions",
|
||||
"minimumProjectedNodeTextDetail": "context",
|
||||
"minimumRequiredNodeTextPx": 6,
|
||||
"readabilityOk": true,
|
||||
"hasLegend": false,
|
||||
"hasNavigationDock": true,
|
||||
"legendDockIntersectionArea": 0,
|
||||
"dockStageIntersectionArea": 0,
|
||||
"dockStageGap": 10.21875,
|
||||
"requiredDockStageGap": 10,
|
||||
"viewerChromeStageOk": true,
|
||||
"viewerChromeReserve": 51,
|
||||
"viewerChromeActive": true,
|
||||
"viewerChromeOk": true,
|
||||
"resolvedTheme": "light"
|
||||
},
|
||||
{
|
||||
"width": 2048,
|
||||
"height": 1320,
|
||||
"theme": "light",
|
||||
"innerWidth": 2048,
|
||||
"innerHeight": 1320,
|
||||
"scrollWidth": 2048,
|
||||
"scrollHeight": 1320,
|
||||
"overflowX": false,
|
||||
"overflowY": false,
|
||||
"ok": true,
|
||||
"readerWidth": 1797,
|
||||
"diagramWidth": 1747,
|
||||
"viewBoxWidth": 1140,
|
||||
"minimumProjectedNodeTextPx": 7.8,
|
||||
"minimumProjectedNodeText": "channels · voice · reactions",
|
||||
"minimumProjectedNodeTextDetail": "context",
|
||||
"minimumRequiredNodeTextPx": 6,
|
||||
"readabilityOk": true,
|
||||
"hasLegend": false,
|
||||
"hasNavigationDock": true,
|
||||
"legendDockIntersectionArea": 0,
|
||||
"dockStageIntersectionArea": 0,
|
||||
"dockStageGap": 10.21875,
|
||||
"requiredDockStageGap": 10,
|
||||
"viewerChromeStageOk": true,
|
||||
"viewerChromeReserve": 41,
|
||||
"viewerChromeActive": true,
|
||||
"viewerChromeOk": true,
|
||||
"resolvedTheme": "light"
|
||||
}
|
||||
]
|
||||
},
|
||||
"viewerChrome": {
|
||||
"status": "pass",
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"theme": "light",
|
||||
"innerWidth": 1440,
|
||||
"innerHeight": 900,
|
||||
"scrollWidth": 1440,
|
||||
"scrollHeight": 900,
|
||||
"overflowX": false,
|
||||
"overflowY": false,
|
||||
"ok": true,
|
||||
"readerWidth": 1163,
|
||||
"diagramWidth": 1133,
|
||||
"viewBoxWidth": 1140,
|
||||
"minimumProjectedNodeTextPx": 7.752105263157895,
|
||||
"minimumProjectedNodeText": "channels · voice · reactions",
|
||||
"minimumProjectedNodeTextDetail": "context",
|
||||
"minimumRequiredNodeTextPx": 6,
|
||||
"readabilityOk": true,
|
||||
"hasLegend": false,
|
||||
"hasNavigationDock": true,
|
||||
"legendDockIntersectionArea": 0,
|
||||
"dockStageIntersectionArea": 0,
|
||||
"dockStageGap": 10.21875,
|
||||
"requiredDockStageGap": 10,
|
||||
"viewerChromeStageOk": true,
|
||||
"viewerChromeReserve": 51,
|
||||
"viewerChromeActive": true,
|
||||
"viewerChromeOk": true,
|
||||
"resolvedTheme": "light"
|
||||
},
|
||||
{
|
||||
"width": 1600,
|
||||
"height": 1000,
|
||||
"theme": "light",
|
||||
"innerWidth": 1600,
|
||||
"innerHeight": 1000,
|
||||
"scrollWidth": 1600,
|
||||
"scrollHeight": 1000,
|
||||
"overflowX": false,
|
||||
"overflowY": false,
|
||||
"ok": true,
|
||||
"readerWidth": 1207,
|
||||
"diagramWidth": 1177,
|
||||
"viewBoxWidth": 1140,
|
||||
"minimumProjectedNodeTextPx": 7.8,
|
||||
"minimumProjectedNodeText": "channels · voice · reactions",
|
||||
"minimumProjectedNodeTextDetail": "context",
|
||||
"minimumRequiredNodeTextPx": 6,
|
||||
"readabilityOk": true,
|
||||
"hasLegend": false,
|
||||
"hasNavigationDock": true,
|
||||
"legendDockIntersectionArea": 0,
|
||||
"dockStageIntersectionArea": 0,
|
||||
"dockStageGap": 10.21875,
|
||||
"requiredDockStageGap": 10,
|
||||
"viewerChromeStageOk": true,
|
||||
"viewerChromeReserve": 51,
|
||||
"viewerChromeActive": true,
|
||||
"viewerChromeOk": true,
|
||||
"resolvedTheme": "light"
|
||||
},
|
||||
{
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"theme": "light",
|
||||
"innerWidth": 1920,
|
||||
"innerHeight": 1080,
|
||||
"scrollWidth": 1920,
|
||||
"scrollHeight": 1080,
|
||||
"overflowX": false,
|
||||
"overflowY": false,
|
||||
"ok": true,
|
||||
"readerWidth": 1383,
|
||||
"diagramWidth": 1353,
|
||||
"viewBoxWidth": 1140,
|
||||
"minimumProjectedNodeTextPx": 7.8,
|
||||
"minimumProjectedNodeText": "channels · voice · reactions",
|
||||
"minimumProjectedNodeTextDetail": "context",
|
||||
"minimumRequiredNodeTextPx": 6,
|
||||
"readabilityOk": true,
|
||||
"hasLegend": false,
|
||||
"hasNavigationDock": true,
|
||||
"legendDockIntersectionArea": 0,
|
||||
"dockStageIntersectionArea": 0,
|
||||
"dockStageGap": 10.21875,
|
||||
"requiredDockStageGap": 10,
|
||||
"viewerChromeStageOk": true,
|
||||
"viewerChromeReserve": 51,
|
||||
"viewerChromeActive": true,
|
||||
"viewerChromeOk": true,
|
||||
"resolvedTheme": "light"
|
||||
},
|
||||
{
|
||||
"width": 2048,
|
||||
"height": 1320,
|
||||
"theme": "light",
|
||||
"innerWidth": 2048,
|
||||
"innerHeight": 1320,
|
||||
"scrollWidth": 2048,
|
||||
"scrollHeight": 1320,
|
||||
"overflowX": false,
|
||||
"overflowY": false,
|
||||
"ok": true,
|
||||
"readerWidth": 1797,
|
||||
"diagramWidth": 1747,
|
||||
"viewBoxWidth": 1140,
|
||||
"minimumProjectedNodeTextPx": 7.8,
|
||||
"minimumProjectedNodeText": "channels · voice · reactions",
|
||||
"minimumProjectedNodeTextDetail": "context",
|
||||
"minimumRequiredNodeTextPx": 6,
|
||||
"readabilityOk": true,
|
||||
"hasLegend": false,
|
||||
"hasNavigationDock": true,
|
||||
"legendDockIntersectionArea": 0,
|
||||
"dockStageIntersectionArea": 0,
|
||||
"dockStageGap": 10.21875,
|
||||
"requiredDockStageGap": 10,
|
||||
"viewerChromeStageOk": true,
|
||||
"viewerChromeReserve": 41,
|
||||
"viewerChromeActive": true,
|
||||
"viewerChromeOk": true,
|
||||
"resolvedTheme": "light"
|
||||
}
|
||||
]
|
||||
},
|
||||
"captures": {
|
||||
"status": "pass",
|
||||
"screenshots": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"theme": "light",
|
||||
"innerWidth": 1440,
|
||||
"innerHeight": 900,
|
||||
"scrollWidth": 1440,
|
||||
"scrollHeight": 900,
|
||||
"overflowX": false,
|
||||
"overflowY": false,
|
||||
"ok": true,
|
||||
"readerWidth": 1163,
|
||||
"diagramWidth": 1133,
|
||||
"viewBoxWidth": 1140,
|
||||
"minimumProjectedNodeTextPx": 7.752105263157895,
|
||||
"minimumProjectedNodeText": "channels · voice · reactions",
|
||||
"minimumProjectedNodeTextDetail": "context",
|
||||
"minimumRequiredNodeTextPx": 6,
|
||||
"readabilityOk": true,
|
||||
"hasLegend": false,
|
||||
"hasNavigationDock": true,
|
||||
"legendDockIntersectionArea": 0,
|
||||
"dockStageIntersectionArea": 0,
|
||||
"dockStageGap": 10.21875,
|
||||
"requiredDockStageGap": 10,
|
||||
"viewerChromeStageOk": true,
|
||||
"viewerChromeReserve": 51,
|
||||
"viewerChromeActive": true,
|
||||
"viewerChromeOk": true,
|
||||
"resolvedTheme": "light",
|
||||
"file": "gmw-architecture.visual-check.1440x900.light.png"
|
||||
},
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"theme": "dark",
|
||||
"innerWidth": 1440,
|
||||
"innerHeight": 900,
|
||||
"scrollWidth": 1440,
|
||||
"scrollHeight": 900,
|
||||
"overflowX": false,
|
||||
"overflowY": false,
|
||||
"ok": true,
|
||||
"readerWidth": 1163,
|
||||
"diagramWidth": 1133,
|
||||
"viewBoxWidth": 1140,
|
||||
"minimumProjectedNodeTextPx": 7.752105263157895,
|
||||
"minimumProjectedNodeText": "channels · voice · reactions",
|
||||
"minimumProjectedNodeTextDetail": "context",
|
||||
"minimumRequiredNodeTextPx": 6,
|
||||
"readabilityOk": true,
|
||||
"hasLegend": false,
|
||||
"hasNavigationDock": true,
|
||||
"legendDockIntersectionArea": 0,
|
||||
"dockStageIntersectionArea": 0,
|
||||
"dockStageGap": 10.21875,
|
||||
"requiredDockStageGap": 10,
|
||||
"viewerChromeStageOk": true,
|
||||
"viewerChromeReserve": 51,
|
||||
"viewerChromeActive": true,
|
||||
"viewerChromeOk": true,
|
||||
"resolvedTheme": "dark",
|
||||
"file": "gmw-architecture.visual-check.1440x900.dark.png"
|
||||
},
|
||||
{
|
||||
"width": 2048,
|
||||
"height": 1320,
|
||||
"theme": "light",
|
||||
"innerWidth": 2048,
|
||||
"innerHeight": 1320,
|
||||
"scrollWidth": 2048,
|
||||
"scrollHeight": 1320,
|
||||
"overflowX": false,
|
||||
"overflowY": false,
|
||||
"ok": true,
|
||||
"readerWidth": 1797,
|
||||
"diagramWidth": 1747,
|
||||
"viewBoxWidth": 1140,
|
||||
"minimumProjectedNodeTextPx": 7.8,
|
||||
"minimumProjectedNodeText": "channels · voice · reactions",
|
||||
"minimumProjectedNodeTextDetail": "context",
|
||||
"minimumRequiredNodeTextPx": 6,
|
||||
"readabilityOk": true,
|
||||
"hasLegend": false,
|
||||
"hasNavigationDock": true,
|
||||
"legendDockIntersectionArea": 0,
|
||||
"dockStageIntersectionArea": 0,
|
||||
"dockStageGap": 10.21875,
|
||||
"requiredDockStageGap": 10,
|
||||
"viewerChromeStageOk": true,
|
||||
"viewerChromeReserve": 41,
|
||||
"viewerChromeActive": true,
|
||||
"viewerChromeOk": true,
|
||||
"resolvedTheme": "light",
|
||||
"file": "gmw-architecture.visual-check.2048x1320.light.png"
|
||||
},
|
||||
{
|
||||
"width": 2048,
|
||||
"height": 1320,
|
||||
"theme": "dark",
|
||||
"innerWidth": 2048,
|
||||
"innerHeight": 1320,
|
||||
"scrollWidth": 2048,
|
||||
"scrollHeight": 1320,
|
||||
"overflowX": false,
|
||||
"overflowY": false,
|
||||
"ok": true,
|
||||
"readerWidth": 1797,
|
||||
"diagramWidth": 1747,
|
||||
"viewBoxWidth": 1140,
|
||||
"minimumProjectedNodeTextPx": 7.8,
|
||||
"minimumProjectedNodeText": "channels · voice · reactions",
|
||||
"minimumProjectedNodeTextDetail": "context",
|
||||
"minimumRequiredNodeTextPx": 6,
|
||||
"readabilityOk": true,
|
||||
"hasLegend": false,
|
||||
"hasNavigationDock": true,
|
||||
"legendDockIntersectionArea": 0,
|
||||
"dockStageIntersectionArea": 0,
|
||||
"dockStageGap": 10.21875,
|
||||
"requiredDockStageGap": 10,
|
||||
"viewerChromeStageOk": true,
|
||||
"viewerChromeReserve": 41,
|
||||
"viewerChromeActive": true,
|
||||
"viewerChromeOk": true,
|
||||
"resolvedTheme": "dark",
|
||||
"file": "gmw-architecture.visual-check.2048x1320.dark.png"
|
||||
}
|
||||
],
|
||||
"contactSheet": "gmw-architecture.visual-check.html"
|
||||
},
|
||||
"sidecars": {
|
||||
"receipt": "gmw-architecture.visual-check.json",
|
||||
"contactSheet": "gmw-architecture.visual-check.html"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# Spec: <Judul singkat, aktif, deskriptif>
|
||||
|
||||
Status: **DRAFT** | **APPROVED** | **IN PROGRESS** | **DONE**
|
||||
Date: YYYY-MM-DD
|
||||
Author: <nama/agent>
|
||||
Related: <link ke spec/plan terkait jika ada>
|
||||
Todo: <link ke todo.md untuk tugas ini, jika ada>
|
||||
|
||||
## Problem
|
||||
|
||||
<Deskripsi masalah atau keinginan. Apa yang rusak? Apa yang belum ada?
|
||||
Sebutkan siapa yang melaporkan (user/bot/audit) dan konteksnya.
|
||||
Gunakan **evidence**: log, error, metrik, atau observasi langsung.>
|
||||
|
||||
## Root cause
|
||||
|
||||
<Analisis teknis mengapa masalah ini terjadi. Jika bug: trace dari symptom ke cause.
|
||||
Jika fitur baru: jelaskan gap saat ini. Referensikan file + line number yang relevan.>
|
||||
|
||||
## Behavior target
|
||||
|
||||
<Deskripsi eksplisit perilaku yang diharapkan setelah fix/fitur.
|
||||
Buat list bernomor. Setiap item harus bisa di-verify.>
|
||||
|
||||
## Verified facts
|
||||
|
||||
>Fakta-fakta teknis yang **sudah diverifikasi** dari pembacaan kode, log produksi,
|
||||
>atau eksperimen langsung. Setiap fakta harus citation ke file:line.
|
||||
>Jika belum diverifikasi, tulis `UNVERIFIED` dan rencana verifikasi.
|
||||
|
||||
- `path/to/file.ts:42` — <apa yang terjadi di sini>
|
||||
- `path/to/file.ts:88` — <apa yang terjadi di sini>
|
||||
- Kolom DB `table.column` — <tipe, constraint, index>
|
||||
- Config `ENV_VAR` default <value> — <dibaca di mana>
|
||||
|
||||
## Keputusan desain
|
||||
|
||||
<Pilihan desain yang dibuat, beserta rationale (mengapa bukan alternatif lain).
|
||||
Format: nomor, judul singkat, penjelasan.>
|
||||
|
||||
1. **<Judul keputusan>**: <penjelasan + rationale>
|
||||
- Alternatif yang ditolak: <apa + mengapa ditolak>
|
||||
|
||||
## Perubahan file
|
||||
|
||||
>Daftar **semua file** yang perlu diubah/ditambah/dihapus, dikelompokkan per service.
|
||||
>Untuk setiap file: sebutkan **apa yang berubah** (bukan copy-paste kode).
|
||||
|
||||
### Gateway (`services/discord-gateway/`)
|
||||
- `src/modules/<module>/<file>.ts` — <ringkasan perubahan>
|
||||
|
||||
### Backend (`services/backend/`)
|
||||
- `src/modules/<module>/<file>.ts` — <ringkasan perubahan>
|
||||
|
||||
### Frontend (`services/frontend/`)
|
||||
- `src/<path>/<file>.ts|tsx` — <ringkasan perubahan>
|
||||
|
||||
### Database / Config
|
||||
- <migration file atau config change jika ada>
|
||||
|
||||
## Schema/type changes
|
||||
|
||||
<Perubahan tipe, interface, atau DB schema. Jika tidak ada, tulis "TIDAK ada perubahan.">
|
||||
|
||||
## Verification
|
||||
|
||||
>**Harus spesifik dan executable.** Jangan tulis "works correctly".
|
||||
>Tulis command yang bisa dijalankan dan expected outcome.
|
||||
|
||||
- **Typecheck**: `<service> pnpm typecheck` — clean
|
||||
- **Lint**: `<service> pnpm lint` — no errors
|
||||
- **Build**: `<service> pnpm build` — compiles
|
||||
- **Test**: `<service> pnpm test` — all pass (+ test baru jika ada)
|
||||
- **Smoke test**: <langkah manual untuk verifikasi visual/behavioral>
|
||||
- **DB**: <query untuk cek data jika applicable>
|
||||
- **Deploy**: CI green → deploy → cek <specific observable>
|
||||
|
||||
## Notes (opsional)
|
||||
|
||||
<Catatan tambahan: risiko, fallback, future work, atau hal yang sengaja di-scope-out.>
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,27 @@
|
||||
# Todo — <judul tugas>
|
||||
|
||||
Status: **IN PROGRESS** | **BLOCKED** | **DONE**
|
||||
Spec: <link ke spec: `docs/specs/YYYY-MM-DD_<slug>-spec.md`>
|
||||
Date: YYYY-MM-DD
|
||||
|
||||
## Task
|
||||
|
||||
> Urutan implementasi sesuai spec. Task harus konkret & verifiable
|
||||
> ("ubah X di file Y"), bukan "fix bug". Satu task dikerjakan pada satu
|
||||
> waktu; ceklis `[x]` segera setelah selesai.
|
||||
|
||||
- [ ] Tulis spec
|
||||
- [ ] Verifikasi facts (baca kode: `file:line`)
|
||||
- [ ] Implementasi tahap 1: <apa + file mana>
|
||||
- [ ] Implementasi tahap 2: <apa + file mana>
|
||||
- [ ] Implementasi tahap 3: <apa + file mana>
|
||||
- [ ] Typecheck: `pnpm typecheck` — clean
|
||||
- [ ] Lint: `pnpm lint` — no errors
|
||||
- [ ] Test: `pnpm test` — all pass
|
||||
- [ ] Build: `pnpm build` — compiles
|
||||
- [ ] Daftar ulang hasil verifikasi di spec
|
||||
- [ ] Commit + push (reference spec di commit message)
|
||||
|
||||
## Notes
|
||||
|
||||
<Blocking issue, temuan yang mengubah approach, atau hal yang perlu dicatat.>
|
||||
@@ -1,3 +0,0 @@
|
||||
*
|
||||
!.gitkeep
|
||||
!README.md
|
||||
Whitespace-only changes.
@@ -1,15 +0,0 @@
|
||||
# Recordings Directory
|
||||
|
||||
This directory is mounted as a volume in the discord-gateway container.
|
||||
Voice recordings are stored here with the following structure:
|
||||
|
||||
```
|
||||
recordings/
|
||||
├── <user-id>/
|
||||
│ ├── <timestamp>.ogg
|
||||
│ └── <timestamp>.json
|
||||
└── sessions/
|
||||
└── <session-id>.json
|
||||
```
|
||||
|
||||
The directory must be writable by UID 1000 (app user in the container).
|
||||
@@ -0,0 +1,114 @@
|
||||
# Backend Service — Agent Guide
|
||||
|
||||
> **Read `../../AGENTS.md` first.** This file adds backend-specific conventions.
|
||||
|
||||
Backend service: Express HTTP server + WebSocket, serves the GMW dashboard API.
|
||||
|
||||
## Quick reference
|
||||
|
||||
```bash
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm lint # biome check --diagnostic-level=error .
|
||||
pnpm build # tsc
|
||||
pnpm test # vitest run
|
||||
pnpm format # biome format --write .
|
||||
```
|
||||
|
||||
## Architecture (Modular MVC)
|
||||
|
||||
```
|
||||
src/
|
||||
├── shared/ # Infrastructure (no business logic)
|
||||
│ ├── config/index.ts # Zod-validated env
|
||||
│ ├── database/ # Drizzle ORM + pg Pool
|
||||
│ ├── errors/index.ts # AppError hierarchy
|
||||
│ ├── logger/index.ts # pino + createChildLogger()
|
||||
│ ├── middlewares/index.ts # errorHandler, asyncHandler, rateLimit
|
||||
│ └── utils/ # Pagination, messageMapper
|
||||
├── modules/ # Feature modules
|
||||
│ └── <module>/
|
||||
│ ├── <module>.schema.ts # Zod validation schemas
|
||||
│ ├── <module>.repository.ts # DB operations only
|
||||
│ ├── <module>.service.ts # Business logic
|
||||
│ ├── <module>.controller.ts # HTTP handlers
|
||||
│ └── routes/index.ts # Express router
|
||||
├── http/ # app.ts (factory) + server.ts (startup)
|
||||
├── ws/ # WebSocket server + Redis bridge
|
||||
└── index.ts # Entry point
|
||||
```
|
||||
|
||||
## Dependency rules
|
||||
|
||||
- Controller → Service → Repository → Database
|
||||
- No cross-module repo imports (each module owns its data)
|
||||
- No HTTP in Service layer (no req/res)
|
||||
- No DB in Controller layer
|
||||
- Any layer → Config, Logger, Errors
|
||||
|
||||
## API: oRPC + Express
|
||||
|
||||
- **oRPC** (`src/orpc/router.ts` + `src/orpc/ws.ts`): type-safe procedures for frontend
|
||||
- **Express routes** (`src/modules/*/routes/`): REST endpoints under `/api/`
|
||||
- **WebSocket** (`src/ws/`): Redis bridge → broadcast to connected browsers
|
||||
- Never invent endpoints. Match what the frontend calls (`src/lib/api/`).
|
||||
|
||||
## Key modules
|
||||
|
||||
| Module | Purpose | DB tables |
|
||||
|---|---|---|
|
||||
| messages | Store & query Discord messages | `messages`, `ai_moderations`, `ai_moderation_flags` |
|
||||
| moderation | Moderation actions & metrics | `ai_moderations`, `moderation_actions` |
|
||||
| media | Media file management | `media_attachments` |
|
||||
| dashboard | Stats aggregation | Various (read-only) |
|
||||
| knowledge | Semantic search | Qdrant vector DB |
|
||||
| chatbot | AI chatbot with tools | `chatbot_history` |
|
||||
| health | Health checks + metrics | Various |
|
||||
| analysis | Text analysis cache | `text_analysis_cache` |
|
||||
| ui-state | Persist UI preferences | `ui_state` |
|
||||
|
||||
## Config (env vars)
|
||||
|
||||
All validated via Zod in `shared/config/index.ts`. Key vars:
|
||||
|
||||
- `WEBSERVER_PORT` (default 4001)
|
||||
- `DATABASE_URL` or individual `DATABASE_HOST/PORT/NAME/USER/PASSWORD`
|
||||
- `REDIS_URL` — for pub/sub with gateway
|
||||
- `MONITOR_GUILD_ID` — primary Discord guild
|
||||
- `ADMIN_PASSWORD` — admin endpoints
|
||||
|
||||
## Data contract with frontend
|
||||
|
||||
The frontend fetches via `src/lib/api/server.ts` (SSR, server-side) and
|
||||
`src/lib/api/client.ts` (browser, same-origin through proxy).
|
||||
|
||||
**Do not change response shapes without updating both sides.** Check
|
||||
`services/frontend/src/lib/types/` for frontend type definitions.
|
||||
|
||||
## Redis channels (inbound from gateway)
|
||||
|
||||
```
|
||||
discord:message:{created,updated,deleted,analyzed}
|
||||
discord:attachment:{created,uploaded}
|
||||
discord:analysis:queue_status
|
||||
discord:reaction:{added,removed}
|
||||
discord:thread:{created,deleted,updated}
|
||||
discord:channel_topic:updated
|
||||
discord:presence:updated
|
||||
discord:guild_member:{added,removed}
|
||||
```
|
||||
|
||||
Canonical names: `src/shared/redis-channels.ts`.
|
||||
|
||||
## Testing
|
||||
|
||||
- Vitest for unit tests
|
||||
- Mock database and external services
|
||||
- Test files: `src/modules/<module>/*.test.ts` or `tests/*.test.ts`
|
||||
- Run: `pnpm test`
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **oRPC vs REST**: check both routers when adding an endpoint
|
||||
- **Redis channel mismatch**: gateway publishes → backend subscribes. Channel
|
||||
names must match exactly (see `redis-channels.ts` in BOTH services)
|
||||
- **DB pool**: backend uses one pool (main thread). Gateway has per-piscina-thread pools.
|
||||
@@ -35,16 +35,6 @@ services/backend/
|
||||
│ │ │ └── routes/
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── media/
|
||||
│ │ │ ├── media.service.ts
|
||||
│ │ │ └── routes/
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── voice/
|
||||
│ │ │ ├── voice.service.ts
|
||||
│ │ │ └── routes/
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ └── health/
|
||||
│ │ ├── health.schema.ts
|
||||
│ │ ├── health.repository.ts
|
||||
@@ -148,8 +138,6 @@ export const messageQuerySchema = z.object({
|
||||
|--------|---------|--------|
|
||||
| **messages** | Text message storage & retrieval | GET /api/messages, GET /api/messages/:channelId |
|
||||
| **analytics** | Moderation statistics & trends | GET /api/analytics/overview, /daily-trend, /hourly-stats |
|
||||
| **media** | Media file management | GET /api/media/list, POST /api/media/upload |
|
||||
| **voice** | Voice recording management | GET /api/voice/recordings, POST /api/voice/connect |
|
||||
| **health** | Service health checks | GET /api/health |
|
||||
|
||||
## Data Flow
|
||||
|
||||
@@ -2,22 +2,27 @@
|
||||
"name": "discord-moderation-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend service for Discord moderation monitoring",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"packageManager": "pnpm@11.20.0",
|
||||
"engines": {
|
||||
"node": ">=22.12.0",
|
||||
"pnpm": ">=9.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check --diagnostic-level=error .",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"format": "biome format --write .",
|
||||
"test": "vitest run"
|
||||
"lint": "biome check --diagnostic-level=error .",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/voice": "^0.19.2",
|
||||
"@orpc/server": "1.15.0",
|
||||
"@orpc/server": "1.15.2",
|
||||
"axios": "^1.20.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"dotenv": "^18.0.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^5.2.1",
|
||||
"helmet": "^8.1.0",
|
||||
@@ -26,16 +31,16 @@
|
||||
"pino": "^10.3.1",
|
||||
"prom-client": "^15.1.3",
|
||||
"ws": "^8.21.3",
|
||||
"zod": "^4.5.4"
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "latest",
|
||||
"@biomejs/biome": "^2.5.14",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^26.4.0",
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsx": "^4.23.13",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "latest"
|
||||
"vitest": "^5.0.1"
|
||||
}
|
||||
}
|
||||
Generated
+221
-478
File diff suppressed because it is too large.
Load diff
Loaded 50 of 200 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user