Compare commits
211
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96c85397c6 | ||
|
|
2d0b653870 | ||
|
|
276062fdd4 | ||
|
|
9682abef82 | ||
|
|
c5219ee866 | ||
|
|
a5fd58a7c1 | ||
|
|
85204ca6f0 | ||
|
|
68fbaa631a | ||
|
|
d4d36e8819 | ||
|
|
b761de8b98 | ||
|
|
5dccb76d08 | ||
|
|
3bf172b692 | ||
|
|
1aac95b368 | ||
|
|
73727fbdc4 | ||
|
|
be660210e3 | ||
|
|
43e48c1f26 | ||
|
|
53dfd3be41 | ||
|
|
2f02196864 | ||
|
|
1cd17f53d5 | ||
|
|
955da396c7 | ||
|
|
ccfdbc860e | ||
|
|
e6aa9af283 | ||
|
|
4c38d53972 | ||
|
|
e5304fde29 | ||
|
|
43594af3c8 | ||
|
|
fdcd53d149 | ||
|
|
bc66babd45 | ||
|
|
ac32179bb1 | ||
|
|
cee33c0d1f | ||
|
|
dd9f2f6bbc | ||
|
|
82f1698043 | ||
|
|
7e50c000dc | ||
|
|
d0453881b8 | ||
|
|
7122258d7c | ||
|
|
30d640ca5b | ||
|
|
7c21629404 | ||
|
|
5fdd6ed80c | ||
|
|
38b74cc1db | ||
|
|
7bfdf9c85c | ||
|
|
1367a2257f | ||
|
|
c9ad75b512 | ||
|
|
d9a64d693f | ||
|
|
a70ecb7bfd | ||
|
|
ca0a015ebf | ||
|
|
4ec9685194 | ||
|
|
446a4f28ea | ||
|
|
f70148308b | ||
|
|
eb2f8f5b6f | ||
|
|
1f196d2267 | ||
|
|
297320a75d | ||
|
|
49d27a8a1c | ||
|
|
596ff04902 | ||
|
|
fa72fe03cd | ||
|
|
26a690943d | ||
|
|
c704fbf7a5 | ||
|
|
7ef86c81ca | ||
|
|
0e31aa06b8 | ||
|
|
12cc956329 | ||
|
|
3ce594d9b5 | ||
|
|
0de5929711 | ||
|
|
91cf0ad33f | ||
|
|
a0794a67d1 | ||
|
|
81c6a95f4c | ||
|
|
d8a52eeb4b | ||
|
|
400466b5c5 | ||
|
|
60a3b729aa | ||
|
|
e291a0e2e3 | ||
|
|
425cee15b6 | ||
|
|
62fe25e5c1 | ||
|
|
b4fc51993f | ||
|
|
cb36a2fbb0 | ||
|
|
1a494260db | ||
|
|
04ab117044 | ||
|
|
ad21aa07ed | ||
|
|
2ae3c06c4a | ||
|
|
2b6a1eca19 | ||
|
|
9606187861 | ||
|
|
b423d21b23 | ||
|
|
266e149233 | ||
|
|
026c66a03a | ||
|
|
f1a7b0c2a1 | ||
|
|
dea284357c | ||
|
|
87f1f8be8d | ||
|
|
4405647b33 | ||
|
|
4534b7a17d | ||
|
|
b3e350d40f | ||
|
|
75050bc088 | ||
|
|
f1da40691f | ||
|
|
374fd5a9c2 | ||
|
|
77c8454bb2 | ||
|
|
0ea76a8373 | ||
|
|
0995c2db81 | ||
|
|
6aeebe7826 | ||
|
|
6738279bad | ||
|
|
260ecabb3c | ||
|
|
5241f9784a | ||
|
|
feca8a476c | ||
|
|
999c054bb2 | ||
|
|
80e9b913c1 | ||
|
|
419946f38e | ||
|
|
462f643d6f | ||
|
|
441b5282ef | ||
|
|
50967f6481 | ||
|
|
7dfb4035b7 | ||
|
|
7f45cbff2d | ||
|
|
e12b1302ff | ||
|
|
22c09ecd28 | ||
|
|
a3a91aa2ce | ||
|
|
75ff9274b3 | ||
|
|
e3016a858a | ||
|
|
f0874d8634 | ||
|
|
8d6b48fb4c | ||
|
|
9c9cd8917e | ||
|
|
4b58a159d0 | ||
|
|
f53a2d4f4a | ||
|
|
76daa7026c | ||
|
|
430e3d137c | ||
|
|
de8b521bb4 | ||
|
|
8bd7f5a98f | ||
|
|
ffbe9959ab | ||
|
|
14bd20f072 | ||
|
|
4d0bbed596 | ||
|
|
ae41f64e46 | ||
|
|
e7b0f02620 | ||
|
|
120aa95818 | ||
|
|
870f7546fb | ||
|
|
565b3da086 | ||
|
|
a54c34c6b3 | ||
|
|
7375e2ed52 | ||
|
|
5196cb0221 | ||
|
|
631b5e1027 | ||
|
|
8049b70738 | ||
|
|
ea23c405fa | ||
|
|
4991164591 | ||
|
|
1590479f58 | ||
|
|
0de393625f | ||
|
|
cccfd89266 | ||
|
|
17aad2cc39 | ||
|
|
a6e2c1fa4f | ||
|
|
7e0d0d5123 | ||
|
|
4f4f92706c | ||
|
|
611ba39973 | ||
|
|
192685e1ce | ||
|
|
cacebfd94e | ||
|
|
709074935f | ||
|
|
9f02edd646 | ||
|
|
5fc3cf86d5 | ||
|
|
e81391484b | ||
|
|
3b8fe1b1c3 | ||
|
|
54e7220d06 | ||
|
|
46d889271e | ||
|
|
0c3a82ad77 | ||
|
|
b640079cb6 | ||
|
|
37d15456dc | ||
|
|
153e628aba | ||
|
|
a5f908dc06 | ||
|
|
7fc36170c8 | ||
|
|
ccfe769b2f | ||
|
|
3b688e0d0f | ||
|
|
29f59fd91f | ||
|
|
c3165ed702 | ||
|
|
c72fadbbb4 | ||
|
|
613840e2d1 | ||
|
|
24336186dc | ||
|
|
6b66863897 | ||
|
|
fc40609a3a | ||
|
|
e157ae3ce4 | ||
|
|
3f4c05f5bb | ||
|
|
9c25e0887c | ||
|
|
2aa12b9ec5 | ||
|
|
cab451f0e5 | ||
|
|
183a51003a | ||
|
|
f34811e9b6 | ||
|
|
2c30140ddd | ||
|
|
7e92de63d6 | ||
|
|
68be4352da | ||
|
|
754ef78ff1 | ||
|
|
2959744e0c | ||
|
|
cffa72c21f | ||
|
|
d64c5ba7b6 | ||
|
|
87851f7a71 | ||
|
|
91de43a6c6 | ||
|
|
f8fc08de41 | ||
|
|
dfa69e9f45 | ||
|
|
0bd4b4075e | ||
|
|
b679a02cb9 | ||
|
|
4ed1d3c053 | ||
|
|
8dbcc2b527 | ||
|
|
bda210475d | ||
|
|
9b78abaf3d | ||
|
|
53c4158511 | ||
|
|
a2a8c824bf | ||
|
|
dec548fa2f | ||
|
|
12033e5537 | ||
|
|
d06caa7e58 | ||
|
|
173ae3c61c | ||
|
|
2637bbc549 | ||
|
|
6fc68126ac | ||
|
|
28d09a24c9 | ||
|
|
f2ec642a0e | ||
|
|
8a491b9ec2 | ||
|
|
7d54d3a660 | ||
|
|
21509c25ff | ||
|
|
09724287cb | ||
|
|
1ac3d50315 | ||
|
|
9c48d50d25 | ||
|
|
8bd3ec8e83 | ||
|
|
5a4a203f01 | ||
|
|
7f0aa5a7ad | ||
|
|
eb0be981c6 | ||
|
|
466e117bd5 |
No files matched your search
+6
-3
@@ -85,8 +85,8 @@ BACKLOG_SYNC_BATCH_SIZE=100 # Messages per backlog batch, max 100 (d
|
|||||||
# === AI Analysis ===
|
# === AI Analysis ===
|
||||||
AI_ANALYSIS_ENABLED=false # Enable AI content moderation (default: false)
|
AI_ANALYSIS_ENABLED=false # Enable AI content moderation (default: false)
|
||||||
AI_LLM_API_KEY= # REQUIRED if AI_ANALYSIS_ENABLED=true. LLM API key
|
AI_LLM_API_KEY= # REQUIRED if AI_ANALYSIS_ENABLED=true. LLM API key
|
||||||
AI_LLM_BASE_URL=https://9router.asepharyana.my.id/v1 # LLM API base URL (9router — OpenAI-compatible router, replaces omniroute)
|
AI_LLM_BASE_URL=http://100.121.180.82:20128/api/v1 # LLM API base URL (omniroute — OpenAI-compatible router on imrnes, Tailscale 100.121.180.82)
|
||||||
AI_LLM_MODEL=claude-opus-5 # LLM text model name (default: claude-opus-5)
|
AI_LLM_MODEL=text # LLM text model name (default: text)
|
||||||
# AI_LLM_VISION_MODEL= # Vision model for image analysis (falls back to AI_LLM_MODEL)
|
# AI_LLM_VISION_MODEL= # Vision model for image analysis (falls back to AI_LLM_MODEL)
|
||||||
# AI_LLM_EMBEDDING_MODEL= # Embedding model for semantic moderation cache (optional; enables near-duplicate text reuse to save LLM calls)
|
# AI_LLM_EMBEDDING_MODEL= # Embedding model for semantic moderation cache (optional; enables near-duplicate text reuse to save LLM calls)
|
||||||
# AI_LLM_EMBEDDING_MIN_SIMILARITY=0.97 # Min cosine similarity to reuse a cached verdict (default: 0.97)
|
# AI_LLM_EMBEDDING_MIN_SIMILARITY=0.97 # Min cosine similarity to reuse a cached verdict (default: 0.97)
|
||||||
@@ -134,4 +134,7 @@ RETENTION_DRY_RUN=true # Dry-run: log but do not delete (defaul
|
|||||||
AUTO_MIGRATE_ON_STARTUP=true # Run database migrations on startup (default: true)
|
AUTO_MIGRATE_ON_STARTUP=true # Run database migrations on startup (default: true)
|
||||||
|
|
||||||
# === Worker Pool ===
|
# === Worker Pool ===
|
||||||
# PISCINA_MAX_THREADS=4 # Worker thread pool size (optional, defaults to CPU cores)
|
# Text and media AI-analysis batches run on separate Piscina pools so a slow
|
||||||
|
# image/vision batch can never queue-block fast text-only batches.
|
||||||
|
# PISCINA_MAX_THREADS=4 # Text-analysis worker pool size (optional, defaults to CPU cores)
|
||||||
|
# PISCINA_MEDIA_MAX_THREADS=2 # Media-analysis worker pool size (optional, default 2)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
name: Dependabot Auto-Merge
|
||||||
|
on: pull_request
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
auto-merge:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.actor == 'dependabot[bot]'
|
||||||
|
steps:
|
||||||
|
- name: Enable auto-merge for Dependabot PR
|
||||||
|
run: gh pr merge --auto --merge "$PR_URL"
|
||||||
|
env:
|
||||||
|
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/services/backend"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
groups:
|
||||||
|
production:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types: ["minor", "patch"]
|
||||||
|
development:
|
||||||
|
dependency-type: "development"
|
||||||
|
update-types: ["minor", "patch"]
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/services/discord-gateway"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
groups:
|
||||||
|
production:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types: ["minor", "patch"]
|
||||||
|
development:
|
||||||
|
dependency-type: "development"
|
||||||
|
update-types: ["minor", "patch"]
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/services/frontend"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
groups:
|
||||||
|
production:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types: ["minor", "patch"]
|
||||||
|
development:
|
||||||
|
dependency-type: "development"
|
||||||
|
update-types: ["minor", "patch"]
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
name: Publish to FlakeHub
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, master]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
flakehub-publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- uses: DeterminateSystems/determinate-nix-action@main
|
|
||||||
- uses: DeterminateSystems/flakehub-push@main
|
|
||||||
with:
|
|
||||||
visibility: public
|
|
||||||
rolling: true
|
|
||||||
+6
-2
@@ -1,7 +1,9 @@
|
|||||||
node_modules
|
node_modules
|
||||||
/recordings
|
/recordings
|
||||||
.env
|
.env
|
||||||
dist/
|
services/discord-gateway/dist/
|
||||||
|
services/backend/dist/
|
||||||
|
services/frontend/frontend/dist/
|
||||||
public/app/
|
public/app/
|
||||||
.muxer-queue.**
|
.muxer-queue.**
|
||||||
.claude/
|
.claude/
|
||||||
@@ -10,7 +12,6 @@ logs/
|
|||||||
.codegraph/
|
.codegraph/
|
||||||
worktrees/
|
worktrees/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
services/frontend/frontend/dist/
|
|
||||||
target/
|
target/
|
||||||
nix/
|
nix/
|
||||||
# Gitea CI runner logs
|
# Gitea CI runner logs
|
||||||
@@ -18,3 +19,6 @@ nix/
|
|||||||
|
|
||||||
# Nix build result symlink
|
# Nix build result symlink
|
||||||
result
|
result
|
||||||
|
|
||||||
|
# Playwright MCP artifacts
|
||||||
|
.playwright-mcp/
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Spec: Simpan pesan NSFW tanpa analisis AI (Request 1)
|
||||||
|
|
||||||
|
Date: 2026-08-30
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Saat ini GMW **skip capture** untuk pesan di channel age-restricted/NSFW
|
||||||
|
(`messageCapture.ts` baris 291 & 310 memanggil `isAgeRestrictedMessage` lalu
|
||||||
|
`return`). User ingin pesan NSFW tetap **disimpan** ke database (jadi terlihat
|
||||||
|
di dashboard), tetapi **tidak dianalisis AI** (tidak dipanggil LLM).
|
||||||
|
|
||||||
|
## Behavior target
|
||||||
|
1. Pesan NSFW/age-restricted di `messageCreate` → DISIMPAN (capture normal).
|
||||||
|
2. Pesan NSFW di `messageUpdate`/`messageDelete` → tetap diproses seperti pesan
|
||||||
|
biasa (edit/deleted state tercatat).
|
||||||
|
3. AI analysis TIDAK berjalan untuk pesan NSFW. Sudah ada jalur yang benar:
|
||||||
|
`queueMessageAnalysis` → `isAgeRestrictedMessage(message)` →
|
||||||
|
`buildAgeRestrictedSkipResult()` yang menulis `status=clean` + flag
|
||||||
|
`age_restricted` + `action=none` TANPA memanggil LLM. Jalur ini sudah ada dan
|
||||||
|
dipakai di `aiAnalyzer.queueMessageAnalysis` (baris 72-84) dan
|
||||||
|
`batchProcessor.skipAgeRestrictedMessages`. Jadi tinggal membuka capture.
|
||||||
|
4. Konten NSFW TIDAK masuk ke Qdrant public archive (semantic search publik).
|
||||||
|
`archiveMessageEmbedded` harus di-guard untuk pesan age-restricted.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
- `services/discord-gateway/src/modules/message-capture/messageCapture.ts`
|
||||||
|
- Hapus guard `isAgeRestrictedMessage` di `messageCreate` (baris 291) dan
|
||||||
|
`messageUpdate` (baris 310). Jangan hapus guard `isExcludedThread`,
|
||||||
|
`isBotExcludedChannel`, `shouldCaptureForAnyTarget`.
|
||||||
|
- `services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts`
|
||||||
|
- `archiveMessageEmbedded` terima flag/cek metadata age-restricted → skip
|
||||||
|
embed untuk NSFW.
|
||||||
|
- Call-site di `messageCapture.ts` `captureMessage` (baris 218) pass isNSFW
|
||||||
|
atau cek dulu.
|
||||||
|
|
||||||
|
## Schema/type changes
|
||||||
|
- TIDAK ada perubahan DB schema. Metadata pesan sudah membawa `channel.nsfw`
|
||||||
|
(`getMessageLocation`). Tidak perlu kolom baru — flag `age_restricted` sudah
|
||||||
|
ditulis ke `ai_moderation_flags` lewat skip-result.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `pnpm typecheck`, `pnpm lint` (biome), `pnpm build` di `services/discord-gateway`.
|
||||||
|
- Unit test: pastikan ada test untuk age-restricted skip (sudah ada
|
||||||
|
`tests/conversationContext.test.ts` ref nsfw; cek apakah ada test sesuai).
|
||||||
|
- Deploy via GHA push; verify pesan NSFW muncul di DB dan `ai_status` = clean
|
||||||
|
dengan flag age_restricted, dan TIDAK ada panggilan LLM (log llm-caller tidak
|
||||||
|
menampilkan id pesan NSFW).
|
||||||
|
|
||||||
|
## Status
|
||||||
|
- Request 1: DONE & DEPLOYED (commit 50967f64, CI green, gateway restart 20:17 WIB).
|
||||||
|
- Request 2 (video): dicatat, belum dikerjakan — lihat section di bawah.
|
||||||
|
|
||||||
|
## Request 2 (video) — NOT in this change
|
||||||
|
Fitur record video kamera/screenshare orang lain. @discordjs/voice hanya
|
||||||
|
mendukung **audio** receive. Video orang lain butuh WebRTC viewer baru
|
||||||
|
(SDP answer, decrypt H264/VP8, decode frame, mux MP4/WebM). Diluar scope
|
||||||
|
changeset ini; dicatat untuk desain lanjutan.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# 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`.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# 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)?
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# 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,61 @@
|
|||||||
|
# GMW — Fix mobile navbar "tidak bisa pindah halaman" (root cause)
|
||||||
|
|
||||||
|
## Context / symptom
|
||||||
|
- User (phone, mobile viewport <md): pressing the bottom mobile nav items does NOT
|
||||||
|
navigate ("Masih sama, walau sudah ditekan tidak pindah halaman").
|
||||||
|
- Previous fix (commits ae41f64e, 4d0bbed5) repositioned the chatbot FAB above the
|
||||||
|
nav and expanded nav to all 7 items — but the bug persisted.
|
||||||
|
|
||||||
|
## Root cause (verified live 2026-08-28)
|
||||||
|
Reproduced on the LIVE site (`imphnen.asepharyana.my.id`) with Playwright @375px:
|
||||||
|
1. **Next `<Link>` client-side navigation is dead app-wide.** Clicking a mobile nav
|
||||||
|
`<Link>` fires the click (event reaches document, `inLink=true`,
|
||||||
|
`defaultPrevented=false`) but **the URL never changes**. `window.next.router.push('/voice')`
|
||||||
|
also does nothing. The desktop NavRail navigates only because it uses a **plain
|
||||||
|
`<a href>`** (full browser navigation bypasses the broken router).
|
||||||
|
2. **A React hydration failure (#418 "server rendered text didn't match the client")**
|
||||||
|
is thrown on live pages — the only console error. Cause: relative-time text
|
||||||
|
(`formatRelativeTime(e.edited_at)` / `formatRelativeTime(m.created_at)` in the
|
||||||
|
SSR-seeded messages/edit-history feed uses `Date.now()`; server and client
|
||||||
|
render slightly different text → hydration mismatch → React re-renders, and the
|
||||||
|
Next client router ends up non-functional.
|
||||||
|
|
||||||
|
## Why desktop "worked" / mobile didn't
|
||||||
|
- `NavRail` = plain `<a href>` → **hard** navigation (works).
|
||||||
|
- `MobileNav` = Next `<Link>` → **client** navigation → dead router.
|
||||||
|
User's instruction "ikuti cara kerja sidebar" = make mobile nav behave like the
|
||||||
|
sidebar (plain anchors).
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
### 1. `src/components/shell/mobile-nav.tsx`
|
||||||
|
Replace Next `<Link>` with a **plain `<a href>`** (mirrors NavRail). Keep:
|
||||||
|
`usePathname` for active-state styling + `scrollIntoView` for snap-to-active.
|
||||||
|
Drop the now-unused `Link` import (biome import-order — run biome check --write).
|
||||||
|
|
||||||
|
### 2. Hydration root cause — `formatRelativeTime` in SSR-seeded feeds
|
||||||
|
Add `suppressHydrationWarning` to the timestamp elements in the SSR-seeded
|
||||||
|
components that render live-relative text so server/client text drift no longer
|
||||||
|
throws #418:
|
||||||
|
- `src/components/EditHistory.tsx` (line ~111 `edited {formatRelativeTime(...)}`)
|
||||||
|
- `src/app/(dashboard)/messages/view.tsx` MessageRow channel/time (line ~580) +
|
||||||
|
semantic row (line ~364)
|
||||||
|
- `src/components/LiveModerationFeed.tsx` (line ~139)
|
||||||
|
- (recordings/view.tsx, TermGlossary, ChannelCultureGlossary, CategoryDrilldown,
|
||||||
|
chatbot — chatbot is client-after-mount; add where SSR-seeded.)
|
||||||
|
|
||||||
|
NOTE: `suppressHydrationWarning` is safe for these single-text spans; the values
|
||||||
|
are cosmetic and self-correct on the next interval/render.
|
||||||
|
|
||||||
|
## Verification (non-prod, NEVER 4017)
|
||||||
|
- `pnpm format` + `pnpm lint` (biome) + `pnpm build` clean.
|
||||||
|
- Smoke on :4024 (fresh `next dev`): mobile click on nav item actually navigates
|
||||||
|
(URL changes) AND no React #418 in console.
|
||||||
|
- After push: `gh run watch`, then live `imphnen.asepharyana.my.id` @375px: nav tap
|
||||||
|
navigates, console free of #418.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- services/frontend/src/components/shell/mobile-nav.tsx
|
||||||
|
- services/frontend/src/components/EditHistory.tsx
|
||||||
|
- services/frontend/src/app/(dashboard)/messages/view.tsx
|
||||||
|
- services/frontend/src/components/LiveModerationFeed.tsx
|
||||||
|
- (others only if #418 persists)
|
||||||
@@ -82,6 +82,17 @@
|
|||||||
[ "$d" = "node_modules" ] && continue; \
|
[ "$d" = "node_modules" ] && continue; \
|
||||||
grep -qF ".pnpm/$d" $TMPDIR/prod-pnms.txt || rm -rf "$d"; \
|
grep -qF ".pnpm/$d" $TMPDIR/prod-pnms.txt || rm -rf "$d"; \
|
||||||
done ) || true
|
done ) || true
|
||||||
|
# Drop runtime-dead packages that still land in the prod graph:
|
||||||
|
# - `@types/*` (pure TypeScript declarations) get pulled in as
|
||||||
|
# REAL dependencies by type-aware deps (discord-api-types ->
|
||||||
|
# @types/node, pg-protocol -> @types/pg, ...) even though nothing
|
||||||
|
# ever `require`s them at runtime. Safe to strip.
|
||||||
|
# - `opusscript` is only a pure-JS fallback Opus engine that
|
||||||
|
# prism-media's loader uses IF `@discordjs/opus` (native, always
|
||||||
|
# present/prebuilt) fails to load. Since the native engine loads,
|
||||||
|
# opusscript is never executed — dead weight pulled in via
|
||||||
|
# discord.js-selfbot-v13's dependency. Strip it too.
|
||||||
|
( cd node_modules/.pnpm && rm -rf @types+* opusscript@* 2>/dev/null ) || true
|
||||||
# Drop symlinks whose .pnpm target was pruned (top-level, scoped dirs,
|
# Drop symlinks whose .pnpm target was pruned (top-level, scoped dirs,
|
||||||
# hoist, .bin — any depth). Mirrors stdenv's noBrokenSymlinks check,
|
# hoist, .bin — any depth). Mirrors stdenv's noBrokenSymlinks check,
|
||||||
# which would otherwise fail the fixupPhase.
|
# which would otherwise fail the fixupPhase.
|
||||||
@@ -132,14 +143,17 @@ WRAPPER
|
|||||||
|
|
||||||
src = ./services/discord-gateway;
|
src = ./services/discord-gateway;
|
||||||
|
|
||||||
|
# cmake + rust/cargo were inherited for node-datachannel-style native
|
||||||
|
# deps — that's 9router/omniroute, NOT GMW. GMW's only native deps
|
||||||
|
# are @discordjs/opus + sharp, both PREBUILT (node-pre-gyp / @img
|
||||||
|
# libvips download), so no cmake or rust toolchain is needed.
|
||||||
|
# python3/gnumake/gcc stay as node-gyp fallback for @discordjs/opus.
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
nodejs pnpm
|
nodejs pnpm
|
||||||
pkgs.python3 pkgs.gnumake pkgs.gcc pkgs.cmake
|
pkgs.python3 pkgs.gnumake pkgs.gcc
|
||||||
pkgs.rustc pkgs.cargo
|
|
||||||
pkgs.pkg-config
|
pkgs.pkg-config
|
||||||
pkgs.openssl
|
pkgs.openssl
|
||||||
pkgs.openssl.dev
|
pkgs.openssl.dev
|
||||||
pkgs.git # for any FetchContent-based deps during native builds
|
|
||||||
pkgs.cacert
|
pkgs.cacert
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -16,26 +16,26 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/voice": "^0.19.2",
|
"@discordjs/voice": "^0.19.2",
|
||||||
"@orpc/server": "1.15.0",
|
"@orpc/server": "1.15.0",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.20.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.11.0",
|
"ioredis": "^6.0.0",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.23.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^10.3.1",
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
"ws": "^8.20.1",
|
"ws": "^8.21.3",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "latest",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^25.9.0",
|
"@types/node": "^26.4.0",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.23.1",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"tsx": "^4.22.2",
|
"tsx": "^4.23.13",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^7.0.2",
|
||||||
"vitest": "latest"
|
"vitest": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+496
-297
File diff suppressed because it is too large.
Load diff
@@ -26,13 +26,25 @@ class ChatbotService {
|
|||||||
// longer bake server stats into the prompt — the model must pull current
|
// longer bake server stats into the prompt — the model must pull current
|
||||||
// data via tools (see buildSystemPrompt), so it always answers from live
|
// data via tools (see buildSystemPrompt), so it always answers from live
|
||||||
// numbers instead of a stale snapshot.
|
// numbers instead of a stale snapshot.
|
||||||
const scope = {
|
//
|
||||||
|
// If the current request doesn't carry a guild/channel scope (e.g. a
|
||||||
|
// cross-server dashboard surface), fall back to the most recent scope the
|
||||||
|
// user was chatting in from history, so the agent doesn't drift to the
|
||||||
|
// wrong server between turns.
|
||||||
|
const explicitScope = {
|
||||||
guildId: context?.guildId,
|
guildId: context?.guildId,
|
||||||
channelId: context?.channelId,
|
channelId: context?.channelId,
|
||||||
};
|
};
|
||||||
|
const scope =
|
||||||
|
explicitScope.guildId || explicitScope.channelId
|
||||||
|
? explicitScope
|
||||||
|
: (recentContext.lastScope ?? {
|
||||||
|
guildId: undefined,
|
||||||
|
channelId: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
const systemPrompt = this.buildSystemPrompt(scope);
|
const systemPrompt = this.buildSystemPrompt(scope);
|
||||||
const conversationHistory = this.buildHistoryMessages(recentContext);
|
const conversationHistory = this.buildHistoryMessages(recentContext.turns);
|
||||||
const llmResponse = await this.callLLM(
|
const llmResponse = await this.callLLM(
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
conversationHistory,
|
conversationHistory,
|
||||||
@@ -61,14 +73,28 @@ class ChatbotService {
|
|||||||
await chatbotRepository.clearChatHistory(userId);
|
await chatbotRepository.clearChatHistory(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getRecentConversationContext(
|
private async getRecentConversationContext(userId: string): Promise<{
|
||||||
userId: string,
|
turns: string[];
|
||||||
): Promise<string[]> {
|
lastScope: { guildId?: string; channelId?: string } | null;
|
||||||
const history = await chatbotRepository.getChatHistory(userId, 3);
|
}> {
|
||||||
return history.flatMap((row) => [
|
const history = await chatbotRepository.getChatHistory(userId, 8);
|
||||||
`User: ${row.user_message}`,
|
const turns: string[] = [];
|
||||||
`Bot: ${row.bot_response}`,
|
let lastScope: { guildId?: string; channelId?: string } | null = null;
|
||||||
]);
|
for (const row of history) {
|
||||||
|
turns.push(`User: ${row.user_message}`, `Bot: ${row.bot_response}`);
|
||||||
|
// Capture the most recent scope this user was chatting in, so the
|
||||||
|
// agent keeps server context across turns even if the current request
|
||||||
|
// doesn't carry one.
|
||||||
|
const g = row.context?.guildId;
|
||||||
|
const c = row.context?.channelId;
|
||||||
|
if (g || c) {
|
||||||
|
lastScope = {
|
||||||
|
guildId: g ?? undefined,
|
||||||
|
channelId: c ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { turns, lastScope };
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildSystemPrompt(scope: {
|
private buildSystemPrompt(scope: {
|
||||||
@@ -82,11 +108,27 @@ class ChatbotService {
|
|||||||
|
|
||||||
${scopeLine}
|
${scopeLine}
|
||||||
|
|
||||||
|
TOOLS YANG TERSEDIA (panggil saat user tanya soal data server):
|
||||||
|
- get_server_stats → statistik server: total pesan, user aktif, flagged/warn/clean
|
||||||
|
- get_top_channels → channel paling aktif (jumlah pesan terbanyak)
|
||||||
|
- get_recent_activity → pesan terbaru (siapa, channel mana, jam berapa, isinya)
|
||||||
|
- get_top_flagged → pesan yang di-flag AI (alasan, severity, analysis)
|
||||||
|
- search_messages → cari pesan berdasarkan kata kunci (LIKE search)
|
||||||
|
- get_user_messages → pesan terbaru dari satu user tertentu
|
||||||
|
- get_user_profile → profil AI dari seorang user (pola perilaku, gaya bicara)
|
||||||
|
- get_channel_culture → norma/slang dari suatu channel
|
||||||
|
- get_message_detail → 1 pesan lengkap beserta hasil analisis AI-nya
|
||||||
|
- get_message_reviews → antrean review moderasi manual
|
||||||
|
- get_voice_recordings → rekaman suara terbaru
|
||||||
|
- get_moderation_timeline → tren harian: total vs flagged vs warn vs clean
|
||||||
|
- get_corrections → riwayat koreksi false-positive AI
|
||||||
|
|
||||||
ATURAN PENTING — JANGAN PAKAI KONTEKS STATIS:
|
ATURAN PENTING — JANGAN PAKAI KONTEKS STATIS:
|
||||||
- Kamu TIDAK punya hafalan soal angka server (jumlah pesan, user aktif, flagged, dll). JANGAN tebak atau karang angka.
|
- Kamu TIDAK punya hafalan soal angka server. JANGAN tebak atau karang angka.
|
||||||
- Untuk SEMUA pertanyaan soal data server (jumlah pesan, user aktif, channel ramai, aktivitas terbaru, pesan di-flag), WAJIB panggil tool yang sesuai (get_server_stats, get_top_channels, get_recent_activity, get_top_flagged). Jawab HANYA dari hasil tool.
|
- Untuk SEMUA pertanyaan soal data server, WAJIB panggil tool yang sesuai. Jawab HANYA dari hasil tool.
|
||||||
- Tool otomatis di-scope ke guild/channel di atas — kalau argumen guildId/channelId kosong, biarkan kosong (sudah otomatis ter-isi). Jangan isi ID yang kamu tebak.
|
- Tool otomatis di-scope ke guild/channel — kalau argumen kosong, biarkan kosong. Jangan isi ID tebakan.
|
||||||
- Kalau tool balas error atau kosong, bilang aja data lagi ga ketemu, jangan karang.
|
- Kalau tool balik error atau kosong, bilang aja data lagi ga ketemu, jangan karang.
|
||||||
|
- Boleh panggil banyak tool dalam satu jawaban kalau pertanyaan butuh beberapa data (multi-hop).
|
||||||
|
|
||||||
Gaya ngobrol:
|
Gaya ngobrol:
|
||||||
- Santai, hangat, kayak ngobrol sama temen
|
- Santai, hangat, kayak ngobrol sama temen
|
||||||
@@ -127,27 +169,31 @@ Gaya ngobrol:
|
|||||||
try {
|
try {
|
||||||
const { default: axios } = await import("axios");
|
const { default: axios } = await import("axios");
|
||||||
|
|
||||||
// Gateway tidak handle role system — gabung konteks ke user message.
|
const scopeHint = scope.guildId
|
||||||
// The system section stays visible to the model as the first user turn.
|
? ` (scope: guild ${scope.guildId}${scope.channelId ? `, channel ${scope.channelId}` : ""})`
|
||||||
const contextPrefixed = `${systemPrompt}\n\nPertanyaan user: ${userMessage}`;
|
: "";
|
||||||
|
// Local message type that models the shapes the agent loop emits and
|
||||||
// Seed conversation: prior turns + current question.
|
// accepts: plain system/user/assistant turns and assistant tool-calls +
|
||||||
const messages: Array<
|
// tool results. role is a union string so history entries typed as
|
||||||
| { role: "user" | "assistant"; content: string }
|
// `"user" | "assistant"` don't break the discriminant.
|
||||||
| {
|
type ChatMsg = {
|
||||||
role: "assistant";
|
role: "system" | "user" | "assistant" | "tool";
|
||||||
content: string | null;
|
content?: string | null;
|
||||||
tool_calls: Array<{
|
tool_call_id?: string;
|
||||||
id: string;
|
tool_calls?: Array<{
|
||||||
type: "function";
|
id: string;
|
||||||
function: { name: string; arguments: string };
|
type: "function";
|
||||||
}>;
|
function: { name: string; arguments: string };
|
||||||
}
|
}>;
|
||||||
| { role: "tool"; tool_call_id: string; content: string }
|
};
|
||||||
> = [...history, { role: "user", content: contextPrefixed }];
|
const messages: ChatMsg[] = [
|
||||||
|
{ role: "system", content: systemPrompt },
|
||||||
|
...history,
|
||||||
|
{ role: "user", content: `${userMessage}${scopeHint}` },
|
||||||
|
];
|
||||||
|
|
||||||
// ── Agentic tool loop ─────────────────────────────────────────
|
// ── Agentic tool loop ─────────────────────────────────────────
|
||||||
const MAX_TOOL_ROUNDS = 4;
|
const MAX_TOOL_ROUNDS = 6;
|
||||||
for (let round = 0; round <= MAX_TOOL_ROUNDS; round += 1) {
|
for (let round = 0; round <= MAX_TOOL_ROUNDS; round += 1) {
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${baseUrl}/chat/completions`,
|
`${baseUrl}/chat/completions`,
|
||||||
@@ -158,7 +204,7 @@ Gaya ngobrol:
|
|||||||
tool_choice: "auto",
|
tool_choice: "auto",
|
||||||
max_tokens: 600,
|
max_tokens: 600,
|
||||||
temperature: 0.4,
|
temperature: 0.4,
|
||||||
// Non-streaming: request a single complete response. 9router may
|
// Non-streaming: request a single complete response. omniroute may
|
||||||
// still emit SSE even with stream:false, so the parser below
|
// still emit SSE even with stream:false, so the parser below
|
||||||
// handle both raw-JSON and SSE bodies.
|
// handle both raw-JSON and SSE bodies.
|
||||||
stream: false,
|
stream: false,
|
||||||
@@ -176,7 +222,7 @@ Gaya ngobrol:
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parse the body into content + tool_calls. 9router may return either
|
// Parse the body into content + tool_calls. omniroute may return either
|
||||||
// a single JSON object (stream:false honored) or SSE text (stream
|
// a single JSON object (stream:false honored) or SSE text (stream
|
||||||
// implied) — parseResponse handles both.
|
// implied) — parseResponse handles both.
|
||||||
const { content, toolCalls } = this.parseResponse(
|
const { content, toolCalls } = this.parseResponse(
|
||||||
@@ -193,35 +239,44 @@ Gaya ngobrol:
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (toolCalls.length > 0) {
|
if (toolCalls.length > 0) {
|
||||||
// Execute each tool, append tool results, continue loop.
|
// Executor for a single tool call: pushes the assistant tool_call,
|
||||||
for (const tc of toolCalls) {
|
// runs the tool, returns { tc, result } so results can be appended
|
||||||
messages.push({
|
// in order after all tools execute in parallel.
|
||||||
role: "assistant",
|
const executions = await Promise.all(
|
||||||
content: null,
|
toolCalls.map(async (tc) => {
|
||||||
tool_calls: [
|
messages.push({
|
||||||
{
|
role: "assistant",
|
||||||
id: tc.id,
|
content: null,
|
||||||
type: "function",
|
tool_calls: [
|
||||||
function: { name: tc.name, arguments: tc.arguments },
|
{
|
||||||
},
|
id: tc.id,
|
||||||
],
|
type: "function",
|
||||||
});
|
function: { name: tc.name, arguments: tc.arguments },
|
||||||
// Auto-scope: if the model omitted guildId/channelId, fill them
|
},
|
||||||
// from the request scope so tools query the right server without
|
],
|
||||||
// the model having to guess IDs.
|
});
|
||||||
const scopedArgs = { ...tc.args };
|
// Auto-scope: if the model omitted guildId/channelId, fill them
|
||||||
if (scope.guildId && scopedArgs.guildId == null) {
|
// from the request scope so tools query the right server without
|
||||||
scopedArgs.guildId = scope.guildId;
|
// the model having to guess IDs.
|
||||||
}
|
const scopedArgs = { ...tc.args };
|
||||||
if (scope.channelId && scopedArgs.channelId == null) {
|
if (scope.guildId && scopedArgs.guildId == null) {
|
||||||
scopedArgs.channelId = scope.channelId;
|
scopedArgs.guildId = scope.guildId;
|
||||||
}
|
}
|
||||||
let result = "";
|
if (scope.channelId && scopedArgs.channelId == null) {
|
||||||
try {
|
scopedArgs.channelId = scope.channelId;
|
||||||
result = await executeTool(tc.name, scopedArgs);
|
}
|
||||||
} catch (e) {
|
let result = "";
|
||||||
result = `Tool error: ${(e as Error).message}`;
|
try {
|
||||||
}
|
result = await executeTool(tc.name, scopedArgs);
|
||||||
|
} catch (e) {
|
||||||
|
result = `Tool error: ${(e as Error).message}`;
|
||||||
|
}
|
||||||
|
return { tc, result };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Append tool results in the SAME order as the tool_calls so the
|
||||||
|
// API's function-calling contract isn't violated by reordering.
|
||||||
|
for (const { tc, result } of executions) {
|
||||||
messages.push({
|
messages.push({
|
||||||
role: "tool",
|
role: "tool",
|
||||||
tool_call_id: tc.id,
|
tool_call_id: tc.id,
|
||||||
@@ -252,7 +307,7 @@ Gaya ngobrol:
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse an LLM HTTP body into content + tool_calls. Handles both shapes
|
* Parse an LLM HTTP body into content + tool_calls. Handles both shapes
|
||||||
* 9router can return: a single JSON object (stream:false honored) or SSE
|
* omniroute can return: a single JSON object (stream:false honored) or SSE
|
||||||
* text (stream implied). For SSE we delegate to parseSse.
|
* text (stream implied). For SSE we delegate to parseSse.
|
||||||
*/
|
*/
|
||||||
private parseResponse(body: string): {
|
private parseResponse(body: string): {
|
||||||
@@ -306,7 +361,7 @@ Gaya ngobrol:
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse an SSE stream body into accumulated content + any tool_calls.
|
* Parse an SSE stream body into accumulated content + any tool_calls.
|
||||||
* 9router (and most OpenAI-compatible routers) emit `data: {json}` lines
|
* omniroute (and most OpenAI-compatible routers) emit `data: {json}` lines
|
||||||
* even when stream is only implied; we must collect deltas manually.
|
* even when stream is only implied; we must collect deltas manually.
|
||||||
*/
|
*/
|
||||||
private parseSse(body: string): {
|
private parseSse(body: string): {
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export const tools: ToolDef[] = [
|
|||||||
function: {
|
function: {
|
||||||
name: "get_user_reputation",
|
name: "get_user_reputation",
|
||||||
description:
|
description:
|
||||||
"Ambil skor trust, jumlah infraction, dan streak pesan bersih seorang user dari user_reputations. Untuk 'berapa trust score si A?' / riwayat pelanggaran. Butuh user_id.",
|
"Cek status skor reputasi per-user. CATATAN: fitur trust score per-user telah dihapus dari sistem — tool ini menjawab 'unavailable' dan menyarankan rasio flag vs total sebagai pengganti.",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { and, desc, eq, like, sql } from "drizzle-orm";
|
import { and, desc, eq, like, sql } from "drizzle-orm";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { getDatabase } from "../../shared/database/index.js";
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
import {
|
import {
|
||||||
pgChannelCulturesTable,
|
pgChannelCulturesTable,
|
||||||
@@ -6,7 +7,6 @@ import {
|
|||||||
pgMessageReviewsTable,
|
pgMessageReviewsTable,
|
||||||
pgMessagesTable,
|
pgMessagesTable,
|
||||||
pgUserProfilesTable,
|
pgUserProfilesTable,
|
||||||
pgUserReputationsTable,
|
|
||||||
pgVoiceRecordingsTable,
|
pgVoiceRecordingsTable,
|
||||||
} from "../../shared/index.js";
|
} from "../../shared/index.js";
|
||||||
|
|
||||||
@@ -20,6 +20,20 @@ import {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type ToolResult = string;
|
export type ToolResult = string;
|
||||||
|
|
||||||
|
const logger = createChildLogger("chatbot.tools");
|
||||||
|
|
||||||
|
/** Saturating counter of chatbot tool execution errors (for observability). */
|
||||||
|
export let toolErrors = 0;
|
||||||
|
const TOOL_ERROR_CAP = 1000;
|
||||||
|
|
||||||
|
function toolExecError(err: unknown, name: string): string {
|
||||||
|
if (toolErrors < TOOL_ERROR_CAP) toolErrors++;
|
||||||
|
const detail = (err as Error)?.message ?? "unknown";
|
||||||
|
logger.warn({ tool: name, error: detail }, "Chatbot tool execution failed");
|
||||||
|
return `Tool ${name} gagal: ${detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Executes a tool call against the real DB and returns a readable result. */
|
/** Executes a tool call against the real DB and returns a readable result. */
|
||||||
export async function executeTool(
|
export async function executeTool(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -91,7 +105,7 @@ export async function executeTool(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Best-effort: if a tool fails, return readable error instead of crashing
|
// Best-effort: if a tool fails, return readable error instead of crashing
|
||||||
return `Terjadi kesalahan saat ambil data: ${(error as Error).message ?? "unknown"}`;
|
return toolExecError(error, name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,26 +296,18 @@ async function userProfile(userId?: string, guildId?: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function userReputation(
|
async function userReputation(
|
||||||
userId?: string,
|
_userId?: string,
|
||||||
guildId?: string,
|
_guildId?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const db = getDatabase();
|
// The per-user reputation feature (trust scores, streaks, infractions) was
|
||||||
if (!userId) return JSON.stringify({ error: "userId wajib" });
|
// removed from the gateway (migration 0016 drops user_reputations). Return
|
||||||
const conds = [eq(pgUserReputationsTable.user_id, userId)];
|
// an honest "unavailable" answer from the derived moderation signals instead
|
||||||
if (guildId) conds.push(eq(pgUserReputationsTable.guild_id, guildId));
|
// of querying the now-dropped table.
|
||||||
const rows = await db
|
return JSON.stringify({
|
||||||
.select({
|
available: false,
|
||||||
user_id: pgUserReputationsTable.user_id,
|
message:
|
||||||
guild_id: pgUserReputationsTable.guild_id,
|
"Skor trust/skala reputasi per-user telah dihapus dari sistem. Gunakan rasio pesan ter-flag vs total untuk menilai risiko (lihat dashboard Users).",
|
||||||
trust_score: pgUserReputationsTable.trust_score,
|
});
|
||||||
clean_message_streak: pgUserReputationsTable.clean_message_streak,
|
|
||||||
total_infractions: pgUserReputationsTable.total_infractions,
|
|
||||||
last_infraction_at: pgUserReputationsTable.last_infraction_at,
|
|
||||||
})
|
|
||||||
.from(pgUserReputationsTable)
|
|
||||||
.where(and(...conds))
|
|
||||||
.limit(1);
|
|
||||||
return JSON.stringify(rows[0] ?? { error: "reputasi tidak ditemukan" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function channelCulture(channelId?: string): Promise<string> {
|
async function channelCulture(channelId?: string): Promise<string> {
|
||||||
|
|||||||
@@ -155,6 +155,8 @@ export class DashboardRepository {
|
|||||||
p.profile_summary,
|
p.profile_summary,
|
||||||
m.total_messages,
|
m.total_messages,
|
||||||
m.flagged_count,
|
m.flagged_count,
|
||||||
|
m.clean_count,
|
||||||
|
m.warn_count,
|
||||||
m.last_message_at
|
m.last_message_at
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -163,6 +165,8 @@ export class DashboardRepository {
|
|||||||
avatar_url,
|
avatar_url,
|
||||||
COUNT(*)::int AS total_messages,
|
COUNT(*)::int AS total_messages,
|
||||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count,
|
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warn_count,
|
||||||
MAX(created_at) AS last_message_at
|
MAX(created_at) AS last_message_at
|
||||||
FROM ${pgMessagesTable}
|
FROM ${pgMessagesTable}
|
||||||
GROUP BY user_id, username, avatar_url
|
GROUP BY user_id, username, avatar_url
|
||||||
@@ -182,6 +186,8 @@ export class DashboardRepository {
|
|||||||
profile_summary: r.profile_summary as string | null,
|
profile_summary: r.profile_summary as string | null,
|
||||||
total_messages: Number(r.total_messages),
|
total_messages: Number(r.total_messages),
|
||||||
flagged_count: Number(r.flagged_count),
|
flagged_count: Number(r.flagged_count),
|
||||||
|
clean_count: Number(r.clean_count),
|
||||||
|
warn_count: Number(r.warn_count),
|
||||||
last_message_at: r.last_message_at ? Number(r.last_message_at) : null,
|
last_message_at: r.last_message_at ? Number(r.last_message_at) : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -429,6 +435,7 @@ export class DashboardRepository {
|
|||||||
m.total_messages,
|
m.total_messages,
|
||||||
m.flagged_count,
|
m.flagged_count,
|
||||||
m.clean_count,
|
m.clean_count,
|
||||||
|
m.warn_count,
|
||||||
p.profile_summary,
|
p.profile_summary,
|
||||||
p.last_analyzed_at
|
p.last_analyzed_at
|
||||||
FROM (
|
FROM (
|
||||||
@@ -438,7 +445,8 @@ export class DashboardRepository {
|
|||||||
avatar_url,
|
avatar_url,
|
||||||
COUNT(*)::int AS total_messages,
|
COUNT(*)::int AS total_messages,
|
||||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count,
|
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count,
|
||||||
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count
|
COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count,
|
||||||
|
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warn_count
|
||||||
FROM ${pgMessagesTable}
|
FROM ${pgMessagesTable}
|
||||||
WHERE user_id = ${userId}
|
WHERE user_id = ${userId}
|
||||||
GROUP BY user_id, username, avatar_url
|
GROUP BY user_id, username, avatar_url
|
||||||
@@ -466,6 +474,7 @@ export class DashboardRepository {
|
|||||||
total_messages: Number(row.total_messages),
|
total_messages: Number(row.total_messages),
|
||||||
flagged_count: Number(row.flagged_count),
|
flagged_count: Number(row.flagged_count),
|
||||||
clean_count: Number(row.clean_count),
|
clean_count: Number(row.clean_count),
|
||||||
|
warn_count: Number(row.warn_count),
|
||||||
profile_summary: row.profile_summary as string | null,
|
profile_summary: row.profile_summary as string | null,
|
||||||
last_analyzed_at: row.last_analyzed_at
|
last_analyzed_at: row.last_analyzed_at
|
||||||
? Number(row.last_analyzed_at)
|
? Number(row.last_analyzed_at)
|
||||||
|
|||||||
@@ -3,6 +3,26 @@ import { createChildLogger } from "@/shared/logger/index";
|
|||||||
|
|
||||||
const logger = createChildLogger("messages-embed");
|
const logger = createChildLogger("messages-embed");
|
||||||
|
|
||||||
|
/** Max chars for a search query fed to the embedding model. */
|
||||||
|
const MAX_QUERY_CHARS = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a user search query before embedding so it lands in the same
|
||||||
|
* vector space as the archived content (which is normalized the same way on
|
||||||
|
* write). Mirrors the gateway's normalizer: strip control/zero-width chars,
|
||||||
|
* lowercase, collapse whitespace, cap length. Readable punctuation is kept —
|
||||||
|
* a search query is already compact.
|
||||||
|
*/
|
||||||
|
export function normalizeEmbeddingQuery(raw: string): string {
|
||||||
|
if (!raw) return "";
|
||||||
|
return raw
|
||||||
|
.replace(/[\p{Cc}\p{Cf}]/gu, " ")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.slice(0, MAX_QUERY_CHARS);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Embed a search query with the configured OpenAI-compatible embedding model.
|
* Embed a search query with the configured OpenAI-compatible embedding model.
|
||||||
* Uses raw fetch (the backend has no openai SDK dependency) and returns null
|
* Uses raw fetch (the backend has no openai SDK dependency) and returns null
|
||||||
@@ -10,8 +30,10 @@ const logger = createChildLogger("messages-embed");
|
|||||||
*
|
*
|
||||||
* encoding_format: "float" is REQUIRED — Nvidia-backed models reject base64.
|
* encoding_format: "float" is REQUIRED — Nvidia-backed models reject base64.
|
||||||
*/
|
*/
|
||||||
export async function embedQuery(text: string): Promise<number[] | null> {
|
export async function embedQuery(rawQuery: string): Promise<number[] | null> {
|
||||||
if (!config.AI_LLM_API_KEY || !config.AI_LLM_EMBEDDING_MODEL) return null;
|
if (!config.AI_LLM_API_KEY || !config.AI_LLM_EMBEDDING_MODEL) return null;
|
||||||
|
const text = normalizeEmbeddingQuery(rawQuery);
|
||||||
|
if (!text) return null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${config.AI_LLM_BASE_URL}/embeddings`, {
|
const res = await fetch(`${config.AI_LLM_BASE_URL}/embeddings`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -469,17 +469,20 @@ export class MessagesRepository {
|
|||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
const result = await db.execute(sql`
|
const result = await db.execute(sql`
|
||||||
SELECT channel_id,
|
SELECT
|
||||||
EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour,
|
m.channel_id,
|
||||||
COUNT(*)::int AS c
|
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
|
||||||
FROM messages
|
EXTRACT(HOUR FROM to_timestamp(m.created_at / 1000))::int AS hour,
|
||||||
WHERE created_at >= ${since}
|
COUNT(*)::int AS c
|
||||||
GROUP BY channel_id, hour
|
FROM messages m
|
||||||
ORDER BY channel_id, hour
|
WHERE m.created_at >= ${since}
|
||||||
|
GROUP BY m.channel_id, channel_name, hour
|
||||||
|
ORDER BY channel_name, hour
|
||||||
`);
|
`);
|
||||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
channelId: String(r.channel_id ?? "unknown"),
|
channelId: String(r.channel_id ?? "unknown"),
|
||||||
|
channelName: String(r.channel_name ?? r.channel_id ?? "unknown"),
|
||||||
hour: Number(r.hour ?? 0),
|
hour: Number(r.hour ?? 0),
|
||||||
count: Number(r.c ?? 0),
|
count: Number(r.c ?? 0),
|
||||||
}));
|
}));
|
||||||
@@ -491,31 +494,28 @@ export class MessagesRepository {
|
|||||||
*/
|
*/
|
||||||
async getRecentEdits(limit = 50, channelId?: string) {
|
async getRecentEdits(limit = 50, channelId?: string) {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const where = channelId
|
const result = await db.execute(sql`
|
||||||
? `WHERE m.channel_id = '${channelId.replace(/'/g, "''")}'`
|
SELECT
|
||||||
: "";
|
e.id,
|
||||||
const result = await db.execute(
|
e.message_id,
|
||||||
sql.raw(`
|
e.old_content,
|
||||||
SELECT
|
e.edited_at,
|
||||||
e.id,
|
m.channel_id,
|
||||||
e.message_id,
|
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
|
||||||
e.old_content,
|
m.username,
|
||||||
e.edited_at,
|
COALESCE(m.edited_content, m.content) AS new_content
|
||||||
m.channel_id,
|
FROM message_edits e
|
||||||
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
|
JOIN messages m ON m.id = e.message_id
|
||||||
m.username
|
${channelId ? sql`WHERE m.channel_id = ${channelId}` : sql``}
|
||||||
FROM message_edits e
|
ORDER BY e.edited_at DESC
|
||||||
JOIN messages m ON m.id = e.message_id
|
LIMIT ${limit}
|
||||||
${where}
|
`);
|
||||||
ORDER BY e.edited_at DESC
|
|
||||||
LIMIT ${limit}
|
|
||||||
`),
|
|
||||||
);
|
|
||||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
id: String(r.id),
|
id: String(r.id),
|
||||||
message_id: String(r.message_id),
|
message_id: String(r.message_id),
|
||||||
old_content: r.old_content ? String(r.old_content) : "",
|
old_content: r.old_content ? String(r.old_content) : "",
|
||||||
|
new_content: r.new_content ? String(r.new_content) : "",
|
||||||
edited_at: r.edited_at ? Number(r.edited_at) : 0,
|
edited_at: r.edited_at ? Number(r.edited_at) : 0,
|
||||||
channel_id: r.channel_id ? String(r.channel_id) : null,
|
channel_id: r.channel_id ? String(r.channel_id) : null,
|
||||||
channel_name: r.channel_name ? String(r.channel_name) : null,
|
channel_name: r.channel_name ? String(r.channel_name) : null,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { config } from "@/shared/config/index";
|
||||||
import { NotFoundError, ValidationError } from "@/shared/errors/index";
|
import { NotFoundError, ValidationError } from "@/shared/errors/index";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { embedQuery } from "./embed.js";
|
import { embedQuery } from "./embed.js";
|
||||||
@@ -39,12 +40,15 @@ export class MessagesService {
|
|||||||
throw new ValidationError("message ID is required");
|
throw new ValidationError("message ID is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = await messagesRepository.findById(id);
|
const [message, editHistory] = await Promise.all([
|
||||||
|
messagesRepository.findById(id),
|
||||||
|
messagesRepository.getEditHistory(id),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!message) {
|
if (!message) {
|
||||||
throw new NotFoundError(`Message with ID ${id} not found`);
|
throw new NotFoundError(`Message with ID ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const editHistory = await messagesRepository.getEditHistory(id);
|
|
||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
edit_count: editHistory.length,
|
edit_count: editHistory.length,
|
||||||
@@ -97,7 +101,12 @@ export class MessagesService {
|
|||||||
);
|
);
|
||||||
return { results: [], nextCursor: null };
|
return { results: [], nextCursor: null };
|
||||||
}
|
}
|
||||||
const hits = await searchArchive(vector, input.limit, 0.6);
|
const hits = await searchArchive(
|
||||||
|
vector,
|
||||||
|
input.limit,
|
||||||
|
config.AI_LLM_EMBEDDING_ARCHIVE_MIN_SIMILARITY,
|
||||||
|
input.guildId,
|
||||||
|
);
|
||||||
const results = hits.map((h) => mapSearchHit(h));
|
const results = hits.map((h) => mapSearchHit(h));
|
||||||
return { results, nextCursor: null };
|
return { results, nextCursor: null };
|
||||||
}
|
}
|
||||||
@@ -112,16 +121,35 @@ export class MessagesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shape returned to the frontend (text + metadata from the archive payload). */
|
/** Shape returned to the frontend (text + rich metadata from the archive payload). */
|
||||||
function mapSearchHit(hit: {
|
function mapSearchHit(hit: {
|
||||||
score: number;
|
score: number;
|
||||||
payload: { text: string; content_hash?: string; analyzed_at: number };
|
payload: {
|
||||||
|
text: string;
|
||||||
|
content_hash?: string;
|
||||||
|
analyzed_at: number;
|
||||||
|
username?: string;
|
||||||
|
channel_id?: string;
|
||||||
|
guild_id?: string;
|
||||||
|
thread_id?: string | null;
|
||||||
|
channel_name?: string | null;
|
||||||
|
thread_name?: string | null;
|
||||||
|
created_at?: number;
|
||||||
|
};
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
message_id: hit.payload.content_hash ?? null,
|
message_id: hit.payload.content_hash ?? null,
|
||||||
content: hit.payload.text,
|
content: hit.payload.text,
|
||||||
score: hit.score,
|
score: hit.score,
|
||||||
created_at: hit.payload.analyzed_at,
|
// Prefer the real message timestamp; fall back to embed time for old
|
||||||
|
// points that predate rich metadata.
|
||||||
|
created_at: hit.payload.created_at ?? hit.payload.analyzed_at,
|
||||||
|
username: hit.payload.username ?? null,
|
||||||
|
channel_id: hit.payload.channel_id ?? null,
|
||||||
|
guild_id: hit.payload.guild_id ?? null,
|
||||||
|
thread_id: hit.payload.thread_id ?? null,
|
||||||
|
channel_name: hit.payload.channel_name ?? null,
|
||||||
|
thread_name: hit.payload.thread_name ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ export interface ArchiveHit {
|
|||||||
content_hash?: string;
|
content_hash?: string;
|
||||||
analyzed_at: number;
|
analyzed_at: number;
|
||||||
expires_at: number;
|
expires_at: number;
|
||||||
|
username?: string;
|
||||||
|
channel_id?: string;
|
||||||
|
guild_id?: string;
|
||||||
|
thread_id?: string | null;
|
||||||
|
channel_name?: string | null;
|
||||||
|
thread_name?: string | null;
|
||||||
|
created_at?: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +68,7 @@ export async function searchArchive(
|
|||||||
vector: number[],
|
vector: number[],
|
||||||
limit: number,
|
limit: number,
|
||||||
scoreThreshold: number,
|
scoreThreshold: number,
|
||||||
|
guildId?: string,
|
||||||
): Promise<ArchiveHit[]> {
|
): Promise<ArchiveHit[]> {
|
||||||
if (!config.QDRANT_URL) return [];
|
if (!config.QDRANT_URL) return [];
|
||||||
try {
|
try {
|
||||||
@@ -72,6 +80,17 @@ export async function searchArchive(
|
|||||||
limit,
|
limit,
|
||||||
score_threshold: scoreThreshold,
|
score_threshold: scoreThreshold,
|
||||||
with_payload: true,
|
with_payload: true,
|
||||||
|
// Optional scope: only return vectors from a specific guild's archive.
|
||||||
|
// Old points (embedded before rich metadata) have no guild_id payload —
|
||||||
|
// the `must` match simply excludes them, which is the correct behavior
|
||||||
|
// for a guild-scoped search.
|
||||||
|
...(guildId
|
||||||
|
? {
|
||||||
|
filter: {
|
||||||
|
must: [{ key: "guild_id", match: { value: guildId } }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
)) as {
|
)) as {
|
||||||
result?: Array<{
|
result?: Array<{
|
||||||
|
|||||||
@@ -124,7 +124,8 @@ export class ModerationRepository {
|
|||||||
a.score,
|
a.score,
|
||||||
a.evidence,
|
a.evidence,
|
||||||
a.policy_version,
|
a.policy_version,
|
||||||
m.username,
|
a.username,
|
||||||
|
a.server_nick,
|
||||||
LEFT(m.content, 300) AS content
|
LEFT(m.content, 300) AS content
|
||||||
FROM moderation_actions a
|
FROM moderation_actions a
|
||||||
LEFT JOIN messages m ON m.id = a.message_id
|
LEFT JOIN messages m ON m.id = a.message_id
|
||||||
@@ -155,6 +156,7 @@ export class ModerationRepository {
|
|||||||
evidence: parseJsonArray(r.evidence),
|
evidence: parseJsonArray(r.evidence),
|
||||||
policy_version: r.policy_version ? String(r.policy_version) : null,
|
policy_version: r.policy_version ? String(r.policy_version) : null,
|
||||||
username: r.username ? String(r.username) : null,
|
username: r.username ? String(r.username) : null,
|
||||||
|
server_nick: r.server_nick ? String(r.server_nick) : null,
|
||||||
content: r.content ? String(r.content) : null,
|
content: r.content ? String(r.content) : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -314,7 +316,7 @@ export class ModerationRepository {
|
|||||||
SELECT
|
SELECT
|
||||||
a.id, a.message_id, a.user_id, a.guild_id, a.action_type,
|
a.id, a.message_id, a.user_id, a.guild_id, a.action_type,
|
||||||
a.reason, a.status, a.created_at, a.severity, a.confidence, a.score,
|
a.reason, a.status, a.created_at, a.severity, a.confidence, a.score,
|
||||||
m.username, LEFT(m.content, 300) AS content
|
a.username, LEFT(m.content, 300) AS content
|
||||||
FROM moderation_actions a
|
FROM moderation_actions a
|
||||||
LEFT JOIN messages m ON m.id = a.message_id
|
LEFT JOIN messages m ON m.id = a.message_id
|
||||||
WHERE a.created_at >= ${since}
|
WHERE a.created_at >= ${since}
|
||||||
@@ -338,6 +340,7 @@ export class ModerationRepository {
|
|||||||
confidence: r.confidence != null ? Number(r.confidence) : null,
|
confidence: r.confidence != null ? Number(r.confidence) : null,
|
||||||
score: r.score != null ? Number(r.score) : null,
|
score: r.score != null ? Number(r.score) : null,
|
||||||
username: r.username ? String(r.username) : null,
|
username: r.username ? String(r.username) : null,
|
||||||
|
server_nick: r.server_nick ? String(r.server_nick) : null,
|
||||||
content: r.content ? String(r.content) : null,
|
content: r.content ? String(r.content) : null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import { and, desc, eq, lt, type SQL } from "drizzle-orm";
|
import {
|
||||||
|
and,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
gte,
|
||||||
|
ilike,
|
||||||
|
lt,
|
||||||
|
lte,
|
||||||
|
or,
|
||||||
|
type SQL,
|
||||||
|
sql,
|
||||||
|
} from "drizzle-orm";
|
||||||
import { getDatabase } from "../../shared/database/index.js";
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
import { pgVoiceRecordingsTable } from "../../shared/index.js";
|
import { pgVoiceRecordingsTable } from "../../shared/index.js";
|
||||||
import { createChildLogger } from "../../shared/logger/index.js";
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
@@ -20,6 +31,7 @@ export interface RecordingRow {
|
|||||||
upload_error: string | null;
|
upload_error: string | null;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
uploaded_at: number | null;
|
uploaded_at: number | null;
|
||||||
|
transcription: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedRecordings {
|
export interface PaginatedRecordings {
|
||||||
@@ -28,27 +40,67 @@ export interface PaginatedRecordings {
|
|||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RecordingFilters {
|
||||||
|
channelId?: string;
|
||||||
|
userId?: string;
|
||||||
|
cursor?: string;
|
||||||
|
/** keyword search against transcription + username (ILIKE) */
|
||||||
|
q?: string;
|
||||||
|
/** created_at lower bound (epoch ms) */
|
||||||
|
startDate?: number;
|
||||||
|
/** created_at upper bound (epoch ms) */
|
||||||
|
endDate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeakerSummary {
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
clips: number;
|
||||||
|
/** summed MP3 bytes; ~128kbps → est duration in seconds */
|
||||||
|
est_duration_s: number;
|
||||||
|
words: number;
|
||||||
|
transcribed: number;
|
||||||
|
last_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class RecordingsService {
|
export class RecordingsService {
|
||||||
async getRecent(
|
async getRecent(
|
||||||
limit = 50,
|
limit = 50,
|
||||||
filters?: { channelId?: string; userId?: string; cursor?: string },
|
filters: RecordingFilters = {},
|
||||||
): Promise<PaginatedRecordings> {
|
): Promise<PaginatedRecordings> {
|
||||||
logger.info({ limit }, "getRecent called");
|
logger.info({ limit, filters }, "getRecent called");
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
const conditions: SQL[] = [];
|
const conditions: SQL[] = [];
|
||||||
|
|
||||||
if (filters?.cursor) {
|
if (filters.cursor) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
lt(pgVoiceRecordingsTable.created_at, Number(filters.cursor)),
|
lt(pgVoiceRecordingsTable.created_at, Number(filters.cursor)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (filters?.channelId) {
|
if (filters.channelId) {
|
||||||
conditions.push(eq(pgVoiceRecordingsTable.channel_id, filters.channelId));
|
conditions.push(eq(pgVoiceRecordingsTable.channel_id, filters.channelId));
|
||||||
}
|
}
|
||||||
if (filters?.userId) {
|
if (filters.userId) {
|
||||||
conditions.push(eq(pgVoiceRecordingsTable.user_id, filters.userId));
|
conditions.push(eq(pgVoiceRecordingsTable.user_id, filters.userId));
|
||||||
}
|
}
|
||||||
|
if (filters.q) {
|
||||||
|
const like = `%${filters.q}%`;
|
||||||
|
const qOr = or(
|
||||||
|
ilike(pgVoiceRecordingsTable.transcription, like),
|
||||||
|
ilike(pgVoiceRecordingsTable.username, like),
|
||||||
|
);
|
||||||
|
if (qOr) conditions.push(qOr);
|
||||||
|
}
|
||||||
|
if (filters.startDate) {
|
||||||
|
conditions.push(
|
||||||
|
gte(pgVoiceRecordingsTable.created_at, filters.startDate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filters.endDate) {
|
||||||
|
conditions.push(lte(pgVoiceRecordingsTable.created_at, filters.endDate));
|
||||||
|
}
|
||||||
|
|
||||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
|
|
||||||
@@ -68,6 +120,7 @@ export class RecordingsService {
|
|||||||
upload_error: pgVoiceRecordingsTable.upload_error,
|
upload_error: pgVoiceRecordingsTable.upload_error,
|
||||||
created_at: pgVoiceRecordingsTable.created_at,
|
created_at: pgVoiceRecordingsTable.created_at,
|
||||||
uploaded_at: pgVoiceRecordingsTable.uploaded_at,
|
uploaded_at: pgVoiceRecordingsTable.uploaded_at,
|
||||||
|
transcription: pgVoiceRecordingsTable.transcription,
|
||||||
})
|
})
|
||||||
.from(pgVoiceRecordingsTable)
|
.from(pgVoiceRecordingsTable)
|
||||||
.where(where)
|
.where(where)
|
||||||
@@ -89,6 +142,43 @@ export class RecordingsService {
|
|||||||
.delete(pgVoiceRecordingsTable)
|
.delete(pgVoiceRecordingsTable)
|
||||||
.where(eq(pgVoiceRecordingsTable.id, id));
|
.where(eq(pgVoiceRecordingsTable.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speaker leaderboard: aggregate clips / est. duration / transcribed words
|
||||||
|
* per user. `est_duration_s` derives from summed MP3 bytes at the uploader's
|
||||||
|
* 128 kbps transcode rate; `words` sums transcription word counts.
|
||||||
|
*/
|
||||||
|
async getSummary(): Promise<SpeakerSummary[]> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const t = pgVoiceRecordingsTable;
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
user_id: t.user_id,
|
||||||
|
username: t.username,
|
||||||
|
avatar_url: t.avatar_url,
|
||||||
|
clips: sql<number>`count(*)::int`,
|
||||||
|
total_bytes: sql<number>`coalesce(sum(${t.size_bytes}), 0)`,
|
||||||
|
words: sql<number>`coalesce(sum(array_length(string_to_array(${t.transcription}, ' '), 1)), 0)::int`,
|
||||||
|
transcribed: sql<number>`count(${t.transcription})::int`,
|
||||||
|
last_at: sql<number>`max(${t.created_at})`,
|
||||||
|
})
|
||||||
|
.from(t)
|
||||||
|
.groupBy(t.user_id, t.username, t.avatar_url)
|
||||||
|
.orderBy(sql`clips desc, last_at desc`);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
user_id: r.user_id,
|
||||||
|
username: r.username,
|
||||||
|
avatar_url: r.avatar_url,
|
||||||
|
clips: r.clips,
|
||||||
|
// 128 kbps = 128000 bps → bytes*8/128000 = seconds
|
||||||
|
est_duration_s: Math.round((r.total_bytes * 8) / 128000),
|
||||||
|
words: r.words,
|
||||||
|
transcribed: r.transcribed,
|
||||||
|
last_at: r.last_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const recordingsService = new RecordingsService();
|
export const recordingsService = new RecordingsService();
|
||||||
@@ -23,14 +23,26 @@ const speakers = new Map<string, LiveSpeaker>();
|
|||||||
const MAX_SPEAKERS = 200;
|
const MAX_SPEAKERS = 200;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record a voice_active_user event. `speaking: true` upserts the speaker as
|
* Speakers inactive for longer than this are auto-expired from the snapshot.
|
||||||
* active; `speaking: false` marks them inactive while keeping them for the
|
* This handles the case where the gateway disconnects abruptly and never
|
||||||
* activity timeline.
|
* sends `speaking: false` for active users.
|
||||||
*/
|
*/
|
||||||
|
const SPEAKER_TTL_MS = 30_000;
|
||||||
|
|
||||||
|
/** Purge speakers that haven't been active recently. */
|
||||||
|
function purgeStale(): void {
|
||||||
|
const cutoff = Date.now() - SPEAKER_TTL_MS;
|
||||||
|
for (const [id, s] of speakers) {
|
||||||
|
if (!s.speaking && s.lastActiveAt < cutoff) {
|
||||||
|
speakers.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* recordSpeaker(data) — apply a `voice_active_user` event. `speaking: true`
|
* Record a voice_active_user event. `speaking: true` upserts the speaker as
|
||||||
* upserts the speaker as ACTIVE; `speaking: false` marks them inactive while
|
* active; `speaking: false` marks them inactive while keeping them briefly
|
||||||
* keeping them for the activity timeline.
|
* for the activity timeline (until TTL expiry).
|
||||||
*/
|
*/
|
||||||
export function recordSpeaker(data: {
|
export function recordSpeaker(data: {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -49,7 +61,6 @@ export function recordSpeaker(data: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (speakers.size >= MAX_SPEAKERS && !existing) {
|
if (speakers.size >= MAX_SPEAKERS && !existing) {
|
||||||
// Drop the least-recently-active non-speaking speaker to stay bounded.
|
|
||||||
let oldestId: string | null = null;
|
let oldestId: string | null = null;
|
||||||
let oldestTs = Infinity;
|
let oldestTs = Infinity;
|
||||||
for (const [id, s] of speakers) {
|
for (const [id, s] of speakers) {
|
||||||
@@ -65,18 +76,35 @@ export function recordSpeaker(data: {
|
|||||||
speakers.set(userId, speaker);
|
speakers.set(userId, speaker);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All known speakers, most recently active first. */
|
/**
|
||||||
|
* All recently-active speakers, most recently active first.
|
||||||
|
* Stale (non-speaking + old) entries are auto-purged.
|
||||||
|
*/
|
||||||
export function getActiveSpeakers(): LiveSpeaker[] {
|
export function getActiveSpeakers(): LiveSpeaker[] {
|
||||||
|
purgeStale();
|
||||||
return [...speakers.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
return [...speakers.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only speakers currently flagged as speaking. */
|
/** Only speakers currently flagged as speaking. */
|
||||||
export function getSpeakingSpeakers(): LiveSpeaker[] {
|
export function getSpeakingSpeakers(): LiveSpeaker[] {
|
||||||
|
purgeStale();
|
||||||
return [...speakers.values()]
|
return [...speakers.values()]
|
||||||
.filter((s) => s.speaking)
|
.filter((s) => s.speaking)
|
||||||
.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark ALL tracked speakers as not-speaking and purge stale ones.
|
||||||
|
* Called when the gateway disconnects from voice — ensures the authoritative
|
||||||
|
* snapshot doesn't carry ghost speakers.
|
||||||
|
*/
|
||||||
|
export function clearAllSpeakers(): void {
|
||||||
|
for (const [, s] of speakers) {
|
||||||
|
s.speaking = false;
|
||||||
|
}
|
||||||
|
purgeStale();
|
||||||
|
}
|
||||||
|
|
||||||
/** Drop all tracked speakers (used on backend restart). */
|
/** Drop all tracked speakers (used on backend restart). */
|
||||||
export function resetLiveSpeakers(): void {
|
export function resetLiveSpeakers(): void {
|
||||||
speakers.clear();
|
speakers.clear();
|
||||||
|
|||||||
@@ -296,6 +296,9 @@ const recordingsRouter = {
|
|||||||
channelId: z.string().optional(),
|
channelId: z.string().optional(),
|
||||||
userId: z.string().optional(),
|
userId: z.string().optional(),
|
||||||
cursor: z.string().optional(),
|
cursor: z.string().optional(),
|
||||||
|
q: z.string().optional(),
|
||||||
|
startDate: z.coerce.number().int().optional(),
|
||||||
|
endDate: z.coerce.number().int().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.handler(({ input }) =>
|
.handler(({ input }) =>
|
||||||
@@ -303,12 +306,16 @@ const recordingsRouter = {
|
|||||||
channelId: input.channelId,
|
channelId: input.channelId,
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
cursor: input.cursor,
|
cursor: input.cursor,
|
||||||
|
q: input.q,
|
||||||
|
startDate: input.startDate,
|
||||||
|
endDate: input.endDate,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
delete: os.input(z.object({ id: z.string() })).handler(async ({ input }) => {
|
delete: os.input(z.object({ id: z.string() })).handler(async ({ input }) => {
|
||||||
await recordingsService.deleteById(input.id);
|
await recordingsService.deleteById(input.id);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
|
summary: os.handler(async () => recordingsService.getSummary()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Analysis (search) ──────────────────────────────────────────────
|
// ── Analysis (search) ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -131,10 +131,18 @@ export const configSchema = z
|
|||||||
AI_LLM_BASE_URL: z
|
AI_LLM_BASE_URL: z
|
||||||
.string()
|
.string()
|
||||||
.url()
|
.url()
|
||||||
.default("https://9router.asepharyana.my.id/v1"),
|
.default("http://100.121.180.82:20128/api/v1"),
|
||||||
AI_LLM_MODEL: z.string().default("text"),
|
AI_LLM_MODEL: z.string().default("text"),
|
||||||
AI_LLM_VISION_MODEL: z.string().optional(),
|
AI_LLM_VISION_MODEL: z.string().optional(),
|
||||||
AI_LLM_EMBEDDING_MODEL: z.string().optional(),
|
AI_LLM_EMBEDDING_MODEL: z.string().optional(),
|
||||||
|
// Minimum cosine similarity for the public archive semantic search. Lower
|
||||||
|
// = more (noisier) results; raise it to tighten precision. Tuned for a 1B
|
||||||
|
// embedding model — re-tune if the model's dimensionality changes.
|
||||||
|
AI_LLM_EMBEDDING_ARCHIVE_MIN_SIMILARITY: z.coerce
|
||||||
|
.number()
|
||||||
|
.min(0)
|
||||||
|
.max(1)
|
||||||
|
.default(0.6),
|
||||||
AI_LLM_MAX_CONCURRENT: z.coerce.number().int().positive().default(5),
|
AI_LLM_MAX_CONCURRENT: z.coerce.number().int().positive().default(5),
|
||||||
AI_LLM_IMAGE_MAX_DIMENSION: z.coerce
|
AI_LLM_IMAGE_MAX_DIMENSION: z.coerce
|
||||||
.number()
|
.number()
|
||||||
@@ -193,6 +201,7 @@ export const configSchema = z
|
|||||||
.positive()
|
.positive()
|
||||||
.default(50),
|
.default(50),
|
||||||
PISCINA_MAX_THREADS: z.coerce.number().int().positive().optional(),
|
PISCINA_MAX_THREADS: z.coerce.number().int().positive().optional(),
|
||||||
|
PISCINA_MEDIA_MAX_THREADS: z.coerce.number().int().positive().optional(),
|
||||||
|
|
||||||
// ── Voice Transcription ────────────────────────────────────────────────
|
// ── Voice Transcription ────────────────────────────────────────────────
|
||||||
AI_VOICE_TRANSCRIPTION_ENABLED: z
|
AI_VOICE_TRANSCRIPTION_ENABLED: z
|
||||||
|
|||||||
@@ -337,32 +337,6 @@ export const pgUserProfilesTable = pgTable(
|
|||||||
|
|
||||||
export const userProfilesTable = pgUserProfilesTable;
|
export const userProfilesTable = pgUserProfilesTable;
|
||||||
|
|
||||||
/**
|
|
||||||
* User Reputations Table (PostgreSQL)
|
|
||||||
* Tracks user trust score and infractions to provide context to AI.
|
|
||||||
*/
|
|
||||||
export const pgUserReputationsTable = pgTable(
|
|
||||||
"user_reputations",
|
|
||||||
{
|
|
||||||
user_id: pgText("user_id").primaryKey(),
|
|
||||||
guild_id: pgText("guild_id").notNull(),
|
|
||||||
trust_score: pgInteger("trust_score").notNull().default(50),
|
|
||||||
clean_message_streak: pgInteger("clean_message_streak")
|
|
||||||
.notNull()
|
|
||||||
.default(0),
|
|
||||||
total_infractions: pgInteger("total_infractions").notNull().default(0),
|
|
||||||
last_infraction_at: pgBigint("last_infraction_at", { mode: "number" }),
|
|
||||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
|
||||||
updated_at: pgBigint("updated_at", { mode: "number" }).notNull(),
|
|
||||||
},
|
|
||||||
(table) => ({
|
|
||||||
guildIdx: pgIndex("idx_user_reputations_guild_id").on(table.guild_id),
|
|
||||||
scoreIdx: pgIndex("idx_user_reputations_trust_score").on(table.trust_score),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const userReputationsTable = pgUserReputationsTable;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Channel Cultures Table (PostgreSQL)
|
* Channel Cultures Table (PostgreSQL)
|
||||||
* Stores AI-generated summaries of channel norms and slang to inject as context.
|
* Stores AI-generated summaries of channel norms and slang to inject as context.
|
||||||
@@ -563,10 +537,6 @@ export type AIAnalysisRunInsert = typeof aiAnalysisRunsTable.$inferInsert;
|
|||||||
export type UserProfile = typeof userProfilesTable.$inferSelect;
|
export type UserProfile = typeof userProfilesTable.$inferSelect;
|
||||||
export type UserProfileInsert = typeof userProfilesTable.$inferInsert;
|
export type UserProfileInsert = typeof userProfilesTable.$inferInsert;
|
||||||
|
|
||||||
// User Reputations
|
|
||||||
export type UserReputation = typeof userReputationsTable.$inferSelect;
|
|
||||||
export type UserReputationInsert = typeof userReputationsTable.$inferInsert;
|
|
||||||
|
|
||||||
// Channel Cultures
|
// Channel Cultures
|
||||||
export type ChannelCulture = typeof channelCulturesTable.$inferSelect;
|
export type ChannelCulture = typeof channelCulturesTable.$inferSelect;
|
||||||
export type ChannelCultureInsert = typeof channelCulturesTable.$inferInsert;
|
export type ChannelCultureInsert = typeof channelCulturesTable.$inferInsert;
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
// Shared message row mapper for backend repository modules
|
// Shared message row mapper for backend repository modules
|
||||||
|
|
||||||
|
function resolveServerNick(row: Record<string, unknown>): string | null {
|
||||||
|
const metadata = row.metadata;
|
||||||
|
if (!metadata || typeof metadata !== "string") {
|
||||||
|
return (row.username as string | null) ?? null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(metadata) as {
|
||||||
|
member?: { displayName?: string | null } | null;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
parsed?.member?.displayName ?? (row.username as string | null) ?? null
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return (row.username as string | null) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface MappedMessage {
|
export interface MappedMessage {
|
||||||
id: string;
|
id: string;
|
||||||
guild_id: string;
|
guild_id: string;
|
||||||
@@ -7,6 +24,8 @@ export interface MappedMessage {
|
|||||||
thread_id: string | null;
|
thread_id: string | null;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
/** Member's server-specific display name (nickname), from metadata.member.displayName. Falls back to username when absent. */
|
||||||
|
server_nick: string | null;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
content: string;
|
content: string;
|
||||||
edited_content: string | null;
|
edited_content: string | null;
|
||||||
@@ -42,6 +61,7 @@ export function mapMessageRow(row: Record<string, unknown>): MappedMessage {
|
|||||||
thread_id: (row.thread_id as string | null) ?? null,
|
thread_id: (row.thread_id as string | null) ?? null,
|
||||||
user_id: String(row.user_id ?? ""),
|
user_id: String(row.user_id ?? ""),
|
||||||
username: String(row.username ?? ""),
|
username: String(row.username ?? ""),
|
||||||
|
server_nick: resolveServerNick(row),
|
||||||
avatar_url: (row.avatar_url as string | null) ?? null,
|
avatar_url: (row.avatar_url as string | null) ?? null,
|
||||||
content: String(row.content ?? ""),
|
content: String(row.content ?? ""),
|
||||||
edited_content: (row.edited_content as string | null) ?? null,
|
edited_content: (row.edited_content as string | null) ?? null,
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import Redis from "ioredis";
|
import Redis from "ioredis";
|
||||||
import { recordSpeaker } from "../modules/voice/live-speaker.js";
|
import {
|
||||||
|
clearAllSpeakers,
|
||||||
|
recordSpeaker,
|
||||||
|
} from "../modules/voice/live-speaker.js";
|
||||||
import { config } from "../shared/config/index.js";
|
import { config } from "../shared/config/index.js";
|
||||||
import {
|
import {
|
||||||
DISCORD_CHANNEL_TO_WS_EVENT,
|
DISCORD_CHANNEL_TO_WS_EVENT,
|
||||||
DISCORD_VOICE_ACTIVE_USER,
|
DISCORD_VOICE_ACTIVE_USER,
|
||||||
DISCORD_VOICE_PCM,
|
DISCORD_VOICE_PCM,
|
||||||
|
DISCORD_VOICE_STOPPED,
|
||||||
} from "../shared/index.js";
|
} from "../shared/index.js";
|
||||||
import { createChildLogger } from "../shared/logger/index.js";
|
import { createChildLogger } from "../shared/logger/index.js";
|
||||||
import { broadcastBinary, broadcastEvent } from "./broadcast.js";
|
import { broadcastBinary, broadcastEvent } from "./broadcast.js";
|
||||||
@@ -84,6 +88,14 @@ function handleSubscriptionMessage(channel: string, message: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the gateway stops voice recording (disconnects from voice channel),
|
||||||
|
// clear all speakers from the authoritative snapshot so frontends don't
|
||||||
|
// show ghost participants.
|
||||||
|
if (channel === DISCORD_VOICE_STOPPED) {
|
||||||
|
clearAllSpeakers();
|
||||||
|
logger.info("Voice recording stopped — cleared all live speakers");
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug({ channel, eventType }, "Broadcasting Redis event");
|
logger.debug({ channel, eventType }, "Broadcasting Redis event");
|
||||||
broadcastEvent(eventType, data);
|
broadcastEvent(eventType, data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,20 +77,26 @@ handles a whole batch (text + media split internally, parallel paths).
|
|||||||
|
|
||||||
- Main thread owns the LLM semaphore (`AI_LLM_MAX_CONCURRENT`, default 5) via
|
- Main thread owns the LLM semaphore (`AI_LLM_MAX_CONCURRENT`, default 5) via
|
||||||
`llmClient.withLlmConcurrency`.
|
`llmClient.withLlmConcurrency`.
|
||||||
- Piscina pool (`PISCINA_MAX_THREADS`, default 4) runs the heavy LLM work off
|
- Two Piscina pools run the heavy LLM work off the event loop: a text pool
|
||||||
the event loop; **each worker thread initializes its own pg Pool** (min 0,
|
(`PISCINA_MAX_THREADS`, default 4) and a dedicated media pool
|
||||||
grows to `POSTGRES_POOL_MAX`). See "Memory & connections" below.
|
(`PISCINA_MEDIA_MAX_THREADS`, default 2). A batch is routed to the media
|
||||||
|
pool if ANY of its messages carries an attachment/sticker/embed — this
|
||||||
|
keeps a slow image/vision batch from occupying every thread and blocking
|
||||||
|
unrelated text-only batches behind it. **Each worker thread (in either
|
||||||
|
pool) initializes its own pg Pool** (min 0, grows to `POSTGRES_POOL_MAX`).
|
||||||
|
See "Memory & connections" below.
|
||||||
|
|
||||||
## Memory & DB connections
|
## Memory & DB connections
|
||||||
|
|
||||||
`MemoryMax=1G` (raised from 512M — live RSS sits at ~500 MiB, peak 508 MiB,
|
`MemoryMax=1G` (raised from 512M — live RSS sits at ~500 MiB, peak 508 MiB,
|
||||||
so 512M left ~2% headroom and risked an OOM-kill restart). Host has 8 GB free.
|
so 512M left ~2% headroom and risked an OOM-kill restart). Host has 8 GB free.
|
||||||
|
|
||||||
`POSTGRES_POOL_MIN=0` (default). The gateway = main process + up to 4 Piscina
|
`POSTGRES_POOL_MIN=0` (default). The gateway = main process + up to 4 text
|
||||||
worker threads, each with its own pg Pool. With min:0 the pools stay empty
|
Piscina worker threads + up to 2 media Piscina worker threads, each with its
|
||||||
until a query runs and drop idle clients afterward, instead of holding
|
own pg Pool. With min:0 the pools stay empty until a query runs and drop
|
||||||
`(1 main + 4 workers) × 2 = 10` permanently-open idle connections against
|
idle clients afterward, instead of holding `(1 main + 4 text + 2 media) × 2
|
||||||
PgBouncer. The pool still grows on demand up to `POSTGRES_POOL_MAX`.
|
= 14` permanently-open idle connections against PgBouncer. The pool still
|
||||||
|
grows on demand up to `POSTGRES_POOL_MAX`.
|
||||||
|
|
||||||
## Event channels (Redis pub/sub)
|
## Event channels (Redis pub/sub)
|
||||||
|
|
||||||
@@ -139,5 +145,5 @@ pipeline gauges — `ai_analysis_queued_conversations`,
|
|||||||
numeric snowflake IDs never trigger false positives.
|
numeric snowflake IDs never trigger false positives.
|
||||||
- **Semantic cache is batched** (one embed call + one Qdrant batch search),
|
- **Semantic cache is batched** (one embed call + one Qdrant batch search),
|
||||||
not N sequential round-trips. `ensureQdrantCollection` is memoized.
|
not N sequential round-trips. `ensureQdrantCollection` is memoized.
|
||||||
- **Streaming is mandatory** against the 9router base URL (non-stream waits for
|
- **Streaming is mandatory** against the omniroute base URL (non-stream waits for
|
||||||
the full body and times out). `llmClient` aggregates SSE chunks.
|
the full body and times out). `llmClient` aggregates SSE chunks.
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE "moderation_actions" ADD COLUMN IF NOT EXISTS "username" text;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Backfill username from messages table for existing actions
|
||||||
|
UPDATE "moderation_actions" a
|
||||||
|
SET "username" = m."username"
|
||||||
|
FROM "messages" m
|
||||||
|
WHERE a."message_id" = m."id"
|
||||||
|
AND a."username" IS NULL
|
||||||
|
AND m."username" IS NOT NULL;
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "moderation_actions" ADD COLUMN IF NOT EXISTS "server_name" text;
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
-- Rename server_name (guild name) -> server_nick (member's server nickname)
|
||||||
|
-- server_name was added in 0018 but never populated (all NULL), so renaming
|
||||||
|
-- the empty column is safe and keeps retention semantics correct.
|
||||||
|
-- Idempotent across all possible prior states:
|
||||||
|
-- • server_name present, server_nick absent -> rename
|
||||||
|
-- • both present (backfill created server_nick) -> drop the stale server_name
|
||||||
|
-- • server_nick present only -> already done, no-op
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'moderation_actions' AND column_name = 'server_name'
|
||||||
|
) AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'moderation_actions' AND column_name = 'server_nick'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "moderation_actions" RENAME COLUMN "server_name" TO "server_nick";
|
||||||
|
ELSIF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'moderation_actions' AND column_name = 'server_name'
|
||||||
|
) THEN
|
||||||
|
-- server_nick already exists (e.g. backfilled out-of-band) — drop the
|
||||||
|
-- now-redundant server_name column instead of renaming into a conflict.
|
||||||
|
ALTER TABLE "moderation_actions" DROP COLUMN "server_name";
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Voice auto-reconnect: persist desired voice state per guild.
|
||||||
|
-- The gateway rejoins the same channel after restart/reboot and after an
|
||||||
|
-- unexpected voice drop (kick / server move / voice server restart).
|
||||||
|
-- Idempotent: skips if the table already exists.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_name = 'voice_auto_reconnect'
|
||||||
|
) THEN
|
||||||
|
CREATE TABLE "voice_auto_reconnect" (
|
||||||
|
"guild_id" text PRIMARY KEY NOT NULL,
|
||||||
|
"channel_id" text NOT NULL,
|
||||||
|
"channel_name" text,
|
||||||
|
"connected_at" bigint NOT NULL,
|
||||||
|
"updated_at" bigint NOT NULL
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -120,6 +120,34 @@
|
|||||||
"when": 1787185000000,
|
"when": 1787185000000,
|
||||||
"tag": "0016_drop_user_reputations",
|
"tag": "0016_drop_user_reputations",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787810000000,
|
||||||
|
"tag": "0017_add_username_to_moderation_actions",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787820000000,
|
||||||
|
"tag": "0018_add_server_name_to_moderation_actions",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787830000000,
|
||||||
|
"tag": "0019_rename_server_name_to_server_nick",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 20,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787860000000,
|
||||||
|
"tag": "0020_add_voice_auto_reconnect",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -22,36 +22,36 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/opus": "^0.10.0",
|
"@discordjs/opus": "^0.10.0",
|
||||||
"@discordjs/voice": "^0.19.2",
|
"@discordjs/voice": "file:vendor/discord-voice-fork",
|
||||||
"@snazzah/davey": "^0.1.11",
|
"@snazzah/davey": "^0.1.11",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.20.0",
|
||||||
"discord.js-selfbot-v13": "^3.7.1",
|
"discord.js-selfbot-v13": "^3.7.1",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"ioredis": "^5.11.0",
|
"ioredis": "^6.0.0",
|
||||||
"libsodium-wrappers": "^0.8.4",
|
"libsodium-wrappers": "^0.8.4",
|
||||||
"lru-cache": "^11.5.1",
|
"lru-cache": "^11.5.1",
|
||||||
"openai": "^6.38.0",
|
"openai": "^7.8.0",
|
||||||
"opusscript": "^0.0.8",
|
"opusscript": "^0.1.1",
|
||||||
"p-limit": "^7.3.0",
|
"p-limit": "^7.3.0",
|
||||||
"p-retry": "^8.0.0",
|
"p-retry": "^8.0.0",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.23.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^10.3.1",
|
||||||
"piscina": "^5.1.4",
|
"piscina": "^5.3.2",
|
||||||
"prism-media": "2.0.0-alpha.0",
|
"prism-media": "2.0.0-alpha.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.35.4",
|
||||||
"tiktoken": "^1.0.22",
|
"tiktoken": "^1.0.22",
|
||||||
"ws": "^8.20.1",
|
"ws": "^8.21.3",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "latest",
|
||||||
"@types/node": "^25.9.0",
|
"@types/node": "^26.4.0",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.23.1",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"tsx": "^4.22.2",
|
"tsx": "^4.23.13",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^7.0.2",
|
||||||
"vitest": "latest"
|
"vitest": "latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+697
-525
File diff suppressed because it is too large.
Load diff
@@ -7,7 +7,10 @@ import {
|
|||||||
getAnalysisQueueStatus,
|
getAnalysisQueueStatus,
|
||||||
startPendingAIAnalysisWorker,
|
startPendingAIAnalysisWorker,
|
||||||
} from "../modules/ai-moderation/aiAnalyzer.js";
|
} from "../modules/ai-moderation/aiAnalyzer.js";
|
||||||
import { workerPool } from "../modules/ai-moderation/circuitBreaker.js";
|
import {
|
||||||
|
mediaWorkerPool,
|
||||||
|
textWorkerPool,
|
||||||
|
} from "../modules/ai-moderation/circuitBreaker.js";
|
||||||
import { registerChannelTopicCapture } from "../modules/channel-topic/index.js";
|
import { registerChannelTopicCapture } from "../modules/channel-topic/index.js";
|
||||||
import { CommandHandler } from "../modules/command-handler/commandHandler.js";
|
import { CommandHandler } from "../modules/command-handler/commandHandler.js";
|
||||||
import {
|
import {
|
||||||
@@ -33,9 +36,14 @@ import { registerPresenceCapture } from "../modules/user-presence/index.js";
|
|||||||
import { VoicePcmWsClient } from "../modules/voice-pcm-ws/index.js";
|
import { VoicePcmWsClient } from "../modules/voice-pcm-ws/index.js";
|
||||||
import { startMuxerWorker } from "../modules/voice-recording/muxer.js";
|
import { startMuxerWorker } from "../modules/voice-recording/muxer.js";
|
||||||
import {
|
import {
|
||||||
|
registerSelfVoiceStateGuard,
|
||||||
setPcmWsClient,
|
setPcmWsClient,
|
||||||
setEventBroadcaster as setRecorderEventBroadcaster,
|
setEventBroadcaster as setRecorderEventBroadcaster,
|
||||||
} from "../modules/voice-recording/recorder.js";
|
} from "../modules/voice-recording/recorder.js";
|
||||||
|
import {
|
||||||
|
setVideoRecorderClient,
|
||||||
|
setVideoRecordingsDir,
|
||||||
|
} from "../modules/voice-recording/videoRecorder.js";
|
||||||
import { VoiceController } from "../modules/voice-recording/voiceController.js";
|
import { VoiceController } from "../modules/voice-recording/voiceController.js";
|
||||||
import { config } from "../shared/config/config.js";
|
import { config } from "../shared/config/config.js";
|
||||||
import {
|
import {
|
||||||
@@ -182,6 +190,12 @@ export async function initializeDiscordGateway() {
|
|||||||
const client = new Client(createDiscordClientOptions());
|
const client = new Client(createDiscordClientOptions());
|
||||||
const voiceController = new VoiceController(client);
|
const voiceController = new VoiceController(client);
|
||||||
|
|
||||||
|
// Wire the video recorder (others' camera/screen share) to the selfbot's
|
||||||
|
// native watch/receive stack + its recordings dir. Best-effort: failures are
|
||||||
|
// logged inside, never fatal.
|
||||||
|
setVideoRecorderClient(client);
|
||||||
|
setVideoRecordingsDir(config.RECORDINGS_DIR);
|
||||||
|
|
||||||
// Initialize Redis event broadcaster
|
// Initialize Redis event broadcaster
|
||||||
const redisPublisher = new RedisEventPublisher(config.REDIS_URL, logger);
|
const redisPublisher = new RedisEventPublisher(config.REDIS_URL, logger);
|
||||||
const eventBroadcaster = new EventBroadcaster(redisPublisher);
|
const eventBroadcaster = new EventBroadcaster(redisPublisher);
|
||||||
@@ -274,6 +288,23 @@ export async function initializeDiscordGateway() {
|
|||||||
commandHandler.start(client, voiceController);
|
commandHandler.start(client, voiceController);
|
||||||
logger.info("Command handler started");
|
logger.info("Command handler started");
|
||||||
|
|
||||||
|
// Rejoin persisted voice channels (auto-reconnect on restart/reboot).
|
||||||
|
// Non-fatal: failures are logged inside autoReconnect.
|
||||||
|
void voiceController
|
||||||
|
.autoReconnect()
|
||||||
|
.catch((err) =>
|
||||||
|
logger.warn(
|
||||||
|
{ err: err instanceof Error ? err.message : String(err) },
|
||||||
|
"Voice auto-reconnect on startup failed",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Attach the immediate self-undeafen/self-unmute guard so the bot reacts
|
||||||
|
// INSTANTLY when an admin server-mutes or server-deafens it (previously
|
||||||
|
// only re-asserted on video-watch/reconnect, leaving the bot muted for
|
||||||
|
// minutes).
|
||||||
|
registerSelfVoiceStateGuard(client);
|
||||||
|
|
||||||
// Start retention cleanup scheduler
|
// Start retention cleanup scheduler
|
||||||
startRetentionCleanup();
|
startRetentionCleanup();
|
||||||
// Start weekly moderation digest (public, automated)
|
// Start weekly moderation digest (public, automated)
|
||||||
@@ -375,12 +406,25 @@ export async function initializeDiscordGateway() {
|
|||||||
if (typeof status.lastError === "string") {
|
if (typeof status.lastError === "string") {
|
||||||
setGauge("ai_analysis_last_error_present", status.lastError ? 1 : 0);
|
setGauge("ai_analysis_last_error_present", status.lastError ? 1 : 0);
|
||||||
}
|
}
|
||||||
const pool = workerPool as unknown as {
|
type PoolState = { _poolState?: { size: number; active: number } };
|
||||||
_poolState?: { size: number; active: number };
|
const textPool = textWorkerPool as unknown as PoolState;
|
||||||
};
|
const mediaPool = mediaWorkerPool as unknown as PoolState;
|
||||||
if (pool._poolState) {
|
// Reported per queue (2026-08-31 text/media pool split) so the text
|
||||||
setGauge("ai_analysis_worker_threads", pool._poolState.size);
|
// and media backlogs are distinguishable in dashboards/alerts instead
|
||||||
setGauge("ai_analysis_worker_threads_active", pool._poolState.active);
|
// of one combined "worker threads" number.
|
||||||
|
if (textPool._poolState) {
|
||||||
|
setGauge("ai_analysis_worker_threads_text", textPool._poolState.size);
|
||||||
|
setGauge(
|
||||||
|
"ai_analysis_worker_threads_active_text",
|
||||||
|
textPool._poolState.active,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mediaPool._poolState) {
|
||||||
|
setGauge("ai_analysis_worker_threads_media", mediaPool._poolState.size);
|
||||||
|
setGauge(
|
||||||
|
"ai_analysis_worker_threads_active_media",
|
||||||
|
mediaPool._poolState.active,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn({ error: String(err) }, "AI metrics collector failed");
|
logger.warn({ error: String(err) }, "AI metrics collector failed");
|
||||||
|
|||||||
@@ -93,12 +93,6 @@ type BatchOkResponse = {
|
|||||||
ok: true;
|
ok: true;
|
||||||
conversationKey: string;
|
conversationKey: string;
|
||||||
rows: MessageRecord[];
|
rows: MessageRecord[];
|
||||||
/**
|
|
||||||
* Race-guard signal (2026-08-25): target ids whose attachment upload is
|
|
||||||
* still in-flight — NO analysis ran for them. The processor must defer
|
|
||||||
* these (requeue + poll), never fan them out as failures.
|
|
||||||
*/
|
|
||||||
uploadPendingIds?: string[];
|
|
||||||
};
|
};
|
||||||
type BatchErrorResponse = {
|
type BatchErrorResponse = {
|
||||||
ok: false;
|
ok: false;
|
||||||
@@ -109,12 +103,6 @@ type BatchErrorResponse = {
|
|||||||
type IndividualOkResponse = {
|
type IndividualOkResponse = {
|
||||||
ok: true;
|
ok: true;
|
||||||
results: AnalysisResult[];
|
results: AnalysisResult[];
|
||||||
/**
|
|
||||||
* Race-guard signal (2026-08-24): the message's attachment upload is still
|
|
||||||
* in-flight — NO analysis ran. The processor must re-queue the message as
|
|
||||||
* `pending` and re-schedule, never treat this as a completed moderation.
|
|
||||||
*/
|
|
||||||
uploadPending?: boolean;
|
|
||||||
};
|
};
|
||||||
type IndividualErrorResponse = {
|
type IndividualErrorResponse = {
|
||||||
ok: false;
|
ok: false;
|
||||||
@@ -309,40 +297,9 @@ async function processBatch(job: {
|
|||||||
...contextIds,
|
...contextIds,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Attachment-upload race guard: a message whose attachment is still being
|
|
||||||
// uploaded (upload_status='pending') must not be analyzed yet. Its
|
|
||||||
// uploaded_url is not ready, and falling back to the Discord CDN link often
|
|
||||||
// 404s (expired/purged) — which used to silently produce a text-only
|
|
||||||
// verdict ("lampiran yang gagal terbaca"). Leave those targets pending; the
|
|
||||||
// next worker cycle picks them up after the upload lands.
|
|
||||||
const pendingUploadTargetIds = new Set(
|
|
||||||
(attachments ?? [])
|
|
||||||
.filter((a) => a.upload_status === "pending")
|
|
||||||
.map((a) => a.message_id),
|
|
||||||
);
|
|
||||||
const readyMessages =
|
|
||||||
pendingUploadTargetIds.size === 0
|
|
||||||
? messages
|
|
||||||
: messages.filter((m) => !pendingUploadTargetIds.has(m.id));
|
|
||||||
if (readyMessages.length === 0) {
|
|
||||||
// Explicit signal (2026-08-25): every target is still upload-pending.
|
|
||||||
// Returning bare {ok:true, rows:[]} made the processor classify all of
|
|
||||||
// them "incomplete" and fan out to the individual queue — a hot ~300ms
|
|
||||||
// requeue loop for the whole upload duration.
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
conversationKey,
|
|
||||||
rows: [],
|
|
||||||
uploadPendingIds: messages.map((m) => m.id),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// The orchestrator handles text/media split + caching + parallel paths
|
|
||||||
// internally, so a 20-message batch = 1 text LLM call (+1 media call
|
|
||||||
// when media is present), not N per-message calls.
|
|
||||||
const analysisStart = Date.now();
|
const analysisStart = Date.now();
|
||||||
const moderationResult = await runModerationAnalysis({
|
const moderationResult = await runModerationAnalysis({
|
||||||
targets: readyMessages,
|
targets: messages,
|
||||||
contextBlock,
|
contextBlock,
|
||||||
attachments,
|
attachments,
|
||||||
});
|
});
|
||||||
@@ -351,7 +308,7 @@ async function processBatch(job: {
|
|||||||
const results = moderationResult.results.map((r) =>
|
const results = moderationResult.results.map((r) =>
|
||||||
normalizeResult(
|
normalizeResult(
|
||||||
r as unknown as AnalysisResult,
|
r as unknown as AnalysisResult,
|
||||||
readyMessages.find((m) => m.id === r.messageId),
|
messages.find((m) => m.id === r.messageId),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -380,10 +337,9 @@ async function processBatch(job: {
|
|||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
total: readyMessages.length,
|
total: messages.length,
|
||||||
saved: allRows.length,
|
saved: allRows.length,
|
||||||
conversationKey,
|
conversationKey,
|
||||||
skippedPendingUpload: messages.length - readyMessages.length,
|
|
||||||
},
|
},
|
||||||
"LLM batch analysis complete",
|
"LLM batch analysis complete",
|
||||||
);
|
);
|
||||||
@@ -431,16 +387,9 @@ async function processIndividual(job: {
|
|||||||
...contextIds,
|
...contextIds,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Same attachment-upload race guard as the batch path: while the upload is
|
// Analysis uses the Discord CDN URL directly (archive-only uploader).
|
||||||
// still in-flight the uploaded_url is not ready and the Discord CDN fallback
|
// No upload-pending race guard: analysis proceeds regardless of upload
|
||||||
// often 404s — analyzing now would silently produce a text-only verdict.
|
// status, since discord_url is available immediately at capture time.
|
||||||
// Return no results so the message stays pending for the next cycle.
|
|
||||||
const uploadStillPending = (attachments ?? []).some(
|
|
||||||
(a) => a.message_id === message.id && a.upload_status === "pending",
|
|
||||||
);
|
|
||||||
if (uploadStillPending) {
|
|
||||||
return { ok: true, results: [], uploadPending: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const moderationResult = await runModerationAnalysis({
|
const moderationResult = await runModerationAnalysis({
|
||||||
|
|||||||
@@ -163,12 +163,18 @@ export function startPendingAIAnalysisWorker(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
messageStore.revertStuckProcessingMessages(300000).catch((err: unknown) => {
|
// Only revert stuck processing messages if there's active processing.
|
||||||
logger.error(
|
// Avoids a DB query every recovery interval when the pipeline is idle.
|
||||||
{ error: String(err) },
|
if (conversationProcessing.size > 0) {
|
||||||
"Failed to run stuck processing recovery",
|
messageStore
|
||||||
);
|
.revertStuckProcessingMessages(300000)
|
||||||
});
|
.catch((err: unknown) => {
|
||||||
|
logger.error(
|
||||||
|
{ error: String(err) },
|
||||||
|
"Failed to run stuck processing recovery",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
messageStore.getPendingConversationKeys(500),
|
messageStore.getPendingConversationKeys(500),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function deriveRecommendedAction(msg: MessageRecord): string {
|
|||||||
const severity = deriveSeverity(msg);
|
const severity = deriveSeverity(msg);
|
||||||
if (
|
if (
|
||||||
msg.ai_status === "flagged" &&
|
msg.ai_status === "flagged" &&
|
||||||
(severity === "critical" || severity === "high")
|
(severity === "critical" || severity === "high" || severity === "medium")
|
||||||
)
|
)
|
||||||
return "delete";
|
return "delete";
|
||||||
if (msg.ai_status === "flagged") return "review";
|
if (msg.ai_status === "flagged") return "review";
|
||||||
@@ -72,13 +72,58 @@ export function parseModerationFlags(
|
|||||||
* correct enforcement is resetting the nickname to the default username.
|
* correct enforcement is resetting the nickname to the default username.
|
||||||
* Any other flag (sara, harassment, vulgar_language, ...) keeps the normal
|
* Any other flag (sara, harassment, vulgar_language, ...) keeps the normal
|
||||||
* delete path.
|
* delete path.
|
||||||
|
*
|
||||||
|
* Two detection paths (belt-and-suspenders):
|
||||||
|
* 1. Exact flags — the ONLY flag is `offensive_username`.
|
||||||
|
* 2. Analysis attribution — the flag set is exactly the username-attributable
|
||||||
|
* set (`offensive_username`, `sara`, `conflict_instigation`) AND the
|
||||||
|
* analysis text explicitly attributes the violation SOLELY to the
|
||||||
|
* username while stating the message content itself is clean/ordinary.
|
||||||
|
* This catches the false-positive case where the LLM wrongly applies a
|
||||||
|
* content-level zero-tolerance flag (e.g. `sara`/`conflict_instigation`
|
||||||
|
* for a username like "matikanetanyahu") instead of `offensive_username`.
|
||||||
*/
|
*/
|
||||||
|
const USERNAME_ATTRIBUTABLE_FLAGS = new Set([
|
||||||
|
"offensive_username",
|
||||||
|
"sara",
|
||||||
|
"conflict_instigation",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Analysis text that clearly states the message content itself is clean. */
|
||||||
|
const CONTENT_CLEAN_PATTERN =
|
||||||
|
/(?:isi pesan\s*(?:hanya|bersih|tidak (?:melanggar|ada)|membahas|berisi|bukan)|pesan\s*(?:bersih|tidak (?:melanggar|ada))|tidak ada (?:diskusi|konten|indikasi|pelanggaran))/i;
|
||||||
|
|
||||||
|
/** Analysis text that clearly attributes the violation to the username. */
|
||||||
|
const USERNAME_ATTRIBUTION_PATTERN =
|
||||||
|
/(?:username|nickname|nama pengguna)[\s\S]{0,40}?(?:mengandung|memiliki|berisi|melanggar|ofensif|mengecam|menyerang)/i;
|
||||||
|
|
||||||
export function isNicknameOnlyViolation(
|
export function isNicknameOnlyViolation(
|
||||||
message: MessageRecord,
|
message: MessageRecord,
|
||||||
analysisResult?: AnalysisResult,
|
analysisResult?: AnalysisResult,
|
||||||
): boolean {
|
): boolean {
|
||||||
const flags = parseModerationFlags(message, analysisResult);
|
const flags = parseModerationFlags(message, analysisResult);
|
||||||
return flags.length > 0 && flags.every((f) => f === "offensive_username");
|
if (flags.length === 0) return false;
|
||||||
|
|
||||||
|
// Path 1: exact offensive_username-only.
|
||||||
|
if (flags.every((f) => f === "offensive_username")) return true;
|
||||||
|
|
||||||
|
// Path 2: username-attributable flag set + analysis text corroborates
|
||||||
|
// that the violation is username-only with clean content.
|
||||||
|
const onlyUsernameAttributable = flags.every((f) =>
|
||||||
|
USERNAME_ATTRIBUTABLE_FLAGS.has(f),
|
||||||
|
);
|
||||||
|
if (!onlyUsernameAttributable) return false;
|
||||||
|
|
||||||
|
const analysis =
|
||||||
|
(analysisResult?.analysis ?? typeof message.ai_analysis === "string")
|
||||||
|
? message.ai_analysis
|
||||||
|
: "";
|
||||||
|
if (!analysis) return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
USERNAME_ATTRIBUTION_PATTERN.test(analysis) &&
|
||||||
|
CONTENT_CLEAN_PATTERN.test(analysis)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,10 +180,14 @@ export function isEligibleForAutoDelete(
|
|||||||
// Recommended action check
|
// Recommended action check
|
||||||
const recommendedAction =
|
const recommendedAction =
|
||||||
analysisResult?.recommendedAction ?? deriveRecommendedAction(message);
|
analysisResult?.recommendedAction ?? deriveRecommendedAction(message);
|
||||||
if (recommendedAction !== "delete" && recommendedAction !== "escalate") {
|
if (
|
||||||
|
recommendedAction !== "delete" &&
|
||||||
|
recommendedAction !== "escalate" &&
|
||||||
|
recommendedAction !== "warn"
|
||||||
|
) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
{ messageId: message.id, recommendedAction },
|
{ messageId: message.id, recommendedAction },
|
||||||
"Message not eligible for auto-delete: recommended action is not delete/escalate",
|
"Message not eligible for auto-delete: recommended action is not delete/escalate/warn",
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Client, PermissionString } from "discord.js-selfbot-v13";
|
|||||||
import { LRUCache } from "lru-cache";
|
import { LRUCache } from "lru-cache";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { config } from "../../shared/config/config.js";
|
import { config } from "../../shared/config/config.js";
|
||||||
|
import { parseRichMessageMetadata } from "../message-capture/messageMetadata.js";
|
||||||
import { messageStore } from "../message-capture/messageStore.js";
|
import { messageStore } from "../message-capture/messageStore.js";
|
||||||
import type { MessageRecord } from "../message-capture/types.js";
|
import type { MessageRecord } from "../message-capture/types.js";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
} from "./autoDeleteEligibility.js";
|
} from "./autoDeleteEligibility.js";
|
||||||
import { logDeletionToChannel } from "./autoDeleteLogger.js";
|
import { logDeletionToChannel } from "./autoDeleteLogger.js";
|
||||||
import { sendDeletionNotification } from "./autoDeleteNotify.js";
|
import { sendDeletionNotification } from "./autoDeleteNotify.js";
|
||||||
|
import { llmChat } from "./llmClient.js";
|
||||||
import { verdictToActionFields } from "./verdictToActionFields.js";
|
import { verdictToActionFields } from "./verdictToActionFields.js";
|
||||||
|
|
||||||
const logger = createChildLogger("auto-delete-manager");
|
const logger = createChildLogger("auto-delete-manager");
|
||||||
@@ -94,6 +96,26 @@ export async function resetOffensiveNickname(
|
|||||||
}
|
}
|
||||||
// setNickname(null) = remove nickname → Discord shows global username
|
// setNickname(null) = remove nickname → Discord shows global username
|
||||||
await member.setNickname(null, "[auto] nickname melanggar aturan server");
|
await member.setNickname(null, "[auto] nickname melanggar aturan server");
|
||||||
|
|
||||||
|
// ── Check if the global username is ALSO offensive ──────────────
|
||||||
|
// After reset, the member shows their global username. If that is
|
||||||
|
// also offensive (gambling/scam keywords), replace it with a random
|
||||||
|
// generated username so the user can't circumvent the filter by
|
||||||
|
// having an offensive global username.
|
||||||
|
const refreshedMember = await guild.members.fetch(userId);
|
||||||
|
const globalUsername = refreshedMember.user?.username ?? "";
|
||||||
|
if (await isGlobalUsernameOffensiveAI(globalUsername)) {
|
||||||
|
const randomName = generateRandomUsername();
|
||||||
|
await refreshedMember.setNickname(
|
||||||
|
randomName,
|
||||||
|
"[auto] global username juga melanggar, diganti random",
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
{ messageId, guildId, userId, globalUsername, randomName },
|
||||||
|
"Global username also offensive — replaced with random username",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
recentNicknameResets.set(cooldownKey, Date.now());
|
recentNicknameResets.set(cooldownKey, Date.now());
|
||||||
logger.info(
|
logger.info(
|
||||||
{ messageId, guildId, userId },
|
{ messageId, guildId, userId },
|
||||||
@@ -119,6 +141,69 @@ export async function resetOffensiveNickname(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── AI-Based Global Username Check ──────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the AI moderation LLM whether a global username is offensive
|
||||||
|
* (gambling, scam, spam, NSFW, etc.). Returns `true` if the LLM
|
||||||
|
* judges the username as violating server rules.
|
||||||
|
*
|
||||||
|
* Fail-open: if the LLM call fails or times out, returns `false`
|
||||||
|
* so the nickname reset still completes — we don't want a broken LLM
|
||||||
|
* to block enforcement.
|
||||||
|
*/
|
||||||
|
async function isGlobalUsernameOffensiveAI(username: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const completion = await llmChat({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content:
|
||||||
|
'Kamu adalah moderator Discord. Tentukan apakah username berikut melanggar aturan server (judi, togel, scam, spam, NSFW, SARA, atau ofensif). Jawab HANYA dengan JSON: {"offensive": true} atau {"offensive": false}. Jangan penjelasan tambahan.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `Username: "${username}"`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
max_tokens: 50,
|
||||||
|
temperature: 0,
|
||||||
|
stream: false,
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = completion?.choices?.[0]?.message?.content?.trim() ?? "";
|
||||||
|
// Parse JSON response — handle both strict JSON and markdown-wrapped
|
||||||
|
const jsonMatch = content.match(
|
||||||
|
/\{[^}]*"offensive"\s*:\s*(true|false)[^}]*\}/,
|
||||||
|
);
|
||||||
|
if (jsonMatch) {
|
||||||
|
const parsed = JSON.parse(jsonMatch[0]) as { offensive: boolean };
|
||||||
|
return parsed.offensive === true;
|
||||||
|
}
|
||||||
|
// Fallback: if response contains "true" anywhere, treat as offensive
|
||||||
|
return content.toLowerCase().includes("true");
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
username,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
"AI username check failed — failing open (not offensive)",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random fallback username: "User" + 5-digit number.
|
||||||
|
* Guaranteed unique per call (random suffix).
|
||||||
|
*/
|
||||||
|
function generateRandomUsername(): string {
|
||||||
|
const suffix = Math.floor(10000 + Math.random() * 90000);
|
||||||
|
return `User${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Error Handling Utilities ────────────────────────────────────────
|
// ─── Error Handling Utilities ────────────────────────────────────────
|
||||||
|
|
||||||
function getErrorCode(error: unknown): number | string | undefined {
|
function getErrorCode(error: unknown): number | string | undefined {
|
||||||
@@ -130,7 +215,28 @@ function getErrorCode(error: unknown): number | string | undefined {
|
|||||||
|
|
||||||
function isAlreadyDeletedError(error: unknown): boolean {
|
function isAlreadyDeletedError(error: unknown): boolean {
|
||||||
const code = getErrorCode(error);
|
const code = getErrorCode(error);
|
||||||
return code === 10008 || code === 404 || code === "10008" || code === "404";
|
// Discord REST error codes for "message not found":
|
||||||
|
// 10008 = Unknown Message, 10003 = Unknown Channel,
|
||||||
|
// 50001 = Missing Access (channel deleted/hidden), 404 = HTTP
|
||||||
|
if (
|
||||||
|
code === 10008 ||
|
||||||
|
code === 10003 ||
|
||||||
|
code === 50001 ||
|
||||||
|
code === 404 ||
|
||||||
|
code === "10008" ||
|
||||||
|
code === "10003" ||
|
||||||
|
code === "50001" ||
|
||||||
|
code === "404"
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
// Fallback: check the message text for the Discord "Unknown Message" string
|
||||||
|
const msg =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: typeof error === "string"
|
||||||
|
? error
|
||||||
|
: "";
|
||||||
|
return msg.includes("Unknown Message") || msg.includes("Unknown Channel");
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasChannelMessagesApi(channel: unknown): channel is {
|
function hasChannelMessagesApi(channel: unknown): channel is {
|
||||||
@@ -164,6 +270,21 @@ function hasPermissionApi(channel: unknown): channel is {
|
|||||||
|
|
||||||
// ─── Database Action Log ─────────────────────────────────────────────
|
// ─── Database Action Log ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the member's server-specific display name (nickname) from a
|
||||||
|
* captured message's metadata. Falls back to the global username when no
|
||||||
|
* server nickname exists (member.displayName defaults to the guild nickname
|
||||||
|
* when set, otherwise the global username).
|
||||||
|
*/
|
||||||
|
function resolveServerNick(message: MessageRecord): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = parseRichMessageMetadata(message.metadata);
|
||||||
|
return parsed?.member?.displayName ?? message.username ?? null;
|
||||||
|
} catch {
|
||||||
|
return message.username ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function logAutoDeleteAttempt(
|
async function logAutoDeleteAttempt(
|
||||||
message: MessageRecord,
|
message: MessageRecord,
|
||||||
result: AutoDeleteResult,
|
result: AutoDeleteResult,
|
||||||
@@ -176,6 +297,8 @@ async function logAutoDeleteAttempt(
|
|||||||
action_type: "delete_message",
|
action_type: "delete_message",
|
||||||
reason: result.reason,
|
reason: result.reason,
|
||||||
...verdictToActionFields(message),
|
...verdictToActionFields(message),
|
||||||
|
username: message.username,
|
||||||
|
server_nick: resolveServerNick(message),
|
||||||
executed_by: "auto-delete-manager",
|
executed_by: "auto-delete-manager",
|
||||||
status: result.deleted
|
status: result.deleted
|
||||||
? "executed"
|
? "executed"
|
||||||
@@ -241,6 +364,8 @@ export async function attemptAutoDeleteFlaggedMessage(
|
|||||||
reason:
|
reason:
|
||||||
"nickname melanggar aturan server (offensive_username); pesan dibiarkan",
|
"nickname melanggar aturan server (offensive_username); pesan dibiarkan",
|
||||||
...verdictToActionFields(message),
|
...verdictToActionFields(message),
|
||||||
|
username: message.username,
|
||||||
|
server_nick: resolveServerNick(message),
|
||||||
executed_by: "auto-delete-manager",
|
executed_by: "auto-delete-manager",
|
||||||
status: resetOk ? "executed" : "failed",
|
status: resetOk ? "executed" : "failed",
|
||||||
error: resetOk ? null : "nickname_reset_failed",
|
error: resetOk ? null : "nickname_reset_failed",
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* batchOutcomeClassifier.ts
|
* batchOutcomeClassifier.ts
|
||||||
*
|
*
|
||||||
* Pure partitioner of the batch worker response (2026-08-25).
|
* Pure partitioner of the batch worker response.
|
||||||
*
|
|
||||||
* Bug history: the batch race guard returned `{ok:true, rows:[]}` when every
|
|
||||||
* target's attachment upload was still in-flight. The processor classified all
|
|
||||||
* of them as "incomplete" and fanned out to the individual queue, where the
|
|
||||||
* guard there requeued + rescheduled at the 250ms debounce — a hot ~300ms loop
|
|
||||||
* for the entire upload duration (~10 cycles in 3s in prod logs). Root fix:
|
|
||||||
* the worker now reports `uploadPendingIds` explicitly and this pure function
|
|
||||||
* partitions the outcome so upload-pending targets NEVER enter the fanout.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface BatchRowLike {
|
export interface BatchRowLike {
|
||||||
@@ -21,15 +13,12 @@ export interface BatchRowLike {
|
|||||||
export interface BatchWorkerResponseLike {
|
export interface BatchWorkerResponseLike {
|
||||||
ok?: boolean;
|
ok?: boolean;
|
||||||
rows?: BatchRowLike[];
|
rows?: BatchRowLike[];
|
||||||
/** Explicit race-guard signal from the worker (2026-08-25). */
|
|
||||||
uploadPendingIds?: string[];
|
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One target's per-message disposition after a batch attempt. */
|
/** One target's per-message disposition after a batch attempt. */
|
||||||
export type BatchTargetKind =
|
export type BatchTargetKind =
|
||||||
| "completed"
|
| "completed"
|
||||||
| "upload_pending"
|
|
||||||
| "incomplete"
|
| "incomplete"
|
||||||
| "parse_failed"
|
| "parse_failed"
|
||||||
| "api_failed";
|
| "api_failed";
|
||||||
@@ -40,7 +29,7 @@ function flagsOf(row: { ai_moderation_flags?: string | null }): string[] {
|
|||||||
const parsed = JSON.parse(row.ai_moderation_flags) as unknown;
|
const parsed = JSON.parse(row.ai_moderation_flags) as unknown;
|
||||||
return Array.isArray(parsed) ? (parsed as string[]) : [];
|
return Array.isArray(parsed) ? (parsed as string[]) : [];
|
||||||
} catch {
|
} catch {
|
||||||
return [] as string[];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,14 +37,13 @@ function flagsOf(row: { ai_moderation_flags?: string | null }): string[] {
|
|||||||
* Partition the input message ids into per-message dispositions for one batch
|
* Partition the input message ids into per-message dispositions for one batch
|
||||||
* worker response. Pure: no DB/Piscina/logger — unit-testable directly.
|
* worker response. Pure: no DB/Piscina/logger — unit-testable directly.
|
||||||
*
|
*
|
||||||
* Priority per id: explicit uploadPendingIds → completed row → flag-based
|
* Priority per id: completed row → flag-based failure kinds → unexplained
|
||||||
* failure kinds → unexplained missing (treated like incomplete).
|
* missing (treated like incomplete).
|
||||||
*/
|
*/
|
||||||
export function partitionBatchOutcome(
|
export function partitionBatchOutcome(
|
||||||
messages: ReadonlyArray<{ id: string }>,
|
messages: ReadonlyArray<{ id: string }>,
|
||||||
response: BatchWorkerResponseLike,
|
response: BatchWorkerResponseLike,
|
||||||
): Map<string, BatchTargetKind> {
|
): Map<string, BatchTargetKind> {
|
||||||
const pendingSet = new Set(response.uploadPendingIds ?? []);
|
|
||||||
const rowsById = new Map(
|
const rowsById = new Map(
|
||||||
(response.rows ?? [])
|
(response.rows ?? [])
|
||||||
.filter((r): r is BatchRowLike & { id: string } => Boolean(r?.id))
|
.filter((r): r is BatchRowLike & { id: string } => Boolean(r?.id))
|
||||||
@@ -64,10 +52,6 @@ export function partitionBatchOutcome(
|
|||||||
|
|
||||||
const out = new Map<string, BatchTargetKind>();
|
const out = new Map<string, BatchTargetKind>();
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
if (pendingSet.has(msg.id)) {
|
|
||||||
out.set(msg.id, "upload_pending");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const row = rowsById.get(msg.id);
|
const row = rowsById.get(msg.id);
|
||||||
if (!row) {
|
if (!row) {
|
||||||
// Unexplained drop: LLM silently omitted it. Same retryable bucket as
|
// Unexplained drop: LLM silently omitted it. Same retryable bucket as
|
||||||
@@ -92,17 +76,3 @@ export function partitionBatchOutcome(
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Linear backoff ramp for consecutive upload-pending polls:
|
|
||||||
* poll N (1-based) waits min(base × N, cap). Keeps latency low for fast
|
|
||||||
* uploads while bounding total polling cost for long uploads.
|
|
||||||
*/
|
|
||||||
export function computeUploadPollDelayMs(
|
|
||||||
consecutivePolls: number,
|
|
||||||
baseMs: number,
|
|
||||||
capMs: number,
|
|
||||||
): number {
|
|
||||||
const n = Math.max(1, Math.floor(consecutivePolls));
|
|
||||||
return Math.min(Math.round(baseMs * n), Math.round(capMs));
|
|
||||||
}
|
|
||||||
Loaded 50 of 198 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user