Compare commits
47
Commits
23a7bde4a5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b6ec59286 | ||
|
|
fdc7f01c26 | ||
|
|
f31b1f62d4 | ||
|
|
7be069d73f | ||
|
|
88373985ec | ||
|
|
7acc49e1fb | ||
|
|
81b3128bb0 | ||
|
|
1d0b3f422d | ||
|
|
0aef0b6c08 | ||
|
|
cd1d6b2d0d | ||
|
|
c888f23901 | ||
|
|
54d02098c8 | ||
|
|
5fc0e8b3cb | ||
|
|
045cdf1f75 | ||
|
|
deed7bdfb0 | ||
|
|
c4d9ade85e | ||
|
|
80daa9f045 | ||
|
|
ef7708bf7d | ||
|
|
750f3aa598 | ||
|
|
c57ee12da1 | ||
|
|
494e16b3b3 | ||
|
|
6bf3b40cc7 | ||
|
|
8743fcc0b5 | ||
|
|
e34dcd6bc6 | ||
|
|
f9fecfc144 | ||
|
|
a59f3132ee | ||
|
|
c7f53e4f7e | ||
|
|
8583bcdf17 | ||
|
|
b12eb0a038 | ||
|
|
b72423c64d | ||
|
|
6b34fc97ec | ||
|
|
401155b501 | ||
|
|
771783d991 | ||
|
|
7ad6487050 | ||
|
|
8ce8978755 | ||
|
|
869cad88e4 | ||
|
|
f136cf3f6b | ||
|
|
a13884d80f | ||
|
|
cd3ee5b5d8 | ||
|
|
93288afa0b | ||
|
|
2c69c51fba | ||
|
|
ce784f8305 | ||
|
|
33013697e0 | ||
|
|
b5b370e6eb | ||
|
|
b9bba643bd | ||
|
|
5ba891ecfb | ||
|
|
efca86c50e |
No files matched your search
@@ -18,31 +18,12 @@ MONITOR_GUILD_ID=your_guild_id_here # Target guild for text monitoring
|
||||
TEXT_GUILD_ID=optional_text_guild_id # Override text capture guild (falls back to MONITOR_GUILD_ID)
|
||||
TEXT_CHANNEL_ID=optional_text_channel_id # Restrict text capture to a single channel
|
||||
|
||||
# === Voice Channels ===
|
||||
# VOICE_GUILD_ID= # Guild for voice connection (optional)
|
||||
# VOICE_CHANNEL_ID= # Channel for voice connection (optional)
|
||||
|
||||
# === Recording ===
|
||||
RECORDINGS_DIR=./recordings # Audio file output directory (default: ./recordings)
|
||||
RECORDING_SEGMENT_MS=5000 # OGG segment duration in ms (default: 5000)
|
||||
|
||||
# === Decoder ===
|
||||
DECODER_ROTATE_MS=5000 # Opus decoder rotation interval in ms (default: 5000)
|
||||
DECODER_COOLDOWN_MS=30000 # Decoder error cooldown in ms (default: 30000)
|
||||
|
||||
# === Audio ===
|
||||
AUDIO_STREAM_SILENCE_DURATION_MS=3000 # Silence threshold in ms before stopping stream (default: 3000)
|
||||
PACKET_FILTER_MIN_SIZE=8 # Minimum Opus packet size in bytes (default: 8)
|
||||
OPUS_FRAME_SIZE=960 # Opus frame size in samples (default: 960)
|
||||
AUDIO_SAMPLE_RATE=48000 # Audio sample rate in Hz (default: 48000)
|
||||
AUDIO_CHANNELS=2 # Number of audio channels (default: 2)
|
||||
AVATAR_SIZE=64 # User avatar size in pixels (default: 64)
|
||||
|
||||
# === Webserver ===
|
||||
WEBSERVER_PORT=4001 # Backend HTTP/WS server port (default: 4001)
|
||||
|
||||
# === Connection ===
|
||||
VOICE_CONNECTION_TIMEOUT_MS=15000 # Voice connection timeout in ms (default: 15000)
|
||||
RECONNECT_TIMEOUT_MS=5000 # Reconnect timeout in ms (default: 5000)
|
||||
|
||||
# === Logging ===
|
||||
@@ -69,11 +50,6 @@ POSTGRES_POOL_MAX=10 # Maximum pool connections (default: 10)
|
||||
# === Redis ===
|
||||
REDIS_URL=redis://100.121.180.82:6379 # Redis connection string (default: redis://localhost:6379)
|
||||
|
||||
# === Voice PCM WebSocket (direct gateway→backend, bypasses Redis) ===
|
||||
VOICE_PCM_WS_ENABLED=true # Use direct WS for PCM audio (default: true)
|
||||
BACKEND_WS_URL=ws://backend:4001/ws # Backend WebSocket URL for gateway PCM streaming
|
||||
BACKEND_WS_TOKEN= # REQUIRED if VOICE_PCM_WS_ENABLED=true. Internal shared secret
|
||||
|
||||
# === Attachments ===
|
||||
TELE_UPLOAD_URL=https://upload.asepharyana.my.id/api/upload # Attachment upload endpoint (default)
|
||||
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000 # Upload timeout in ms (default: 30000)
|
||||
@@ -88,11 +64,6 @@ 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_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_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_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)
|
||||
@@ -126,7 +97,6 @@ AUTO_DELETE_LOG_CHANNEL_ID= # Channel ID to log auto-delete actions
|
||||
# === Retention (0 = disabled) ===
|
||||
RETENTION_MESSAGES_DAYS=0 # Message retention in days (default: 0 = off)
|
||||
RETENTION_ATTACHMENTS_DAYS=0 # Attachment retention in days (default: 0 = off)
|
||||
RETENTION_VOICE_DAYS=0 # Voice recording retention in days (default: 0 = off)
|
||||
RETENTION_CLEANUP_INTERVAL_MS=86400000 # Cleanup interval in ms (default: 24h)
|
||||
RETENTION_DRY_RUN=true # Dry-run: log but do not delete (default: true)
|
||||
|
||||
|
||||
@@ -32,29 +32,26 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install pnpm
|
||||
run: corepack enable && corepack prepare pnpm@11 --activate
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Install deps (backend)
|
||||
working-directory: services/backend
|
||||
run: pnpm install --ignore-scripts --no-frozen-lockfile
|
||||
|
||||
- name: Typecheck + test (backend)
|
||||
- name: Install deps + test (backend)
|
||||
working-directory: services/backend
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
./node_modules/.bin/tsc --noEmit
|
||||
# e2e.test.ts requires a live backend (API_BASE) — run unit tests only
|
||||
./node_modules/.bin/vitest run --exclude "src/e2e.test.ts"
|
||||
# src/e2e.test.ts requires a live backend (API_BASE) — unit tests
|
||||
# live in tests/ and are excluded by the bun test dir.
|
||||
bun test tests/
|
||||
|
||||
- name: Install deps (discord-gateway)
|
||||
working-directory: services/discord-gateway
|
||||
run: pnpm install --ignore-scripts --no-frozen-lockfile
|
||||
|
||||
- name: Typecheck + test (discord-gateway)
|
||||
- name: Install deps + test (discord-gateway)
|
||||
working-directory: services/discord-gateway
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
./node_modules/.bin/tsc --noEmit
|
||||
./node_modules/.bin/vitest run
|
||||
bun test tests/
|
||||
|
||||
- name: Biome check (all services)
|
||||
run: |
|
||||
@@ -151,9 +148,17 @@ jobs:
|
||||
|
||||
attic_push_vps_hop() {
|
||||
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)
|
||||
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.
|
||||
# --ignore-upstream-cache-filter is REQUIRED: without it, attic skips
|
||||
# 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
|
||||
# the imrnes-ts server → Tailscale. Non-root users' configs only
|
||||
# have the public `pub` server → "Server imrnes-ts does not exist".
|
||||
ssh "$VPS_USER@$VPS_HOST" "sudo $ATTIC_BIN push imrnes-ts:gmw '$STORE_PATH' --jobs 4 --ignore-upstream-cache-filter" \
|
||||
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)"
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -17,9 +17,13 @@ target/
|
||||
nix/
|
||||
# Gitea CI runner logs
|
||||
.gitea/workflows/*.log
|
||||
|
||||
.planning/
|
||||
# Nix build result symlink
|
||||
result
|
||||
|
||||
# Playwright MCP artifacts
|
||||
.playwright-mcp/
|
||||
findings.md
|
||||
findings.md
|
||||
progress.md
|
||||
task_plan.md
|
||||
@@ -1,17 +1,17 @@
|
||||
# GMW — Agent Guide
|
||||
|
||||
GMW (Guild Moderation Watcher) is a Discord bot + web dashboard for AI-powered moderation. A monorepo with three services: a selfbot gateway that captures messages/voice and runs LLM moderation, an Express/oRPC backend that serves the dashboard API, and a Next.js 16 SSR frontend. They communicate via Redis pub/sub (gateway→backend) and WebSocket (backend→browser).
|
||||
GMW (Guild Moderation Watcher) is a Discord bot + web dashboard for AI-powered moderation. A monorepo with three services: a selfbot gateway that captures Discord events and runs LLM moderation, an Express/oRPC backend that serves the dashboard API, and a Next.js 16 SSR frontend. They communicate via Redis pub/sub (gateway→backend) and WebSocket (backend→browser).
|
||||
|
||||
## Quick reference
|
||||
|
||||
```bash
|
||||
# Per-service — cd into the service first
|
||||
pnpm install # install deps (pnpm 11, not npm or bun)
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm lint # biome check
|
||||
pnpm format # biome format --write
|
||||
pnpm build # gateway/backend: tsc + fix-imports.mjs; frontend: next build
|
||||
pnpm test # vitest run (gateway & backend only — frontend has no tests)
|
||||
bun install # install deps (bun 1.3.14, not pnpm/npm)
|
||||
bun typecheck # tsc --noEmit
|
||||
bun lint # biome check
|
||||
bun format # biome format --write
|
||||
bun build # gateway/backend: tsc + fix-imports.mjs; frontend: next build
|
||||
bun test # bun test tests/ (gateway & backend only — frontend has no tests)
|
||||
```
|
||||
|
||||
No monorepo-level scripts exist. Run each command from inside the service directory.
|
||||
@@ -40,18 +40,19 @@ services/
|
||||
│
|
||||
└── frontend/ Next.js 16 App Router, React 19, Tailwind v4 (:4017).
|
||||
├── src/
|
||||
│ ├── app/ Pages — route groups under (dashboard)/
|
||||
│ ├── src/ Pages — route groups under (dashboard)/
|
||||
│ ├── components/ UI components (primitives, shell, charts, etc.)
|
||||
│ ├── hooks/ React hooks
|
||||
│ └── lib/ API clients, types, utils, WebSocket, audio
|
||||
└── pnpm-workspace.yaml Build-script approvals (sharp only)
|
||||
```
|
||||
```
|
||||
|
||||
## Conventions
|
||||
## Conventions
|
||||
|
||||
### Package manager & runtime
|
||||
### Package manager & runtime
|
||||
|
||||
- **pnpm** (v11), not npm or bun. Lockfiles are committed. Node ≥ 22.
|
||||
- **bun** (v1.3.14), package manager + test runner. Lockfiles (`bun.lock`) are committed. Node 22 is still used at runtime for the tsc-built `dist/` (via `fix-imports.mjs`), plus bun for dev/install/test. Bun is NOT the production runtime for gateway/backend — the Nix wrapper execs `node dist/index.js` — so native modules resolve against the Node ABI (e.g. `@discordjs/opus` prebuilds for node-v127).
|
||||
- ESM throughout (`"type": "module"` in all package.json files).
|
||||
- Test runner: **bun test** (`bun test tests/`). The old vitest configs are replaced by `bunfig.toml` `[test] preload = ["./tests/setup-env.ts"]` — note bun 1.3.14 **ignores `[test] env`**, so env vars for tests go in the preload file. Bun's jest-compat layer aliases most vitest APIs (`vi.fn`→`jest.fn`, `vi.useFakeTimers`→`jest.useFakeTimers`, `vi.spyOn`→`spyOn`, `vi.mock`→`mock.module`), but there is no `vi.waitFor` or `jest` global — use the small `waitForCompat` helper in `teLine truncated
|
||||
- ESM throughout (`"type": "module"` in all package.json files).
|
||||
|
||||
### Import style
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
version: "neuform-top-creators-featured"
|
||||
name: "AeroNet Visualization"
|
||||
description: "Aeronet Visualization Dashboard Section is designed for demonstrating application workflows and interface hierarchy. Key features include clear information density, modular panels, and interface rhythm. It is suitable for product showcases, admin panels, and analytics experiences."
|
||||
colors:
|
||||
primary: "#FFFFFF"
|
||||
secondary: "#000000"
|
||||
accent: "#FFFFFF"
|
||||
background: "#FFFFFF"
|
||||
surface: "#18181B"
|
||||
text-primary: "#111827"
|
||||
text-secondary: "#4B5563"
|
||||
border: "#E5E7EB"
|
||||
typography:
|
||||
display-lg:
|
||||
fontFamily: "Inter"
|
||||
fontSize: "64px"
|
||||
fontWeight: 500
|
||||
lineHeight: "1.04"
|
||||
letterSpacing: "0"
|
||||
body-md:
|
||||
fontFamily: "Inter"
|
||||
fontSize: "16px"
|
||||
fontWeight: 400
|
||||
lineHeight: "1.6"
|
||||
label-md:
|
||||
fontFamily: "JetBrains Mono"
|
||||
fontSize: "12px"
|
||||
fontWeight: 600
|
||||
lineHeight: "1.2"
|
||||
spacing:
|
||||
base: "8px"
|
||||
gap: "16px"
|
||||
card-padding: "24px"
|
||||
section-padding: "80px"
|
||||
rounded:
|
||||
card: "16px"
|
||||
control: "8px"
|
||||
pill: "9999px"
|
||||
components:
|
||||
card:
|
||||
background: "Use the surface token with subtle borders and HTML-matched shadow depth"
|
||||
radius: "Match the declared card radius token"
|
||||
button:
|
||||
background: "Use primary or accent colors for the main action"
|
||||
radius: "Use the control or pill radius based on the source HTML"
|
||||
---
|
||||
# AeroNet Visualization
|
||||
Source: Neuform Featured templates from top creators. Author: Sourasith Phomhome (@madebysourasith). Views: 419; favorites: 14; remixes: 4.
|
||||
Tags: dashboard, animated, threejs, charts, navigation, links, support, effect.
|
||||
## Overview
|
||||
Aeronet Visualization Dashboard Section is designed for demonstrating application workflows and interface hierarchy. Key features include clear information density, modular panels, and interface rhythm. It is suitable for product showcases, admin panels, and analytics experiences.
|
||||
|
||||
AeroNet Mapping Framework Metrics Docs Log In Deploy Node Planet Core Uninterrupted worldwide relay networks driving next-generation infrastructure. 19.5 TB/s +924 820 GB/s +418 Relay established successfully. 2.41 PB/s…
|
||||
## Composition
|
||||
Use the attached HTML reference as the source of truth. Preserve the visible hierarchy, first-screen composition, section rhythm, density, and interaction tone before adapting copy or content.
|
||||
Key visible headings include: Planet Core.
|
||||
## Colors
|
||||
Anchor the palette in primary #FFFFFF, secondary #000000, accent #FFFFFF, background #FFFFFF, surface #18181B, text-primary #111827. Keep background, surface, text, and border roles distinct so generated layouts retain the same contrast pattern as the source.
|
||||
## Typography
|
||||
Use Inter for display moments and Inter for body copy unless the HTML clearly demands a compatible fallback. Labels and technical metadata should use JetBrains Mono or an equivalent mono face.
|
||||
## Layout
|
||||
Keep spacing deliberate and stable. Favor the same grid direction, max-width behavior, card density, and responsive stacking seen in the HTML. Do not replace distinctive source structures with generic SaaS sections.
|
||||
## Components
|
||||
Dashboard, chart, and data panels should preserve their compact operational hierarchy, nested surfaces, and metric emphasis.
|
||||
## Motion
|
||||
Preserve existing motion cues such as masked reveals, staggered entrance, hover lift, scroll-triggered transitions, and ambient movement. Keep easing smooth and restrained.
|
||||
## WebGL & Effects
|
||||
|
||||
If the source includes canvas, WebGL, Three.js, gradients, particles, or atmospheric effects, rebuild them as supporting layers behind the content. Keep effects performant, responsive, and secondary to the interface.
|
||||
|
||||
## Guardrails
|
||||
- Do not flatten the source into a generic card grid.
|
||||
- Do not swap the color mode unless the source clearly supports it.
|
||||
- Preserve the first viewport signal, focal object, and visual density.
|
||||
- Keep buttons, cards, and badges aligned to the same radius and border language.
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
version: "neuform-top-creators-featured"
|
||||
name: "Nexus Capital Render"
|
||||
description: "Nexus Capital Feature Section is designed for highlighting product capabilities and value points. Key features include reusable structure, responsive behavior, and production-ready presentation. It is suitable for component libraries and responsive product interfaces."
|
||||
colors:
|
||||
primary: "#F2EAD3"
|
||||
secondary: "#000000"
|
||||
accent: "#F2EAD3"
|
||||
background: "#000000"
|
||||
surface: "#F2EAD3"
|
||||
text-primary: "#FFFFFF"
|
||||
text-secondary: "#A1A1AA"
|
||||
border: "#27272A"
|
||||
typography:
|
||||
display-lg:
|
||||
fontFamily: "Inter"
|
||||
fontSize: "64px"
|
||||
fontWeight: 500
|
||||
lineHeight: "1.04"
|
||||
letterSpacing: "0"
|
||||
body-md:
|
||||
fontFamily: "Playfair Display"
|
||||
fontSize: "16px"
|
||||
fontWeight: 400
|
||||
lineHeight: "1.6"
|
||||
label-md:
|
||||
fontFamily: "JetBrains Mono"
|
||||
fontSize: "12px"
|
||||
fontWeight: 600
|
||||
lineHeight: "1.2"
|
||||
spacing:
|
||||
base: "8px"
|
||||
gap: "16px"
|
||||
card-padding: "24px"
|
||||
section-padding: "80px"
|
||||
rounded:
|
||||
card: "16px"
|
||||
control: "8px"
|
||||
pill: "9999px"
|
||||
components:
|
||||
card:
|
||||
background: "Use the surface token with subtle borders and HTML-matched shadow depth"
|
||||
radius: "Match the declared card radius token"
|
||||
button:
|
||||
background: "Use primary or accent colors for the main action"
|
||||
radius: "Use the control or pill radius based on the source HTML"
|
||||
---
|
||||
# Nexus Capital Render
|
||||
Source: Neuform Featured templates from top creators. Author: Meng To (@mengto). Views: 311; favorites: 22; remixes: 15.
|
||||
Tags: feature, section, animated, bento, charts.
|
||||
## Overview
|
||||
Nexus Capital Feature Section is designed for highlighting product capabilities and value points. Key features include reusable structure, responsive behavior, and production-ready presentation. It is suitable for component libraries and responsive product interfaces.
|
||||
|
||||
NEXUS WEALTH CORP. Beyond traditional equity. The multi-asset strategy. A dynamic portfolio framework where capital transitions seamlessly from baseline reserves to high-yield synthetic instruments. Drag the timeline to…
|
||||
## Composition
|
||||
Use the attached HTML reference as the source of truth. Preserve the visible hierarchy, first-screen composition, section rhythm, density, and interaction tone before adapting copy or content.
|
||||
Key visible headings include: Beyond traditional equity. The multi-asset strategy..
|
||||
## Colors
|
||||
Anchor the palette in primary #F2EAD3, secondary #000000, accent #F2EAD3, background #000000, surface #F2EAD3, text-primary #FFFFFF. Keep background, surface, text, and border roles distinct so generated layouts retain the same contrast pattern as the source.
|
||||
## Typography
|
||||
Use Inter for display moments and Playfair Display for body copy unless the HTML clearly demands a compatible fallback. Labels and technical metadata should use JetBrains Mono or an equivalent mono face.
|
||||
## Layout
|
||||
Keep spacing deliberate and stable. Favor the same grid direction, max-width behavior, card density, and responsive stacking seen in the HTML. Do not replace distinctive source structures with generic SaaS sections.
|
||||
## Components
|
||||
Dashboard, chart, and data panels should preserve their compact operational hierarchy, nested surfaces, and metric emphasis.
|
||||
## Motion
|
||||
Preserve existing motion cues such as masked reveals, staggered entrance, hover lift, scroll-triggered transitions, and ambient movement. Keep easing smooth and restrained.
|
||||
## WebGL & Effects
|
||||
|
||||
If the source includes canvas, WebGL, Three.js, gradients, particles, or atmospheric effects, rebuild them as supporting layers behind the content. Keep effects performant, responsive, and secondary to the interface.
|
||||
|
||||
## Guardrails
|
||||
- Do not flatten the source into a generic card grid.
|
||||
- Do not swap the color mode unless the source clearly supports it.
|
||||
- Preserve the first viewport signal, focal object, and visual density.
|
||||
- Keep buttons, cards, and badges aligned to the same radius and border language.
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
version: "neuform-staff-featured-2026-05-22"
|
||||
name: "Summit — Cloud Migration Platform"
|
||||
description: "Summit Cloud Pricing Section is designed for comparing plans and supporting conversion decisions. Key features include plan comparison blocks and conversion-oriented actions. It is suitable for subscription pricing pages and plan comparison experiences."
|
||||
colors:
|
||||
primary: "#2563EB"
|
||||
secondary: "#050510"
|
||||
accent: "#3B82F6"
|
||||
background: "#050510"
|
||||
surface: "#18181B"
|
||||
text-primary: "#FFFFFF"
|
||||
text-secondary: "#A1A1AA"
|
||||
border: "#27272A"
|
||||
typography:
|
||||
display-lg:
|
||||
fontFamily: "Inter"
|
||||
fontSize: "64px"
|
||||
fontWeight: 500
|
||||
lineHeight: "1.04"
|
||||
letterSpacing: "0"
|
||||
body-md:
|
||||
fontFamily: "Inter"
|
||||
fontSize: "16px"
|
||||
fontWeight: 400
|
||||
lineHeight: "1.6"
|
||||
label-md:
|
||||
fontFamily: "JetBrains Mono"
|
||||
fontSize: "12px"
|
||||
fontWeight: 600
|
||||
lineHeight: "1.2"
|
||||
spacing:
|
||||
base: "8px"
|
||||
gap: "16px"
|
||||
card-padding: "24px"
|
||||
section-padding: "80px"
|
||||
rounded:
|
||||
card: "23px"
|
||||
control: "18px"
|
||||
pill: "9999px"
|
||||
components:
|
||||
card:
|
||||
background: "Use the surface token with subtle borders and HTML-matched shadow depth"
|
||||
radius: "Match the declared card radius token"
|
||||
button:
|
||||
background: "Use primary or accent colors for the main action"
|
||||
radius: "Use the control or pill radius based on the source HTML"
|
||||
---
|
||||
# Summit — Cloud Migration Platform
|
||||
Source: Neuform staff featured templates. Author: Vanh DesignCode (@vanh). Views: 161; favorites: 11; remixes: 3.
|
||||
Tags: pricing, section, animated, cta, charts, billing.
|
||||
## Overview
|
||||
Summit Cloud Pricing Section is designed for comparing plans and supporting conversion decisions. Key features include plan comparison blocks and conversion-oriented actions. It is suitable for subscription pricing pages and plan comparison experiences.
|
||||
|
||||
Summit Platform Solutions Pricing Docs Start Migration Now in General Availability Migrate to the cloud with confidence Automated discovery, dependency mapping, and zero-downtime migration for enterprise workloads. Move…
|
||||
## Composition
|
||||
Use the attached HTML reference as the source of truth. Preserve the visible hierarchy, first-screen composition, section rhythm, density, and interaction tone before adapting copy or content.
|
||||
Key visible headings include: Migrate to the cloud with confidence; Discover untethered scalability. The platform built to dynamically map, migrate, and optimize your entire infrastructure without lifting a finger.; Architectural Clarity; Dynamic Scaling.
|
||||
## Colors
|
||||
Anchor the palette in primary #2563EB, secondary #050510, accent #3B82F6, background #050510, surface #18181B, text-primary #FFFFFF. Keep background, surface, text, and border roles distinct so generated layouts retain the same contrast pattern as the source.
|
||||
## Typography
|
||||
Use Inter for display moments and Inter for body copy unless the HTML clearly demands a compatible fallback. Labels and technical metadata should use JetBrains Mono or an equivalent mono face.
|
||||
## Layout
|
||||
Keep spacing deliberate and stable. Favor the same grid direction, max-width behavior, card density, and responsive stacking seen in the HTML. Do not replace distinctive source structures with generic SaaS sections.
|
||||
## Components
|
||||
Dashboard, chart, and data panels should preserve their compact operational hierarchy, nested surfaces, and metric emphasis.
|
||||
## Motion
|
||||
Preserve existing motion cues such as masked reveals, staggered entrance, hover lift, scroll-triggered transitions, and ambient movement. Keep easing smooth and restrained.
|
||||
## WebGL & Effects
|
||||
|
||||
If the source includes canvas, WebGL, Three.js, gradients, particles, or atmospheric effects, rebuild them as supporting layers behind the content. Keep effects performant, responsive, and secondary to the interface.
|
||||
|
||||
## Guardrails
|
||||
- Do not flatten the source into a generic card grid.
|
||||
- Do not swap the color mode unless the source clearly supports it.
|
||||
- Preserve the first viewport signal, focal object, and visual density.
|
||||
- Keep buttons, cards, and badges aligned to the same radius and border language.
|
||||
@@ -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": "backend", "type": "backend", "label": "gmw-backend", "sublabel": "Express · oRPC · WS :4001", "pos": [640, 240], "size": [140, 60], "tag": "Drizzle ORM" },
|
||||
{ "id": "proxy", "type": "cloud", "label": "nginx proxy", "sublabel": "reverse proxy :4009", "pos": [960, 240], "size": [140, 60], "tag": "nginx" },
|
||||
{ "id": "ai", "type": "backend", "label": "AI Moderation", "sublabel": "LLM caller · embeddings", "pos": [260, 400], "size": [140, 60] },
|
||||
{ "id": "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": "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] }
|
||||
|
||||
@@ -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).
|
||||
@@ -35,9 +35,11 @@
|
||||
|
||||
# ---- Shared build tools ----
|
||||
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 npm_config_cache=$TMPDIR/npm-cache
|
||||
mkdir -p $npm_config_cache
|
||||
@@ -48,15 +50,9 @@
|
||||
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
|
||||
|
||||
# pnpm uses node-gyp for native addons — provide build tools (kept for
|
||||
# the rare case a prebuilt is unavailable and it falls back to compile).
|
||||
export CPPFLAGS="-I${pkgs.lib.getDev pkgs.openssl}/include"
|
||||
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
|
||||
# Build native addons (bun install runs postinstall scripts for
|
||||
# @discordjs/opus / sharp unless trustedDependencies restricts).
|
||||
bun install 2>&1
|
||||
'';
|
||||
|
||||
# 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.
|
||||
pruneProd = ''
|
||||
echo "=== Pruning devDependencies (production-only node_modules) ==="
|
||||
pnpm list --prod --depth 999 --parseable 2>/dev/null \
|
||||
| grep -o '\.pnpm/[^/]*' | sort -u > $TMPDIR/prod-pnms.txt
|
||||
( cd node_modules/.pnpm \
|
||||
&& for d in */; do \
|
||||
d="''${d%/}"; \
|
||||
[ "$d" = "node_modules" ] && continue; \
|
||||
grep -qF ".pnpm/$d" $TMPDIR/prod-pnms.txt || rm -rf "$d"; \
|
||||
done ) || true
|
||||
# Drop runtime-dead packages that still land in the prod graph:
|
||||
# - `@types/*` (pure TypeScript declarations) get pulled in as
|
||||
# REAL dependencies by type-aware deps (discord-api-types ->
|
||||
# @types/node, pg-protocol -> @types/pg, ...) even though nothing
|
||||
# ever `require`s them at runtime. Safe to strip.
|
||||
# - `opusscript` is only a pure-JS fallback Opus engine that
|
||||
# prism-media's loader uses IF `@discordjs/opus` (native, always
|
||||
# present/prebuilt) fails to load. Since the native engine loads,
|
||||
# opusscript is never executed — dead weight pulled in via
|
||||
# discord.js-selfbot-v13's dependency. Strip it too.
|
||||
( cd node_modules/.pnpm && rm -rf @types+* opusscript@* 2>/dev/null ) || true
|
||||
# Drop symlinks whose .pnpm target was pruned (top-level, scoped dirs,
|
||||
# hoist, .bin — any depth). Mirrors stdenv's noBrokenSymlinks check,
|
||||
# which would otherwise fail the fixupPhase.
|
||||
# bun install's layout: node_modules/<pkg> for prod deps; devDeps are
|
||||
# also present during build (needed for tsc). Keep only what the prod
|
||||
# graph needs: simplest robust approach is `bun install --production`
|
||||
# semantics — but bun keeps the same flat layout; since the Nix build
|
||||
# already ran `bun install` (full, scripts on), prune dev-only top
|
||||
# entries that were only pulled by devDeps (typescript, biome, vitest,
|
||||
# drizzle-kit, tsx, @types/*).
|
||||
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
|
||||
rm -rf node_modules/.bin/tsc node_modules/.bin/vitest node_modules/.bin/biome node_modules/.bin/drizzle-kit 2>/dev/null || true
|
||||
find node_modules -type l ! -exec test -e {} \; -delete 2>/dev/null || true
|
||||
du -sh node_modules
|
||||
'';
|
||||
@@ -107,11 +90,11 @@
|
||||
|
||||
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 ==="
|
||||
npx tsc 2>&1
|
||||
./node_modules/.bin/tsc 2>&1
|
||||
echo "=== Fixing @/ path aliases + extensionless relative imports for node ESM ==="
|
||||
node scripts/fix-imports.mjs
|
||||
echo "=== Build complete ==="
|
||||
@@ -149,7 +132,7 @@ WRAPPER
|
||||
# libvips download), so no cmake or rust toolchain is needed.
|
||||
# python3/gnumake/gcc stay as node-gyp fallback for @discordjs/opus.
|
||||
nativeBuildInputs = [
|
||||
nodejs pnpm
|
||||
nodejs bun
|
||||
pkgs.python3 pkgs.gnumake pkgs.gcc
|
||||
pkgs.pkg-config
|
||||
pkgs.openssl
|
||||
@@ -177,22 +160,11 @@ WRAPPER
|
||||
# neither needed nor wanted here. Skip it entirely.
|
||||
dontFixup = true;
|
||||
|
||||
buildPhase = pnpmInstall + ''
|
||||
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.
|
||||
buildPhase = bunInstall + ''
|
||||
echo "=== Rebuilding @discordjs/opus (prebuilt download) ==="
|
||||
pnpm rebuild @discordjs/opus 2>&1 || true
|
||||
bun pm rebuild @discordjs/opus 2>&1 || true
|
||||
echo "=== Compiling TypeScript ===="
|
||||
npx tsc 2>&1
|
||||
./node_modules/.bin/tsc 2>&1
|
||||
echo "=== Fixing @/ path aliases + extensionless relative imports for node ESM ==="
|
||||
node scripts/fix-imports.mjs
|
||||
echo "=== Build complete ==="
|
||||
@@ -228,13 +200,13 @@ WRAPPER
|
||||
|
||||
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) ==="
|
||||
export NEXT_TELEMETRY_DISABLED=1
|
||||
export GMW_BACKEND_URL=http://127.0.0.1:4001
|
||||
npx next build 2>&1
|
||||
./node_modules/.bin/next build 2>&1
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
@@ -312,13 +284,13 @@ WRAPPER
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
nodejs pnpm
|
||||
nodejs bun
|
||||
pkgs.python3 pkgs.gnumake pkgs.gcc
|
||||
pkgs.rustc pkgs.cargo
|
||||
pkgs.ffmpeg-headless
|
||||
];
|
||||
shellHook = ''
|
||||
echo "GMW dev shell ready — node $(node --version), pnpm $(pnpm --version)"
|
||||
echo "GMW dev shell ready — node $(node --version), bun $(bun --version)"
|
||||
'';
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
*
|
||||
!.gitkeep
|
||||
!README.md
|
||||
Whitespace-only changes.
@@ -1,15 +0,0 @@
|
||||
# Recordings Directory
|
||||
|
||||
This directory is mounted as a volume in the discord-gateway container.
|
||||
Voice recordings are stored here with the following structure:
|
||||
|
||||
```
|
||||
recordings/
|
||||
├── <user-id>/
|
||||
│ ├── <timestamp>.ogg
|
||||
│ └── <timestamp>.json
|
||||
└── sessions/
|
||||
└── <session-id>.json
|
||||
```
|
||||
|
||||
The directory must be writable by UID 1000 (app user in the container).
|
||||
@@ -59,10 +59,8 @@ src/
|
||||
| messages | Store & query Discord messages | `messages`, `ai_moderations`, `ai_moderation_flags` |
|
||||
| moderation | Moderation actions & metrics | `ai_moderations`, `moderation_actions` |
|
||||
| media | Media file management | `media_attachments` |
|
||||
| voice | Live speakers + recordings | `voice_recordings` |
|
||||
| recordings | Recordings API | `voice_recordings` |
|
||||
| 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` |
|
||||
| health | Health checks + metrics | Various |
|
||||
| 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:attachment:{created,uploaded}
|
||||
discord:voice:{started,stopped,uploaded,active_user,pcm,analyzed}
|
||||
discord:analysis:queue_status
|
||||
discord:reaction:{added,removed}
|
||||
discord:thread:{created,deleted,updated}
|
||||
|
||||
@@ -35,16 +35,6 @@ services/backend/
|
||||
│ │ │ └── routes/
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── media/
|
||||
│ │ │ ├── media.service.ts
|
||||
│ │ │ └── routes/
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── voice/
|
||||
│ │ │ ├── voice.service.ts
|
||||
│ │ │ └── routes/
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ └── health/
|
||||
│ │ ├── health.schema.ts
|
||||
│ │ ├── health.repository.ts
|
||||
@@ -148,8 +138,6 @@ export const messageQuerySchema = z.object({
|
||||
|--------|---------|--------|
|
||||
| **messages** | Text message storage & retrieval | GET /api/messages, GET /api/messages/:channelId |
|
||||
| **analytics** | Moderation statistics & trends | GET /api/analytics/overview, /daily-trend, /hourly-stats |
|
||||
| **media** | Media file management | GET /api/media/list, POST /api/media/upload |
|
||||
| **voice** | Voice recording management | GET /api/voice/recordings, POST /api/voice/connect |
|
||||
| **health** | Service health checks | GET /api/health |
|
||||
|
||||
## Data Flow
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "discord-moderation-backend",
|
||||
"dependencies": {
|
||||
"@orpc/server": "1.15.3",
|
||||
"axios": "^1.20.0",
|
||||
"dotenv": "^18.0.1",
|
||||
"drizzle-orm": "^0.45.3",
|
||||
"express": "^5.2.1",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^6.0.0",
|
||||
"pg": "^8.23.0",
|
||||
"pino": "^10.3.1",
|
||||
"prom-client": "^15.1.3",
|
||||
"ws": "^8.21.3",
|
||||
"zod": "^4.6.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.5.14",
|
||||
"@types/bun": "latest",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^7.0.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@biomejs/biome": ["@biomejs/biome@2.5.14", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.14", "@biomejs/cli-darwin-x64": "2.5.14", "@biomejs/cli-linux-arm64": "2.5.14", "@biomejs/cli-linux-arm64-musl": "2.5.14", "@biomejs/cli-linux-x64": "2.5.14", "@biomejs/cli-linux-x64-musl": "2.5.14", "@biomejs/cli-win32-arm64": "2.5.14", "@biomejs/cli-win32-x64": "2.5.14" }, "bin": { "biome": "bin/biome" } }, "sha512-0FabLIjd4M/dm8VFI86RMaLLdgepzbdfiAL2R8cr7V81OYYrP1w7Z73KfAwPK5h9SrLine truncated
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UnzaXO65L4tsZimFITFP2M121GyhDcWFrT3pL5ZJ5U4XcS/0L5VHYytVohdCf/gnDUFHgl2I9xnt5bV/J1kxHQ=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-kiy8qA16K93J7uvFfWi4LgjqDpnRKyePAna6A0Y4jxyUga35SYpIZ2cNWlhy0lsfgqRenCpfymnJWEZ6mgpRgA=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-vO/9BaU1n30CiFNLx49gMTMtbCAAqlo/EqFoG0BU3deQInTbJrmXSmTQ9e3FoDZIKQnvSLDVNL4lx1ci7y1T1Q=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-SJ9PrZkBnnH9dHJDnxk34vKs0GB2dbsioaft6/hPhWJ7AHpwYH83Isnhj0FSRpFkGgpVfJ1lds23ApB2czUDLQ=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-VHZRa7CCQBUWKxNwUvHJeuW03WFRgN5NWO//SDGOitc9QIeNyfA42V9zlKm+x82xFSG9Bzi5fi+hbhm9anO8yA=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.14", "", { "os": "linux", "cpu": "x64" }, "sha512-2kI5PrMgW5dcEZYrstLPmUmCwkUwZY39rP4BN93Vxbwcs2O57LDQOktUZZYPvvspN5i0mhGr3TJtF5sU26NUWg=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-pHgAFffmZtaYoxavEsWEYNvcA7NwOIjLyqw2HXyLbt16xYryX0L4fMV2u0G9ag6LNYPIoqWaWqzUcnc6rLGGXg=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.14", "", { "os": "win32", "cpu": "x64" }, "sha512-oJWmBhoHsnhUKIke+0gXDX0mltJrWHA1UyHsrTlXwX0TL64ilVZAo+TYZm97baecV0esBdpzy3k96q+09JaZUQ=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
||||
|
||||
"@ioredis/commands": ["@ioredis/commands@2.0.0", "", {}, "sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
|
||||
|
||||
"@orpc/client": ["@orpc/client@1.15.3", "", { "dependencies": { "@orpc/shared": "1.15.3", "@orpc/standard-server": "1.15.3", "@orpc/standard-server-fetch": "1.15.3", "@orpc/standard-server-peer": "1.15.3" } }, "sha512-ceMe1zGG0uTJr6RfB8TWLJU54DJe3dULkn1A8AUVUDJ4eS+JqMkMmG8zJdEXrkvm4Dwh8majVCpWfCwKuq57Pg=="],
|
||||
|
||||
"@orpc/contract": ["@orpc/contract@1.15.3", "", { "dependencies": { "@orpc/client": "1.15.3", "@orpc/shared": "1.15.3", "@standard-schema/spec": "1.1.0", "openapi-types": "12.1.3" } }, "sha512-ta7LFMMua24obEaYTQerKaR9oGV17mZJ/w0TggJGbBKOsQ5IXxZsDFRh4ugIGFP9KCNPK4XqrENe6SUmc4CEfA=="],
|
||||
|
||||
"@orpc/interop": ["@orpc/interop@1.15.3", "", {}, "sha512-E24sWenXmAy84vTgHsnYsCWT1dciKIq+zEa0Ip3xGHFiz4Sx9s/AA/oNAoKLkM05Q7onH0oMlV2EiImBqa88eQ=="],
|
||||
|
||||
"@orpc/server": ["@orpc/server@1.15.3", "", { "dependencies": { "@orpc/client": "1.15.3", "@orpc/contract": "1.15.3", "@orpc/interop": "1.15.3", "@orpc/shared": "1.15.3", "@orpc/standard-server": "1.15.3", "@orpc/standard-server-aws-lambda": "1.15.3", "@orpc/standard-server-fastify": "1.15.3", "@orpc/standard-server-fetch": "1.15.3", "@orpc/standard-server-node": "1.15.3", "@orpc/standard-server-peer": "1.15.3", "cookie": "1.1.1" }, "optionalDependencies": { "ws": "8.21.3" } }, "sha512-V/ZJLine truncated
|
||||
|
||||
"@orpc/shared": ["@orpc/shared@1.15.3", "", { "dependencies": { "radash": "12.1.1", "type-fest": "5.10.0" }, "optionalDependencies": { "@opentelemetry/api": "1.9.1" } }, "sha512-b8Y0Zt+OhxOJP5Fp7seoPhmSb5Pej8qKqzRxsSbYVb2C88E3XktWaTJVD9fC6lRXED8wDvy4SYag6XSKALwDbg=="],
|
||||
|
||||
"@orpc/standard-server": ["@orpc/standard-server@1.15.3", "", { "dependencies": { "@orpc/shared": "1.15.3" } }, "sha512-ssLeP7EjtBw1k+7jkksnon7CkldSZPG9aBcUT9F46fvPX74ysIcX5swOuek8vS6WazEjd5NPkXYH3OxIpFdi6g=="],
|
||||
|
||||
"@orpc/standard-server-aws-lambda": ["@orpc/standard-server-aws-lambda@1.15.3", "", { "dependencies": { "@orpc/shared": "1.15.3", "@orpc/standard-server": "1.15.3", "@orpc/standard-server-fetch": "1.15.3", "@orpc/standard-server-node": "1.15.3" } }, "sha512-m/0+x/h+T+0MhBn4oFTssvr9Nvf/GZe9DPHqHlY0739I6MAVUOsHbaVV57YAR7Z0NZvvnjJ6IgmWYNg4IO0IOQ=="],
|
||||
|
||||
"@orpc/standard-server-fastify": ["@orpc/standard-server-fastify@1.15.3", "", { "dependencies": { "@orpc/shared": "1.15.3", "@orpc/standard-server": "1.15.3", "@orpc/standard-server-node": "1.15.3" } }, "sha512-2232+PBDJA5E+aex9hc8WHDdRBkYV+o5tYYnebbrDTNmhYEjiD9sp6z9je3RQvC9ap0yuXLHI38IYirNey5A9A=="],
|
||||
|
||||
"@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.15.3", "", { "dependencies": { "@orpc/shared": "1.15.3", "@orpc/standard-server": "1.15.3" } }, "sha512-m1S5pQuDngbYc+FX7Y6wekENjUphSPbTO0dEnfK7KBeZSH9mteh/0gyMS0YynXBU8AA7Yn3qTckoPzdRvgD8oA=="],
|
||||
|
||||
"@orpc/standard-server-node": ["@orpc/standard-server-node@1.15.3", "", { "dependencies": { "@orpc/shared": "1.15.3", "@orpc/standard-server": "1.15.3", "@orpc/standard-server-fetch": "1.15.3" } }, "sha512-jxsBRszSWG2je6cpZLJzWeTK/iz2xJHGYthsOuexFmmWWB9WqALwx2gjbOMi9zythFiU/AqD1pgTH2rnPYRU3w=="],
|
||||
|
||||
"@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.15.3", "", { "dependencies": { "@orpc/shared": "1.15.3", "@orpc/standard-server": "1.15.3" } }, "sha512-1/vKuHc/dibDVKyT6PkzZ0V2T9TchQBz9lw7avCc0oGmIqNKqCsF3cWxMh1FEH8orI8VWu1o54s5uq63Px5rvw=="],
|
||||
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "3.4.38", "@types/node": "26.6.2" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
|
||||
|
||||
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "26.6.2" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
|
||||
|
||||
"@types/express": ["@types/express@5.0.6", "", { "dependencies": { "@types/body-parser": "1.19.6", "@types/express-serve-static-core": "5.1.2", "@types/serve-static": "2.2.0" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="],
|
||||
|
||||
"@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.2", "", { "dependencies": { "@types/node": "26.6.2", "@types/qs": "6.15.1", "@types/range-parser": "1.2.7", "@types/send": "1.2.1" } }, "sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg=="],
|
||||
|
||||
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
|
||||
|
||||
"@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="],
|
||||
|
||||
"@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "26.6.2", "pg-protocol": "1.16.0", "pg-types": "2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="],
|
||||
|
||||
"@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="],
|
||||
|
||||
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
|
||||
|
||||
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "26.6.2" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
|
||||
|
||||
"@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "2.0.5", "@types/node": "26.6.2" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "26.6.2" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
|
||||
|
||||
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
|
||||
|
||||
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
|
||||
|
||||
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
|
||||
|
||||
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
|
||||
|
||||
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
|
||||
|
||||
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
|
||||
|
||||
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
|
||||
|
||||
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
|
||||
|
||||
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
|
||||
|
||||
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
|
||||
|
||||
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
|
||||
|
||||
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
|
||||
|
||||
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
|
||||
|
||||
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
|
||||
|
||||
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
|
||||
|
||||
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
|
||||
|
||||
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
|
||||
|
||||
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
|
||||
|
||||
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "3.0.2", "negotiator": "1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4.4.3" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||
|
||||
"axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "1.16.0", "form-data": "4.0.6", "https-proxy-agent": "5.0.1", "proxy-from-env": "2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="],
|
||||
|
||||
"bintrees": ["bintrees@1.0.2", "", {}, "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw=="],
|
||||
|
||||
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "3.1.2", "content-type": "2.0.0", "debug": "4.4.3", "http-errors": "2.0.1", "iconv-lite": "0.7.3", "on-finished": "2.4.1", "qs": "6.15.3", "raw-body": "3.0.2", "type-is": "2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "1.3.0", "function-bind": "1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "get-intrinsic": "1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"dotenv": ["dotenv@18.0.1", "", { "bin": { "dotenv": "dist/index.cjs" } }, "sha512-0eR4m4D/jH5eaI3evo2ZqqMii5mrTCR12v12VChdie6O1gPHs7XAjzTep0CWc1Bd+oNx3vVfbi734dK4zghigw=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.45.3", "", { "optionalDependencies": { "@opentelemetry/api": "1.9.1", "@types/pg": "8.23.1", "pg": "8.23.0" } }, "sha512-CAloER21cDdcgZ1OmjrZX82EeRNsdP+y0onX/eyoYDaYzki/adrAa91lzM5jWh7zZPK4qUbw/6LGv9J+A+uizA=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "es-errors": "1.3.0", "gopd": "1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "1.3.0", "get-intrinsic": "1.3.0", "has-tostringtag": "1.0.2", "hasown": "2.0.4" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64elLine truncated
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "2.0.0", "body-parser": "2.3.0", "content-disposition": "1.1.0", "content-type": "1.0.5", "cookie": "0.7.2", "cookie-signature": "1.2.2", "debug": "4.4.3", "depd": "2.0.0", "encodeurl": "2.0.0", "escape-html": "1.0.3", "etag": "1.8.1", "finalhandler": "2.1.1", "fresh": "2.0.0", "http-errors": "2.0.1", "merge-descriptors": "2.0.0", "mime-types": "3.0.2", "on-finished": "2.4.1", "once": "1.4.0", "parseurl": "1.3.3", "proxy-addr":Line truncated
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "4.4.3", "encodeurl": "2.0.0", "escape-html": "1.0.3", "on-finished": "2.4.1", "parseurl": "1.3.3", "statuses": "2.0.2" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.16.0", "", { "optionalDependencies": { "debug": "4.4.3" } }, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
|
||||
|
||||
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "0.4.0", "combined-stream": "1.0.8", "es-set-tostringtag": "2.1.0", "hasown": "2.0.4", "mime-types": "2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "es-define-property": "1.0.1", "es-errors": "1.3.0", "es-object-atoms": "1.1.2", "function-bind": "1.1.2", "get-proto": "1.0.1", "gopd": "1.2.0", "has-symbols": "1.1.0", "hasown": "2.0.4", "math-intrinsics": "1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "1.0.1", "es-object-atoms": "1.1.2" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "1.1.0" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||
|
||||
"helmet": ["helmet@8.3.0", "", {}, "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.2", "toidentifier": "1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6.0.2", "debug": "4.4.3" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": "2.1.2" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ioredis": ["ioredis@6.0.0", "", { "dependencies": { "@ioredis/commands": "2.0.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "standard-as-callback": "2.1.0" } }, "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1.0.2" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"pg": ["pg@8.23.0", "", { "dependencies": { "pg-connection-string": "2.14.0", "pg-pool": "3.14.0", "pg-protocol": "1.16.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "1.4.0" } }, "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg=="],
|
||||
|
||||
"pg-cloudflare": ["pg-cloudflare@1.4.0", "", {}, "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A=="],
|
||||
|
||||
"pg-connection-string": ["pg-connection-string@2.14.0", "", {}, "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg=="],
|
||||
|
||||
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
|
||||
|
||||
"pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": "8.23.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="],
|
||||
|
||||
"pg-protocol": ["pg-protocol@1.16.0", "", {}, "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg=="],
|
||||
|
||||
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "2.0.0", "postgres-bytea": "1.0.1", "postgres-date": "1.0.7", "postgres-interval": "1.2.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
|
||||
|
||||
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "4.2.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
|
||||
|
||||
"pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "0.4.0", "atomic-sleep": "1.0.0", "on-exit-leak-free": "2.1.2", "pino-abstract-transport": "3.0.0", "pino-std-serializers": "7.1.0", "process-warning": "5.1.0", "quick-format-unescaped": "4.0.4", "real-require": "0.2.0", "safe-stable-stringify": "2.5.0", "sonic-boom": "4.2.1", "thread-stream": "4.2.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1Line truncated
|
||||
|
||||
"pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "4.2.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
|
||||
|
||||
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
|
||||
|
||||
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
||||
|
||||
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
|
||||
|
||||
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
|
||||
|
||||
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "4.0.2" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
|
||||
|
||||
"process-warning": ["process-warning@5.1.0", "", {}, "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw=="],
|
||||
|
||||
"prom-client": ["prom-client@15.1.3", "", { "dependencies": { "@opentelemetry/api": "1.9.1", "tdigest": "0.1.2" } }, "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
||||
|
||||
"qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "1.0.1", "side-channel": "1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="],
|
||||
|
||||
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||
|
||||
"radash": ["radash@12.1.1", "", {}, "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA=="],
|
||||
|
||||
"range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.1", "iconv-lite": "0.7.3", "unpipe": "1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||
|
||||
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "4.4.3", "depd": "2.0.0", "is-promise": "4.0.0", "parseurl": "1.3.3", "path-to-regexp": "8.4.2" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "4.4.3", "encodeurl": "2.0.0", "escape-html": "1.0.3", "etag": "1.8.1", "fresh": "2.0.0", "http-errors": "2.0.1", "mime-types": "3.0.2", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "1.3.0", "statuses": "2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "2.0.0", "escape-html": "1.0.3", "parseurl": "1.3.3", "send": "1.2.1" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "1.3.0", "object-inspect": "1.13.4", "side-channel-list": "1.0.1", "side-channel-map": "1.0.1", "side-channel-weakmap": "1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "1.3.0", "object-inspect": "1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "get-intrinsic": "1.3.0", "object-inspect": "1.13.4" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "1.0.4", "es-errors": "1.3.0", "get-intrinsic": "1.3.0", "object-inspect": "1.13.4", "side-channel-map": "1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
|
||||
|
||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
"tdigest": ["tdigest@0.1.2", "", { "dependencies": { "bintrees": "1.0.2" } }, "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA=="],
|
||||
|
||||
"thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"tsx": ["tsx@4.23.15", "", { "dependencies": { "esbuild": "0.28.2" }, "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw=="],
|
||||
|
||||
"type-fest": ["type-fest@5.10.0", "", { "dependencies": { "tagged-tag": "1.0.0" } }, "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA=="],
|
||||
|
||||
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "2.0.0", "media-typer": "1.1.1", "mime-types": "3.0.2" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||
|
||||
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@Line truncated
|
||||
|
||||
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"ws": ["ws@8.21.3", "", {}, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||
|
||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
|
||||
|
||||
"body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="],
|
||||
|
||||
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
preload = ["./tests/setup-env.ts"]
|
||||
@@ -5,7 +5,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"packageManager": "pnpm@11.20.0",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"engines": {
|
||||
"node": ">=22.12.0",
|
||||
"pnpm": ">=9.0.0"
|
||||
@@ -16,15 +16,15 @@
|
||||
"format": "biome format --write .",
|
||||
"lint": "biome check --diagnostic-level=error .",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"test": "bun test tests/",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:e2e": "bun test src/e2e.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/voice": "^0.19.2",
|
||||
"@orpc/server": "1.15.2",
|
||||
"@orpc/server": "1.15.3",
|
||||
"axios": "^1.20.0",
|
||||
"dotenv": "^18.0.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"drizzle-orm": "^0.45.3",
|
||||
"express": "^5.2.1",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^6.0.0",
|
||||
@@ -40,8 +40,8 @@
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsx": "^4.23.13",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.1"
|
||||
"@types/bun": "latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
-2883
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,13 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
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}`, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...init?.headers },
|
||||
@@ -23,7 +29,8 @@ describe("API Health", () => {
|
||||
});
|
||||
|
||||
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);
|
||||
const text = await res.text();
|
||||
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", () => {
|
||||
it("GET /config returns 200", async () => {
|
||||
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", () => {
|
||||
it("GET /nonexistent returns 404", async () => {
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
async findMany(
|
||||
query: MessageQuery,
|
||||
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> {
|
||||
async findMany(query: MessageQuery): Promise<PageResult<MessageRow>> {
|
||||
const db = getDatabase();
|
||||
const limit = query.limit ?? 50;
|
||||
const conditions: SQL[] = [];
|
||||
@@ -76,13 +117,8 @@ export class MessagesRepository {
|
||||
}
|
||||
|
||||
// Exclude spam threads (NULL-safe: non-thread messages are kept)
|
||||
if (EXCLUDED_THREAD_IDS.length > 0) {
|
||||
const excludeThreads = or(
|
||||
isNull(pgMessagesTable.thread_id),
|
||||
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||
);
|
||||
if (excludeThreads) conditions.push(excludeThreads);
|
||||
}
|
||||
const excludeThreads = excludeSpamThreads();
|
||||
if (excludeThreads) conditions.push(excludeThreads);
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
const rows = await db
|
||||
@@ -90,7 +126,7 @@ export class MessagesRepository {
|
||||
.from(pgMessagesTable)
|
||||
.where(where)
|
||||
.orderBy(desc(pgMessagesTable.created_at))
|
||||
.limit(limit + 1);
|
||||
.limit(cursorLimit(limit));
|
||||
|
||||
const data = rows
|
||||
.slice(0, limit)
|
||||
@@ -138,7 +174,7 @@ export class MessagesRepository {
|
||||
async findByChannel(
|
||||
channelId: string,
|
||||
query: MessageQuery,
|
||||
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> {
|
||||
): Promise<PageResult<MessageRow>> {
|
||||
const db = getDatabase();
|
||||
const limit = query.limit ?? 50;
|
||||
const conditions: SQL[] = [eq(pgMessagesTable.channel_id, channelId)];
|
||||
@@ -148,20 +184,15 @@ export class MessagesRepository {
|
||||
}
|
||||
|
||||
// Exclude spam threads (NULL-safe)
|
||||
if (EXCLUDED_THREAD_IDS.length > 0) {
|
||||
const excludeThreads = or(
|
||||
isNull(pgMessagesTable.thread_id),
|
||||
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||
);
|
||||
if (excludeThreads) conditions.push(excludeThreads);
|
||||
}
|
||||
const excludeThreads = excludeSpamThreads();
|
||||
if (excludeThreads) conditions.push(excludeThreads);
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(pgMessagesTable)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(pgMessagesTable.created_at))
|
||||
.limit(limit + 1);
|
||||
.limit(cursorLimit(limit));
|
||||
|
||||
const data = rows
|
||||
.slice(0, limit)
|
||||
@@ -181,7 +212,7 @@ export class MessagesRepository {
|
||||
async *streamMany(
|
||||
query: MessageQuery,
|
||||
pageSize = 50,
|
||||
): AsyncGenerator<ReturnType<typeof mapMessageRow>, void, unknown> {
|
||||
): AsyncGenerator<MessageRow, void, unknown> {
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (query.guildId) {
|
||||
@@ -196,13 +227,8 @@ export class MessagesRepository {
|
||||
if (query.status) {
|
||||
conditions.push(eq(pgMessagesTable.ai_status, query.status));
|
||||
}
|
||||
if (EXCLUDED_THREAD_IDS.length > 0) {
|
||||
const excludeThreads = or(
|
||||
isNull(pgMessagesTable.thread_id),
|
||||
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
|
||||
);
|
||||
if (excludeThreads) conditions.push(excludeThreads);
|
||||
}
|
||||
const excludeThreads = excludeSpamThreads();
|
||||
if (excludeThreads) conditions.push(excludeThreads);
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
let cursor: string | undefined = query.cursor;
|
||||
@@ -221,7 +247,7 @@ export class MessagesRepository {
|
||||
.from(pgMessagesTable)
|
||||
.where(pageWhere)
|
||||
.orderBy(desc(pgMessagesTable.created_at))
|
||||
.limit(pageSize + 1);
|
||||
.limit(cursorLimit(pageSize));
|
||||
|
||||
if (rows.length === 0) return;
|
||||
|
||||
@@ -366,31 +392,30 @@ export class MessagesRepository {
|
||||
async getImageMessages(
|
||||
guildId: string,
|
||||
limit: number = 50,
|
||||
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> {
|
||||
): Promise<PageResult<MessageRow>> {
|
||||
const db = getDatabase();
|
||||
|
||||
// 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
|
||||
.select({ id: pgAttachmentsTable.message_id })
|
||||
.from(pgAttachmentsTable)
|
||||
.where(
|
||||
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] : [];
|
||||
})()
|
||||
: []),
|
||||
),
|
||||
)
|
||||
.where(and(...attachmentConditions))
|
||||
.orderBy(desc(pgAttachmentsTable.created_at))
|
||||
.limit(limit + 1);
|
||||
.limit(cursorLimit(limit));
|
||||
|
||||
// Fetch full message rows for those IDs
|
||||
const rows = await db
|
||||
@@ -398,7 +423,7 @@ export class MessagesRepository {
|
||||
.from(pgMessagesTable)
|
||||
.where(inArray(pgMessagesTable.id, imageMsgIds))
|
||||
.orderBy(desc(pgMessagesTable.created_at))
|
||||
.limit(limit + 1);
|
||||
.limit(cursorLimit(limit));
|
||||
|
||||
const data = rows
|
||||
.slice(0, limit)
|
||||
@@ -433,28 +458,16 @@ export class MessagesRepository {
|
||||
.from(pgAttachmentsTable)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(pgAttachmentsTable.created_at))
|
||||
.limit(limit + 1);
|
||||
.limit(cursorLimit(limit));
|
||||
|
||||
const data = rows.map((r) => ({
|
||||
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,
|
||||
}));
|
||||
const data = rows.map((r) =>
|
||||
mapAttachmentRow(r as Record<string, unknown>),
|
||||
);
|
||||
|
||||
// nextCursor derives from the fetched-but-untrimmed overflow row (index
|
||||
// `limit`), matching the other cursor-paginated queries.
|
||||
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);
|
||||
|
||||
return { data: trimmed, nextCursor };
|
||||
@@ -522,6 +535,44 @@ export class MessagesRepository {
|
||||
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();
|
||||
@@ -41,11 +41,3 @@ export const messageUpdateSchema = z.object({
|
||||
export type MessageQuery = z.infer<typeof messageQuerySchema>;
|
||||
export type MessageCreate = z.infer<typeof messageCreateSchema>;
|
||||
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 { createChildLogger } from "@/shared/logger/index";
|
||||
import { embedQuery } from "./embed.js";
|
||||
import { messagesRepository } from "./messages.repository.js";
|
||||
import type { MessageQuery, SemanticSearchQuery } from "./messages.schema.js";
|
||||
import { searchArchive } from "./qdrant.js";
|
||||
import { type MessageRow, messagesRepository } from "./messages.repository.js";
|
||||
import type { MessageQuery } from "./messages.schema.js";
|
||||
|
||||
const logger = createChildLogger("messages.service");
|
||||
|
||||
export class MessagesService {
|
||||
async listMessages(query: MessageQuery) {
|
||||
async listMessages(
|
||||
query: MessageQuery,
|
||||
): Promise<Awaited<ReturnType<typeof messagesRepository.findMany>>> {
|
||||
if (!query.channelId && !query.guildId) {
|
||||
throw new ValidationError("Either channelId or guildId is required");
|
||||
}
|
||||
@@ -26,7 +25,10 @@ export class MessagesService {
|
||||
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) {
|
||||
throw new ValidationError("channelId is required");
|
||||
}
|
||||
@@ -35,7 +37,14 @@ export class MessagesService {
|
||||
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) {
|
||||
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) {
|
||||
throw new ValidationError("channelId is required");
|
||||
}
|
||||
@@ -85,72 +99,33 @@ export class MessagesService {
|
||||
return messagesRepository.getReviewMessages(channelId, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public, read-only semantic search over the persistent message archive.
|
||||
* Embeds the query, searches Qdrant, returns text + metadata. Best-effort:
|
||||
* 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) {
|
||||
async getActivity(
|
||||
days = 30,
|
||||
): Promise<Awaited<ReturnType<typeof messagesRepository.getActivity>>> {
|
||||
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");
|
||||
return messagesRepository.getRecentEdits(limit, channelId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape returned to the frontend (text + rich metadata from the archive payload). */
|
||||
function mapSearchHit(hit: {
|
||||
score: number;
|
||||
payload: {
|
||||
text: string;
|
||||
content_hash?: string;
|
||||
analyzed_at: number;
|
||||
username?: string;
|
||||
channel_id?: string;
|
||||
guild_id?: string;
|
||||
thread_id?: string | null;
|
||||
channel_name?: string | null;
|
||||
thread_name?: string | null;
|
||||
created_at?: number;
|
||||
};
|
||||
}) {
|
||||
return {
|
||||
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,
|
||||
};
|
||||
/** Distinct guilds present in the message archive (guild picker). */
|
||||
async getGuilds(): Promise<
|
||||
Awaited<ReturnType<typeof messagesRepository.listGuilds>>
|
||||
> {
|
||||
return messagesRepository.listGuilds();
|
||||
}
|
||||
|
||||
/** Text channels for a guild (channel picker). */
|
||||
async getTextChannels(
|
||||
guildId: string,
|
||||
): Promise<Awaited<ReturnType<typeof messagesRepository.listTextChannels>>> {
|
||||
return messagesRepository.listTextChannels(guildId);
|
||||
}
|
||||
}
|
||||
|
||||
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,33 +5,10 @@ import { chatRequestSchema } from "../modules/chatbot/chatbot.schema";
|
||||
import { chatbotService } from "../modules/chatbot/chatbot.service";
|
||||
import { dashboardService } from "../modules/dashboard/dashboard.service";
|
||||
import { knowledgeService } from "../modules/knowledge/knowledge.service";
|
||||
import {
|
||||
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 { messageQuerySchema } from "../modules/messages/messages.schema";
|
||||
import { messagesService } from "../modules/messages/messages.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 {
|
||||
connectVoice,
|
||||
disconnectVoice,
|
||||
getGuilds,
|
||||
getTextChannels,
|
||||
getVoiceChannels,
|
||||
getVoiceStatus,
|
||||
} from "../modules/voice/voice.service";
|
||||
import { config } from "../shared/config/index";
|
||||
import { publishCommandNoReply } from "../shared/redis/index";
|
||||
|
||||
@@ -89,6 +66,10 @@ const dashboardRouter = {
|
||||
|
||||
// ── Messages ─────────────────────────────────────────────────────
|
||||
const messagesRouter = {
|
||||
guilds: os.handler(() => messagesService.getGuilds()),
|
||||
textChannels: os
|
||||
.input(z.object({ guildId: z.string() }))
|
||||
.handler(({ input }) => messagesService.getTextChannels(input.guildId)),
|
||||
list: os
|
||||
.input(messageQuerySchema)
|
||||
.handler(({ input }) => messagesService.listMessages(input)),
|
||||
@@ -139,10 +120,6 @@ const messagesRouter = {
|
||||
);
|
||||
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).
|
||||
activity: os
|
||||
.input(
|
||||
@@ -238,86 +215,6 @@ const moderationRouter = {
|
||||
.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) ──────────────────────────────────────────────
|
||||
const analysisRouter = {
|
||||
search: os
|
||||
@@ -416,11 +313,8 @@ const configRouter = {
|
||||
backlogSyncBatchSize: config.BACKLOG_SYNC_BATCH_SIZE,
|
||||
retentionMessagesDays: config.RETENTION_MESSAGES_DAYS,
|
||||
retentionAttachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
|
||||
retentionVoiceDays: config.RETENTION_VOICE_DAYS,
|
||||
autoDeleteFlaggedEnabled: config.AUTO_DELETE_FLAGGED_ENABLED,
|
||||
aiAnalysisEnabled: config.AI_ANALYSIS_ENABLED,
|
||||
voiceGuildId: config.VOICE_GUILD_ID || null,
|
||||
voiceChannelId: config.VOICE_CHANNEL_ID || null,
|
||||
logLevel: config.LOG_LEVEL,
|
||||
})),
|
||||
};
|
||||
@@ -438,9 +332,6 @@ export const appRouter = {
|
||||
dashboard: dashboardRouter,
|
||||
messages: messagesRouter,
|
||||
moderation: moderationRouter,
|
||||
media: mediaRouter,
|
||||
voice: voiceRouter,
|
||||
recordings: recordingsRouter,
|
||||
analysis: analysisRouter,
|
||||
chatbot: chatbotRouter,
|
||||
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();
|
||||
}
|
||||
@@ -33,29 +33,6 @@ export const configSchema = z
|
||||
.transform((v) => v.split(",").filter(Boolean))
|
||||
.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 ───────────────────────────────────────────────────────────
|
||||
WEBSERVER_PORT: z.coerce.number().positive().default(3001),
|
||||
NODE_ENV: z
|
||||
@@ -92,17 +69,8 @@ export const configSchema = z
|
||||
|
||||
// ── Redis ────────────────────────────────────────────────────────────
|
||||
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 ───────────────────────────────────────────────────────
|
||||
VOICE_CONNECTION_TIMEOUT_MS: z.coerce.number().positive().default(15000),
|
||||
RECONNECT_TIMEOUT_MS: z.coerce.number().positive().default(5000),
|
||||
|
||||
// ── Attachments ─────────────────────────────────────────────────────
|
||||
@@ -128,21 +96,12 @@ export const configSchema = z
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
AI_LLM_API_KEY: z.string().optional(),
|
||||
AI_LLM_BASE_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("http://100.121.180.82:20128/api/v1"),
|
||||
// 9router — OpenAI-compatible router on this host (127.0.0.1:4014).
|
||||
// Loopback on purpose: backend runs on the same machine as 9router, so no
|
||||
// TLS/proxy hop is needed.
|
||||
AI_LLM_BASE_URL: z.string().url().default("http://127.0.0.1:4014/v1"),
|
||||
AI_LLM_MODEL: z.string().default("text"),
|
||||
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_IMAGE_MAX_DIMENSION: z.coerce
|
||||
.number()
|
||||
@@ -203,13 +162,6 @@ export const configSchema = z
|
||||
PISCINA_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_API_KEY: z.string().optional(),
|
||||
OPENAI_MODERATION_BASE_URL: z
|
||||
@@ -218,11 +170,6 @@ export const configSchema = z
|
||||
.default("https://api.openai.com/v1"),
|
||||
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_FLAGGED_ENABLED: z
|
||||
.string()
|
||||
@@ -252,7 +199,6 @@ export const configSchema = z
|
||||
// ── Retention ───────────────────────────────────────────────────────
|
||||
RETENTION_MESSAGES_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
|
||||
.number()
|
||||
.positive()
|
||||
@@ -269,9 +215,8 @@ export const configSchema = z
|
||||
.default(true),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (!value.AI_ANALYSIS_ENABLED) {
|
||||
// skip: AI analysis not enabled
|
||||
} else if (!value.AI_LLM_API_KEY) {
|
||||
// AI analysis requires credentials to run.
|
||||
if (value.AI_ANALYSIS_ENABLED && !value.AI_LLM_API_KEY) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["AI_LLM_API_KEY"],
|
||||
@@ -291,7 +236,6 @@ export const configSchema = z
|
||||
|
||||
export type AppConfig = z.infer<typeof configSchema> & {
|
||||
EFFECTIVE_TEXT_GUILD_ID?: string;
|
||||
EFFECTIVE_VOICE_GUILD_ID?: string;
|
||||
EFFECTIVE_MONITOR_GUILD_IDS: string[];
|
||||
};
|
||||
|
||||
@@ -301,7 +245,6 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
return {
|
||||
...parsed,
|
||||
EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID,
|
||||
EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID,
|
||||
EFFECTIVE_MONITOR_GUILD_IDS:
|
||||
parsed.MONITOR_GUILD_IDS.length > 0
|
||||
? parsed.MONITOR_GUILD_IDS
|
||||
|
||||
@@ -24,9 +24,6 @@ export interface BroadcasterClient {
|
||||
messageAnalyzed: (data: unknown) => void;
|
||||
attachmentCreated: (data: unknown) => void;
|
||||
attachmentUploaded: (data: unknown) => void;
|
||||
voiceRecordingStarted: (data: unknown) => void;
|
||||
voiceRecordingStopped: (data: unknown) => void;
|
||||
voiceRecordingUploaded: (data: unknown) => void;
|
||||
analysisQueueStatus: (data: unknown) => void;
|
||||
}
|
||||
|
||||
@@ -102,17 +99,6 @@ export interface AttachmentRecord {
|
||||
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 {
|
||||
id: string;
|
||||
channel_id: string;
|
||||
@@ -121,7 +107,7 @@ export interface DashboardMessage {
|
||||
avatar_url: string | null;
|
||||
content: string;
|
||||
created_at: number;
|
||||
type: "text" | "image" | "voice";
|
||||
type: "text" | "image";
|
||||
}
|
||||
|
||||
export interface MessageQuery {
|
||||
@@ -154,23 +140,6 @@ export interface AnalysisResult {
|
||||
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 {
|
||||
queuedConversations: number;
|
||||
activeRequests: number;
|
||||
@@ -221,7 +190,6 @@ export interface RetentionPolicy {
|
||||
channel_id: string | null;
|
||||
retention_days: number;
|
||||
apply_to_media: boolean;
|
||||
apply_to_voice: boolean;
|
||||
enabled: boolean;
|
||||
created_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_ATTACHMENT_CREATED = "discord:attachment:created";
|
||||
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_REACTION_ADDED = "discord:reaction:added";
|
||||
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_VOICE_TRANSMIT = "backend:voice:transmit";
|
||||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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_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 DISCORD_VOICE_ANALYZED = "discord:voice:analyzed";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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_ATTACHMENT_CREATED]: "attachment_created",
|
||||
[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_VOICE_ACTIVE_USER]: "voice_active_user",
|
||||
[DISCORD_VOICE_PCM]: "voice_pcm_data",
|
||||
[DISCORD_VOICE_ANALYZED]: "voice_analyzed",
|
||||
[DISCORD_REACTION_ADDED]: "reaction_added",
|
||||
[DISCORD_REACTION_REMOVED]: "reaction_removed",
|
||||
[DISCORD_THREAD_CREATED]: "thread_created",
|
||||
|
||||
@@ -46,18 +46,11 @@ export function clearBroadcastFunctions(): void {
|
||||
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.
|
||||
*/
|
||||
export function broadcastEvent(type: string, data: unknown): void {
|
||||
if (shouldLog(type)) {
|
||||
if (_enabled) {
|
||||
logger.debug({ event: type }, "Broadcasting event");
|
||||
}
|
||||
_broadcast?.(type, data);
|
||||
|
||||
Loaded 50 of 260 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user