41 Commits
Author SHA1 Message Date
mytheclipsebotreview 2b6ec59286 refactor(ai): remove semantic embedding cache + Qdrant vector store
Hapus seluruh fitur embedding/Qdrant (tidak dipakai lagi):

- gateway: drop embeddingClient.ts, qdrantClient.ts, archiveEmbedder.ts
  dan tes qdrantEnsure.test.ts; moderationOrchestrator kembali ke
  exact-hash cache -> LLM (tanpa phase-2 semantic lookup); textCacheStore
  kehilangan findSimilarTextModeration / parseQdrantVerdict /
  isSemanticBandAccepted / upsertBareKeyToQdrant; cache-prune hanya
  menyapu Postgres.
- backend: drop embed.ts + qdrant.ts, endpoint messages.semanticSearch
  dan schema/type terkait; kolom embedding dilepas dari schema
  text_analysis_cache.
- frontend: hapus toggle EXACT/SEMANTIC, hook useSemanticSearch,
  API client + tipe SemanticSearchResult.
- config: buang AI_LLM_EMBEDDING_* dan QDRANT_* (env + .env.example).
- docs: ARCHITECTURE.md / AGENTS.md / README.md / diagram arsitektur
  disesuaikan (LLM caller - vision, cache = exact-hash saja).

Verifikasi: tsc 0 (backend, gateway, frontend); bun test 135 pass +
37 pass, 0 fail; biome 0 error.
2026-09-25 01:38:51 +07:00
mytheclipsebotreview fdc7f01c26 fix(ci): replace leftover pnpm reference in gateway nativeBuildInputs with bun (build failure) 2026-09-25 00:47:56 +07:00
mytheclipsebotreview f31b1f62d4 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	services/frontend/pnpm-lock.yaml
2026-09-25 00:38:10 +07:00
mytheclipsebotreviewandgit-migration[bot] 7be069d73f chore: migrate monorepo toolchain pnpm+vitest → bun (bun test, bunfig preload, bun.lock)
- Gateway + backend + frontend: pnpm/vitest fully removed → bun 1.3.14
  (bun install, bun test tests/, bunfig.toml [test] preload, bun.lock).
- vitest configs deleted; vitest→bun facade (jest/mock/spyOn/waitForCompat)
  keeps the vitest-style assertions working under bun:test.
- flake.nix: bunInstall switch; pruned prod-pass now removes post-pnpm
  dev-toolchain trees; frontend builds Next standalone via bun's next.
- CI: deploy.yml installs with bun + runs bun test tests/ per service.
- 190 tests green (146 gateway + 37 backend pass, 14 skip), 0 fail;
  tsc + biome across all 3 services clean.

Co-authored-by: git-migration[bot] <noreply@gmw.local>
2026-09-24 23:50:30 +07:00
mytheclipsebotreview[bot] 88373985ec Auto-merge PR #89
build(deps): bump @orpc/client from 1.15.2 to 1.15.3 in /services/frontend in the production group
2026-09-24 14:48:31 +00:00
mytheclipsebotreview[bot] 7acc49e1fb Auto-merge PR #88
build(deps-dev): bump the development group in /services/discord-gateway with 2 updates
2026-09-24 14:45:56 +00:00
mytheclipsebotreview[bot] 81b3128bb0 Auto-merge PR #87
build(deps): bump the production group in /services/backend with 2 updates
2026-09-24 14:39:35 +00:00
dependabot[bot] 1d0b3f422d build(deps): bump @orpc/client
Bumps the production group in /services/frontend with 1 update: [@orpc/client](https://github.com/middleapi/orpc/tree/HEAD/packages/client).


Updates `@orpc/client` from 1.15.2 to 1.15.3
- [Release notes](https://github.com/middleapi/orpc/releases)
- [Commits](https://github.com/middleapi/orpc/commits/v1.15.3/packages/client)

---
updated-dependencies:
- dependency-name: "@orpc/client"
  dependency-version: 1.15.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-24 14:25:59 +00:00
dependabot[bot] 0aef0b6c08 build(deps-dev): bump the development group
Bumps the development group in /services/discord-gateway with 2 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [drizzle-kit](https://github.com/drizzle-team/drizzle-orm).


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

Updates `drizzle-kit` from 0.31.10 to 0.31.11
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](https://github.com/drizzle-team/drizzle-orm/compare/drizzle-kit@0.31.10...drizzle-kit@0.31.11)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.6.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: drizzle-kit
  dependency-version: 0.31.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-24 14:25:56 +00:00
dependabot[bot] cd1d6b2d0d build(deps): bump the production group
Bumps the production group in /services/backend with 2 updates: [@orpc/server](https://github.com/middleapi/orpc/tree/HEAD/packages/server) and [drizzle-orm](https://github.com/drizzle-team/drizzle-orm).


Updates `@orpc/server` from 1.15.2 to 1.15.3
- [Release notes](https://github.com/middleapi/orpc/releases)
- [Commits](https://github.com/middleapi/orpc/commits/v1.15.3/packages/server)

Updates `drizzle-orm` from 0.45.2 to 0.45.3
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](https://github.com/drizzle-team/drizzle-orm/compare/0.45.2...0.45.3)

---
updated-dependencies:
- dependency-name: "@orpc/server"
  dependency-version: 1.15.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: drizzle-orm
  dependency-version: 0.45.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-24 14:25:31 +00:00
asepharyana c888f23901 fix(gateway): flagged high/critical severity always eligible for auto-delete
The LLM's recommended_action is conservative — for a flagged message at
high/critical severity it frequently emits 'review' (screenshot/context
ambiguity) even when the violation is severe (harassment, SARA,
threats). autoDeleteEligibility trusted that value, so serious violations
slipped through undeleted (e.g. harassment flagged high but
review → not eligible).

Fix: flagged + high/critical severity bypasses the recommended-action
check entirely (always eligible); the action check now only gates
warn/flagged-medium. deriveRecommendedAction also returns delete for
flagged high/critical BEFORE consulting the stored LLM action. +5
regression tests.
2026-09-24 18:57:41 +07:00
asepharyana 54d02098c8 fix(frontend): rename stale 'AI heuristic reasoning' label to 'AI moderation analysis'
The label predates the LLM pipeline — all analysis (including LLM output)
was mislabeled as heuristic. The analysis field is LLM-generated
moderation justification.
2026-09-24 18:08:55 +07:00
asepharyana 5fc0e8b3cb fix(gateway): surface failed URL fetches to moderation LLM
Links that fail to fetch (Facebook share 400, login walls, anti-bot)
previously vanished from the prompt entirely — the LLM only saw the raw
URL in the message body, so its analysis degraded to a template like
"Pengguna membagikan tautan Facebook". Now the text batch tracks
failed-fetch URLs and injects <web_content fetch_error="true"> telling
the LLM the page content is unverifiable and to NOT invent it, plus a
prompt rule: describe what the user actually expressed from the text +
conversation context, or admit the subject is unclear — never make the
link itself the subject.
2026-09-24 18:03:41 +07:00
asepharyana 045cdf1f75 fix(gateway): align stuck-recovery threshold with batch timeout (300s→120s)
recovery-worker used a hardcoded STUCK_PROCESSING_AGE_MS=300_000 while
messagesCleanup's default and AI_ANALYSIS_PROCESSING_TIMEOUT_MS are both
120s. Rows stuck between 2 and 5 minutes were never reverted by the
recovery worker — they looked permanently stuck (and accumulated under
load) even though the batch budget had long passed. Now derives the
threshold from config so the two knobs can never drift again.
2026-09-24 17:52:17 +07:00
asepharyana deed7bdfb0 fix(gateway): destroy AI worker pools on graceful shutdown
Piscina worker threads outlive process.exit() and linger as orphaned
processes holding DB connections/locks after a deploy restart. Two live
gateways then fight over the same messages table rows (one claims
processing, the other reverts), which left messages stuck in
ai_status='processing' forever.

Destroy both worker pools before closing the DB, with a 5s fallback so
a hung vision job cannot block shutdown indefinitely.
2026-09-24 17:26:54 +07:00
asepharyana c4d9ade85e debug(gateway): log lane-lock skip + dispatch in scheduleLaneTimer 2026-09-24 17:11:04 +07:00
asepharyana 80daa9f045 fix(gateway): un-claim budget-overflow messages stuck in processing
getPendingMessagesByConversation() flips every fetched pending row to
'processing', then pickBatchWithinBudget() may stop early on the token
budget. The tail rows that did NOT make the batch were never un-claimed,
so they stayed 'processing' forever — the recovery worker reverted them
(120s) only for the next wave to re-claim them, an infinite loop of
stuck messages that never get analyzed (saw 22 rows, some recycled for
40+ minutes).

- add computeBudgetOverflowMessages() pure helper (batchBudget.ts)
- batchScheduler un-claims overflow rows back to 'pending' before
  dispatching the trimmed batch
- recovery-worker now reverts stuck processing unconditionally (the old
  'conversationProcessing.size > 0' guard skipped the revert when the
  in-memory lock map was empty, e.g. fresh boot — exactly when stranded
  rows from a previous process need rescuing)
- 3 regression tests for the overflow helper
2026-09-24 16:49:27 +07:00
asepharyana ef7708bf7d feat(gmw): route all LLM traffic through 9router
GMW moves off omniroute (100.121.180.82:20128) and off the direct NVIDIA
vision endpoint onto 9router, which runs on the same host as both services
(127.0.0.1:4014) — loopback avoids the TLS/proxy hop and localhost calls
bypass 9router's remote-key guard.

- gateway + backend: AI_LLM_BASE_URL default -> http://127.0.0.1:4014/v1
- drop stale 'omniroute' router references from comments/docs now that the
  active router is 9router (llmClient, llmCaller, ARCHITECTURE, AGENTS)

Verified against 9router before wiring: model 'text' -> gemini-3.5-flash-lite
(SSE, as the pipeline expects), 'multimodal' -> nemotron-3-nano-omni answers
image input, and gemini/gemini-embedding-001 returns 3072 dims — matching the
existing Qdrant collections (no reindex needed). The GMW key is already
registered in 9router's apiKeys table.

typecheck + lint + tests green (gateway 138, backend 37 excluding e2e).
2026-09-24 16:05:36 +07:00
asepharyana 750f3aa598 docs(gateway): correct metrics port — 4016 was wrong
The metrics server binds METRICS_PORT (code default 9090; this host runs it
on 4018 — 4016 is occupied by another process). Docs said 4016 in three
places, which is not what the service does.
2026-09-24 15:50:15 +07:00
asepharyana c57ee12da1 docs(gateway): consolidate README/ARCHITECTURE, drop stale MODULE_STRUCTURE
README.md was the extraction-era document (referenced winston, mock-crc.ts,
llmModerationClient.ts, indonesianTextNormalizer.ts — all long gone) and
duplicated ARCHITECTURE.md. Rewritten as a short run-the-service guide;
layout/design lives only in ARCHITECTURE.md.

MODULE_STRUCTURE.md deleted: it was a stale duplicate of ARCHITECTURE.md,
referenced by nothing but itself.

ARCHITECTURE.md updated to the post-refactor reality: app/ lifecycle split
(bootstrap/lifecycle/process-guards/metrics-collector), ai-moderation
recovery-worker + cache-prune, per-module index.ts facades, one-way
dependency rule, corrected init/shutdown/observability sections.
2026-09-24 15:09:13 +07:00
asepharyana 494e16b3b3 refactor(gateway): split bootstrap + aiAnalyzer, add module barrels
app/:
- bootstrap.ts 277 -> 145 lines: config guard, DB connect, client debug
  logging and startup order are now named steps with a comment header
- lifecycle.ts (new): everything wired on the Discord 'ready' hook, in
  explicit order (inject broadcaster -> register listeners -> start workers)
- process-guards.ts (new): SIGINT/SIGTERM/uncaughtException/unhandledRejection
  in ONE place, using isTransientStreamError() instead of two duplicated
  inline code lists
- metrics-collector.ts (new): AI pipeline Prometheus gauges

modules/:
- ai-moderation/index.ts + message-capture/index.ts (new): public facades so
  app/ never reaches into internal files
- aiAnalyzer.ts 317 -> 146 lines: pure entry API; skip-verdict recording
  extracted into recordSkip()
- recovery-worker.ts (new): stranded-message recovery + stale lane/CB pruning
- cache-prune.ts (new): 6h expired-verdict sweep, throttled + resettable
- drop 3 dead re-exports (pickBatchWithinBudget/onCircuitBreakerAlert/
  getConversationKey) whose consumers import the origin files directly

No behavior change. typecheck + lint + 138 tests green; nix build OK.
2026-09-24 15:04:53 +07:00
asepharyana 6bf3b40cc7 refactor(gateway): drop shared barrel, migrate to granular imports + shared error helpers
- delete src/shared/index.ts fat barrel; point 10 importers at the exact
  module they use (redis-channels, moderation-types, utils/pagination)
- message-capture/types.ts re-exports from shared/moderation-types directly
- shared/errors: add errorMessage() + isTransientStreamError() helpers,
  replacing the repeated err-message and transient-code checks
- drop unused imports flagged by biome

No behavior change. typecheck + lint + 138 tests green.
2026-09-24 14:58:19 +07:00
asepharyana 8743fcc0b5 fix(ci): recover attic client binary in VPS-hop fallback path 2026-09-24 14:31:26 +07:00
asepharyana e34dcd6bc6 fix(gateway): separate text and media lanes in AI analysis queue (#86)
Image messages previously blocked the whole analysis pipeline:
- conversationProcessing was a single lock per conversation; processBatch
  awaited BOTH text and media worker jobs before releasing it, so a fast
  text verdict sat unused until the slow vision/media batch finished
- one global LLM semaphore (AI_LLM_MAX_CONCURRENT) was shared by text and
  media, so a vision backlog could starve text inference
- recovery worker gated on conversationProcessing.size

Now the queue is split into independent text/media lanes:
- conversationProcessing maps key -> Partial<Record<lane, startedAt>>;
  each lane holds its own lock and frees it the moment ITS worker job
  resolves (ownership-guarded clear prevents stale timers clearing newer
  slots)
- two LLM semaphores: AI_LLM_MAX_CONCURRENT (text, default 8) and
  AI_LLM_MEDIA_MAX_CONCURRENT (media, default 4) via
  withLlmConcurrency(fn, { lane })
- batchScheduler schedules per conversation+lane (timer keys
  '<key>::<lane>'); splitMessagesByLane/laneOfMessage moved to pure
  analysisLanes.ts (unit-testable without Piscina)
- ai-analysis-worker batch jobs carry a lane field; per-lane active
  request gauges (active_text_requests / active_media_requests)
- added tests/analysisLaneLock.test.ts (7 tests: independent lane locks,
  preserving other-lane lock, clear-all, ownership guard, lane split)

Docs: ARCHITECTURE.md + AGENTS.md concurrency model updated.
typecheck/lint/test(138)/build all green.
2026-09-24 14:09:53 +07:00
asepharyana f9fecfc144 chore: clean up dead barrels, duplicate config, and orphaned frontend components
Gateway:
- Remove dead barrels (ai-moderation/index, attachment-upload/index, message-capture/index) — all consumers import files directly
- Remove orphaned schema/ split dir (analytics/cache/messages/meta) — schema.ts is monolithic
- Merge duplicate config singleton: delete shared/config/config.ts, point all 44 imports at shared/config/index

Backend:
- Remove dead commandHelper.ts (voice-era fallback), ws/index.ts barrel, health.schema.ts, moderationMetrics.ts, analysis.schema.ts (0 importers; metrics/handlers route directly)

Frontend:
- Remove orphaned CategoryDrilldown/CoverageTiles/TopicTrends, primitives/slot, use-mobile, use-mounted
- Remove unused charts donut/sparkline (TopicTrends was only consumer)

Kept (verified active): shared/database/index.ts facade (11 importers), hooks/index + lib/api/index barrels (10 importers), orpc/ws.ts, charts/index.ts barrel.
Verified: tsc + biome + vitest per service (backend e2e 3 failures pre-existing on main); frontend next build 8 routes.
2026-09-24 13:23:38 +07:00
asepharyana a59f3132ee fix: add findings and progress documentation to .gitignore 2026-09-24 12:11:21 +07:00
asepharyana c7f53e4f7e feat(gateway): remove Jev (System One) analyzer, restore LLM-only text moderation
Jev (oc/jev-1.13-free via 9router /v1/systemone) added as primary text
analyzer was underperforming. Delete the whole feature:
- jevAnalyzer.ts + its unit & live-smoke tests
- Jev-first branch in textBatchProcessor, restore pure callModerationLLM path
- AI_LLM_JEV_* config vars (zod) and .env.example entries
- @typesafe-ai/sdk dependency (+ lockfile)

Behavior: text moderation is LLM-only again, exactly as before the
Jev feature; AGENTS.md invariant 'LLM is the only judge' holds.
2026-09-24 12:06:36 +07:00
asepharyana 8583bcdf17 fe(fe): enrich dashboard Top Reacted Messages
Show relative time, always-on username with channel context, and
collapse top emojis to two; add VIEW ALL toggle to reveal all 20
fetched reactions instead of the top 4.
2026-09-23 23:56:02 +07:00
asepharyana b12eb0a038 fe(fe): surface moderation explainability in live feed
Show confidence bar, flag chips, status dot, and error text per action
in the Live Stream Audit Log; drop stale media comment on SkeletonHero.
2026-09-23 23:46:22 +07:00
asepharyana b72423c64d fix(fe): remove remaining voice/recording/media leftovers from navigation, dashboard, and ws layer 2026-09-23 23:21:05 +07:00
asepharyana 6b34fc97ec Merge branch 'fix/remove-voice-recording' 2026-09-23 22:13:00 +07:00
asepharyana 401155b501 fix: auto-fix code quality [skip ci] 2026-09-23 22:11:00 +07:00
dependabot[bot] 771783d991 build(deps-dev): bump tsx in /services/backend in the development group
Bumps the development group in /services/backend with 1 update: [tsx](https://github.com/privatenumber/tsx).


Updates `tsx` from 4.23.13 to 4.23.15
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.13...v4.23.15)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-23 22:11:00 +07:00
dependabot[bot] 7ad6487050 build(deps): bump openai
Bumps the production group in /services/discord-gateway with 1 update: [openai](https://github.com/openai/openai-node).

Updates `openai` from 7.19.0 to 7.20.0
- [Release notes](https://github.com/openai/openai-node/releases)
- [Changelog](https://github.com/openai/openai-node/blob/main/CHANGELOG.md)
- [Commits](https://github.com/openai/openai-node/compare/v7.19.0...v7.20.0)

---
updated-dependencies:
- dependency-name: openai
  dependency-version: 7.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-23 22:11:00 +07:00
dependabot[bot] 8ce8978755 build(deps-dev): bump tsx (#84)
Bumps the development group in /services/discord-gateway with 1 update: [tsx](https://github.com/privatenumber/tsx).


Updates `tsx` from 4.23.13 to 4.23.15
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.13...v4.23.15)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-23 22:10:04 +07:00
mytheclipsebotreview[bot] 869cad88e4 Auto-merge PR #83
build(deps-dev): bump tsx from 4.23.13 to 4.23.15 in /services/backend in the development group
2026-09-23 14:41:14 +00:00
asepharyana a13884d80f docs: remove voice/recording/media references from AGENTS/ARCHITECTURE/README 2026-09-23 21:25:03 +07:00
dependabot[bot] cd3ee5b5d8 build(deps-dev): bump tsx in /services/backend in the development group
Bumps the development group in /services/backend with 1 update: [tsx](https://github.com/privatenumber/tsx).


Updates `tsx` from 4.23.13 to 4.23.15
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.13...v4.23.15)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-23 14:24:58 +00:00
asepharyana 2c69c51fba chore: drop pnpm-install-generated junk pnpm-workspace.yaml (backend) 2026-09-23 19:45:25 +07:00
asepharyana ce784f8305 refactor: remove voice/recording/media features from frontend + prune lockfiles
- Delete pages: (dashboard)/{recordings,voice,media}/ incl. view.tsx
- Delete components/{voice,media}, hooks/{use-voice,use-recordings,use-media},
  lib/audio/ (mic-transmit, pcm-player, wav), api/{voice,recordings,media},
  types/{voice,recording,media}, lib/hash.ts
- Cut nav tiles (Active Voice Stages, Voice Recording Archive) from dashboard,
  MiniPlayer from ambient-app, hooks/types barrel exports, WS voice/media events
- Re-home guilds + textChannels to messages oRPC router (DB-derived) so the
  messages page picker keeps working; guild-picker simplified to text-only
- Clean Channel/AppConfig types of voice remnants
- Delete infra/docker/recordings/ + 11 voice/recording/video spec docs
- pnpm install: prune direct voice deps from gateway + backend lockfiles
  (prism-media/opusscript remain only as transitive discord.js deps)
2026-09-23 19:45:14 +07:00
asepharyana 33013697e0 refactor: remove voice, recording, and music/media features (gateway + backend)
- Gateway: delete voice-recording/, voice-pcm-ws/, voice/video/media handlers,
  vendor/discord-voice-fork/, voice DB repos, 2 voice migrations
- Gateway: cut voice wiring from bootstrap/shutdown/commandHandler/handler-registry,
  eventBroadcaster/eventTypes, redis-channels, moderation-types, message-capture,
  config keys, and deps (@discordjs/voice, opus, libsodium, prism-media, davey)
- Backend: delete voice/, recordings/, media/ modules + @discordjs/voice dep
- Backend: cut voice/media/recordings oRPC routers, WS gateway-PCM auth + voice
  handlers, Redis bridge voice aggregation, redis-channels voice/media constants,
  config keys, moderation-types voice items, e2e voice/recordings suites
- Keep voice DB tables (destructive migration avoided); chatbot voiceRecordings
  tool + dashboard count remain as read-only historical data access
2026-09-23 19:31:06 +07:00
260 changed files with 3643 additions and 39946 deletions
-36
View File
@@ -18,31 +18,12 @@ MONITOR_GUILD_ID=your_guild_id_here # Target guild for text monitoring
TEXT_GUILD_ID=optional_text_guild_id # Override text capture guild (falls back to MONITOR_GUILD_ID) TEXT_GUILD_ID=optional_text_guild_id # Override text capture guild (falls back to MONITOR_GUILD_ID)
TEXT_CHANNEL_ID=optional_text_channel_id # Restrict text capture to a single channel TEXT_CHANNEL_ID=optional_text_channel_id # Restrict text capture to a single channel
# === Voice Channels ===
# VOICE_GUILD_ID= # Guild for voice connection (optional)
# VOICE_CHANNEL_ID= # Channel for voice connection (optional)
# === Recording ===
RECORDINGS_DIR=./recordings # Audio file output directory (default: ./recordings)
RECORDING_SEGMENT_MS=5000 # OGG segment duration in ms (default: 5000)
# === Decoder ===
DECODER_ROTATE_MS=5000 # Opus decoder rotation interval in ms (default: 5000)
DECODER_COOLDOWN_MS=30000 # Decoder error cooldown in ms (default: 30000)
# === Audio ===
AUDIO_STREAM_SILENCE_DURATION_MS=3000 # Silence threshold in ms before stopping stream (default: 3000)
PACKET_FILTER_MIN_SIZE=8 # Minimum Opus packet size in bytes (default: 8)
OPUS_FRAME_SIZE=960 # Opus frame size in samples (default: 960)
AUDIO_SAMPLE_RATE=48000 # Audio sample rate in Hz (default: 48000)
AUDIO_CHANNELS=2 # Number of audio channels (default: 2)
AVATAR_SIZE=64 # User avatar size in pixels (default: 64) AVATAR_SIZE=64 # User avatar size in pixels (default: 64)
# === Webserver === # === Webserver ===
WEBSERVER_PORT=4001 # Backend HTTP/WS server port (default: 4001) WEBSERVER_PORT=4001 # Backend HTTP/WS server port (default: 4001)
# === Connection === # === Connection ===
VOICE_CONNECTION_TIMEOUT_MS=15000 # Voice connection timeout in ms (default: 15000)
RECONNECT_TIMEOUT_MS=5000 # Reconnect timeout in ms (default: 5000) RECONNECT_TIMEOUT_MS=5000 # Reconnect timeout in ms (default: 5000)
# === Logging === # === Logging ===
@@ -69,11 +50,6 @@ POSTGRES_POOL_MAX=10 # Maximum pool connections (default: 10)
# === Redis === # === Redis ===
REDIS_URL=redis://100.121.180.82:6379 # Redis connection string (default: redis://localhost:6379) REDIS_URL=redis://100.121.180.82:6379 # Redis connection string (default: redis://localhost:6379)
# === Voice PCM WebSocket (direct gateway→backend, bypasses Redis) ===
VOICE_PCM_WS_ENABLED=true # Use direct WS for PCM audio (default: true)
BACKEND_WS_URL=ws://backend:4001/ws # Backend WebSocket URL for gateway PCM streaming
BACKEND_WS_TOKEN= # REQUIRED if VOICE_PCM_WS_ENABLED=true. Internal shared secret
# === Attachments === # === Attachments ===
TELE_UPLOAD_URL=https://upload.asepharyana.my.id/api/upload # Attachment upload endpoint (default) TELE_UPLOAD_URL=https://upload.asepharyana.my.id/api/upload # Attachment upload endpoint (default)
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000 # Upload timeout in ms (default: 30000) ATTACHMENT_UPLOAD_TIMEOUT_MS=30000 # Upload timeout in ms (default: 30000)
@@ -88,22 +64,11 @@ AI_LLM_API_KEY= # REQUIRED if AI_ANALYSIS_ENABLED=true. L
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_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=text # LLM text model name (default: text) 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_MIN_SIMILARITY=0.97 # Min cosine similarity to reuse a cached verdict (default: 0.97)
QDRANT_URL=http://100.121.180.82:6333 # Qdrant vector store for embeddings (semantic cache); when set, vectors are stored/searched in Qdrant instead of Postgres
# QDRANT_COLLECTION=gmw_text_moderation # Qdrant collection name (default: gmw_text_moderation)
# QDRANT_API_KEY= # Qdrant API key (optional)
AI_LLM_MAX_CONCURRENT=5 # Max concurrent LLM API calls (default: 5) AI_LLM_MAX_CONCURRENT=5 # Max concurrent LLM API calls (default: 5)
AI_LLM_IMAGE_MAX_DIMENSION=1024 # Max image dimension in pixels before resize (default: 1024) AI_LLM_IMAGE_MAX_DIMENSION=1024 # Max image dimension in pixels before resize (default: 1024)
AI_LLM_TEXT_BATCH_SIZE=20 # Max messages per text-only moderation batch (default: 20) AI_LLM_TEXT_BATCH_SIZE=20 # Max messages per text-only moderation batch (default: 20)
AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS=60000 # Timeout in ms for media analysis calls (default: 60000) AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS=60000 # Timeout in ms for media analysis calls (default: 60000)
AI_LLM_TEXT_ANALYSIS_TIMEOUT_MS=30000 # Timeout in ms for text-only analysis calls (default: 30000) AI_LLM_TEXT_ANALYSIS_TIMEOUT_MS=30000 # Timeout in ms for text-only analysis calls (default: 30000)
AI_LLM_JEV_ENABLED=false # Use TypeSafe Jev (System One) as PRIMARY text analyzer; LLM is fallback
AI_LLM_JEV_API_KEY= # REQUIRED if AI_LLM_JEV_ENABLED=true. 9router/TypeSafe API key for /v1/systemone
AI_LLM_JEV_BASE_URL=http://127.0.0.1:4014 # 9router base URL (default: local 9router; prod: https://9router.asepharyana.my.id)
AI_LLM_JEV_MODEL=oc/jev-1.13-free # Jev model id (default: oc/jev-1.13-free)
AI_LLM_JEV_TIMEOUT_MS=45000 # Timeout in ms for a Jev systemone batch call (default: 45000)
AI_LLM_JEV_MIN_CONFIDENCE=0.9 # Min status-choice confidence to accept a Jev verdict (default: 0.9)
# === AI Analysis Tuning === # === AI Analysis Tuning ===
AI_ANALYSIS_DEBOUNCE_MS=500 # Debounce window for batching messages in ms (default: 500) AI_ANALYSIS_DEBOUNCE_MS=500 # Debounce window for batching messages in ms (default: 500)
@@ -132,7 +97,6 @@ AUTO_DELETE_LOG_CHANNEL_ID= # Channel ID to log auto-delete actions
# === Retention (0 = disabled) === # === Retention (0 = disabled) ===
RETENTION_MESSAGES_DAYS=0 # Message retention in days (default: 0 = off) RETENTION_MESSAGES_DAYS=0 # Message retention in days (default: 0 = off)
RETENTION_ATTACHMENTS_DAYS=0 # Attachment retention in days (default: 0 = off) RETENTION_ATTACHMENTS_DAYS=0 # Attachment retention in days (default: 0 = off)
RETENTION_VOICE_DAYS=0 # Voice recording retention in days (default: 0 = off)
RETENTION_CLEANUP_INTERVAL_MS=86400000 # Cleanup interval in ms (default: 24h) RETENTION_CLEANUP_INTERVAL_MS=86400000 # Cleanup interval in ms (default: 24h)
RETENTION_DRY_RUN=true # Dry-run: log but do not delete (default: true) RETENTION_DRY_RUN=true # Dry-run: log but do not delete (default: true)
+22 -17
View File
@@ -32,29 +32,26 @@ jobs:
with: with:
node-version: 22 node-version: 22
- name: Install pnpm - name: Setup Bun
run: corepack enable && corepack prepare pnpm@11 --activate uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Install deps (backend) - name: Install deps + test (backend)
working-directory: services/backend
run: pnpm install --ignore-scripts --no-frozen-lockfile
- name: Typecheck + test (backend)
working-directory: services/backend working-directory: services/backend
run: | run: |
bun install --frozen-lockfile
./node_modules/.bin/tsc --noEmit ./node_modules/.bin/tsc --noEmit
# e2e.test.ts requires a live backend (API_BASE) — run unit tests only # src/e2e.test.ts requires a live backend (API_BASE) — unit tests
./node_modules/.bin/vitest run --exclude "src/e2e.test.ts" # live in tests/ and are excluded by the bun test dir.
bun test tests/
- name: Install deps (discord-gateway) - name: Install deps + test (discord-gateway)
working-directory: services/discord-gateway
run: pnpm install --ignore-scripts --no-frozen-lockfile
- name: Typecheck + test (discord-gateway)
working-directory: services/discord-gateway working-directory: services/discord-gateway
run: | run: |
bun install --frozen-lockfile
./node_modules/.bin/tsc --noEmit ./node_modules/.bin/tsc --noEmit
./node_modules/.bin/vitest run bun test tests/
- name: Biome check (all services) - name: Biome check (all services)
run: | run: |
@@ -151,9 +148,17 @@ jobs:
attic_push_vps_hop() { attic_push_vps_hop() {
echo "Fallback: VPS-hop attic push" echo "Fallback: VPS-hop attic push"
# Recover the client binary BEFORE the fallback can use it: the
# bootstrap cascade below resets ATTIC_BIN="" and never restores it
# in the fallback branch, so `sudo $ATTIC_BIN push` used to run as
# `sudo push` -> "sudo: 'push': command not found". On the VPS the
# closure lives at the canonical ATTIC_DIR path.
VPS_ATTIC="/nix/store/fygyy3yk4rqdknxkiwkqambpnhyax0k4-attic-0.1.0/bin/attic"
ssh "$VPS_USER@$VPS_HOST" "test -x '$VPS_ATTIC'" \
|| ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-store --realise '$ATTIC_DIR'"
# Copy closure to VPS (fast if attic already has it via substitute) # Copy closure to VPS (fast if attic already has it via substitute)
ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-store --realise '$STORE_PATH'" 2>/dev/null \ ssh "$VPS_USER@$VPS_HOST" "sudo /nix/var/nix/profiles/default/bin/nix-store --realise '$STORE_PATH'" 2>/dev/null \
|| nix copy --to "ssh://$VPS_USER@$VPS_HOST" "$STORE_PATH" || nix copy --to "ssh://***@$VPS_HOST" "$STORE_PATH"
# Push from VPS → Attic over Tailscale. # Push from VPS → Attic over Tailscale.
# --ignore-upstream-cache-filter is REQUIRED: without it, attic skips # --ignore-upstream-cache-filter is REQUIRED: without it, attic skips
# writing the narinfo to gmw when chunks exist in the upstream # writing the narinfo to gmw when chunks exist in the upstream
@@ -162,7 +167,7 @@ jobs:
# sudo: attic must read root's config (~/.config/attic), which has # sudo: attic must read root's config (~/.config/attic), which has
# the imrnes-ts server → Tailscale. Non-root users' configs only # the imrnes-ts server → Tailscale. Non-root users' configs only
# have the public `pub` server → "Server imrnes-ts does not exist". # have the public `pub` server → "Server imrnes-ts does not exist".
ssh "$VPS_USER@$VPS_HOST" "sudo $ATTIC_BIN push imrnes-ts:gmw '$STORE_PATH' --jobs 4 --ignore-upstream-cache-filter" \ ssh "$VPS_USER@$VPS_HOST" "sudo $VPS_ATTIC push imrnes-ts:gmw '$STORE_PATH' --jobs 4 --ignore-upstream-cache-filter" \
|| echo "attic push failed (non-fatal; ssh copy fallback below)" || echo "attic push failed (non-fatal; ssh copy fallback below)"
} }
+4
View File
@@ -23,3 +23,7 @@ result
# Playwright MCP artifacts # Playwright MCP artifacts
.playwright-mcp/ .playwright-mcp/
findings.md
findings.md
progress.md
task_plan.md
+14 -13
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -14,7 +14,7 @@
{ "id": "gateway", "type": "backend", "label": "discord-gateway", "sublabel": "selfbot :4016", "pos": [260, 240], "size": [140, 60], "tag": "discord.js-selfbot-v13" }, { "id": "gateway", "type": "backend", "label": "discord-gateway", "sublabel": "selfbot :4016", "pos": [260, 240], "size": [140, 60], "tag": "discord.js-selfbot-v13" },
{ "id": "backend", "type": "backend", "label": "gmw-backend", "sublabel": "Express · oRPC · WS :4001", "pos": [640, 240], "size": [140, 60], "tag": "Drizzle ORM" }, { "id": "backend", "type": "backend", "label": "gmw-backend", "sublabel": "Express · oRPC · WS :4001", "pos": [640, 240], "size": [140, 60], "tag": "Drizzle ORM" },
{ "id": "proxy", "type": "cloud", "label": "nginx proxy", "sublabel": "reverse proxy :4009", "pos": [960, 240], "size": [140, 60], "tag": "nginx" }, { "id": "proxy", "type": "cloud", "label": "nginx proxy", "sublabel": "reverse proxy :4009", "pos": [960, 240], "size": [140, 60], "tag": "nginx" },
{ "id": "ai", "type": "backend", "label": "AI Moderation", "sublabel": "LLM caller · embeddings", "pos": [260, 400], "size": [140, 60] }, { "id": "ai", "type": "backend", "label": "AI Moderation", "sublabel": "LLM caller · vision", "pos": [260, 400], "size": [140, 60] },
{ "id": "frontend", "type": "frontend", "label": "gmw-frontend", "sublabel": "Next.js 16 SSR :4017", "pos": [960, 400], "size": [140, 60], "tag": "React 19 · Tailwind v4" }, { "id": "frontend", "type": "frontend", "label": "gmw-frontend", "sublabel": "Next.js 16 SSR :4017", "pos": [960, 400], "size": [140, 60], "tag": "React 19 · Tailwind v4" },
{ "id": "llm", "type": "cloud", "label": "9router LLM", "sublabel": "text + vision API", "pos": [260, 540], "size": [140, 60], "tag": "AI_LLM_BASE_URL" }, { "id": "llm", "type": "cloud", "label": "9router LLM", "sublabel": "text + vision API", "pos": [260, 540], "size": [140, 60], "tag": "AI_LLM_BASE_URL" },
{ "id": "browser", "type": "external", "label": "Dashboard Users", "sublabel": "browser · partysocket", "pos": [960, 540], "size": [140, 60] } { "id": "browser", "type": "external", "label": "Dashboard Users", "sublabel": "browser · partysocket", "pos": [960, 540], "size": [140, 60] }
@@ -1,67 +0,0 @@
# Spec: Perbagus fitur Voice + Audio Playback (GMW frontend)
Tanggal: 2026-08-22 · Scope: **frontend only** (backend/gateway API sudah cukup)
## Masalah (audit)
1. Recordings: semua kartu pakai `<audio controls>` native — tampilan identik,
tidak ada indikasi which-clip-playing / loading / paused, dan N audio bisa
play bareng (overlap).
2. Media view: `thumbnailUrl` dari gateway tidak dipakai; tidak ada visual
"sedang playing" selain disc spin; queue item semua sama tanpa badge up-next.
3. Mini-player (`lib/hooks/use-media-player.tsx`) ada tapi TIDAK PERNAH
dimount → dead code, user tidak lihat status musik di halaman lain.
4. Voice page: `useMicTransmit.setVolume` + `useVoiceListen.setVolume`
tersedia tapi tak ada UI-nya; mic live tidak punya level feedback.
## Desain
### A. RecordingAudioPlayer (baru, `components/voice/recording-audio-player.tsx`)
Custom player menggantikan `<audio controls>`:
- Play/pause button (ikon berubah), spinner saat buffering (`waiting` event).
- Progress bar seekable (click-to-seek) + time label `m:ss / m:ss`.
- Waveform-ish equalizer bars saat playing (CSS animation, reduced-motion safe).
- **Single-playback**: module-level registry `activePlayers` — memainkan satu
clip otomatis pause yang lain.
- Kartu pemilik player aktif dapat highlight border signal + "Now playing" chip.
### B. Recordings view — pasang player baru
- Ganti `<audio>` → `<RecordingAudioPlayer src download_url>`.
- Highlight kartu via state lifted: `playingId` di view, callback `onPlay`.
### C. Media view polish
- Hero: thumbnail (jika `current.thumbnailUrl`) sebagai disc center image;
fallback ListMusic icon. Equalizer bars animasi CSS saat `playing`.
- Queue row pertama: badge "up next"; baris current track diberi ring signal.
- Volume read-only tetap.
### D. MiniPlayer global
- Hapus `lib/hooks/use-media-player.tsx` (dead) — ganti dengan komponen
`components/media/mini-player.tsx` yang subscribe `useMediaState` +
`useMediaWsSync` langsung (SWR cache shared antar route), mounted di
`AppFrame` bawah layar (fixed bottom, hidden di route `/media`).
- Menampilkan: thumbnail kecil/judul, tombol skip/stop, link ke /media.
### E. Voice UI
- Mic live: level meter (Equalizer bars) — mic-transmitter sudah punya worklet;
tambah `getLevel()` via AnalyserNode pada stream (simple RMS) di hook.
- Listen: volume slider (input range) wired ke `listen.setVolume`.
- Mic volume slider wired ke `mic.setVolume`.
## File touched
| File | Aksi |
|---|---|
| services/frontend/src/components/voice/recording-audio-player.tsx | new |
| services/frontend/src/app/(dashboard)/recordings/view.tsx | edit |
| services/frontend/src/app/(dashboard)/media/view.tsx | edit |
| services/frontend/src/components/media/mini-player.tsx | new |
| services/frontend/src/components/shell/ambient-app.tsx | mount MiniPlayer |
| services/frontend/src/lib/hooks/use-media-player.tsx | delete |
| services/frontend/src/hooks/use-voice.ts | tambah micLevel |
| services/frontend/src/lib/audio/mic-transmit.ts | expose analyser level |
| services/frontend/src/app/(dashboard)/voice/view.tsx | sliders + meter |
## Verifikasi
1. `pnpm lint` (biome) + `pnpm build` clean.
2. Smoke di port **4024** (BUKAN 4017) → curl 200 semua route.
3. Commit (tanpa trailer) → push → `gh run watch` → live check
https://imphnen.asepharyana.my.id/{media,recordings,voice}/ = 200.
@@ -1,82 +0,0 @@
# Spec: Recordings — filter per user + export WAV (Audacity)
## Konteks / Gejala
Halaman `services/frontend/src/app/(dashboard)/recordings` menampilkan semua
rekaman voice (deck). User ingin:
1. **Filter per orang** (tampil rekaman satu user saja).
2. **Export ke format untuk Audacity** (buka & edit rekaman di Audacity).
## Fakta saat ini (verified)
- Backend `recordings.list` (services/backend/src/orpc/router.ts:291) SUDAH
menerima `userId`/`channelId` filter → `RecordingsService.getRecent`.
- Frontend `recordingsApi.list(limit, channelId, userId, cursor)` (lib/api/recordings.ts)
sudah meneruskan `userId`. `useLoadMoreRecordings` juga sudah bawa userId.
- Tapi UI `RecordingsView` (app/(dashboard)/recordings/view.tsx) TIDAK punya
filter UI, dan `useRecordingsPage` dipanggil tanpa userId → semua tampil.
- Setiap rekaman punya `download_url` (MP3 di TeleUploader), `user_id`, `username`.
- Audacity membuka MP3/OGG tapi editing paling bersih dari WAV (uncompressed)
/ FLAC (lossless). Backend TIDAK punya ffmpeg & Nix flake backend tak include
ffmpeg → transcode server-side bukan pilihan. Browser punya codec MP3 → export
WAV via Web Audio API (client-side) adalah solusi self-contained terbaik.
## Keputusan desain
1. **Filter per user**: UI dropdown (Semua User + per user) di header halaman.
Memilih user → re-fetch `recordingsApi.list(50, undefined, userId)` (server
filter, benar untuk dataset besar + pagination). Dropdown dibangun dari
distinct `user_id`/`username` pada items yang sedang tampil.
2. **Export WAV (Audacity)**: client-side via Web Audio API.
- Per kartu: tombol "WAV" → decode `download_url` → WAV 16-bit PCM → download.
- Header: tombol "EXPORT WAV (N)" → gabung (concat) semua rekaman yang
sedang tampil (ter-filter) jadi 1 file WAV → download. Ideal untuk analisis
/ mixdown per orang.
- Implementasi di `lib/audio/wav.ts` (decode + encode + concat), tanpa dep baru.
## Perubahan
### Frontend
- **`src/lib/audio/wav.ts`** (baru):
- `decodeAudio(url: string): Promise<AudioBuffer>` — fetch arrayBuffer →
`new AudioContext().decodeAudioData`.
- `audioBufferToWav(buf: AudioBuffer, sampleRate=48000): Blob` — PCM 16-bit
interleaved, mono→stereo handling, RIFF/WAVE writer. Audacity-importable.
- `concatBuffers(buffers: AudioBuffer[]): AudioBuffer` — gabung di channel 0
(mono) dengan sample-rate max; untuk export gabungan.
- `downloadWav(blob: Blob, filename: string): void` — obj URL + <a download>.
- **`src/app/(dashboard)/recordings/view.tsx`**:
- Toolbar filter: dropdown user (built from distinct items) + tombol reset.
- State `filterUserId`; saat berubah → `recordingsApi.list(50, undefined, id)`
→ set ke SWR (key includes filter), reset pagination.
- Tombol "WAV" per kartu (disabled jika `!r.download_url`).
- Tombol "EXPORT WAV (N)" di header (disabled jika 0 item punya download_url);
concat semua items ter-filter yang punya download_url.
- Status loading saat export (spinner/disable).
- **`src/hooks/use-recordings.ts`**: `useRecordingsPage` menerima `userId?` dan
memasukkan ke key + call, supaya filter re-fetch bersih (per-user cache key).
`useRecordings`/`useLoadMoreRecordings` propagate `userId`.
- **`src/lib/types/recording.ts`**: tidak berubah (userId dari items).
### Backend / gateway
- Tidak ada perubahan. Filter & export sepenuhnya frontend.
## File yang disentuh (frontend only)
- `src/lib/audio/wav.ts` (baru)
- `src/app/(dashboard)/recordings/view.tsx`
- `src/hooks/use-recordings.ts`
## Verification
1. `cd services/frontend && pnpm typecheck` (tsc --noEmit) — 0 error.
2. `pnpm lint` (biome check src/) — exit 0.
3. `pnpm build` (next build) — hijau.
4. Manual (user): buka /recordings; pilih user di dropdown → hanya rekaman user
itu; klik WAV di kartu → file .wav ter-download & terbuka di Audacity; klik
EXPORT WAV (filtered) → satu .wav gabungan.
5. Push → CI `Build & Deploy (Nix)` (frontend job) hijau → deploy landing.
## Risiko / Trade-off
- Web Audio decode MP3 di client: butuh CORS pada download_url (TeleUploader
asepharyana.my.id — sudah same-serve/proxied, CORS ikut origin). Jika 403/CORS
gagal, error toaster + fallback manual (RAW MP3 tetap ada).
- concat gabungan = mono 48k; Audacity bisa edit per-channel nanti. Acceptable.
- Filter client (dropdown dari items yang dimuat) hanya menawarkan user yang
sudah tampil; dataset besar bisa pakai search nanti. Server filter benar untuk
yang dipilih.
@@ -1,81 +0,0 @@
# Spec: Recordings v2 — transcription, search, filters, leaderboard, sessions
## Konteks
4 fitur lanjutan untuk halaman /recordings (dipilih user):
1. Tampilkan transkripsi + search by kata kunci
2. Filter lanjutan: by channel + rentang tanggal
3. Kelompokkan klip jadi "sesi rapat" + autoplay berurutan + export satu sesi
4. Leaderboard bicara per user + ringkasan
## Fakta terverifikasi (2026-08-30)
- `voice_recordings` kolom: id, user_id, username, avatar_url, guild_id,
channel_id, channel_name, filename, size_bytes, download_url, upload_status,
upload_error, created_at, uploaded_at, **transcription** (schema
`shared/database/schema.ts:246`). Index user_id/channel_id/created_at.
- **Transcription 0/10.640** terisi prod: `AI_VOICE_TRANSCRIPTION_ENABLED`
default **false** (config/index.ts:333) & tidak diset di BWS env →
`transcribeRecording` (voiceTranscriber.ts) langsung return null.
`AI_LLM_BASE_URL` + `AI_LLM_API_KEY` SUDAH dikonfig (Whisper via router GMW).
- Transcriber hardcode `language: "en"` (voiceTranscriber.ts:34) — salah utk
ucapan campur id/en. Utk auto-detect: hapus param `language` (Whisper
auto-detect jika tidak diberikan).
- Backend `RecordingsService.getRecent` (recordings.service.ts) TIDAK select
`transcription`; SUDAH dukung filter `channelId`+`userId`+`cursor`; belum
dukung date-range & keyword search. `RecordingRow` interface juga tak punya
`transcription`.
- Tidak ada kolom `session_id` / `duration_ms` → sesi grouping = heuristik
(channel sama + gap created_at), durasi leaderboard = estimasi dari
size_bytes (MP3 128kbps: durasi_s ≈ size_bytes*8/128000).
## Keputusan desain
1. **Aktifkan transkripsi (fondasi)**: transcriber auto-detect (hapus
`language:"en"`), set secret BWS `AI_VOICE_TRANSCRIPTION_ENABLED=true`
(dibaca runtime oleh bws-exec saat service start). Rekaman BARU dapat
transkripsi. Backfill rekaman lama TIDAK dilakukan (pilih user: fokus baru;
file OGG lama kemungkinan besar sudah tidak dipakai).
2. **Backend** — perluas `getRecent`:
- select `transcription` (+ interface RecordingRow + FE type)
- filter baru: `q` (ILIKE on transcription + username), `startDate`/`endDate`
(created_at range, bigint ms)
- endpoint baru `recordings.summary`: agregasi per user → {user_id,
username, avatar_url, clips, est_duration_s, words, last_at}. `words`
dihitung dari transcription (tokenisasi spasi). Return sorted by clips.
3. **Frontend**:
- Kartu: tampilkan transkripsi (collapse/expand line-clamp) + durasi estimasi.
- Toolbar: search box (q), Select channel, date range (start/end), speaker
(sudah ada), reset filter.
- Tab/segment "Tape Deck" vs "Leaderboard": leaderboard render summary per
user + klik → filter deck by user itu.
- Sesi grouping (deck view): klip di-group jadi sesi bila channel sama &
gap antar klip < SESSION_GAP_MS (default 120s). Header sesi (channel,
waktu mulai, jumlah klip, total durasi). Autoplay tombol "Play session" &
"Export session WAV" (concat klip sesi via lib/audio/wav.ts yg sudah ada).
## Perubahan file
### Gateway (Tahap 1)
- `voiceTranscriber.ts`: hapus baris `language: "en"`.
- (secret) set `AI_VOICE_TRANSCRIPTION_ENABLED=true` via bws.
### Backend (Tahap 2)
- `recordings.service.ts`: interface + select + getRecent tambah transcription;
tambah filter q/startDate/endDate; method getSummary() untuk leaderboard.
- `orpc/router.ts`: procedur `recordings.list` schema tambah fields; prosedur
baru `recordings.summary`.
### Frontend (Tahap 3-5)
- `lib/types/recording.ts`: tambah transcription, est_duration_s opsional,
Summary type.
- `lib/api/recordings.ts`: list tambah q/startDate/endDate; + summary().
- `hooks/use-recordings.ts`: propagate filter baru ke key+call; hook
useRecordingsSummary.
- `app/(dashboard)/recordings/view.tsx`: toolbar search+channel+date, kartu
transkripsi, tab leaderboard, grouping sesi + autoplay + export sesi.
## Verification
- tiap tahap: gateway `pnpm build` + `biome check src/`; backend `pnpm build`
+ `biome check src/ tests/`; FE `pnpm build` + `biome check src/`.
- CI Build & Deploy hijau tiap tahap; deploy landing dicek via
`systemctl show gmw-<svc>.service --property=ActiveEnterTimestamp`.
- Tahap 1c: setelah deploy + rekaman baru, cek
`SELECT COUNT(*) FROM voice_recordings WHERE transcription IS NOT NULL`.
@@ -1,71 +0,0 @@
# Spec: Record video (kamera/screenshare) orang lain — WebRTC receive (Request 2)
Date: 2026-08-30. Status: Phase A + B DONE (capture → playable MP4); Phase C (UI) open.
## Why this is hard (ground truth, verified from @discordjs/voice 0.19.2 source)
`VoiceReceiver.onUdpMessage` (dist/index.mjs:2059) drops EVERY non-opus RTP
packet at line 2068: `if ((msg[1] & 127) !== RTP_OPUS_PAYLOAD_TYPE) return;`.
So video (kamera H264? actually Discord uses VP8/H264; screenshare combines with
video SSRC) is decrypted-capable but never forwarded. `receiver.parsePacket`
(2033) DOES decrypt any payload type generically (audio + video) using
`connectionData.{encryptionMode, nonceBuffer, secretKey}` — the only audio gate
is the opus check inside onUdpMessage.
=> FIX: wrap `receiver.onUdpMessage` (like screenShareAudio.ts already does for
screen-share AUDIO SSRCs): for packets whose payload type is a VIDEO type
(payload 96 VP8, 101/102 H264, 106/116/126/127 AV1, VP9 98...), call
`receiver.parsePacket(...)` myself to decrypt, then depacketize + write frames.
Delegate opus (120) to the original handler. Delegate audio to original.
## Audio already works (screenShareAudio.ts). We add VIDEO.
## Science-of-the-changes below.
## Phase A — capture + decrypt + depacketize to AnnexB h264 (THIS change)
Files (new): `src/modules/voice-recording/videoReceiver.ts`
- Hook into `recorder.startRecording` alongside `hookScreenShareAudio`.
- Wrap `receiver.onUdpMessage`:
- read ssrc = msg.readUInt32BE(8); userData = receiver.ssrcMap.get(ssrc)
- if payload type is video AND we have a "watching" subscription for that user
(videoSSRC present), decrypt via receiver.parsePacket(...), then:
- H264 (101/102 + payload 120 not): strip RTP header, reassemble FU-A
fragments into AnnexB NALs (start-code prefixed), buffer until we have
a full access unit (keyframe SPS/PPS/IDR or slices), append to a per-
user-per-burst `.h264` file.
- else delegate to original onUdpMessage.
- Watch `receiver.ssrcMap` "create"/"update" for `videoSSRC !== undefined` →
signal a video burst started for that user (like screenShareAudio does).
- Per-user video files written to `config.RECORDINGS_DIR/<uid>/video-<ts>.h264`.
- Guard: skip bot's own video (client.user.id).
Dependencies: NO new npm deps for Phase A (only crypto already in
@discordjs/voice via parsePacket + Buffer). ffmpeg-headless (already in Nix
buildInputs) used in Phase B for decode+mux.
## Phase B — decode + mux to playable MP4/WebM (DONE, commit 999c054b)
- `closeBurst` waits for the WriteStream `finish` (full flush/fd close), then
`muxToMp4(rawPath)`: `ffmpeg -f h264 -i raw.h264 -c copy -movflags +faststart
out.mp4`, deletes raw on success (>=1B mp4), keeps it on failure.
- Output: `<RECORDINGS_DIR>/<uid>/video-<ssrc>-<ts>.mp4`.
- ffmpeg is on the gateway runtime PATH (pkgs.ffmpeg-headless, already in the
Nix buildInputs for the music/GoLive players).
- Test: `tests/videoReceiver.test.ts` muxToMp4 case (real ffmpeg, generates a
tiny baseline h264, asserts mp4 non-empty + raw deleted; skipped if no ffmpeg).
## Phase C — frontend playback + session grouping (follow-up)
- Backend oRPC list video files; FE video player, group by call session like audio.
## Verification
- Phase A: join voice, have a member screen-share/camera, confirm `.h264` file
grows with NAL frames + keyframes; journal shows "video burst" logs.
- Run vitest unit: RTP header strip + FU-A reassembly gives correct bytes.
## Open questions / risks
- Discord codec for camera = H264(101/102); screenshare uses H264 (101/103?)
and can also be VP8/VP9. Handle H264 first (depacketize proven), VP8/VP9 in
Phase B via ffmpeg RTP input.
- Encryption: DAVE (dave_protocol_version) adds a session layer; parsePacket
already applies daveSession.decrypt for audio — we must call the SAME
parsePacket path so DAVE/encryption is handled identically.
- ssrc↔user mapping during a broadcast: videoSSRC is in ssrcMap after the
voice state; may need the STREAM_CREATE network events to key reliably.
@@ -1,66 +0,0 @@
# Spec: Voice auto-reconnect (persistent state + rejoin on drop)
## Goal
Setelah `VoiceController.connect()` berhasil, state "sedang merekam di <guild>/<channel>"
disimpan di Postgres. Kalau gateway restart/reboot, atau koneksi voice drop tidak
disengaja (dikeluarkan/moved/server restart), gateway otomatis join ulang ke channel
yang sama.
## Requirement mapping (user's ask)
- "autoreconnect ke channel yg sama jika server restart atau reboot" → reconnect on
startup (ready handler) + keep DB record across graceful shutdown.
- "state nya persistent di db" → `voice_auto_reconnect` table.
- "rejoin jika tidak sengaja dikeluarkan" → watchdog on `Disconnected`/`Destroyed`
(kick / moved / voice server restart) → full rejoin with backoff.
- Manual leave (`/voice disconnect`, dashboard disconnect) MUST NOT rejoin.
## Design decisions
1. **New table** `voice_auto_reconnect` (dedicated, not `ui_state`):
- `guild_id` text PK
- `channel_id` text NOT NULL
- `channel_name` text
- `connected_at` bigint epoch-ms
- `updated_at` bigint epoch-ms
DAO: `voiceAutoReconnectRepo.ts` — `upsert(record)`, `list()`, `delete(guildId)`.
2. **Write on connect**: `VoiceController.connect()` → after `startRecording` success →
`upsert`. IDEMPOTENT (upsert per guild).
3. **Clear on manual leave**: `handleVoiceDisconnect` (all) + `handleVoiceDisconnectGuild`
pass `clearPersisted: true`. Graceful shutdown `disconnect()` keeps the record.
4. **Rejoin on startup**: bootstrap `ready` → `await voiceController.autoReconnect()`
(list persisted → connect each, non-fatal on failure).
5. **Rejoin on unexpected drop**: monitor per-connection; on `Disconnected`/`Destroyed`
with `!intentional` → schedule rejoin `connect(guildId, persistedChannelId)` with
backoff (min 2s, max 30s, max 5 attempts). Track `rejoinAttempts`, reset on success.
6. **Intentional flag**: `disconnectGuild(guildId, { clearPersisted?, intentional? })`.
- shutdown `disconnect()` → `{ intentional: true, clearPersisted: false }`.
- manual `disconnect()` (dashboard) → `{ clearPersisted: true }`, sets intentional.
- manual `disconnectGuild` → `{ clearPersisted: true }`, sets intentional.
The monitor checks `intentional` before rejoin; `clearPersisted` only deletes the row.
## Files touched
- `services/discord-gateway/src/shared/database/schema.ts` — add `pgVoiceAutoReconnectTable`
+ types.
- `services/discord-gateway/src/shared/database/voiceAutoReconnectRepo.ts` (NEW) — DAO.
- `services/discord-gateway/src/modules/voice-recording/voiceController.ts` — upsert on
connect; monitor + rejoin; `autoReconnect()`; `disconnect/disconnectGuild` opts.
- `services/discord-gateway/src/modules/command-handler/voice.handler.ts` — manual
disconnect/disconnectGuild pass `clearPersisted: true`.
- `services/discord-gateway/src/app/bootstrap.ts` — call `voiceController.autoReconnect()`
in `ready`.
- `services/discord-gateway/drizzle/migrations/0020_add_voice_auto_reconnect.sql` +
`meta/_journal.json` entry (apply manually per gmw-ops).
## Edge cases
- Channel deleted / guild lost while persisted → `connect()` throws (channel not found)
→ log + delete persisted row (don't retry forever).
- Rejoin attempts exhausted → keep row (so next restart retries) + log.
- Multiple guilds: per-guild monitor, per-guild persisted row.
- Graceful shutdown order: shutdown sets intentional=true (so no rejoin during teardown)
but keeps row.
## Verification
- `pnpm typecheck && pnpm build && pnpm lint` in `services/discord-gateway`.
- Apply migration `0020` manually; verify table exists.
- CI `Build & Deploy (Nix)` green; gateway deploy lands.
- Manual: connect via dashboard → check `voice_auto_reconnect` row; simulate drop →
confirm rejoin; manual disconnect → row cleared.
@@ -1,166 +0,0 @@
# Spec: Perbaiki alur voice → recording (miss & terpotong)
## Konteks & Gejala
User melaporkan alur voice sampai recording **banyak miss** (audio tidak tercatat)
dan **terpotong** (satu alur bicara kebelah jadi beberapa segmen / audio putus di
tengah). Ini domain `services/discord-gateway/src/modules/voice-recording/`.
Pipeline per user yang mulai bicara (speaking "start"):
```
receiver.speaking "start" → speakingHandler(userId)
├─ await collectUserMetadata(...) ← roundtrip API, subscribe tertunda
├─ receiver.subscribe(userId, {end: AfterSilence, duration: 3000ms}) → audioStream
├─ attach data/end/error handlers → audioStream.pipe(PacketFilter) → oggPacketStream
├─ SegmentManager.open() → OggLogicalBitstream → file .ogg
├─ data: SegmentManager.rotateIfNeeded (rotasi 5s) + decoder.write (web PCM tho
└─ end: SegmentManager.close() → segmen finish → finalizeSegment upload + transkrip
```
## Root cause (dari pembacaan kode — justifikasi di bawah)
### A. MISS bagian awal bicara — subscribe tertunda (utama)
`speakingHandler.ts:53` melakukan `await collectUserMetadata(...)` SEBELUM
`receiver.subscribe`. `collectUserMetadata` (metadata.ts:30) pada cold path
(cache miss) melakukan `client.users.fetch` + `guild.members.fetch` roundtrip
Discord API (ratusan ms–detik). Selama await, seluruh opus awal bocor → awal
kalimat hilang. Cache menghilangkan ini untuk user yang pernah ter-record, tapi
user baru/evict (cache max 200) kena setiap kali.
### B. Double-subscribe race
Guard `receiver.subscriptions.has(userId)` di `speakingHandler.ts:63` diletakkan
SETELAH `await collectUserMetadata`. Dua event "start" cepat keduanya melewati
guard (belum subscribe) → dua subscription → audio terbelah/ganda per user.
### C. TERPOTONG di jeda bicara — AfterSilence 3000ms
`AUDIO_STREAM_SILENCE_DURATION_MS=3000`. Setelah 3s diam, stream auto-`end` →
`SegmentManager.close` → segmen baru saat bicara lagi. Jeda normal (berpikir,
interupsi) memecah 1 alur bicara jadi beberapa segmen/file. Ini source "terpotong".
Segmen pendek hasil jeda <1s juga DIBUANG di `finalizeSegment` (MIN_DURATION_MS=1000)
→ miss kata singkat ("ya", "siap").
### D. Rotasi segmen 5s di tengah bicara
`RECORDING_SEGMENT_MS=5000`: `rotateIfNeeded` menutup bitstream & membuka baru
setiap 5s walau bicara kontinu. Pipenya di-re-wire di dalam handler data →
window drop kecil + continuity file pecah (bukan masalah besar, tapi berkontribusi).
### E. Tidak ada sinkronisasi "stop" speaking & stream "end" flaky
Handler hanya listen "start"; mengandalkan `AfterSilence` untuk emit "end".
Bug @discordjs/voice yang dikenal: `AfterSilence` bisa TIDAK emit "end" saat
koneksi gagal/teardown → segmen menggantung & tidak pernah finalize/upload
(recording "hilang"). Tidak ada watchdog.
## Scope
Hanya `services/discord-gateway/src/modules/voice-recording/` (+ config index bila
perlu default baru). Tidak menyentuh playback (player.ts), transmitter (voice dari
browser → Discord, arah berlawanan), muxer (konsolidasi akhir), atau screen-share
video (hanya audio SSRC via `hookScreenShareAudio` sudah ada & dibiarkan).
## Perubahan
### 1. Speak-before-metadata: subscribe LEBIH DULU, metadata paralel
`recorder/speakingHandler.ts`:
- Pindahkan `receiver.subscribe` + pipeline setup ke ATAS, SEGERA di handler,
sebelum `collectUserMetadata`.
- Jalankan `collectUserMetadata` secara paralel non-blocking; gunakan metadata
cache untuk registrasi segmen saat finalize.
- Pertahankan guard skip bot/user (bot bisa dicek dari `client.users.cache` /
`client.user.id` tanpa await) SEBELUM subscribe — jangan tunggu fetch user.
Detail konkret:
```
async handler(userId):
if (userId === client.user?.id) return;
if (receiver.subscriptions.has(userId)) return; // guard kini di DEPAN, tanpa await
// (belum tahu bot? gunakan cache user; subscribe dulu biar nggak miss)
clone = subscribe(userId, {AfterSilence, duration}) // TANPA await metadata
setup pipeline (data/end/error, pipe, open segment)
collectUserMetadata(...).then(meta => {
if (meta.bot) { drain & close subscription (jangan simpan) }
else { registrasi ulang metadata utk segmen aktif }
})
```
Karena listener `start` dibuang untuk bot, harus tutup subscription bot tanpa
menyimpan segmen (buang hasil). Pakai `receiver.subscriptions.get(userId)?.destroy()`.
### 2. Selesaikan race double-subscribe (bagian dari #1)
Guard `receiver.subscriptions.has(userId)` diletakkan SINCRON di awal (sebelum
await). Karena `subscribe` sinkron dan `subscriptions` terisi sinkron saat
dipanggil, event "start" kedua yang tiba setelah subscribe akan melihat
subscription aktif → di-skip. Tidak ada await antara guard & subscribe.
### 3. Naikkan AfterSilence + tail-length → kurangi "terpotong"
`shared/config/index.ts`:
- `AUDIO_STREAM_SILENCE_DURATION_MS` default 3000 → 4000 (beri ruang jeda
alami; Discord packet 20ms, 4s masih wajar, tidak membengkak file).
Opsional via env override di production (tidak wajib komit env).
### 4. Segmen berorientasi "burst bicara" daripada rotasi jam
`recorder/segment.ts` + `recorder/speakingHandler.ts`:
- Hapus/lepas rotasi SEGMEN berbasis waktu (RECORDING_SEGMENT_MS). Alih-alih,
satu segmen = satu burst bicara (buka di "start", tutup di "end"/AfterSilence
end). Ini menghilangkan pemecahan di tengah kalimat.
- `RECORDING_SEGMENT_MS` tetap dipakai untuk rotasi decoder web-PCM (broadcast
live), di mana segmen besar bisa menunda frame — biarkan seperti ada.
JADI: `SegmentManager.rotateIfNeeded` TIDAK lagi dipanggil pada jalur OGG
recording; decoder rotate tetap dijalankan.
Catatan: dengan satu segmen per burst, ukuran file ~ durasi bicara. File panjang
dibutuhkan transkrip & transcode; tidak ada batas keras yang perlu di-override.
Watchdog di #5 membatasi durasi menggantung.
### 5. Watchdog end-of-burst & teardown recovery
`recorder/speakingHandler.ts`:
- Setelah subscribe, arm timer watchdog (mis. `config twin`/hitung) yang menutup
segmen jika `AfterSilence` tidak emit "end" dalam X detik setelah "stop"
speaking — atau, lebih sederhana & robust: dengarkan BOTH stream "end" DAN
timer dari `receiver.speaking` "stop" (hingga @discordjs/voice meng-klaim
AfterSilence). Bila "stop" fire, mulai countdown kecil (mis. 500ms) lalu
`segmentManager.close` + `decoder.destroy` + destroy subscription jika stream
belum "end".
- Ini menutup A: segmen menggantung → jadi pasti finalize & upload.
Implementasi: subscriptionStream (audioStream) + track milik per-user di
Map<userId, {audioStream, segmentManager, decoder, timer}>; handler "stop"
menjadwalkan finalize.
### 6. Naikkan/ambil MIN segmen duration lebih rendah
`recorder/segmentFinalizer.ts`: MIN_DURATION_MS 1000 → 300ms. Kata pendek
("ya", "siap") tetap tersimpan. GUI biarkan.
## File yang disentuh
- `src/modules/voice-recording/recorder/speakingHandler.ts` (utama: subscribe
first, guard depan, watchdog stop, hapus rotasi segmen dari jalur OGG)
- `src/modules/voice-recording/recorder/segment.ts` (opsional: API close/open,
pertahankan rotate untuk decoder tapi tak dipakai jalur OGG)
- `src/modules/voice-recording/recorder/streamSetup.ts` (kecil: backfill subscribe
supaya return subscription utk cleanup/destroy bot)
- `src/modules/voice-recording/recorder/segmentFinalizer.ts` (MIN_DURATION)
- `src/shared/config/index.ts` (default AfterSilence 4000)
## Yang TIDAK disentuh
- `transmitter.ts` (arah browser→Discord, bukan recording)
- `player.ts`, `mediaSource.ts`, `screenShareAudio.ts` (hook SSRC sudah benar)
- `muxer.ts` (konsolidasi akhir tetap jalan)
- Flake/deps/bundle
## Verification
1. `cd services/discord-gateway && pnpm typecheck` (tsc --noEmit) — 0 error.
2. `pnpm lint` (biome check src/) — exit 0.
3. `pnpm build` (tsc → dist/).
4. Unit test baru (vitest, kalau infra tes ada):
- subscribe terjadi tanpa await metadata (spy urutan panggilan)
- double-start skip lewat guard sinkron
- "stop" → watchdog finalize segmen walau stream tidak "end"
- MIN_DURATION 300ms menyimpan kata pendek
5. Smoke/CI: `nix flake check` (eval). Push → CI `Build & Deploy (Nix)` hijau →
deploy landing (`systemctl show gmw-discord-gateway.service --property=ActiveEnterTimestamp`).
6. Runtime manual (user): join voice, bicara dengan jeda >3s, pastikan 1 alur
kontinu = 1 segmen utuh (bukan 3), dan awal kata tidak hilang.
## Risiko / Trade-off
- Subscribe-before-metadata: burst bot akan dikumpulkan sesaat lalu dibuang
(cost kecil: buang segmen). Lebih baik miss bot daripada miss user.
- AfterSilence naik: file lebih panjang sedikit saat jeda; upload/transkrip
timeout (transcodeToMp3 30s) tetap aman.
- Satu segmen per burst: tidak ada rotasi paksa → durasi segmen = durasi bicara
(bisa menit). Transkrip & transcode tetap ok. Watchdog batasi menggantung.
@@ -1,177 +0,0 @@
# Spec: Receive Others' Screen-Share/Camera Video Under DAVE — Build a DAVE-capable Stream-Watch Connection
Status: **P1–P3 DONE + 4th CRITICAL FIX deployed (f1a7b0c2); DAVE Ready + MLS handshake CONFIRMED live; P4 = waiting on active streamer to confirm video-burst→mp4**
Date: 2026-08-31
Author: Hermes
Related: `docs/specs/2026-08-31_video-receive-eager-selfbot-connection-spec.md` (superseded by this)
`docs/specs/2026-08-31_video-receive-phaseC-spec.md` (Phase C build, selfbot path — dead)
## Problem / Ground truth (established from live logs 2026-08-31)
GMW must record OTHER members' screen-share + camera video in a voice channel it
records. Audio works (via `@discordjs/voice` 0.19.2 negotiating DAVE). Video does
not. Verified live: the selfbot path (`discord.js-selfbot-v13` eager `joinChannel`
→ `joinStreamConnection` → `receiver.createVideoStream`) authenticates but Discord
closes the connection with WS code **4017 "E2EE/DAVE protocol required"** (5x →
`VOICE_CONNECTION_ATTEMPTS_EXCEEDED`). Root cause: **Discord now REQUIRES DAVE
(E2EE) on every voice RTC, and `discord.js-selfbot-v13`'s voice stack predates
DAVE** (identify has no `max_dave_protocol_version`, no MLS handshake). The selfbot
path is dead, cannot be repaired. Full details: skill `gmw-ops` →
`references/video-receive-and-unmute.md` §4.
Facts:
- Watching a stream = a **SEPARATE RTC connection**, not the guild audio socket:
gateway `STREAM_WATCH` (op 20) → Discord replies `STREAM_CREATE` (rtc_server_id)
+ `STREAM_SERVER_UPDATE` (separate token+endpoint) → client opens its own voice
WS+UDP to that endpoint (`StreamConnectionReadonly` in selfbot). The watched
video never rides the @discordjs/voice guild socket.
- The stream-watch RTC ALSO requires DAVE (same 4017 mechanism).
- `@snazzah/davey` (bundled with @discordjs/voice 0.19.2) supports
`MediaType.VIDEO` + `Codec.H264` decrypt — DAVE machinery CAN decrypt H264 video.
- No off-the-shelf DAVE-capable video-RECEIVE path exists. Closing references:
- **Discord-RE/Discord-video-stream** (fork of `@dank074/discord-video-stream`,
master 2026-08-28): full DAVE in `src/client/voice/BaseMediaConnection.ts`
(Davey `DAVESession` init via `initDave`, MLS key-package / proposals /
commit / welcome / transitions; `WebRtcConnWrapper` encrypts audio/video via
`daveSession.encrypt(MediaType.VIDEO, codec, …)`). BUT it is STREAMING only
(send). No STREAM_WATCH / receive.
- `@discordjs/voice`: full DAVE receive for AUDIO only; `DAVESession.decrypt`
hardcodes `MediaType.AUDIO` (dist ~line 892); `onUdpMessage` drops non-opus;
no STREAM_WATCH.
- `discord.js-selfbot-v13`: video receive but no DAVE.
## Goal
Replace the dead selfbot receive path with a **DAVE-capable stream-watch voice
connection**: on detecting a member `voiceState.streaming`, send `STREAM_WATCH`,
connect a DAVE-authenticated RTC to the stream endpoint, decrypt incoming H264
RTP (`MediaType.VIDEO`), reassemble via the existing `H264Depacketizer`, mux to a
playable container. Reuse every tested building block already in the repo.
## Strategy decision (default A; B as fallback) — de-risk in Phase 2
Two implementation routes. Decide by Phase 2 prototype result.
### Strategy A — extend @discordjs/voice's tested native stack (PREFERRED, lighter)
Reuse @discordjs/voice 0.19.2 internals (already a runtime dep, already DAVE-tested
for audio):
- Drive a connection to the stream endpoint using djs/voice's `VoiceWebSocket` +
`VoiceUDPSocket` + `DAVESession` (the same classes that work for the guild
connection — they take arbitrary endpoint/token/session).
- Send the voice identify with `max_dave_protocol_version`, complete the DAVE
handshake (Davey), then on receipt of a video RTP packet call
`daveSession.decrypt(userId, MediaType.VIDEO, packet)` (Davey exposes
`MediaType.VIDEO` + `Codec.H264`) — djs/voice's hardcoded `AUDIO` is the only
blocker, fix by invoking Davey directly with `MediaType.VIDEO` for video SSRCs.
- STREAM_WATCH sent via the existing selfbot `client.ws.broadcast` (cheap, works —
it needs no selfbot voice connection).
- Feed decrypted H264 → `H264Depacketizer` → `.h264` → `muxToMp4` (both already
in `videoReceiver.ts`, unit-tested).
- No new runtime deps. Risk: relies on non-exported djs/voice internals (reachable
via `as any`, as the existing `parsePacket` usage shows).
### Strategy B — port Discord-RE's BaseMediaConnection (heavier, more self-contained)
Port `BaseMediaConnection.ts` DAVE handling + `WebRtcConnWrapper` into a
receive/watch connection in the gateway. Deps: requires `@lng2004/node-datachannel`
(new native WebRTC dep) + `@snazzah/davey` (already available). More code, more
risk (native dep in Nix store), but a clean-room receive path decoupled from
djs/voice internals. Use only if A proves infeasible.
## Files touched (Strategy A shape)
- `services/discord-gateway/src/modules/voice-recording/streamWatchReceiver.ts`
(NEW): DAVE stream-watch connection wrapper. Owns, per watched user:
- `sendStreamWatch(client, streamKey)` (via `client.ws.broadcast({op:20,
d:{stream_key}})`), stream_key = `guild:<gid>:<chid>:<uid>`.
- collects STREAM_CREATE (rtc_server_id) + STREAM_SERVER_UPDATE (token+
endpoint) via `client.on('raw')` match on stream_key.
- builds a djs/voice-style connection to `<endpoint>` with the received token/
session; completes DAVE handshake.
- `onUdpMessage` wrapper: for video payload types, decrypt with
`daveSession.decrypt(userId, MediaType.VIDEO, buf)`, depacketize, write.
- teardown on STREAM_DELETE / user stops streaming / leave / channel untrack.
- `recorder.ts`: wire `trackChannel`/`untrackChannel` already exist; ensure the
selfbot *eager voice connection* attempt is REMOVED (it only 4017-spams logs) —
but KEEP `client.ws.broadcast` availability for STREAM_WATCH.
- `videoRecorder.ts`: remove the dead selfbot `joinChannel`/`joinStreamConnection`
calls; keep the `voiceStateUpdate` streaming detection + teardown bookkeeping as
the entry point; delegate the actual receive to `streamWatchReceiver`.
- `videoReceiver.ts`: keep `H264Depacketizer` + `muxToMp4` (reused). The
guild-socket `hookVideoReceiver` can be removed or left inert.
- Tests: `tests/streamWatchReceiver.test.ts` (DAVE-handshake stub, RTP decrypt path
with a mocked Davey, STREAM_WATCH packet shape); keep `tests/videoReceiver.test.ts`.
## Phases (each independently verifiable)
1. **Phase 1 (this session): spec + source reconnaissance.** Confirm djs/voice
internals are reachable (VoiceWebSocket/VoiceUDPSocket/DAVESession exports &
shapes), confirm Davey `MediaType.VIDEO` decrypt signature, confirm how a raw
stream-watch connection's identify/select-protocol flows. Verify the selfbot
`streamKey` format + `raw` STREAM_CREATE/SERVER_UPDATE payload. GATE: accurate
spec + no unknowns blocking A.
2. **Phase 2: de-risk prototype.** Standalone script (not in the gateway) that:
logs into the same selfbot token, joins a real voice channel, sends STREAM_WATCH
for a live streamer, receives STREAM_CREATE/SERVER_UPDATE, and attempts a
DAVE-authenticated connect + receive of ≥1 H264 packet to prove the path before
any gateway integration. GATE: at least one decrypted H264 NAL captured in the
lab.
3. **Phase 3: gateway integration** per files-touched. GATE: typecheck + build +
biome + unit tests green; CI deploy ok.
4. **Phase 4: live verify.** With a real streamer in a recorded channel: journal
shows `STREAM_WATCH sent`, `DAVE ready`, `Video burst opened`, and a playable
`.h264`/`.mp4`/`.mkv` on disk. GATE: playable file with real video content.
## Risks / open questions
- Does Discord require the stream-watch connection to use the SAME session_id as
the bot's active voice session, or a fresh one? (Affects identify.) Resolve in P2.
- Which video codec does Discord actually send for camera vs GoLive (H264 likely,
but VP9/AV1 possible) — the depacketizer only handles H264. P2 measures the
payload type live; add depacketizers for other codecs only if observed.
- djs/voice `DAVESession`/`VoiceUDPSocket` reachability via `as any` must be
confirmed against the installed 0.19.2 build (P1).
- The separate stream RTC may need `selectProtocol`/SDP even for receive-only; the
Discord-RE SDP shows a `m=video ... inactive` section. Follow the same shape.
## Verification (overall)
- Per-phase gates above.
- No regression: audio recording + message capture still work after changes.
- `pnpm typecheck && pnpm build && pnpm lint` green in discord-gateway.
- Commit + push; CI `Build & Deploy (Nix)` green; live streamer produces a file.
## Phase 1 findings (CONFIRMED 2026-08-31, Strategy A feasible)
- `@discordjs/voice` 0.19.2 dist/index.mjs PUBLICLY exports exactly the primitives
needed: `DAVESession`, `Networking`, `NetworkingStatusCode`, `VoiceConnection`,
`VoiceReceiver`, `VoiceUDPSocket`, `VoiceWebSocket`, `SSRCMap`,
`RTP_OPUS_PAYLOAD_TYPE` (export block ~3143). So a stream-watch connection can be
built OUTSIDE the lib using these constructors — no `as any` needed for the heavy
lifting.
- `Networking` child wiring (~line 1364-1484): `new VoiceWebSocket('wss://' +
endpoint + '?v=8', debug)`; on WS open send Identify `{op, d:{server_id,
user_id, session_id, token, max_dave_protocol_version: getMaxProtocolVersion()}}`;
`createDaveSession(protocolVersion)` → `new DAVESession(protocolVersion, userId,
channelId, {decryptionFailureTolerance})` then `.reinit()`; UDP via
`new VoiceUDPSocket({ip, port})` after Ready gives modes + ssrc.
- `DAVESession` wraps `@snazzah/davey` `Davey.DAVESession(protocolVersion, userId,
channelId)`; on network packets it calls `this.session.decrypt(userId,
Davey.MediaType.AUDIO, packet)` — hardcoded AUDIO (line ~892). For video we call
Davey directly with `MediaType.VIDEO` + `Codec.H264`.
- `@snazzah/davey` MediaType enum: AUDIO=0, VIDEO=1; Codec H264=4; methods
`decrypt(mediaType, codec, packet): Buffer` + `encrypt(...)`. Confirmed in davey
index.d.ts.
- Stream key format (selfbot VoiceConnection.js ~1240): `guild:<gid>:<chid>:<uid>`
for guild channels; `STREAM_WATCH` = gateway op 20, `d:{stream_key}`;
`sendSignalScreenshare` = `client.ws.broadcast({op:20,d:{stream_key}})`. Replies
come as gateway `raw` events `STREAM_CREATE` (`d.rtc_server_id`) +
`STREAM_SERVER_UPDATE` (`d.token`, `d.endpoint`); selfbot routes them to the
stream connection via `client.on('raw')` matching `d.stream_key`, setting
`setSessionId(sessionId)` + `setTokenAndEndpoint(token, endpoint)` (Watch case in
`StreamConnectionReadonly`).
- Discord-RE reference for the identify SDP: stream connections send a `m=video`
section with `a=inactive` (receive-only-ish) + standard DAVE/VoiceOpCodes
(op 0 identify, op 2 select protocol incl. `max_dave_protocol_version`). See
`BaseMediaConnection.handleProtocolAck` + `initDave`.
- Decision: proceed with **Strategy A**. Selfbot code to REMOVE: the eager
`ensureSelfbotVoice` join + `joinStreamConnection`/`receiver.createVideoStream`
in `videoRecorder.ts` (proven dead — 4017). Keep the `voiceStateUpdate` streaming
detection + bookkeeping; swap the receive plumbing to a new `streamWatchReceiver`
driven by a djs/voice-style connection. STREAM_WATCH itself still sent via the
selfbot `client.ws.broadcast` (needs only the WS, not a selfbot voice conn).
- OPEN (resolve in Phase 2 lab): (a) whether the stream connection's identify must
use the bot's ACTIVE voice session_id or a fresh one; (b) actual video codec Discord
sends for camera vs GoLive (measure payload type live; H264 assumed, VP9/AV1 possible
→ add depacketizers only if observed).
@@ -1,93 +0,0 @@
# Spec: Fix Video Capture — Eagerly Establish the Selfbot Voice Connection at Join Time
Status: PLANNED
Date: 2026-08-31
Author: Hermes
Related: `docs/specs/2026-08-31_video-receive-phaseC-spec.md` (Phase C build, made Option A this fix)
## Symptom (from live logs, 2026-08-31 ~12:34)
A user was actively screen-sharing + on camera in the recorded voice channel.
The gateway recorded MANY users' audio (.ogg) fine, but video capture produced
nothing. The only video signal in `journalctl -u gmw-discord-gateway` was:
```
[VOICE (guild:2)]: Sending voice state update: {"self_mute":false,...,"flags":2}
[VOICE] received voice state update: {member hunterz ...} # OTHER user, not bot
[VOICE] connection? true, guild session channel
[VOICE (guild:2)]: Setting sessionId <S> (stored as "undefined")
[VOICE (guild:2)]: Authenticated with sessionId <S> # debug print only
[VOICE (guild:2)]: Authenticate failed - VOICE_CONNECTION_TIMEOUT # +15s
video-recorder: userId=..., "Connection not established within 15 seconds."
```
## Root cause (verified against discord.js-selfbot-v13 3.7.1 source)
The gateway records audio via `@discordjs/voice` (`joinVoiceChannel` + adapter).
Video receive lives on the SEPARATE selfbot `ClientVoiceManager.connection`
(a singleton `VoiceConnection`). `videoRecorder.ts` currently calls
`client.voice.joinChannel(channel)` LAZILY — only when a `voiceStateUpdate`
shows `newState.streaming === true`.
At that moment the bot is ALREADY connected to the channel via @discordjs/voice.
A selfbot `joinChannel` then does `VoiceConnection.authenticate()` →
`sendVoiceStateUpdate()`, and waits for a fresh `VOICE_SERVER_UPDATE`
(`setTokenAndEndpoint`) + `VOICE_STATE_UPDATE` (`setSessionId`) to reach
`checkAuthenticated()` (needs token+endpoint+sessionId). Because the bot is
already in an established voice session, Discord does NOT emit a new
`VOICE_SERVER_UPDATE` for the lazy selfbot re-join → token/endpoint never set →
15s `VOICE_CONNECTION_TIMEOUT`.
This is fatal to video: `joinStreamConnection(userId)` (STREAM_WATCH op 20) and
`receiver.createVideoStream(userId, out)` (Recorder/ffmpeg) BOTH live on the
parent selfbot `VoiceConnection` and require it `CONNECTED` (its own voice
WS+UDP socket feeds `PacketHandler.push`, authenticated with
`authentication.secret_key`).
## Fix — establish the selfbot connection eagerly, at voice-join time
The selfbot `VoiceConnection` must exist and be `CONNECTED` before any streamer
appears. Establish it once, synchronously alongside the @discordjs/voice join in
`recorder.startRecording`, so it rides the bot's FRESH voice join — when Discord
DOES emit VOICE_SERVER_UPDATE. Then cache it and let `videoRecorder` reuse it.
Ordering: in `startRecording`, after the @discordjs/voice `joinVoiceChannel`
returns (and retries) — fire `ensureSelfbotVoice(channel)` best-effort:
1. `await client.voice.joinChannel(channel, { selfMute:false, selfDeaf:false,
selfVideo:false })` (rejects ~VOICE_CONNECTION_TIMEOUT on failure → log +
return null; do NOT block audio).
2. Cache the returned selfbot `VoiceConnection` keyed by guildId.
3. Wire teardown: on `recorder` voice stop / destroyed → `untrackChannel` +
destroy the cached selfbot connection (`disconnect()`).
`videoRecorder.startVideoRecording` then uses the cached selfbot connection:
- If cached & `status === CONNECTED` → use it.
- Else → fall back to a lazy `joinChannel` (still best-effort).
## The two-connection coexistence risk (must verify live)
@discordjs/voice (audio) and the selfbot `VoiceConnection` (video) each open
their OWN low-level voice WS+UDP on the same session. The spec's original
open-question flagged this. Mitigations:
- Clear logging: `Selfbot voice connected (guild=...)`, plus a periodic
`djs/voice status` log so we can confirm audio stays `READY` while the selfbot
connection is up.
- If Discord kicks/breaks the audio connection, logs will show
@discordjs/voice `Disconnected`/reconnect churn — we detect and pivot.
## Files touched
- `services/discord-gateway/src/modules/voice-recording/videoRecorder.ts`:
add `ensureSelfbotVoice(channel)` (return cached/connected), use it in
`startVideoRecording`, add `destroyGuildSelfbotVoice(guildId)`,
richer status logging.
- `services/discord-gateway/src/modules/voice-recording/recorder.ts`: call
`ensureSelfbotVoice(channel)` after `joinVoiceChannel` (best-effort);
call `destroyGuildSelfbotVoice` on voice stop/destroy.
- Tests: `tests/videoRecorder.test.ts` (update to assert eager-connection reuse
+ status gating).
## Verification
1. `pnpm typecheck` + `pnpm build` + biome clean (discord-gateway).
2. Tests green.
3. Commit + push; CI `Build & Deploy (Nix)` green, service restarts.
4. LIVE (deploy): join a channel with the bot → journal shows
`Selfbot voice connected` (parent CONNECTED). When a member streams →
`Sender signal screenshare` / `Video recorder ready` + a `.mkv` under
`<RECORDINGS_DIR>/<uid>/video-*.mkv`; playable via ffmpeg. Confirm audio
recording still flows (no djs/voice reconnect churn).
@@ -1,97 +0,0 @@
# Spec: Record Other Users' Video (Camera / Screen Share) — Phase C
Status: PLANNED (not built)
Date: 2026-08-31
Author: Hermes
Related: `docs/specs/2026-08-30_video-record-receive-spec.md` (Phase A/B — raw UDP hook, superseded for receive)
## TL;DR — what changed vs Phase A/B
Phase A/B (commit `999c054b` etc.) hooked `@discordjs/voice`'s UDP socket to capture non-opus RTP and
depacketize H264 → mp4. **It captured ZERO video** because `@discordjs/voice` never authorizes the bot to
receive others' video (no STREAM_WATCH). This spec replaces that approach with the **native, selfbot-lib
receive path**, which is battle-tested and does the authorization + decryption + ffmpeg muxing for us.
## Ground truth (verified in discord.js-selfbot-v13 3.7.1 source)
1. `ClientVoiceManager.joinChannel(channel, config)` → a **selfbot `VoiceConnection`** with
`.receiver` (`VoiceReceiver` → `PacketHandler`). [ClientVoiceManager.js:102-118]
2. `VoiceConnection.receiver` is created in the constructor. [VoiceConnection.js:140]
3. `VoiceReceiver.createVideoStream(user, output)` → `PacketHandler.makeVideoStream` → **`Recorder`**
(ffmpeg that muxes H264+Opus RTP over UDP → **Matroska (.mkv)**). [Receiver.js, Recorder.js]
4. `PacketHandler` routes: video RTP → Recorder UDP 65506, opus RTP → UDP 65510; decodes all via
`connection.authentication.{secret_key, mode}` (supports `aead_aes256_gcm_rtpsize` and
`aead_xchacha20_poly1305_rtpsize` = DAVE-compatible). [PacketHandler.js:115-155, 195-240]
5. `StreamConnectionReadonly.joinStreamConnection(userId)` + `sendSignalScreenshare()` sends
gateway op `STREAM_WATCH` so Discord actually forwards the streamer's RTP to us. [VoiceConnection.js:1100-1240]
6. `VoiceState.streaming` = `data.self_stream ?? false` — lets us detect a streamer on voice state update. [VoiceState.js:94]
## Problem / the crux
The gateway's voice today is **`@discordjs/voice`** (audio + music + GoLive-send). The selfbot-lib
video-receive path lives on the **selfbot-lib `VoiceConnection`** — a separate voice stack. Two options:
### Option A (RECOMMENDED): Parallel selfbot video-watch connection
Keep `@discordjs/voice` for everything it does today. Add a **second, selfbot-lib voice connection**
to the same channel whose ONLY job is to watch + record others' video.
- Pros: zero regression risk to audio/music/screenshare-send; uses native `createVideoStream` → mk4.
- Cons: two voice connections for the same bot user in one channel. Need to verify Discord tolerates it
(real selfbots like Discord-RE do exactly this for multi-stream). The selfbot lib's `joinChannel`
reuses `ClientVoiceManager.connection` (it's a singleton) — see caveat below.
### Option B: Migrate primary voice to selfbot lib
Make the selfbot `VoiceConnection` THE voice layer (it also does audio via `receiver.createStream`).
- Pros: one connection; video+audio unified.
- Cons: large refactor; high regression risk to the entire existing audio/music/GoLive stack. NOT chosen now.
## CAVEAT — ClientVoiceManager.connection is a singleton
`ClientVoiceManager.connection` is a single `VoiceConnection`. The gateway's `@discordjs/voice` adapter and
the selfbot lib both drive the same client voice state. Need to verify whether `client.voice.joinChannel()`
can coexist with the active `@discordjs/voice` session, or whether we must create the selfbot VoiceConnection
manually / re-use the existing voice state. This is the #1 technical risk to validate in the spike before
committing to Option A.
## Implementation plan (Option A)
### 1. Streamer detector (new: `modules/voice-recording/videoRecorder.ts`)
- Listen to voice state updates (`client.on('voiceStateUpdate')` or the existing voice-state hook).
- When `voiceState.streaming === true` for a member in the bot's channel → candidate to record.
- Skip bot's own user id (unless we also want self-video; default skip).
### 2. Watch + record wiring
- Ensure a selfbot-lib `VoiceConnection` exists for the channel (spike: `client.voice.joinChannel(channel)`,
fallback: build a `VoiceConnection` directly from the existing voice auth).
- `await selfbotVoiceConn.joinStreamConnection(userId)` → STREAM_WATCH op 20.
- `const recorder = selfbotVoiceConn.receiver.createVideoStream(userId, outPath)` where outPath points under
`<RECORDINGS_DIR>/<uid>/video-<streamKey>-<ts>.mkv` (Recorder outputs MKV natively).
- On `recorder.on('ready')` → mark recording; `recorder.on('closed')` → finalize.
- Transcript later: MKV → mp4 via ffmpeg (Phase B `muxToMp4` can accept mkv) for dashboard playback.
### 3. Teardown
- When `voiceState.streaming === false` / user leaves / channel emptied → `recorder.destroy()`,
`selfbotVoiceConn.streamWatchConnection.delete(userId)` / `sendStopScreenshare()`.
### 4. Frontend (Phase UI, later)
- oRPC/backend list `.mkv` per call session + FE `<video>` player (mirror audio recordings UI).
## Files touched
- `services/discord-gateway/src/modules/voice-recording/videoRecorder.ts` (new)
- `services/discord-gateway/src/modules/voice-recording/recorder.ts` (wire streamer detector on voice join)
- Possibly `voiceController.ts` (voice state update subscription)
- Tests: `tests/videoRecorder.test.ts` (mock selfbot VoiceConnection + Recorder)
## Verification
1. `pnpm typecheck` + `pnpm build` + biome clean in discord-gateway.
2. Unit: Recorder wiring + streamer detection with mocked VoiceConnection.
3. Live (deploy): user shares screen → journal shows `STREAM_WATCH` sent + `Recorder ready` + `.mkv` file
appears under recordings dir; playable via ffmpeg.
4. CI Build & Deploy (Nix) green.
## Open questions for spike (before full build)
- [ ] Can `client.voice.joinChannel()` run alongside the active `@discordjs/voice` session, or does the
singleton `ClientVoiceManager.connection` collide / tear down the existing audio connection?
- [ ] Does the selfbot `VoiceConnection` need the bot's `video: true` flag in IDENTIFY to receive video
(it advertises `streams` in IDENTIFY — see BaseMediaConnection/identify vs selfbot VoiceConnection)?
- [ ] Does `Recorder` (spawns system ffmpeg, UDP loopback on 65506/65510) work in the Nix store runtime
(ffmpeg-headless on PATH confirmed; UDP loopback fine)?
@@ -1,33 +0,0 @@
# Video Recording Splitting — Like Voice Recording
## Goal
Camera + screen share (stream watch) recording should split into per-burst
segments just like voice recording does — each time a streamer pauses/stops
and resumes, a new MP4 segment is created and registered in the DB + uploaded.
## Voice Recording Model (to replicate)
1. `receiver.speaking.start` → new OGG segment per burst
2. AfterSilence (4000ms) → stream "end" → segment finalized + uploaded
3. Each segment → DB insert → OGG→MP3 transcode → upload → update DB
4. File stored as `<userId>/<startTime>.ogg` + `.json`
## Video Recording Splitting
1. DAVE video RTP → depacketize H264 → write to current segment .h264
2. Silence detection: no H264 packets for 4000ms → close segment → flush →
mux to MP4 → insert DB record → upload → start new segment on next packet
3. Each segment: `<userId>/video-<channelId>-<startTime>.h264` → `.mp4`
4. DB: reuse `voice_recordings` table (filename indicates video, e.g. `video-XXX-1234.mp4`)
5. Upload: MP4 to TeleUploader (no transcode needed — MP4 plays everywhere)
## Files Modified
- `services/discord-gateway/src/modules/voice-recording/streamWatchReceiver.ts`
— Main change: silence-based splitting + DB registration + upload
## Constants
- `VIDEO_SILENCE_MS = 4000` (matches voice AfterSilence)
- `VIDEO_MIN_SEGMENT_MS = 1000` (skip segments <1s — avoid noise)
## Verification
- `pnpm typecheck` in `services/discord-gateway`
- `pnpm build` (dist/ is the deployed artifact)
- Push → CI deploy → live test with a streamer
@@ -1,81 +0,0 @@
# Spec: Selfbot-Viable Video Capture — manual screen-share watch command (Phase D)
Status: PLANNED (not yet built)
Date: 2026-09-02
Author: Hermes
Related: `docs/specs/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).
+28 -56
View File
@@ -35,9 +35,11 @@
# ---- Shared build tools ---- # ---- Shared build tools ----
nodejs = pkgs.nodejs_22; nodejs = pkgs.nodejs_22;
pnpm = pkgs.pnpm.override { nodejs = nodejs; }; # Bun for deps/install (replaces pnpm); keeps nodejs for the tsc +
# fix-imports.mjs build path (Bun's own bundler is not used for dist).
bun = pkgs.bun;
pnpmInstall = '' bunInstall = ''
export HOME=$TMPDIR/home export HOME=$TMPDIR/home
export npm_config_cache=$TMPDIR/npm-cache export npm_config_cache=$TMPDIR/npm-cache
mkdir -p $npm_config_cache mkdir -p $npm_config_cache
@@ -48,15 +50,9 @@
export GIT_SSL_CAINFO=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt export GIT_SSL_CAINFO=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
export NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt export NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
# pnpm uses node-gyp for native addons — provide build tools (kept for # Build native addons (bun install runs postinstall scripts for
# the rare case a prebuilt is unavailable and it falls back to compile). # @discordjs/opus / sharp unless trustedDependencies restricts).
export CPPFLAGS="-I${pkgs.lib.getDev pkgs.openssl}/include" bun install 2>&1
export LDFLAGS="-L${pkgs.lib.getLib pkgs.openssl}/lib"
pnpm install --no-frozen-lockfile --ignore-scripts 2>&1
# Build native addons that need compilation
pnpm rebuild 2>&1 || true
''; '';
# Shrink the shipped node_modules to production deps only. The full # Shrink the shipped node_modules to production deps only. The full
@@ -74,28 +70,15 @@
# Must run AFTER tsc (typescript is a devDep) and after native builds. # Must run AFTER tsc (typescript is a devDep) and after native builds.
pruneProd = '' pruneProd = ''
echo "=== Pruning devDependencies (production-only node_modules) ===" echo "=== Pruning devDependencies (production-only node_modules) ==="
pnpm list --prod --depth 999 --parseable 2>/dev/null \ # bun install's layout: node_modules/<pkg> for prod deps; devDeps are
| grep -o '\.pnpm/[^/]*' | sort -u > $TMPDIR/prod-pnms.txt # also present during build (needed for tsc). Keep only what the prod
( cd node_modules/.pnpm \ # graph needs: simplest robust approach is `bun install --production`
&& for d in */; do \ # semantics — but bun keeps the same flat layout; since the Nix build
d="''${d%/}"; \ # already ran `bun install` (full, scripts on), prune dev-only top
[ "$d" = "node_modules" ] && continue; \ # entries that were only pulled by devDeps (typescript, biome, vitest,
grep -qF ".pnpm/$d" $TMPDIR/prod-pnms.txt || rm -rf "$d"; \ # drizzle-kit, tsx, @types/*).
done ) || true find node_modules -maxdepth 2 -type d \( -name 'typescript' -o -name '@biomejs' -o -name 'vitest' -o -name 'drizzle-kit' -o -name 'tsx' -o -name 'esbuild' \) -prune -exec rm -rf {} + 2>/dev/null || true
# Drop runtime-dead packages that still land in the prod graph: rm -rf node_modules/.bin/tsc node_modules/.bin/vitest node_modules/.bin/biome node_modules/.bin/drizzle-kit 2>/dev/null || true
# - `@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,
# hoist, .bin — any depth). Mirrors stdenv's noBrokenSymlinks check,
# which would otherwise fail the fixupPhase.
find node_modules -type l ! -exec test -e {} \; -delete 2>/dev/null || true find node_modules -type l ! -exec test -e {} \; -delete 2>/dev/null || true
du -sh node_modules du -sh node_modules
''; '';
@@ -107,11 +90,11 @@
src = ./services/backend; src = ./services/backend;
nativeBuildInputs = [ nodejs pnpm pkgs.python3 pkgs.gnumake pkgs.gcc pkgs.cacert ]; nativeBuildInputs = [ nodejs bun pkgs.python3 pkgs.gnumake pkgs.gcc pkgs.cacert ];
buildPhase = pnpmInstall + '' buildPhase = bunInstall + ''
echo "=== Compiling TypeScript ===" echo "=== Compiling TypeScript ==="
npx tsc 2>&1 ./node_modules/.bin/tsc 2>&1
echo "=== Fixing @/ path aliases + extensionless relative imports for node ESM ===" echo "=== Fixing @/ path aliases + extensionless relative imports for node ESM ==="
node scripts/fix-imports.mjs node scripts/fix-imports.mjs
echo "=== Build complete ===" echo "=== Build complete ==="
@@ -149,7 +132,7 @@ WRAPPER
# libvips download), so no cmake or rust toolchain is needed. # libvips download), so no cmake or rust toolchain is needed.
# python3/gnumake/gcc stay as node-gyp fallback for @discordjs/opus. # python3/gnumake/gcc stay as node-gyp fallback for @discordjs/opus.
nativeBuildInputs = [ nativeBuildInputs = [
nodejs pnpm nodejs bun
pkgs.python3 pkgs.gnumake pkgs.gcc pkgs.python3 pkgs.gnumake pkgs.gcc
pkgs.pkg-config pkgs.pkg-config
pkgs.openssl pkgs.openssl
@@ -177,22 +160,11 @@ WRAPPER
# neither needed nor wanted here. Skip it entirely. # neither needed nor wanted here. Skip it entirely.
dontFixup = true; dontFixup = true;
buildPhase = pnpmInstall + '' buildPhase = bunInstall + ''
echo "=== Building native voice deps ==="
# pnpm rebuild aborts on the first failing package and runs scripts
# from the wrong cwd — build each native dep explicitly with its own
# install script. Each failure is tolerated (|| true); the packages
# @discordjs/opus ships prebuilt binaries for Node 22 (ABI node-v127,
# linux-x64-glibc-2.35) — node-pre-gyp downloads the prebuilt .node
# instead of compiling C++ from source. With build_from_source unset
# (above), `pnpm rebuild` runs the package's own install script which
# fetches the matching prebuilt; it only falls back to a source build
# if the download fails. This keeps voice working without a per-build
# native compile.
echo "=== Rebuilding @discordjs/opus (prebuilt download) ===" echo "=== Rebuilding @discordjs/opus (prebuilt download) ==="
pnpm rebuild @discordjs/opus 2>&1 || true bun pm rebuild @discordjs/opus 2>&1 || true
echo "=== Compiling TypeScript ====" echo "=== Compiling TypeScript ===="
npx tsc 2>&1 ./node_modules/.bin/tsc 2>&1
echo "=== Fixing @/ path aliases + extensionless relative imports for node ESM ===" echo "=== Fixing @/ path aliases + extensionless relative imports for node ESM ==="
node scripts/fix-imports.mjs node scripts/fix-imports.mjs
echo "=== Build complete ===" echo "=== Build complete ==="
@@ -228,13 +200,13 @@ WRAPPER
src = frontendSrc; src = frontendSrc;
nativeBuildInputs = [ nodejs pnpm pkgs.gnumake pkgs.gcc pkgs.cacert ]; nativeBuildInputs = [ nodejs bun pkgs.gnumake pkgs.gcc pkgs.cacert ];
buildPhase = pnpmInstall + '' buildPhase = bunInstall + ''
echo "=== Building Next.js SSR (standalone) ===" echo "=== Building Next.js SSR (standalone) ==="
export NEXT_TELEMETRY_DISABLED=1 export NEXT_TELEMETRY_DISABLED=1
export GMW_BACKEND_URL=http://127.0.0.1:4001 export GMW_BACKEND_URL=http://127.0.0.1:4001
npx next build 2>&1 ./node_modules/.bin/next build 2>&1
''; '';
installPhase = '' installPhase = ''
@@ -312,13 +284,13 @@ WRAPPER
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
buildInputs = [ buildInputs = [
nodejs pnpm nodejs bun
pkgs.python3 pkgs.gnumake pkgs.gcc pkgs.python3 pkgs.gnumake pkgs.gcc
pkgs.rustc pkgs.cargo pkgs.rustc pkgs.cargo
pkgs.ffmpeg-headless pkgs.ffmpeg-headless
]; ];
shellHook = '' shellHook = ''
echo "GMW dev shell ready — node $(node --version), pnpm $(pnpm --version)" echo "GMW dev shell ready — node $(node --version), bun $(bun --version)"
''; '';
}; };
}); });
-3
View File
@@ -1,3 +0,0 @@
*
!.gitkeep
!README.md
View File
-15
View File
@@ -1,15 +0,0 @@
# Recordings Directory
This directory is mounted as a volume in the discord-gateway container.
Voice recordings are stored here with the following structure:
```
recordings/
├── <user-id>/
│ ├── <timestamp>.ogg
│ └── <timestamp>.json
└── sessions/
└── <session-id>.json
```
The directory must be writable by UID 1000 (app user in the container).
+1 -4
View File
@@ -59,10 +59,8 @@ src/
| messages | Store & query Discord messages | `messages`, `ai_moderations`, `ai_moderation_flags` | | messages | Store & query Discord messages | `messages`, `ai_moderations`, `ai_moderation_flags` |
| moderation | Moderation actions & metrics | `ai_moderations`, `moderation_actions` | | moderation | Moderation actions & metrics | `ai_moderations`, `moderation_actions` |
| media | Media file management | `media_attachments` | | media | Media file management | `media_attachments` |
| voice | Live speakers + recordings | `voice_recordings` |
| recordings | Recordings API | `voice_recordings` |
| dashboard | Stats aggregation | Various (read-only) | | dashboard | Stats aggregation | Various (read-only) |
| knowledge | Semantic search | Qdrant vector DB | | knowledge | Channel cultures & glossary browser | `channel_cultures`, `term_glossary_cache` |
| chatbot | AI chatbot with tools | `chatbot_history` | | chatbot | AI chatbot with tools | `chatbot_history` |
| health | Health checks + metrics | Various | | health | Health checks + metrics | Various |
| analysis | Text analysis cache | `text_analysis_cache` | | analysis | Text analysis cache | `text_analysis_cache` |
@@ -91,7 +89,6 @@ The frontend fetches via `src/lib/api/server.ts` (SSR, server-side) and
``` ```
discord:message:{created,updated,deleted,analyzed} discord:message:{created,updated,deleted,analyzed}
discord:attachment:{created,uploaded} discord:attachment:{created,uploaded}
discord:voice:{started,stopped,uploaded,active_user,pcm,analyzed}
discord:analysis:queue_status discord:analysis:queue_status
discord:reaction:{added,removed} discord:reaction:{added,removed}
discord:thread:{created,deleted,updated} discord:thread:{created,deleted,updated}
-12
View File
@@ -35,16 +35,6 @@ services/backend/
│ │ │ └── routes/ │ │ │ └── routes/
│ │ │ └── index.ts │ │ │ └── index.ts
│ │ │ │ │ │
│ │ ├── media/
│ │ │ ├── media.service.ts
│ │ │ └── routes/
│ │ │ └── index.ts
│ │ │
│ │ ├── voice/
│ │ │ ├── voice.service.ts
│ │ │ └── routes/
│ │ │ └── index.ts
│ │ │
│ │ └── health/ │ │ └── health/
│ │ ├── health.schema.ts │ │ ├── health.schema.ts
│ │ ├── health.repository.ts │ │ ├── health.repository.ts
@@ -148,8 +138,6 @@ export const messageQuerySchema = z.object({
|--------|---------|--------| |--------|---------|--------|
| **messages** | Text message storage & retrieval | GET /api/messages, GET /api/messages/:channelId | | **messages** | Text message storage & retrieval | GET /api/messages, GET /api/messages/:channelId |
| **analytics** | Moderation statistics & trends | GET /api/analytics/overview, /daily-trend, /hourly-stats | | **analytics** | Moderation statistics & trends | GET /api/analytics/overview, /daily-trend, /hourly-stats |
| **media** | Media file management | GET /api/media/list, POST /api/media/upload |
| **voice** | Voice recording management | GET /api/voice/recordings, POST /api/voice/connect |
| **health** | Service health checks | GET /api/health | | **health** | Service health checks | GET /api/health |
## Data Flow ## Data Flow
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["./tests/setup-env.ts"]
+8 -8
View File
@@ -5,7 +5,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"packageManager": "pnpm@11.20.0", "packageManager": "bun@1.3.14",
"engines": { "engines": {
"node": ">=22.12.0", "node": ">=22.12.0",
"pnpm": ">=9.0.0" "pnpm": ">=9.0.0"
@@ -16,15 +16,15 @@
"format": "biome format --write .", "format": "biome format --write .",
"lint": "biome check --diagnostic-level=error .", "lint": "biome check --diagnostic-level=error .",
"start": "node dist/index.js", "start": "node dist/index.js",
"test": "vitest run", "test": "bun test tests/",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"test:e2e": "bun test src/e2e.test.ts"
}, },
"dependencies": { "dependencies": {
"@discordjs/voice": "^0.19.2", "@orpc/server": "1.15.3",
"@orpc/server": "1.15.2",
"axios": "^1.20.0", "axios": "^1.20.0",
"dotenv": "^18.0.1", "dotenv": "^18.0.1",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.3",
"express": "^5.2.1", "express": "^5.2.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"ioredis": "^6.0.0", "ioredis": "^6.0.0",
@@ -40,8 +40,8 @@
"@types/node": "^26.6.2", "@types/node": "^26.6.2",
"@types/pg": "^8.23.1", "@types/pg": "^8.23.1",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"tsx": "^4.23.13", "tsx": "^4.23.15",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vitest": "^5.0.1" "@types/bun": "latest"
} }
} }
-2883
View File
File diff suppressed because it is too large Load Diff
+9 -23
View File
@@ -6,7 +6,13 @@ import { describe, expect, it } from "vitest";
const BASE = process.env.API_BASE ?? "http://localhost:4001/api"; const BASE = process.env.API_BASE ?? "http://localhost:4001/api";
async function api(path: string, init?: RequestInit) { /** Result of a JSON API call: status + parsed body (or null for 204/empty). */
interface ApiResult {
status: number;
body: Record<string, unknown> | null;
}
async function api(path: string, init?: RequestInit): Promise<ApiResult> {
const res = await fetch(`${BASE}${path}`, { const res = await fetch(`${BASE}${path}`, {
...init, ...init,
headers: { "Content-Type": "application/json", ...init?.headers }, headers: { "Content-Type": "application/json", ...init?.headers },
@@ -23,7 +29,8 @@ describe("API Health", () => {
}); });
it("GET /metrics returns prometheus text", async () => { it("GET /metrics returns prometheus text", async () => {
const res = await fetch(`${BASE.replace("/api", "")}/api/metrics`); const base = BASE.endsWith("/api") ? BASE.slice(0, -4) : BASE;
const res = await fetch(`${base}/api/metrics`);
expect(res.status).toBe(200); expect(res.status).toBe(200);
const text = await res.text(); const text = await res.text();
expect(text).toContain("nodejs"); expect(text).toContain("nodejs");
@@ -41,20 +48,6 @@ describe("API Dashboard", () => {
}); });
}); });
describe("API Recordings", () => {
it("GET /recordings returns items with pagination", async () => {
const { status, body } = await api("/recordings?limit=5");
expect(status).toBe(200);
expect(body).toHaveProperty("items");
expect(Array.isArray(body.items)).toBe(true);
if (body.items.length > 0) {
expect(body.items[0]).toHaveProperty("id");
expect(body.items[0]).toHaveProperty("username");
expect(body.items[0]).toHaveProperty("created_at");
}
});
});
describe("API Config", () => { describe("API Config", () => {
it("GET /config returns 200", async () => { it("GET /config returns 200", async () => {
const { status } = await api("/config"); const { status } = await api("/config");
@@ -62,13 +55,6 @@ describe("API Config", () => {
}); });
}); });
describe("API Voice", () => {
it("GET /guilds returns 200", async () => {
const { status } = await api("/guilds");
expect(status).toBe(200);
});
});
describe("API Negative", () => { describe("API Negative", () => {
it("GET /nonexistent returns 404", async () => { it("GET /nonexistent returns 404", async () => {
const { status } = await api("/nonexistent"); const { status } = await api("/nonexistent");
@@ -1,10 +0,0 @@
import { z } from "zod";
export const searchQuerySchema = z.object({
q: z.string().default(""),
channelId: z.string().optional(),
guildId: z.string().optional(),
limit: z.coerce.number().int().positive().max(100).default(20),
});
export type SearchQuery = z.infer<typeof searchQuerySchema>;
@@ -1,5 +0,0 @@
import { z } from "zod";
export const healthCheckSchema = z.object({
verbose: z.coerce.boolean().optional().default(false),
});
@@ -1,80 +0,0 @@
/**
* moderationMetrics.ts
*
* Prometheus metrics for AI moderation pipeline.
* Defined in backend (where prom-client is installed + /api/metrics endpoint).
*/
import { Counter, Histogram } from "prom-client";
// ── LLM Call Metrics ──
export const llmCallsTotal = new Counter({
name: "moderation_llm_calls_total",
help: "Total LLM moderation calls",
labelNames: ["path", "model"] as const,
});
export const llmCallDuration = new Histogram({
name: "moderation_llm_call_duration_ms",
help: "LLM moderation call duration (ms)",
labelNames: ["path", "status"] as const,
buckets: [500, 1000, 2000, 5000, 10000, 20000, 30000, 60000, 120000],
});
export const llmTokensTotal = new Counter({
name: "moderation_llm_tokens_total",
help: "Total tokens consumed by LLM moderation",
labelNames: ["type"] as const,
});
// ── Cache Metrics ──
export const moderationCacheHits = new Counter({
name: "moderation_cache_hits_total",
help: "Moderation cache hits",
labelNames: ["layer"] as const,
});
export const moderationCacheMisses = new Counter({
name: "moderation_cache_misses_total",
help: "Moderation cache misses",
labelNames: ["layer"] as const,
});
// ── Media Analysis Metrics ──
export const mediaAnalysesTotal = new Counter({
name: "moderation_media_analyses_total",
help: "Media analyses performed",
labelNames: ["type"] as const,
});
export const mediaDownloadDuration = new Histogram({
name: "moderation_media_download_duration_ms",
help: "Media download duration (ms)",
labelNames: ["source"] as const,
buckets: [100, 500, 1000, 2000, 5000, 10000, 30000],
});
// ── Batch & Error Metrics ──
export const moderationBatchSize = new Histogram({
name: "moderation_batch_size",
help: "Messages per batch",
labelNames: ["path"] as const,
buckets: [1, 5, 10, 20, 50, 100],
});
export const moderationErrors = new Counter({
name: "moderation_errors_total",
help: "Moderation errors",
labelNames: ["type"] as const,
});
export const webSearchCalls = new Counter({
name: "moderation_websearch_calls_total",
help: "Wikipedia web-search calls",
labelNames: ["status"] as const,
});
export const autoDeleteActions = new Counter({
name: "moderation_auto_delete_total",
help: "Auto-delete actions",
labelNames: ["action"] as const,
});
@@ -1,13 +0,0 @@
import { z } from "zod";
export const mediaQueueSchema = z.object({
source: z.string().min(1, "source is required"),
mode: z.enum(["music", "screen"]).default("music"),
});
export const mediaLoopSchema = z.object({
loop: z.boolean().default(false),
});
export type MediaQueueInput = z.infer<typeof mediaQueueSchema>;
export type MediaLoopInput = z.infer<typeof mediaLoopSchema>;
@@ -1,171 +0,0 @@
import {
createChildLogger,
tryCommandThenFallback,
} from "../../shared/commandHelper.js";
import {
COMMAND_MEDIA_LOOP,
COMMAND_MEDIA_QUEUE,
COMMAND_MEDIA_SKIP,
COMMAND_MEDIA_STOP,
MEDIA_STATUS_KEY,
} from "../../shared/index.js";
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
const logger = createChildLogger("media.service");
// ---------------------------------------------------------------------------
// Types — match frontend exactly
// ---------------------------------------------------------------------------
export interface MediaItem {
id?: string;
source: string;
title: string;
mode?: "music" | "screen";
durationMs?: number | null;
thumbnailUrl?: string | null;
}
export interface MediaState {
playing: boolean;
/** null/absent when idle; "music" | "screen" while a track is active. */
activeMode?: "music" | "screen" | null;
musicVolume: number;
loop: boolean;
current: MediaItem | null;
queue: MediaItem[];
}
// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
const DEFAULT_COMMAND_TIMEOUT_MS = 5000;
const DEFAULT_STATE: MediaState = {
playing: false,
activeMode: null,
musicVolume: 0.3,
loop: false,
current: null,
queue: [],
};
// ---------------------------------------------------------------------------
// Normalisation — handle both boolean (new) and string (legacy) playing values
// ---------------------------------------------------------------------------
function normalizeMediaState(raw: Record<string, unknown>): MediaState {
const rawPlaying = raw.playing;
const playing =
rawPlaying === true ||
rawPlaying === "playing" ||
rawPlaying === "buffering";
const mode = raw.activeMode;
const activeMode: "music" | "screen" | null =
mode === "music" || mode === "screen" ? mode : null;
return {
playing,
activeMode,
musicVolume: Number(raw.musicVolume ?? 0.3),
loop: Boolean(raw.loop ?? false),
current: (raw.current as MediaItem | null) ?? null,
queue: (raw.queue as MediaItem[]) ?? [],
};
}
type MediaReplyData = Record<string, unknown> | MediaState;
function _fromReply(data: MediaReplyData): MediaState {
return normalizeMediaState(data as Record<string, unknown>);
}
async function readStatusFallback(): Promise<MediaState> {
const cached = await readRedisStatus(MEDIA_STATUS_KEY);
return cached ? normalizeMediaState(cached) : DEFAULT_STATE;
}
// ---------------------------------------------------------------------------
// Service methods
// ---------------------------------------------------------------------------
/**
* Read media status from Redis key `media:status` set by discord-gateway.
*/
export async function getStatus(): Promise<MediaState> {
logger.debug("getStatus called");
return readStatusFallback();
}
/**
* Queue a media source via Redis command to discord-gateway.
*/
export async function queue(
source: string,
mode: "music" | "screen" = "music",
): Promise<MediaState> {
logger.info({ source, mode }, "queue called");
return tryCommandThenFallback(
() =>
publishCommand<MediaState>(
COMMAND_MEDIA_QUEUE,
// NOTE: gateway MediaHandler reads `payload.url` (not `source`) —
// keep the field name aligned or playback silently no-ops.
{ url: source, mode },
DEFAULT_COMMAND_TIMEOUT_MS,
),
() => readStatusFallback(),
"queue",
);
}
/**
* Skip current track via Redis command to discord-gateway.
*/
export async function skip(): Promise<MediaState> {
logger.info("skip called");
return tryCommandThenFallback(
() =>
publishCommand<MediaState>(
COMMAND_MEDIA_SKIP,
{},
DEFAULT_COMMAND_TIMEOUT_MS,
),
() => readStatusFallback(),
"skip",
);
}
/**
* Stop playback via Redis command to discord-gateway.
*/
export async function stop(): Promise<MediaState> {
logger.info("stop called");
return tryCommandThenFallback(
() =>
publishCommand<MediaState>(
COMMAND_MEDIA_STOP,
{},
DEFAULT_COMMAND_TIMEOUT_MS,
),
() => readStatusFallback(),
"stop",
);
}
/**
* Toggle loop mode (replay current track on natural end) via Redis command.
*/
export async function setLoop(loop: boolean): Promise<MediaState> {
logger.info({ loop }, "setLoop called");
return tryCommandThenFallback(
() =>
publishCommand<MediaState>(
COMMAND_MEDIA_LOOP,
{ loop },
DEFAULT_COMMAND_TIMEOUT_MS,
),
() => readStatusFallback(),
"setLoop",
);
}
@@ -1,65 +0,0 @@
import { config } from "@/shared/config/index";
import { createChildLogger } from "@/shared/logger/index";
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.
* Uses raw fetch (the backend has no openai SDK dependency) and returns null
* when embeddings are not configured (search unavailable).
*
* encoding_format: "float" is REQUIRED — Nvidia-backed models reject base64.
*/
export async function embedQuery(rawQuery: string): Promise<number[] | null> {
if (!config.AI_LLM_API_KEY || !config.AI_LLM_EMBEDDING_MODEL) return null;
const text = normalizeEmbeddingQuery(rawQuery);
if (!text) return null;
try {
const res = await fetch(`${config.AI_LLM_BASE_URL}/embeddings`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.AI_LLM_API_KEY}`,
},
body: JSON.stringify({
model: config.AI_LLM_EMBEDDING_MODEL,
input: text,
encoding_format: "float",
}),
});
if (!res.ok) {
logger.warn({ status: res.status }, "query embed HTTP error");
return null;
}
const json = (await res.json()) as {
data?: Array<{ embedding?: number[] }>;
};
return json.data?.[0]?.embedding ?? null;
} catch (error) {
logger.warn(
{ error: error instanceof Error ? error.message : String(error) },
"query embed failed",
);
return null;
}
}
@@ -51,10 +51,51 @@ export interface AttachmentResult {
uploaded_at: number | null; uploaded_at: number | null;
} }
type MessageRow = ReturnType<typeof mapMessageRow>;
export type { MessageRow };
/**
* Build the NULL-safe "exclude spam threads" condition. Non-thread messages
* (NULL thread_id) are always kept; thread messages are kept only when their
* thread is not in the configured exclusion list.
*/
function excludeSpamThreads(): SQL | undefined {
if (EXCLUDED_THREAD_IDS.length === 0) return undefined;
return or(
isNull(pgMessagesTable.thread_id),
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
);
}
/** Normalize a raw attachment DB row to the API shape. */
function mapAttachmentRow(r: Record<string, unknown>): AttachmentResult {
return {
id: String(r.id ?? ""),
message_id: String(r.message_id ?? ""),
guild_id: String(r.guild_id ?? ""),
channel_id: String(r.channel_id ?? ""),
thread_id: (r.thread_id as string | null) ?? null,
user_id: String(r.user_id ?? ""),
filename: String(r.filename ?? ""),
size: Number(r.size ?? 0),
type: String(r.type ?? ""),
discord_url: String(r.discord_url ?? ""),
uploaded_url: (r.uploaded_url as string | null) ?? null,
upload_status: String(r.upload_status ?? "pending"),
upload_error: (r.upload_error as string | null) ?? null,
created_at: Number(r.created_at ?? 0),
uploaded_at: (r.uploaded_at as number | null) ?? null,
};
}
/** Select the first `limit + 1` rows so the caller can derive the next cursor. */
function cursorLimit(limit: number): number {
return limit + 1;
}
export class MessagesRepository { export class MessagesRepository {
async findMany( async findMany(query: MessageQuery): Promise<PageResult<MessageRow>> {
query: MessageQuery,
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> {
const db = getDatabase(); const db = getDatabase();
const limit = query.limit ?? 50; const limit = query.limit ?? 50;
const conditions: SQL[] = []; const conditions: SQL[] = [];
@@ -76,13 +117,8 @@ export class MessagesRepository {
} }
// Exclude spam threads (NULL-safe: non-thread messages are kept) // Exclude spam threads (NULL-safe: non-thread messages are kept)
if (EXCLUDED_THREAD_IDS.length > 0) { const excludeThreads = excludeSpamThreads();
const excludeThreads = or( if (excludeThreads) conditions.push(excludeThreads);
isNull(pgMessagesTable.thread_id),
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
);
if (excludeThreads) conditions.push(excludeThreads);
}
const where = conditions.length > 0 ? and(...conditions) : undefined; const where = conditions.length > 0 ? and(...conditions) : undefined;
const rows = await db const rows = await db
@@ -90,7 +126,7 @@ export class MessagesRepository {
.from(pgMessagesTable) .from(pgMessagesTable)
.where(where) .where(where)
.orderBy(desc(pgMessagesTable.created_at)) .orderBy(desc(pgMessagesTable.created_at))
.limit(limit + 1); .limit(cursorLimit(limit));
const data = rows const data = rows
.slice(0, limit) .slice(0, limit)
@@ -138,7 +174,7 @@ export class MessagesRepository {
async findByChannel( async findByChannel(
channelId: string, channelId: string,
query: MessageQuery, query: MessageQuery,
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> { ): Promise<PageResult<MessageRow>> {
const db = getDatabase(); const db = getDatabase();
const limit = query.limit ?? 50; const limit = query.limit ?? 50;
const conditions: SQL[] = [eq(pgMessagesTable.channel_id, channelId)]; const conditions: SQL[] = [eq(pgMessagesTable.channel_id, channelId)];
@@ -148,20 +184,15 @@ export class MessagesRepository {
} }
// Exclude spam threads (NULL-safe) // Exclude spam threads (NULL-safe)
if (EXCLUDED_THREAD_IDS.length > 0) { const excludeThreads = excludeSpamThreads();
const excludeThreads = or( if (excludeThreads) conditions.push(excludeThreads);
isNull(pgMessagesTable.thread_id),
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
);
if (excludeThreads) conditions.push(excludeThreads);
}
const rows = await db const rows = await db
.select() .select()
.from(pgMessagesTable) .from(pgMessagesTable)
.where(and(...conditions)) .where(and(...conditions))
.orderBy(desc(pgMessagesTable.created_at)) .orderBy(desc(pgMessagesTable.created_at))
.limit(limit + 1); .limit(cursorLimit(limit));
const data = rows const data = rows
.slice(0, limit) .slice(0, limit)
@@ -181,7 +212,7 @@ export class MessagesRepository {
async *streamMany( async *streamMany(
query: MessageQuery, query: MessageQuery,
pageSize = 50, pageSize = 50,
): AsyncGenerator<ReturnType<typeof mapMessageRow>, void, unknown> { ): AsyncGenerator<MessageRow, void, unknown> {
const conditions: SQL[] = []; const conditions: SQL[] = [];
if (query.guildId) { if (query.guildId) {
@@ -196,13 +227,8 @@ export class MessagesRepository {
if (query.status) { if (query.status) {
conditions.push(eq(pgMessagesTable.ai_status, query.status)); conditions.push(eq(pgMessagesTable.ai_status, query.status));
} }
if (EXCLUDED_THREAD_IDS.length > 0) { const excludeThreads = excludeSpamThreads();
const excludeThreads = or( if (excludeThreads) conditions.push(excludeThreads);
isNull(pgMessagesTable.thread_id),
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
);
if (excludeThreads) conditions.push(excludeThreads);
}
const where = conditions.length > 0 ? and(...conditions) : undefined; const where = conditions.length > 0 ? and(...conditions) : undefined;
let cursor: string | undefined = query.cursor; let cursor: string | undefined = query.cursor;
@@ -221,7 +247,7 @@ export class MessagesRepository {
.from(pgMessagesTable) .from(pgMessagesTable)
.where(pageWhere) .where(pageWhere)
.orderBy(desc(pgMessagesTable.created_at)) .orderBy(desc(pgMessagesTable.created_at))
.limit(pageSize + 1); .limit(cursorLimit(pageSize));
if (rows.length === 0) return; if (rows.length === 0) return;
@@ -366,31 +392,30 @@ export class MessagesRepository {
async getImageMessages( async getImageMessages(
guildId: string, guildId: string,
limit: number = 50, limit: number = 50,
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> { ): Promise<PageResult<MessageRow>> {
const db = getDatabase(); const db = getDatabase();
// Subquery: find distinct message_ids from attachments with image MIME type // Subquery: find distinct message_ids from attachments with image MIME type
const attachmentConditions: SQL[] = [
eq(pgAttachmentsTable.guild_id, guildId),
like(pgAttachmentsTable.type, "image/%"),
];
// Exclude spam threads (NULL-safe for non-thread messages)
const excludeThreads =
EXCLUDED_THREAD_IDS.length > 0
? or(
isNull(pgAttachmentsTable.thread_id),
notInArray(pgAttachmentsTable.thread_id, EXCLUDED_THREAD_IDS),
)
: undefined;
if (excludeThreads) attachmentConditions.push(excludeThreads);
const imageMsgIds = db const imageMsgIds = db
.select({ id: pgAttachmentsTable.message_id }) .select({ id: pgAttachmentsTable.message_id })
.from(pgAttachmentsTable) .from(pgAttachmentsTable)
.where( .where(and(...attachmentConditions))
and(
eq(pgAttachmentsTable.guild_id, guildId),
like(pgAttachmentsTable.type, "image/%"),
// Exclude spam threads (NULL-safe for non-thread messages)
...(EXCLUDED_THREAD_IDS.length > 0
? (() => {
const excludeThreads = or(
isNull(pgAttachmentsTable.thread_id),
notInArray(pgAttachmentsTable.thread_id, EXCLUDED_THREAD_IDS),
);
return excludeThreads ? [excludeThreads] : [];
})()
: []),
),
)
.orderBy(desc(pgAttachmentsTable.created_at)) .orderBy(desc(pgAttachmentsTable.created_at))
.limit(limit + 1); .limit(cursorLimit(limit));
// Fetch full message rows for those IDs // Fetch full message rows for those IDs
const rows = await db const rows = await db
@@ -398,7 +423,7 @@ export class MessagesRepository {
.from(pgMessagesTable) .from(pgMessagesTable)
.where(inArray(pgMessagesTable.id, imageMsgIds)) .where(inArray(pgMessagesTable.id, imageMsgIds))
.orderBy(desc(pgMessagesTable.created_at)) .orderBy(desc(pgMessagesTable.created_at))
.limit(limit + 1); .limit(cursorLimit(limit));
const data = rows const data = rows
.slice(0, limit) .slice(0, limit)
@@ -433,28 +458,16 @@ export class MessagesRepository {
.from(pgAttachmentsTable) .from(pgAttachmentsTable)
.where(and(...conditions)) .where(and(...conditions))
.orderBy(desc(pgAttachmentsTable.created_at)) .orderBy(desc(pgAttachmentsTable.created_at))
.limit(limit + 1); .limit(cursorLimit(limit));
const data = rows.map((r) => ({ const data = rows.map((r) =>
id: String(r.id ?? ""), mapAttachmentRow(r as Record<string, unknown>),
message_id: String(r.message_id ?? ""), );
guild_id: String(r.guild_id ?? ""),
channel_id: String(r.channel_id ?? ""),
thread_id: (r.thread_id as string | null) ?? null,
user_id: String(r.user_id ?? ""),
filename: String(r.filename ?? ""),
size: Number(r.size ?? 0),
type: String(r.type ?? ""),
discord_url: String(r.discord_url ?? ""),
uploaded_url: (r.uploaded_url as string | null) ?? null,
upload_status: String(r.upload_status ?? "pending"),
upload_error: (r.upload_error as string | null) ?? null,
created_at: Number(r.created_at ?? 0),
uploaded_at: (r.uploaded_at as number | null) ?? null,
}));
// nextCursor derives from the fetched-but-untrimmed overflow row (index
// `limit`), matching the other cursor-paginated queries.
const nextCursor = const nextCursor =
data.length > limit ? String(data[limit].created_at) : null; rows.length > limit ? String(rows[limit].created_at) : null;
const trimmed = data.slice(0, limit); const trimmed = data.slice(0, limit);
return { data: trimmed, nextCursor }; return { data: trimmed, nextCursor };
@@ -522,6 +535,44 @@ export class MessagesRepository {
username: r.username ? String(r.username) : null, username: r.username ? String(r.username) : null,
})); }));
} }
/**
* Distinct guilds present in the message archive (drives the guild picker).
*/
async listGuilds(): Promise<
Array<{ id: string; name: string; icon: string | null }>
> {
const db = getDatabase();
const rows = await db
.selectDistinct({ guild_id: pgMessagesTable.guild_id })
.from(pgMessagesTable)
.orderBy(pgMessagesTable.guild_id);
return rows.map((row) => ({
id: String(row.guild_id ?? ""),
name: `Guild ${String(row.guild_id).slice(0, 8)}`,
icon: null,
}));
}
/**
* Text channels for a guild, derived from the message archive
* (drives the channel picker).
*/
async listTextChannels(
guildId: string,
): Promise<Array<{ id: string; name: string; type: "text" }>> {
const db = getDatabase();
const rows = await db
.selectDistinct({ channel_id: pgMessagesTable.channel_id })
.from(pgMessagesTable)
.where(eq(pgMessagesTable.guild_id, guildId))
.orderBy(pgMessagesTable.channel_id);
return rows.map((row) => ({
id: String(row.channel_id ?? ""),
name: `Channel ${String(row.channel_id).slice(0, 8)}`,
type: "text" as const,
}));
}
} }
export const messagesRepository = new MessagesRepository(); export const messagesRepository = new MessagesRepository();
@@ -41,11 +41,3 @@ export const messageUpdateSchema = z.object({
export type MessageQuery = z.infer<typeof messageQuerySchema>; export type MessageQuery = z.infer<typeof messageQuerySchema>;
export type MessageCreate = z.infer<typeof messageCreateSchema>; export type MessageCreate = z.infer<typeof messageCreateSchema>;
export type MessageUpdate = z.infer<typeof messageUpdateSchema>; export type MessageUpdate = z.infer<typeof messageUpdateSchema>;
export const semanticSearchSchema = z.object({
query: z.string().min(1).max(500),
limit: z.coerce.number().int().positive().max(50).default(10),
guildId: z.string().optional(),
});
export type SemanticSearchQuery = z.infer<typeof semanticSearchSchema>;
@@ -1,15 +1,14 @@
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 { type MessageRow, messagesRepository } from "./messages.repository.js";
import { messagesRepository } from "./messages.repository.js"; import type { MessageQuery } from "./messages.schema.js";
import type { MessageQuery, SemanticSearchQuery } from "./messages.schema.js";
import { searchArchive } from "./qdrant.js";
const logger = createChildLogger("messages.service"); const logger = createChildLogger("messages.service");
export class MessagesService { export class MessagesService {
async listMessages(query: MessageQuery) { async listMessages(
query: MessageQuery,
): Promise<Awaited<ReturnType<typeof messagesRepository.findMany>>> {
if (!query.channelId && !query.guildId) { if (!query.channelId && !query.guildId) {
throw new ValidationError("Either channelId or guildId is required"); throw new ValidationError("Either channelId or guildId is required");
} }
@@ -26,7 +25,10 @@ export class MessagesService {
return messagesRepository.streamMany(query, pageSize); return messagesRepository.streamMany(query, pageSize);
} }
async getMessagesByChannel(channelId: string, query: MessageQuery) { async getMessagesByChannel(
channelId: string,
query: MessageQuery,
): Promise<Awaited<ReturnType<typeof messagesRepository.findByChannel>>> {
if (!channelId) { if (!channelId) {
throw new ValidationError("channelId is required"); throw new ValidationError("channelId is required");
} }
@@ -35,7 +37,14 @@ export class MessagesService {
return messagesRepository.findByChannel(channelId, query); return messagesRepository.findByChannel(channelId, query);
} }
async getMessageById(id: string) { async getMessageById(id: string): Promise<
NonNullable<Awaited<ReturnType<typeof messagesRepository.findById>>> & {
edit_count: number;
edit_history: Awaited<
ReturnType<typeof messagesRepository.getEditHistory>
>;
}
> {
if (!id) { if (!id) {
throw new ValidationError("message ID is required"); throw new ValidationError("message ID is required");
} }
@@ -56,7 +65,12 @@ export class MessagesService {
}; };
} }
async getAttachmentsByChannel(channelId: string, query: MessageQuery) { async getAttachmentsByChannel(
channelId: string,
query: MessageQuery,
): Promise<
Awaited<ReturnType<typeof messagesRepository.getAttachmentsByChannel>>
> {
if (!channelId) { if (!channelId) {
throw new ValidationError("channelId is required"); throw new ValidationError("channelId is required");
} }
@@ -85,72 +99,33 @@ export class MessagesService {
return messagesRepository.getReviewMessages(channelId, limit); return messagesRepository.getReviewMessages(channelId, limit);
} }
/** async getActivity(
* Public, read-only semantic search over the persistent message archive. days = 30,
* Embeds the query, searches Qdrant, returns text + metadata. Best-effort: ): Promise<Awaited<ReturnType<typeof messagesRepository.getActivity>>> {
* if embeddings/Qdrant are unavailable, returns an empty result set.
*/
async semanticSearch(
input: SemanticSearchQuery,
): Promise<{ results: ReturnType<typeof mapSearchHit>[]; nextCursor: null }> {
const vector = await embedQuery(input.query);
if (!vector) {
logger.debug(
{ query: input.query },
"semantic search skipped: no embedder",
);
return { results: [], nextCursor: null };
}
const hits = await searchArchive(
vector,
input.limit,
config.AI_LLM_EMBEDDING_ARCHIVE_MIN_SIMILARITY,
input.guildId,
);
const results = hits.map((h) => mapSearchHit(h));
return { results, nextCursor: null };
}
async getActivity(days = 30) {
return messagesRepository.getActivity(days); return messagesRepository.getActivity(days);
} }
async getRecentEdits(limit = 50, channelId?: string) { async getRecentEdits(
limit = 50,
channelId?: string,
): Promise<Awaited<ReturnType<typeof messagesRepository.getRecentEdits>>> {
logger.debug({ limit, channelId }, "Getting recent message edits"); logger.debug({ limit, channelId }, "Getting recent message edits");
return messagesRepository.getRecentEdits(limit, channelId); return messagesRepository.getRecentEdits(limit, channelId);
} }
}
/** Shape returned to the frontend (text + rich metadata from the archive payload). */ /** Distinct guilds present in the message archive (guild picker). */
function mapSearchHit(hit: { async getGuilds(): Promise<
score: number; Awaited<ReturnType<typeof messagesRepository.listGuilds>>
payload: { > {
text: string; return messagesRepository.listGuilds();
content_hash?: string; }
analyzed_at: number;
username?: string; /** Text channels for a guild (channel picker). */
channel_id?: string; async getTextChannels(
guild_id?: string; guildId: string,
thread_id?: string | null; ): Promise<Awaited<ReturnType<typeof messagesRepository.listTextChannels>>> {
channel_name?: string | null; return messagesRepository.listTextChannels(guildId);
thread_name?: string | null; }
created_at?: number;
};
}) {
return {
message_id: hit.payload.content_hash ?? null,
content: hit.payload.text,
score: hit.score,
// 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,
};
} }
export const messagesService = new MessagesService(); export const messagesService = new MessagesService();
@@ -1,114 +0,0 @@
import { config } from "@/shared/config/index";
import { createChildLogger } from "@/shared/logger/index";
const logger = createChildLogger("messages-qdrant");
export interface ArchiveHit {
score: number;
payload: {
text: string;
content_hash?: string;
analyzed_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;
};
}
function baseUrl(): string {
return (config.QDRANT_URL ?? "http://100.121.180.82:6333").replace(
/\/+$/,
"",
);
}
function headers(): Record<string, string> {
const h: Record<string, string> = { "Content-Type": "application/json" };
if (config.QDRANT_API_KEY) h["api-key"] = config.QDRANT_API_KEY;
return h;
}
export const ARCHIVE_COLLECTION =
config.QDRANT_ARCHIVE_COLLECTION ?? "gmw_message_archive";
async function request(
method: string,
path: string,
body?: unknown,
timeoutMs = 10_000,
): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`${baseUrl()}${path}`, {
method,
headers: headers(),
body: body === undefined ? undefined : JSON.stringify(body),
signal: controller.signal,
});
const text = await res.text();
if (!res.ok) {
throw new Error(
`Qdrant ${method} ${path} -> ${res.status}: ${text.slice(0, 200)}`,
);
}
return text ? JSON.parse(text) : null;
} finally {
clearTimeout(timer);
}
}
/** Search the archive collection for the nearest vectors to `vector`. */
export async function searchArchive(
vector: number[],
limit: number,
scoreThreshold: number,
guildId?: string,
): Promise<ArchiveHit[]> {
if (!config.QDRANT_URL) return [];
try {
const json = (await request(
"POST",
`/collections/${ARCHIVE_COLLECTION}/points/search`,
{
vector,
limit,
score_threshold: scoreThreshold,
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 {
result?: Array<{
score?: number;
payload?: ArchiveHit["payload"];
}>;
};
return (json.result ?? [])
.filter((h) => h.payload?.text)
.map((h) => ({
score: h.score ?? 0,
payload: h.payload as ArchiveHit["payload"],
}));
} catch (error) {
logger.warn(
{ error: error instanceof Error ? error.message : String(error) },
"archive search failed",
);
return [];
}
}
@@ -1,184 +0,0 @@
import {
and,
desc,
eq,
gte,
ilike,
lt,
lte,
or,
type SQL,
sql,
} from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
import { pgVoiceRecordingsTable } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
const logger = createChildLogger("recordings.service");
export interface RecordingRow {
id: string;
user_id: string;
username: string;
avatar_url: string | null;
guild_id: string | null;
channel_id: string | null;
channel_name: string | null;
filename: string;
size_bytes: number;
download_url: string | null;
upload_status: string;
upload_error: string | null;
created_at: number;
uploaded_at: number | null;
transcription: string | null;
}
export interface PaginatedRecordings {
items: RecordingRow[];
nextCursor: string | null;
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 {
async getRecent(
limit = 50,
filters: RecordingFilters = {},
): Promise<PaginatedRecordings> {
logger.info({ limit, filters }, "getRecent called");
const db = getDatabase();
const conditions: SQL[] = [];
if (filters.cursor) {
conditions.push(
lt(pgVoiceRecordingsTable.created_at, Number(filters.cursor)),
);
}
if (filters.channelId) {
conditions.push(eq(pgVoiceRecordingsTable.channel_id, filters.channelId));
}
if (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 allRows = await db
.select({
id: pgVoiceRecordingsTable.id,
user_id: pgVoiceRecordingsTable.user_id,
username: pgVoiceRecordingsTable.username,
avatar_url: pgVoiceRecordingsTable.avatar_url,
guild_id: pgVoiceRecordingsTable.guild_id,
channel_id: pgVoiceRecordingsTable.channel_id,
channel_name: pgVoiceRecordingsTable.channel_name,
filename: pgVoiceRecordingsTable.filename,
size_bytes: pgVoiceRecordingsTable.size_bytes,
download_url: pgVoiceRecordingsTable.download_url,
upload_status: pgVoiceRecordingsTable.upload_status,
upload_error: pgVoiceRecordingsTable.upload_error,
created_at: pgVoiceRecordingsTable.created_at,
uploaded_at: pgVoiceRecordingsTable.uploaded_at,
transcription: pgVoiceRecordingsTable.transcription,
})
.from(pgVoiceRecordingsTable)
.where(where)
.orderBy(desc(pgVoiceRecordingsTable.created_at))
.limit(limit + 1);
const items = allRows.slice(0, limit) as unknown as RecordingRow[];
const hasMore = allRows.length > limit;
const nextCursor = hasMore
? String(items[items.length - 1]?.created_at)
: null;
return { items, nextCursor, hasMore };
}
async deleteById(id: string): Promise<void> {
const db = getDatabase();
await db
.delete(pgVoiceRecordingsTable)
.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();
@@ -1,111 +0,0 @@
/**
* Authoritative live-voice store.
*
* Single source of truth for who is present / speaking in voice. The backend
* WebSocket server is the one relay every frontend client connects to, so it
* is the correct place to aggregate the gateway's `voice_active_user` deltas
* into a shared snapshot. A late-joining browser must be able to see the same
* state as everyone else — this store makes that possible (seeded into the WS
* initial states and served via GET /api/voice/status).
*/
export interface LiveSpeaker {
userId: string;
username: string;
avatar?: string | null;
speaking: boolean;
/** Epoch ms of the most recent activity (start OR end of speech). */
lastActiveAt: number;
}
const speakers = new Map<string, LiveSpeaker>();
const MAX_SPEAKERS = 200;
/**
* Speakers inactive for longer than this are auto-expired from the snapshot.
* This handles the case where the gateway disconnects abruptly and never
* 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);
}
}
}
/**
* Record a voice_active_user event. `speaking: true` upserts the speaker as
* active; `speaking: false` marks them inactive while keeping them briefly
* for the activity timeline (until TTL expiry).
*/
export function recordSpeaker(data: {
userId: string;
username?: string;
avatar?: string | null;
speaking: boolean;
}): void {
const { userId, speaking } = data;
const existing = speakers.get(userId);
const speaker: LiveSpeaker = {
userId,
username: data.username ?? existing?.username ?? "Unknown",
avatar: data.avatar ?? existing?.avatar ?? null,
speaking,
lastActiveAt: Date.now(),
};
if (speakers.size >= MAX_SPEAKERS && !existing) {
let oldestId: string | null = null;
let oldestTs = Infinity;
for (const [id, s] of speakers) {
if (!s.speaking && s.lastActiveAt < oldestTs) {
oldestTs = s.lastActiveAt;
oldestId = id;
}
}
if (oldestId) speakers.delete(oldestId);
else return;
}
speakers.set(userId, speaker);
}
/**
* All recently-active speakers, most recently active first.
* Stale (non-speaking + old) entries are auto-purged.
*/
export function getActiveSpeakers(): LiveSpeaker[] {
purgeStale();
return [...speakers.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt);
}
/** Only speakers currently flagged as speaking. */
export function getSpeakingSpeakers(): LiveSpeaker[] {
purgeStale();
return [...speakers.values()]
.filter((s) => s.speaking)
.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). */
export function resetLiveSpeakers(): void {
speakers.clear();
}
@@ -1,13 +0,0 @@
import { z } from "zod";
export const connectVoiceSchema = z.object({
guildId: z.string().min(1, "guildId is required"),
channelId: z.string().min(1, "channelId is required"),
});
export const voiceCommandSchema = z.object({
command: z.string().min(1, "command is required"),
});
export type ConnectVoiceInput = z.infer<typeof connectVoiceSchema>;
export type VoiceCommandInput = z.infer<typeof voiceCommandSchema>;
@@ -1,193 +0,0 @@
import { eq } from "drizzle-orm";
import {
createChildLogger,
tryCommandThenFallback,
} from "../../shared/commandHelper.js";
import { getDatabase } from "../../shared/database/index.js";
import {
COMMAND_GUILDS_LIST,
COMMAND_GUILDS_TEXT_CHANNELS,
COMMAND_VOICE_CHANNELS,
COMMAND_VOICE_CONNECT,
COMMAND_VOICE_DISCONNECT,
type CommandReply,
pgMessagesTable,
VOICE_STATUS_KEY,
} from "../../shared/index.js";
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
import { getActiveSpeakers, type LiveSpeaker } from "./live-speaker.js";
const logger = createChildLogger("voice.service");
export interface Guild {
id: string;
name: string;
icon: string | null;
}
export interface Channel {
id: string;
name: string;
type: "voice" | "text";
/** Whether the selfbot account can actually join this voice channel. */
joinable?: boolean;
}
export interface GuildVoiceEntry {
guildId: string;
channelId: string;
channelName: string;
connectedAt: number;
}
export interface VoiceStatus {
connected: boolean;
activeGuildId: string | null;
activeChannelId: string | null;
activeChannelName: string | null;
connections: GuildVoiceEntry[];
/**
* Authoritative shared voice snapshot — who is present / speaking right
* now, aggregated server-side from the gateway's `voice_active_user`
* deltas. All browsers converge on this same list.
*/
activeSpeakers: LiveSpeaker[];
}
export const DEFAULT_VOICE_STATUS: VoiceStatus = {
connected: false,
activeGuildId: null,
activeChannelId: null,
activeChannelName: null,
connections: [],
activeSpeakers: [],
};
/** Attach the live speaker snapshot to any voice status payload. */
function withActiveSpeakers<T extends Partial<VoiceStatus>>(
status: T,
): T & { activeSpeakers: LiveSpeaker[] } {
return { ...status, activeSpeakers: getActiveSpeakers() };
}
/**
* Wraps tryCommandThenFallback with a cleaner signature for use within this module.
* Attempts a Redis command first; on failure, falls back to the provided function.
*/
async function withFallback<T>(
commandFn: () => Promise<CommandReply<T> | null>,
fallbackFn: () => Promise<T>,
name: string,
): Promise<T> {
return tryCommandThenFallback(commandFn, fallbackFn, name);
}
function readVoiceStatusFallback(): Promise<VoiceStatus> {
return readRedisStatus(VOICE_STATUS_KEY).then((cached) =>
withActiveSpeakers(
(cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
),
);
}
/**
* Get guilds — query from discord-gateway via Redis command for real names.
* Falls back to database (distinct guild_id from messages) if gateway unreachable.
*/
export async function getGuilds(): Promise<Guild[]> {
logger.info("getGuilds called");
return withFallback(
() => publishCommand<Guild[]>(COMMAND_GUILDS_LIST, {}),
async () => {
const db = getDatabase();
const rows = await db
.selectDistinct({ guild_id: pgMessagesTable.guild_id })
.from(pgMessagesTable)
.orderBy(pgMessagesTable.guild_id);
return rows.map((row) => ({
id: String(row.guild_id ?? ""),
name: `Guild ${String(row.guild_id).slice(0, 8)}`,
icon: null,
}));
},
"getGuilds",
);
}
/**
* Get text channels — query from discord-gateway via Redis command for real names.
* Falls back to database if gateway unreachable.
*/
export async function getTextChannels(guildId: string): Promise<Channel[]> {
logger.info({ guildId }, "getTextChannels called");
return withFallback(
() => publishCommand<Channel[]>(COMMAND_GUILDS_TEXT_CHANNELS, { guildId }),
async () => {
const db = getDatabase();
const rows = await db
.selectDistinct({ channel_id: pgMessagesTable.channel_id })
.from(pgMessagesTable)
.where(eq(pgMessagesTable.guild_id, guildId))
.orderBy(pgMessagesTable.channel_id);
return rows.map((row) => ({
id: String(row.channel_id ?? ""),
name: `Channel ${String(row.channel_id).slice(0, 8)}`,
type: "text" as const,
}));
},
"getTextChannels",
);
}
/**
* Get voice channels — query from discord-gateway via Redis command.
*/
export async function getVoiceChannels(guildId: string): Promise<Channel[]> {
logger.info({ guildId }, "getVoiceChannels called");
const reply = await publishCommand<Channel[]>(COMMAND_VOICE_CHANNELS, {
guildId,
});
return reply?.success && reply.data ? reply.data : [];
}
/**
* Get current voice connection status from Redis cache set by discord-gateway.
*/
export async function getVoiceStatus(): Promise<VoiceStatus> {
logger.debug("getVoiceStatus called");
const cached = await readRedisStatus(VOICE_STATUS_KEY);
return withActiveSpeakers(
(cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
);
}
/**
* Connect to a voice channel via Redis command to discord-gateway.
*/
export async function connectVoice(
guildId: string,
channelId: string,
): Promise<VoiceStatus> {
logger.info({ guildId, channelId }, "connectVoice called");
return withFallback(
() =>
publishCommand<VoiceStatus>(COMMAND_VOICE_CONNECT, {
guildId,
channelId,
}),
() => readVoiceStatusFallback(),
"connectVoice",
);
}
/**
* Disconnect from voice via Redis command to discord-gateway.
*/
export async function disconnectVoice(): Promise<VoiceStatus> {
logger.info("disconnectVoice called");
return withFallback(
() => publishCommand<VoiceStatus>(COMMAND_VOICE_DISCONNECT, {}),
() => readVoiceStatusFallback(),
"disconnectVoice",
);
}
+5 -114
View File
@@ -5,33 +5,10 @@ import { chatRequestSchema } from "../modules/chatbot/chatbot.schema";
import { chatbotService } from "../modules/chatbot/chatbot.service"; import { chatbotService } from "../modules/chatbot/chatbot.service";
import { dashboardService } from "../modules/dashboard/dashboard.service"; import { dashboardService } from "../modules/dashboard/dashboard.service";
import { knowledgeService } from "../modules/knowledge/knowledge.service"; import { knowledgeService } from "../modules/knowledge/knowledge.service";
import { import { messageQuerySchema } from "../modules/messages/messages.schema";
mediaLoopSchema,
mediaQueueSchema,
} from "../modules/media/media.schema";
import {
getStatus,
queue,
setLoop,
skip,
stop,
} from "../modules/media/media.service";
import {
messageQuerySchema,
semanticSearchSchema,
} from "../modules/messages/messages.schema";
import { messagesService } from "../modules/messages/messages.service"; import { messagesService } from "../modules/messages/messages.service";
import { moderationService } from "../modules/moderation/moderation.service"; import { moderationService } from "../modules/moderation/moderation.service";
import { recordingsService } from "../modules/recordings/recordings.service";
import { uiStateService } from "../modules/ui-state/ui-state.service"; import { uiStateService } from "../modules/ui-state/ui-state.service";
import {
connectVoice,
disconnectVoice,
getGuilds,
getTextChannels,
getVoiceChannels,
getVoiceStatus,
} from "../modules/voice/voice.service";
import { config } from "../shared/config/index"; import { config } from "../shared/config/index";
import { publishCommandNoReply } from "../shared/redis/index"; import { publishCommandNoReply } from "../shared/redis/index";
@@ -89,6 +66,10 @@ const dashboardRouter = {
// ── Messages ───────────────────────────────────────────────────── // ── Messages ─────────────────────────────────────────────────────
const messagesRouter = { const messagesRouter = {
guilds: os.handler(() => messagesService.getGuilds()),
textChannels: os
.input(z.object({ guildId: z.string() }))
.handler(({ input }) => messagesService.getTextChannels(input.guildId)),
list: os list: os
.input(messageQuerySchema) .input(messageQuerySchema)
.handler(({ input }) => messagesService.listMessages(input)), .handler(({ input }) => messagesService.listMessages(input)),
@@ -139,10 +120,6 @@ const messagesRouter = {
); );
return { results: rows, limit: input.limit, cursor: null }; return { results: rows, limit: input.limit, cursor: null };
}), }),
// Public, read-only semantic search over the message archive.
semanticSearch: os
.input(semanticSearchSchema)
.handler(({ input }) => messagesService.semanticSearch(input)),
// Public, read-only activity heatmap data (per-hour volume by channel). // Public, read-only activity heatmap data (per-hour volume by channel).
activity: os activity: os
.input( .input(
@@ -238,86 +215,6 @@ const moderationRouter = {
.handler(({ input }) => moderationService.getCoverage(input.days)), .handler(({ input }) => moderationService.getCoverage(input.days)),
}; };
// ── Media ────────────────────────────────────────────────────────
const mediaRouter = {
status: os.handler(() => getStatus()),
queue: os.input(mediaQueueSchema).handler(async ({ input }) => {
await queue(input.source, input.mode);
return getStatus();
}),
skip: os.handler(async () => {
await skip();
return getStatus();
}),
stop: os.handler(async () => {
await stop();
return getStatus();
}),
loop: os.input(mediaLoopSchema).handler(async ({ input }) => {
await setLoop(input.loop);
return getStatus();
}),
};
// ── Voice ─────────────────────────────────────────────────────────
const voiceRouter = {
guilds: os.handler(() => getGuilds()),
textChannels: os
.input(z.object({ guildId: z.string() }))
.handler(({ input }) => getTextChannels(input.guildId)),
voiceChannels: os
.input(z.object({ guildId: z.string() }))
.handler(({ input }) => getVoiceChannels(input.guildId)),
status: os.handler(() => getVoiceStatus()),
connect: os
.input(z.object({ guildId: z.string(), channelId: z.string() }))
.handler(async ({ input }) => {
await connectVoice(input.guildId, input.channelId);
return getVoiceStatus();
}),
disconnect: os.handler(async () => {
await disconnectVoice();
return getVoiceStatus();
}),
command: os
.input(z.object({ command: z.string().min(1) }))
.handler(async ({ input }) => {
await publishCommandNoReply(input.command);
return { success: true, command: input.command };
}),
};
// ── Recordings ───────────────────────────────────────────────────
const recordingsRouter = {
list: os
.input(
z.object({
limit: z.coerce.number().int().positive().default(50),
channelId: z.string().optional(),
userId: 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 }) =>
recordingsService.getRecent(input.limit, {
channelId: input.channelId,
userId: input.userId,
cursor: input.cursor,
q: input.q,
startDate: input.startDate,
endDate: input.endDate,
}),
),
delete: os.input(z.object({ id: z.string() })).handler(async ({ input }) => {
await recordingsService.deleteById(input.id);
return { ok: true };
}),
summary: os.handler(async () => recordingsService.getSummary()),
};
// ── Analysis (search) ────────────────────────────────────────────── // ── Analysis (search) ──────────────────────────────────────────────
const analysisRouter = { const analysisRouter = {
search: os search: os
@@ -416,11 +313,8 @@ const configRouter = {
backlogSyncBatchSize: config.BACKLOG_SYNC_BATCH_SIZE, backlogSyncBatchSize: config.BACKLOG_SYNC_BATCH_SIZE,
retentionMessagesDays: config.RETENTION_MESSAGES_DAYS, retentionMessagesDays: config.RETENTION_MESSAGES_DAYS,
retentionAttachmentsDays: config.RETENTION_ATTACHMENTS_DAYS, retentionAttachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
retentionVoiceDays: config.RETENTION_VOICE_DAYS,
autoDeleteFlaggedEnabled: config.AUTO_DELETE_FLAGGED_ENABLED, autoDeleteFlaggedEnabled: config.AUTO_DELETE_FLAGGED_ENABLED,
aiAnalysisEnabled: config.AI_ANALYSIS_ENABLED, aiAnalysisEnabled: config.AI_ANALYSIS_ENABLED,
voiceGuildId: config.VOICE_GUILD_ID || null,
voiceChannelId: config.VOICE_CHANNEL_ID || null,
logLevel: config.LOG_LEVEL, logLevel: config.LOG_LEVEL,
})), })),
}; };
@@ -438,9 +332,6 @@ export const appRouter = {
dashboard: dashboardRouter, dashboard: dashboardRouter,
messages: messagesRouter, messages: messagesRouter,
moderation: moderationRouter, moderation: moderationRouter,
media: mediaRouter,
voice: voiceRouter,
recordings: recordingsRouter,
analysis: analysisRouter, analysis: analysisRouter,
chatbot: chatbotRouter, chatbot: chatbotRouter,
config: configRouter, config: configRouter,
@@ -1,25 +0,0 @@
import type { CommandReply } from "./index.js";
import { createChildLogger } from "./logger/index.js";
export { createChildLogger };
/**
* Attempt a Redis command first; if it fails or times out, fall back.
*
* @param commandFn - Function that issues the publishCommand and returns the reply.
* @param fallbackFn - Async fallback, typically reads from Redis status key.
* @param commandLabel - Label used for logging (e.g. "voice:connect").
*/
export async function tryCommandThenFallback<T>(
commandFn: () => Promise<CommandReply<T> | null>,
fallbackFn: () => Promise<T>,
commandLabel: string,
): Promise<T> {
const logger = createChildLogger(`command-helper:${commandLabel}`);
const reply = await commandFn();
if (reply?.success && reply.data !== undefined && reply.data !== null) {
return reply.data;
}
logger.warn("discord-gateway unreachable, falling back");
return fallbackFn();
}
+6 -63
View File
@@ -33,29 +33,6 @@ export const configSchema = z
.transform((v) => v.split(",").filter(Boolean)) .transform((v) => v.split(",").filter(Boolean))
.describe("Thread IDs to exclude from capture"), .describe("Thread IDs to exclude from capture"),
// ── Legacy voice ─────────────────────────────────────────────────────
VOICE_GUILD_ID: z.string().min(1).optional(),
VOICE_CHANNEL_ID: z.string().min(1).optional(),
// ── Recording ────────────────────────────────────────────────────────
RECORDINGS_DIR: z.string().default("./recordings"),
RECORDING_SEGMENT_MS: z.coerce.number().positive().default(5000),
// ── Decoder ──────────────────────────────────────────────────────────
DECODER_ROTATE_MS: z.coerce.number().positive().default(5000),
DECODER_COOLDOWN_MS: z.coerce.number().positive().default(30000),
// ── Audio ────────────────────────────────────────────────────────────
AUDIO_STREAM_SILENCE_DURATION_MS: z.coerce
.number()
.positive()
.default(3000),
PACKET_FILTER_MIN_SIZE: z.coerce.number().positive().default(8),
OPUS_FRAME_SIZE: z.coerce.number().positive().default(960),
AUDIO_SAMPLE_RATE: z.coerce.number().positive().default(48000),
AUDIO_CHANNELS: z.coerce.number().positive().default(2),
AVATAR_SIZE: z.coerce.number().positive().default(64),
// ── Server ─────────────────────────────────────────────────────────── // ── Server ───────────────────────────────────────────────────────────
WEBSERVER_PORT: z.coerce.number().positive().default(3001), WEBSERVER_PORT: z.coerce.number().positive().default(3001),
NODE_ENV: z NODE_ENV: z
@@ -92,17 +69,8 @@ export const configSchema = z
// ── Redis ──────────────────────────────────────────────────────────── // ── Redis ────────────────────────────────────────────────────────────
REDIS_URL: z.string().default("redis://localhost:6379"), REDIS_URL: z.string().default("redis://localhost:6379"),
// ── Voice PCM WebSocket (direct gateway→backend, bypasses Redis) ────
VOICE_PCM_WS_ENABLED: z
.string()
.optional()
.transform((v) => v === "true")
.default(true),
BACKEND_WS_URL: z.string().default("ws://backend:3000/ws"),
BACKEND_WS_TOKEN: z.string().optional().default(""),
// ── Connection ─────────────────────────────────────────────────────── // ── Connection ───────────────────────────────────────────────────────
VOICE_CONNECTION_TIMEOUT_MS: z.coerce.number().positive().default(15000),
RECONNECT_TIMEOUT_MS: z.coerce.number().positive().default(5000), RECONNECT_TIMEOUT_MS: z.coerce.number().positive().default(5000),
// ── Attachments ───────────────────────────────────────────────────── // ── Attachments ─────────────────────────────────────────────────────
@@ -128,21 +96,12 @@ export const configSchema = z
.transform((v) => v === "true") .transform((v) => v === "true")
.default(false), .default(false),
AI_LLM_API_KEY: z.string().optional(), AI_LLM_API_KEY: z.string().optional(),
AI_LLM_BASE_URL: z // 9router — OpenAI-compatible router on this host (127.0.0.1:4014).
.string() // Loopback on purpose: backend runs on the same machine as 9router, so no
.url() // TLS/proxy hop is needed.
.default("http://100.121.180.82:20128/api/v1"), AI_LLM_BASE_URL: z.string().url().default("http://127.0.0.1:4014/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(),
// 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()
@@ -203,13 +162,6 @@ export const configSchema = z
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(), PISCINA_MEDIA_MAX_THREADS: z.coerce.number().int().positive().optional(),
// ── Voice Transcription ────────────────────────────────────────────────
AI_VOICE_TRANSCRIPTION_ENABLED: z
.string()
.optional()
.transform((v) => v === "true")
.default(false),
// ── OpenAI Moderation ─────────────────────────────────────────────── // ── OpenAI Moderation ───────────────────────────────────────────────
OPENAI_MODERATION_API_KEY: z.string().optional(), OPENAI_MODERATION_API_KEY: z.string().optional(),
OPENAI_MODERATION_BASE_URL: z OPENAI_MODERATION_BASE_URL: z
@@ -218,11 +170,6 @@ export const configSchema = z
.default("https://api.openai.com/v1"), .default("https://api.openai.com/v1"),
OPENAI_MODERATION_MODEL: z.string().default("omni-moderation-latest"), OPENAI_MODERATION_MODEL: z.string().default("omni-moderation-latest"),
// ── Qdrant (message archive for semantic search) ──────────────────
QDRANT_URL: z.string().optional(),
QDRANT_API_KEY: z.string().optional(),
QDRANT_ARCHIVE_COLLECTION: z.string().default("gmw_message_archive"),
// ── Auto Delete ───────────────────────────────────────────────────── // ── Auto Delete ─────────────────────────────────────────────────────
AUTO_DELETE_FLAGGED_ENABLED: z AUTO_DELETE_FLAGGED_ENABLED: z
.string() .string()
@@ -252,7 +199,6 @@ export const configSchema = z
// ── Retention ─────────────────────────────────────────────────────── // ── Retention ───────────────────────────────────────────────────────
RETENTION_MESSAGES_DAYS: z.coerce.number().int().min(0).default(0), RETENTION_MESSAGES_DAYS: z.coerce.number().int().min(0).default(0),
RETENTION_ATTACHMENTS_DAYS: z.coerce.number().int().min(0).default(0), RETENTION_ATTACHMENTS_DAYS: z.coerce.number().int().min(0).default(0),
RETENTION_VOICE_DAYS: z.coerce.number().int().min(0).default(0),
RETENTION_CLEANUP_INTERVAL_MS: z.coerce RETENTION_CLEANUP_INTERVAL_MS: z.coerce
.number() .number()
.positive() .positive()
@@ -269,9 +215,8 @@ export const configSchema = z
.default(true), .default(true),
}) })
.superRefine((value, ctx) => { .superRefine((value, ctx) => {
if (!value.AI_ANALYSIS_ENABLED) { // AI analysis requires credentials to run.
// skip: AI analysis not enabled if (value.AI_ANALYSIS_ENABLED && !value.AI_LLM_API_KEY) {
} else if (!value.AI_LLM_API_KEY) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ["AI_LLM_API_KEY"], path: ["AI_LLM_API_KEY"],
@@ -291,7 +236,6 @@ export const configSchema = z
export type AppConfig = z.infer<typeof configSchema> & { export type AppConfig = z.infer<typeof configSchema> & {
EFFECTIVE_TEXT_GUILD_ID?: string; EFFECTIVE_TEXT_GUILD_ID?: string;
EFFECTIVE_VOICE_GUILD_ID?: string;
EFFECTIVE_MONITOR_GUILD_IDS: string[]; EFFECTIVE_MONITOR_GUILD_IDS: string[];
}; };
@@ -301,7 +245,6 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
return { return {
...parsed, ...parsed,
EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID, EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID,
EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID,
EFFECTIVE_MONITOR_GUILD_IDS: EFFECTIVE_MONITOR_GUILD_IDS:
parsed.MONITOR_GUILD_IDS.length > 0 parsed.MONITOR_GUILD_IDS.length > 0
? parsed.MONITOR_GUILD_IDS ? parsed.MONITOR_GUILD_IDS
@@ -24,9 +24,6 @@ export interface BroadcasterClient {
messageAnalyzed: (data: unknown) => void; messageAnalyzed: (data: unknown) => void;
attachmentCreated: (data: unknown) => void; attachmentCreated: (data: unknown) => void;
attachmentUploaded: (data: unknown) => void; attachmentUploaded: (data: unknown) => void;
voiceRecordingStarted: (data: unknown) => void;
voiceRecordingStopped: (data: unknown) => void;
voiceRecordingUploaded: (data: unknown) => void;
analysisQueueStatus: (data: unknown) => void; analysisQueueStatus: (data: unknown) => void;
} }
@@ -102,17 +99,6 @@ export interface AttachmentRecord {
uploaded_at: number | null; uploaded_at: number | null;
} }
export interface VoiceSegmentRecord {
id: string;
user_id: string;
session_id: string;
guild_id: string;
channel_id: string;
filename: string;
duration_ms: number;
created_at: number;
}
export interface DashboardMessage { export interface DashboardMessage {
id: string; id: string;
channel_id: string; channel_id: string;
@@ -121,7 +107,7 @@ export interface DashboardMessage {
avatar_url: string | null; avatar_url: string | null;
content: string; content: string;
created_at: number; created_at: number;
type: "text" | "image" | "voice"; type: "text" | "image";
} }
export interface MessageQuery { export interface MessageQuery {
@@ -154,23 +140,6 @@ export interface AnalysisResult {
evidence?: string[]; evidence?: string[];
} }
export interface VoiceRecordingUploadData {
id: string;
user_id: string;
username: string;
avatar_url: string | null;
guild_id: string | null;
channel_id: string | null;
channel_name: string | null;
filename: string;
size_bytes: number;
download_url: string;
upload_status: string;
created_at: number;
uploaded_at: number;
transcription?: string | null;
}
export interface AnalysisQueueStatus { export interface AnalysisQueueStatus {
queuedConversations: number; queuedConversations: number;
activeRequests: number; activeRequests: number;
@@ -221,7 +190,6 @@ export interface RetentionPolicy {
channel_id: string | null; channel_id: string | null;
retention_days: number; retention_days: number;
apply_to_media: boolean; apply_to_media: boolean;
apply_to_voice: boolean;
enabled: boolean; enabled: boolean;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
@@ -15,11 +15,6 @@ export const DISCORD_MESSAGE_DELETED = "discord:message:deleted";
export const DISCORD_MESSAGE_ANALYZED = "discord:message:analyzed"; export const DISCORD_MESSAGE_ANALYZED = "discord:message:analyzed";
export const DISCORD_ATTACHMENT_CREATED = "discord:attachment:created"; export const DISCORD_ATTACHMENT_CREATED = "discord:attachment:created";
export const DISCORD_ATTACHMENT_UPLOADED = "discord:attachment:uploaded"; export const DISCORD_ATTACHMENT_UPLOADED = "discord:attachment:uploaded";
export const DISCORD_VOICE_STARTED = "discord:voice:started";
export const DISCORD_VOICE_STOPPED = "discord:voice:stopped";
export const DISCORD_VOICE_UPLOADED = "discord:voice:uploaded";
export const DISCORD_VOICE_ACTIVE_USER = "discord:voice:active_user";
export const DISCORD_VOICE_PCM = "discord:voice:pcm";
export const DISCORD_ANALYSIS_QUEUE_STATUS = "discord:analysis:queue_status"; export const DISCORD_ANALYSIS_QUEUE_STATUS = "discord:analysis:queue_status";
export const DISCORD_REACTION_ADDED = "discord:reaction:added"; export const DISCORD_REACTION_ADDED = "discord:reaction:added";
export const DISCORD_REACTION_REMOVED = "discord:reaction:removed"; export const DISCORD_REACTION_REMOVED = "discord:reaction:removed";
@@ -37,35 +32,15 @@ export const DISCORD_MODERATION_ACTION = "discord:moderation:action";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const BACKEND_COMMAND = "backend:command"; export const BACKEND_COMMAND = "backend:command";
export const BACKEND_VOICE_TRANSMIT = "backend:voice:transmit";
export const BACKEND_COMMAND_REPLY_PREFIX = "backend:command:reply:"; export const BACKEND_COMMAND_REPLY_PREFIX = "backend:command:reply:";
// ---------------------------------------------------------------------------
// Status keys (set by discord-gateway, read by backend via Redis GET)
// ---------------------------------------------------------------------------
export const VOICE_STATUS_KEY = "voice:status";
export const MEDIA_STATUS_KEY = "media:status";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Command types (used as the `type` field in CommandMessage envelopes) // Command types (used as the `type` field in CommandMessage envelopes)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const COMMAND_VOICE_CONNECT = "voice:connect";
export const COMMAND_VOICE_DISCONNECT = "voice:disconnect";
export const COMMAND_VOICE_DISCONNECT_GUILD = "voice:disconnect:guild";
export const COMMAND_VOICE_CHANNELS = "voice:channels";
export const COMMAND_VOICE_TRANSMIT_START = "voice:transmit:start";
export const COMMAND_VOICE_TRANSMIT_STOP = "voice:transmit:stop";
export const COMMAND_GUILDS_LIST = "guilds:list"; export const COMMAND_GUILDS_LIST = "guilds:list";
export const COMMAND_GUILDS_TEXT_CHANNELS = "guilds:text-channels"; export const COMMAND_GUILDS_TEXT_CHANNELS = "guilds:text-channels";
export const COMMAND_MEDIA_QUEUE = "media:queue";
export const COMMAND_MEDIA_SKIP = "media:skip";
export const COMMAND_MEDIA_STOP = "media:stop";
export const COMMAND_MEDIA_VOLUME = "media:volume";
export const COMMAND_MEDIA_LOOP = "media:loop";
export const COMMAND_MODERATION_ACTION = "moderation:action"; export const COMMAND_MODERATION_ACTION = "moderation:action";
export const DISCORD_VOICE_ANALYZED = "discord:voice:analyzed";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Event envelope — used by discord-gateway when publishing to Redis // Event envelope — used by discord-gateway when publishing to Redis
@@ -111,13 +86,7 @@ export const DISCORD_CHANNEL_TO_WS_EVENT: Record<string, string> = {
[DISCORD_MESSAGE_ANALYZED]: "message_analyzed", [DISCORD_MESSAGE_ANALYZED]: "message_analyzed",
[DISCORD_ATTACHMENT_CREATED]: "attachment_created", [DISCORD_ATTACHMENT_CREATED]: "attachment_created",
[DISCORD_ATTACHMENT_UPLOADED]: "attachment_uploaded", [DISCORD_ATTACHMENT_UPLOADED]: "attachment_uploaded",
[DISCORD_VOICE_STARTED]: "voice_recording_started",
[DISCORD_VOICE_STOPPED]: "voice_recording_stopped",
[DISCORD_VOICE_UPLOADED]: "voice_recording_uploaded",
[DISCORD_ANALYSIS_QUEUE_STATUS]: "analysis_queue_status", [DISCORD_ANALYSIS_QUEUE_STATUS]: "analysis_queue_status",
[DISCORD_VOICE_ACTIVE_USER]: "voice_active_user",
[DISCORD_VOICE_PCM]: "voice_pcm_data",
[DISCORD_VOICE_ANALYZED]: "voice_analyzed",
[DISCORD_REACTION_ADDED]: "reaction_added", [DISCORD_REACTION_ADDED]: "reaction_added",
[DISCORD_REACTION_REMOVED]: "reaction_removed", [DISCORD_REACTION_REMOVED]: "reaction_removed",
[DISCORD_THREAD_CREATED]: "thread_created", [DISCORD_THREAD_CREATED]: "thread_created",
+1 -8
View File
@@ -46,18 +46,11 @@ export function clearBroadcastFunctions(): void {
logger.info("Broadcast functions cleared"); logger.info("Broadcast functions cleared");
} }
function shouldLog(type: string): boolean {
if (!_enabled) return false;
// Avoid logging high-volume events
if (type === "voice_pcm_data") return false;
return true;
}
/** /**
* Broadcast a JSON event to all connected WebSocket clients. * Broadcast a JSON event to all connected WebSocket clients.
*/ */
export function broadcastEvent(type: string, data: unknown): void { export function broadcastEvent(type: string, data: unknown): void {
if (shouldLog(type)) { if (_enabled) {
logger.debug({ event: type }, "Broadcasting event"); logger.debug({ event: type }, "Broadcasting event");
} }
_broadcast?.(type, data); _broadcast?.(type, data);
-8
View File
@@ -1,8 +0,0 @@
export {
broadcastBinary,
broadcastEvent,
clearBroadcastFunctions,
setBroadcastFunctions,
} from "./broadcast.js";
export { startRedisBridge, stopRedisBridge } from "./redis-bridge.js";
export { closeWebSocketServer, createWebSocketServer } from "./server.js";
+12 -75
View File
@@ -1,23 +1,22 @@
import Redis from "ioredis"; import Redis from "ioredis";
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 } from "../shared/index.js";
DISCORD_CHANNEL_TO_WS_EVENT,
DISCORD_VOICE_ACTIVE_USER,
DISCORD_VOICE_PCM,
DISCORD_VOICE_STOPPED,
} 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 { broadcastEvent } from "./broadcast.js";
const logger = createChildLogger("ws.redis-bridge"); const logger = createChildLogger("ws.redis-bridge");
/** Channels we subscribe to = all keys in DISCORD_CHANNEL_TO_WS_EVENT */ /** Channels we subscribe to = all keys in DISCORD_CHANNEL_TO_WS_EVENT */
const SUBSCRIPTION_CHANNELS = Object.keys(DISCORD_CHANNEL_TO_WS_EVENT); const SUBSCRIPTION_CHANNELS = Object.keys(DISCORD_CHANNEL_TO_WS_EVENT);
/** Shape of the DiscordGatewayEvent envelope published by the gateway. */
interface GatewayEnvelope {
type?: string;
data?: unknown;
timestamp?: number;
source?: string;
}
let subscriber: Redis | null = null; let subscriber: Redis | null = null;
function createSubscriber(): Redis { function createSubscriber(): Redis {
@@ -31,14 +30,9 @@ function handleSubscriptionMessage(channel: string, message: string): void {
return; return;
} }
let envelope: { let envelope: GatewayEnvelope;
type?: string;
data?: unknown;
timestamp?: number;
source?: string;
};
try { try {
envelope = JSON.parse(message); envelope = JSON.parse(message) as GatewayEnvelope;
} catch (err) { } catch (err) {
logger.error({ channel, err }, "Failed to parse Redis message as JSON"); logger.error({ channel, err }, "Failed to parse Redis message as JSON");
return; return;
@@ -49,67 +43,10 @@ function handleSubscriptionMessage(channel: string, message: string): void {
// We only want <actual payload>, not the full envelope. // We only want <actual payload>, not the full envelope.
const data = envelope.data !== undefined ? envelope.data : envelope; const data = envelope.data !== undefined ? envelope.data : envelope;
// Voice PCM: decode base64 → binary broadcast instead of JSON
if (channel === DISCORD_VOICE_PCM) {
const pcmPayload = data as { userId?: string; pcm?: string };
if (pcmPayload?.pcm && pcmPayload?.userId) {
try {
const pcmBuffer = Buffer.from(pcmPayload.pcm, "base64");
// Prepend userId as 4-byte FNV-1a hash
const userIdHash = hashUserId(pcmPayload.userId);
const binary = Buffer.alloc(4 + pcmBuffer.length);
binary.writeUInt32LE(userIdHash, 0);
pcmBuffer.copy(binary, 4);
broadcastBinary(binary);
return;
} catch {
// fallback to JSON broadcast on error
}
}
}
// Aggregate live-voice state authoritatively BEFORE broadcasting.
// Every browser hears the same `voice_active_user` deltas, so the backend
// can maintain the single shared snapshot for late-joining clients.
if (channel === DISCORD_VOICE_ACTIVE_USER) {
const speaker = data as {
userId?: string;
username?: string;
avatar?: string | null;
speaking?: boolean;
};
if (speaker?.userId) {
recordSpeaker({
userId: speaker.userId,
username: speaker.username,
avatar: speaker.avatar,
speaking: Boolean(speaker.speaking),
});
}
}
// 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);
} }
/** Simple 32-bit FNV-1a hash for userId → 4-byte identifier */
function hashUserId(userId: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < userId.length; i++) {
hash ^= userId.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
export async function startRedisBridge(): Promise<void> { export async function startRedisBridge(): Promise<void> {
if (!config.REDIS_URL) { if (!config.REDIS_URL) {
logger.info("Redis not configured, skipping Redis bridge"); logger.info("Redis not configured, skipping Redis bridge");
+36 -168
View File
@@ -1,9 +1,8 @@
import type { IncomingMessage, Server } from "node:http"; import type { IncomingMessage, Server } from "node:http";
import type { Duplex } from "node:stream"; import type { Duplex } from "node:stream";
import { WebSocket, WebSocketServer } from "ws"; import { WebSocket, WebSocketServer } from "ws";
import type { MessageQuery } from "../modules/messages/messages.schema.js";
import { messagesService } from "../modules/messages/messages.service.js"; import { messagesService } from "../modules/messages/messages.service.js";
import { config } from "../shared/config/index.js";
import { BACKEND_COMMAND, BACKEND_VOICE_TRANSMIT } from "../shared/index.js";
import { createChildLogger } from "../shared/logger/index.js"; import { createChildLogger } from "../shared/logger/index.js";
import { setBroadcastFunctions } from "./broadcast.js"; import { setBroadcastFunctions } from "./broadcast.js";
@@ -17,11 +16,17 @@ interface BroadcastEvent {
interface JsonMessage { interface JsonMessage {
type: string; type: string;
buffer?: string;
command?: string;
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
} }
/** Payload accepted by the `stream_messages` JSON command. */
interface StreamMessagesPayload {
guildId?: string;
channelId?: string;
cursor?: string;
limit?: number;
}
// Track the active WebSocket server for lifecycle management // Track the active WebSocket server for lifecycle management
let _wss: WebSocketServer | null = null; let _wss: WebSocketServer | null = null;
@@ -54,36 +59,6 @@ async function sendInitialStates(ws: WebSocket): Promise<void> {
} catch (err) { } catch (err) {
logger.warn({ err }, "Failed to send initial ui_state"); logger.warn({ err }, "Failed to send initial ui_state");
} }
// Send initial media state
try {
const { getStatus } = await import("../modules/media/media.service.js");
const mediaState = await getStatus();
ws.send(
JSON.stringify({
type: "media_state",
state: mediaState,
}),
);
} catch (err) {
logger.warn({ err }, "Failed to send initial media_state");
}
// Send initial live-voice snapshot (shared authoritative state — a browser
// joining mid-call sees the same speakers as everyone else, not an empty DB).
try {
const { getActiveSpeakers } = await import(
"../modules/voice/live-speaker.js"
);
ws.send(
JSON.stringify({
type: "voice_state",
state: { activeSpeakers: getActiveSpeakers() },
}),
);
} catch (err) {
logger.warn({ err }, "Failed to send initial voice_state");
}
} }
export function closeWebSocketServer(): void { export function closeWebSocketServer(): void {
@@ -94,9 +69,7 @@ export function closeWebSocketServer(): void {
} }
export function createWebSocketServer(server: Server): WebSocketServer { export function createWebSocketServer(server: Server): WebSocketServer {
// Separate tracking: gateway sends PCM → forwarded to frontend only
const frontendClients = new Set<WebSocket>(); const frontendClients = new Set<WebSocket>();
const gatewayClients = new Set<WebSocket>();
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: true }); const wss = new WebSocketServer({ noServer: true, perMessageDeflate: true });
_wss = wss; _wss = wss;
@@ -115,43 +88,9 @@ export function createWebSocketServer(server: Server): WebSocketServer {
// Map-based dispatcher for JSON WebSocket message types // Map-based dispatcher for JSON WebSocket message types
const jsonHandlers = new Map<string, MessageHandler>(); const jsonHandlers = new Map<string, MessageHandler>();
jsonHandlers.set("voice_transmit", async (_ws, message) => {
if (!message.buffer) return;
const { getCommandPublisher } = await import("../shared/redis/index.js");
const publisher = getCommandPublisher();
await publisher.publish(
BACKEND_VOICE_TRANSMIT,
JSON.stringify({ type: "pcm", buffer: message.buffer }),
);
});
jsonHandlers.set("voice_command", async (_ws, message) => {
if (!message.command) return;
const { getCommandPublisher } = await import("../shared/redis/index.js");
const publisher = getCommandPublisher();
const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
await publisher.publish(
BACKEND_COMMAND,
JSON.stringify({
id: commandId,
type: message.command,
payload: message.payload ?? {},
replyChannel: `reply:${commandId}`,
}),
);
});
// Stream historical messages one-by-one over WS (no 50-row batch).
// The frontend requests it once per channel switch; the backend emits one
// `message_snapshot` frame per message so the UI renders progressively.
jsonHandlers.set("stream_messages", async (ws, message) => { jsonHandlers.set("stream_messages", async (ws, message) => {
if (ws.readyState !== WebSocket.OPEN) return; if (ws.readyState !== WebSocket.OPEN) return;
const payload = (message.payload ?? {}) as { const payload = (message.payload ?? {}) as StreamMessagesPayload;
guildId?: string;
channelId?: string;
cursor?: string;
limit?: number;
};
const guildId = payload.guildId; const guildId = payload.guildId;
const channelId = payload.channelId; const channelId = payload.channelId;
if (!guildId && !channelId) { if (!guildId && !channelId) {
@@ -162,6 +101,17 @@ export function createWebSocketServer(server: Server): WebSocketServer {
const pageSize = 50; // internal DB page size; still emitted one frame at a time const pageSize = 50; // internal DB page size; still emitted one frame at a time
const maxFrames = Math.min(payload.limit ?? 200, 500); const maxFrames = Math.min(payload.limit ?? 200, 500);
/** Send the end-of-stream frame, reporting sent count + next cursor. */
function sendEnd(data: Record<string, unknown>): void {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(
JSON.stringify({
type: "message_snapshot_end",
data,
}),
);
}
let sent = 0; let sent = 0;
let nextCursor: string | null = null; let nextCursor: string | null = null;
try { try {
@@ -170,7 +120,7 @@ export function createWebSocketServer(server: Server): WebSocketServer {
guildId, guildId,
channelId, channelId,
cursor: payload.cursor, cursor: payload.cursor,
} as never, } as MessageQuery,
pageSize, pageSize,
)) { )) {
if (ws.readyState !== WebSocket.OPEN) break; if (ws.readyState !== WebSocket.OPEN) break;
@@ -187,95 +137,22 @@ export function createWebSocketServer(server: Server): WebSocketServer {
sent++; sent++;
if (sent >= maxFrames) break; if (sent >= maxFrames) break;
} }
if (ws.readyState === WebSocket.OPEN) { sendEnd({ sent, nextCursor });
ws.send(
JSON.stringify({
type: "message_snapshot_end",
data: { sent, nextCursor },
}),
);
}
} catch (err) { } catch (err) {
logger.error({ err }, "stream_messages failed"); logger.error({ err }, "stream_messages failed");
if (ws.readyState === WebSocket.OPEN) { sendEnd({ sent, nextCursor, error: true });
ws.send(
JSON.stringify({
type: "message_snapshot_end",
data: { sent, nextCursor, error: true },
}),
);
}
} }
}); });
wss.on("connection", (ws: WebSocket, req) => { wss.on("connection", (ws: WebSocket) => {
// Parse auth token from query string frontendClients.add(ws);
const rawUrl = req.url ?? "/"; logger.info(`Frontend client connected (${frontendClients.size} total)`);
let isGateway = false; // Send initial states (user, ui) — fire-and-forget
sendInitialStates(ws).catch((err) =>
try { logger.error({ err }, "sendInitialStates failed"),
const url = new URL(rawUrl, "http://localhost"); );
const token = url.searchParams.get("token");
isGateway =
token !== null &&
config.BACKEND_WS_TOKEN !== "" &&
token === config.BACKEND_WS_TOKEN;
} catch {
// Malformed URL — treat as frontend
}
if (isGateway) {
gatewayClients.add(ws);
logger.info("Discord gateway WebSocket client authenticated");
// Gateway doesn't need initial states
} else {
frontendClients.add(ws);
logger.info(`Frontend client connected (${frontendClients.size} total)`);
// Send initial states (user, ui, media) — fire-and-forget
sendInitialStates(ws).catch((err) =>
logger.error({ err }, "sendInitialStates failed"),
);
}
ws.on("message", (data: Buffer) => { ws.on("message", (data: Buffer) => {
// Gateway PCM forward — broadcast raw binary to frontend clients only
if (isGateway && Buffer.isBuffer(data)) {
broadcastBinary(data);
return;
}
// Handle binary PCM from browser (FE→Discord transmit)
// Format: 4-byte magic "PCM\0" + raw PCM Int16 LE
if (
Buffer.isBuffer(data) &&
data.length > 4 &&
data[0] === 0x50 && // 'P'
data[1] === 0x43 && // 'C'
data[2] === 0x4d && // 'M'
data[3] === 0x00 // '\0'
) {
const pcmBuffer = data.subarray(4);
const base64 = pcmBuffer.toString("base64");
import("../shared/redis/index.js").then(({ getCommandPublisher }) => {
const publisher = getCommandPublisher();
publisher
.publish(
BACKEND_VOICE_TRANSMIT,
JSON.stringify({
type: "pcm",
buffer: base64,
}),
)
.catch((err: Error) => {
logger.error(
{ err },
"Failed to publish voice transmit to Redis",
);
});
});
return;
}
// Handle JSON messages from browser // Handle JSON messages from browser
if ( if (
typeof data === "string" || typeof data === "string" ||
@@ -296,24 +173,15 @@ export function createWebSocketServer(server: Server): WebSocketServer {
}); });
ws.on("close", () => { ws.on("close", () => {
if (isGateway) { frontendClients.delete(ws);
gatewayClients.delete(ws); logger.info(
logger.info("Discord gateway WebSocket disconnected"); `Frontend client disconnected (${frontendClients.size} total)`,
} else { );
frontendClients.delete(ws);
logger.info(
`Frontend client disconnected (${frontendClients.size} total)`,
);
}
}); });
ws.on("error", (err: Error) => { ws.on("error", (err: Error) => {
logger.error({ err }, "WebSocket client error"); logger.error({ err }, "WebSocket client error");
if (isGateway) { frontendClients.delete(ws);
gatewayClients.delete(ws);
} else {
frontendClients.delete(ws);
}
}); });
}); });

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