From 81a9d3c7b315d92cd03cb6649de18383f8ea567b Mon Sep 17 00:00:00 2001 From: Lukas Parsons Date: Mon, 16 Mar 2026 22:15:15 -0400 Subject: [PATCH] logic commit --- .claude/settings.local.json | 16 + AGENTS.md | 193 + VOICE_TRANSCRIPT_PLAN.md | 413 + apps/server/package.json | 35 + apps/server/src/app.ts | 31 + apps/server/src/config.ts | 57 + apps/server/src/db/client.ts | 15 + apps/server/src/db/migrate.ts | 38 + apps/server/src/db/migrations/001_init.sql | 141 + .../src/db/migrations/002_transcripts.sql | 64 + .../src/db/migrations/003_blackouts.sql | 11 + .../src/db/migrations/004_guild_settings.sql | 1 + apps/server/src/db/migrations/005_wiki.sql | 20 + .../src/db/migrations/006_wiki_seed.sql | 122 + .../src/db/migrations/007_dndbeyond_sync.sql | 7 + .../src/db/migrations/008_player_seed.sql | 318 + .../src/db/migrations/009_character_stats.sql | 1 + apps/server/src/jobs/scheduler.ts | 22 + apps/server/src/main.ts | 26 + apps/server/src/middleware.ts | 37 + apps/server/src/routes/authRoutes.ts | 104 + apps/server/src/routes/campaignRoutes.ts | 187 + apps/server/src/routes/characterRoutes.ts | 137 + apps/server/src/routes/dmRoutes.ts | 63 + apps/server/src/routes/dndBeyondRoutes.ts | 313 + apps/server/src/routes/gameNightRoutes.ts | 331 + apps/server/src/routes/sessionRoutes.ts | 98 + apps/server/src/routes/userRoutes.ts | 22 + apps/server/src/routes/wikiRoutes.ts | 277 + apps/server/src/services/auditService.ts | 11 + apps/server/src/services/authService.ts | 72 + apps/server/src/services/discordService.ts | 337 + apps/server/src/services/gameNightService.ts | 76 + apps/server/src/services/permissionService.ts | 37 + apps/server/src/services/recap/index.ts | 3 + apps/server/src/services/recap/prompt.ts | 41 + .../src/services/recap/providerRegistry.ts | 36 + .../recap/providers/claudeProvider.ts | 24 + .../recap/providers/ollamaProvider.ts | 40 + .../recap/providers/templateProvider.ts | 42 + .../server/src/services/recap/recapService.ts | 95 + apps/server/src/services/recap/types.ts | 27 + apps/server/src/services/sessionService.ts | 190 + .../src/services/transcriptionService.ts | 179 + apps/server/src/services/voiceAudioService.ts | 160 + .../src/services/voiceMonitorService.ts | 130 + apps/server/src/types.ts | 8 + apps/server/test/gameNightService.test.ts | 10 + apps/server/test/permissionService.test.ts | 9 + apps/server/test/sessionService.test.ts | 12 + apps/server/test/voiceTranscription.test.ts | 97 + apps/server/tsconfig.json | 14 + apps/web/index.html | 15 + apps/web/package.json | 26 + apps/web/src/App.js | 80 + apps/web/src/App.tsx | 237 + apps/web/src/api/client.js | 24 + apps/web/src/api/client.ts | 26 + .../src/components/CharacterSheetDrawer.js | 83 + .../src/components/CharacterSheetDrawer.tsx | 263 + apps/web/src/components/SrdDrawer.js | 137 + apps/web/src/components/SrdDrawer.tsx | 374 + apps/web/src/components/Toast.js | 45 + apps/web/src/components/Toast.tsx | 55 + .../web/src/contexts/CharacterSheetContext.js | 18 + .../src/contexts/CharacterSheetContext.tsx | 30 + apps/web/src/contexts/DebugContext.js | 20 + apps/web/src/contexts/DebugContext.tsx | 29 + apps/web/src/contexts/ToastContext.js | 26 + apps/web/src/contexts/ToastContext.tsx | 53 + apps/web/src/main.js | 7 + apps/web/src/main.tsx | 14 + apps/web/src/pages/AdminPage.js | 58 + apps/web/src/pages/AdminPage.tsx | 162 + apps/web/src/pages/CallbackPage.js | 32 + apps/web/src/pages/CallbackPage.tsx | 46 + apps/web/src/pages/CampaignDetailPage.js | 447 + apps/web/src/pages/CampaignDetailPage.tsx | 1129 +++ apps/web/src/pages/CampaignsPage.js | 46 + apps/web/src/pages/CampaignsPage.tsx | 136 + apps/web/src/pages/CharacterPage.js | 99 + apps/web/src/pages/CharacterPage.tsx | 330 + apps/web/src/pages/DashboardPage.js | 216 + apps/web/src/pages/DashboardPage.tsx | 748 ++ apps/web/src/pages/LoginPage.js | 46 + apps/web/src/pages/LoginPage.tsx | 106 + apps/web/src/smoke.test.js | 6 + apps/web/src/smoke.test.ts | 8 + apps/web/src/styles.css | 2918 ++++++ apps/web/tsconfig.json | 13 + apps/web/tsconfig.tsbuildinfo | 1 + apps/web/vite.config.ts | 10 + docker-compose.yml | 25 + infra/frontend.Dockerfile | 14 + infra/nginx.conf | 10 + infra/server.Dockerfile | 12 + package-lock.json | 7897 +++++++++++++++++ package.json | 21 + 98 files changed, 20848 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 AGENTS.md create mode 100644 VOICE_TRANSCRIPT_PLAN.md create mode 100644 apps/server/package.json create mode 100644 apps/server/src/app.ts create mode 100644 apps/server/src/config.ts create mode 100644 apps/server/src/db/client.ts create mode 100644 apps/server/src/db/migrate.ts create mode 100644 apps/server/src/db/migrations/001_init.sql create mode 100644 apps/server/src/db/migrations/002_transcripts.sql create mode 100644 apps/server/src/db/migrations/003_blackouts.sql create mode 100644 apps/server/src/db/migrations/004_guild_settings.sql create mode 100644 apps/server/src/db/migrations/005_wiki.sql create mode 100644 apps/server/src/db/migrations/006_wiki_seed.sql create mode 100644 apps/server/src/db/migrations/007_dndbeyond_sync.sql create mode 100644 apps/server/src/db/migrations/008_player_seed.sql create mode 100644 apps/server/src/db/migrations/009_character_stats.sql create mode 100644 apps/server/src/jobs/scheduler.ts create mode 100644 apps/server/src/main.ts create mode 100644 apps/server/src/middleware.ts create mode 100644 apps/server/src/routes/authRoutes.ts create mode 100644 apps/server/src/routes/campaignRoutes.ts create mode 100644 apps/server/src/routes/characterRoutes.ts create mode 100644 apps/server/src/routes/dmRoutes.ts create mode 100644 apps/server/src/routes/dndBeyondRoutes.ts create mode 100644 apps/server/src/routes/gameNightRoutes.ts create mode 100644 apps/server/src/routes/sessionRoutes.ts create mode 100644 apps/server/src/routes/userRoutes.ts create mode 100644 apps/server/src/routes/wikiRoutes.ts create mode 100644 apps/server/src/services/auditService.ts create mode 100644 apps/server/src/services/authService.ts create mode 100644 apps/server/src/services/discordService.ts create mode 100644 apps/server/src/services/gameNightService.ts create mode 100644 apps/server/src/services/permissionService.ts create mode 100644 apps/server/src/services/recap/index.ts create mode 100644 apps/server/src/services/recap/prompt.ts create mode 100644 apps/server/src/services/recap/providerRegistry.ts create mode 100644 apps/server/src/services/recap/providers/claudeProvider.ts create mode 100644 apps/server/src/services/recap/providers/ollamaProvider.ts create mode 100644 apps/server/src/services/recap/providers/templateProvider.ts create mode 100644 apps/server/src/services/recap/recapService.ts create mode 100644 apps/server/src/services/recap/types.ts create mode 100644 apps/server/src/services/sessionService.ts create mode 100644 apps/server/src/services/transcriptionService.ts create mode 100644 apps/server/src/services/voiceAudioService.ts create mode 100644 apps/server/src/services/voiceMonitorService.ts create mode 100644 apps/server/src/types.ts create mode 100644 apps/server/test/gameNightService.test.ts create mode 100644 apps/server/test/permissionService.test.ts create mode 100644 apps/server/test/sessionService.test.ts create mode 100644 apps/server/test/voiceTranscription.test.ts create mode 100644 apps/server/tsconfig.json create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/src/App.js create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/api/client.js create mode 100644 apps/web/src/api/client.ts create mode 100644 apps/web/src/components/CharacterSheetDrawer.js create mode 100644 apps/web/src/components/CharacterSheetDrawer.tsx create mode 100644 apps/web/src/components/SrdDrawer.js create mode 100644 apps/web/src/components/SrdDrawer.tsx create mode 100644 apps/web/src/components/Toast.js create mode 100644 apps/web/src/components/Toast.tsx create mode 100644 apps/web/src/contexts/CharacterSheetContext.js create mode 100644 apps/web/src/contexts/CharacterSheetContext.tsx create mode 100644 apps/web/src/contexts/DebugContext.js create mode 100644 apps/web/src/contexts/DebugContext.tsx create mode 100644 apps/web/src/contexts/ToastContext.js create mode 100644 apps/web/src/contexts/ToastContext.tsx create mode 100644 apps/web/src/main.js create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/pages/AdminPage.js create mode 100644 apps/web/src/pages/AdminPage.tsx create mode 100644 apps/web/src/pages/CallbackPage.js create mode 100644 apps/web/src/pages/CallbackPage.tsx create mode 100644 apps/web/src/pages/CampaignDetailPage.js create mode 100644 apps/web/src/pages/CampaignDetailPage.tsx create mode 100644 apps/web/src/pages/CampaignsPage.js create mode 100644 apps/web/src/pages/CampaignsPage.tsx create mode 100644 apps/web/src/pages/CharacterPage.js create mode 100644 apps/web/src/pages/CharacterPage.tsx create mode 100644 apps/web/src/pages/DashboardPage.js create mode 100644 apps/web/src/pages/DashboardPage.tsx create mode 100644 apps/web/src/pages/LoginPage.js create mode 100644 apps/web/src/pages/LoginPage.tsx create mode 100644 apps/web/src/smoke.test.js create mode 100644 apps/web/src/smoke.test.ts create mode 100644 apps/web/src/styles.css create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/tsconfig.tsbuildinfo create mode 100644 apps/web/vite.config.ts create mode 100644 docker-compose.yml create mode 100644 infra/frontend.Dockerfile create mode 100644 infra/nginx.conf create mode 100644 infra/server.Dockerfile create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..343fab5 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(npm install:*)", + "Bash(npx tsc:*)", + "Bash(node:*)", + "Bash(nvm version:*)", + "Bash(npm run:*)", + "Bash(fnm list:*)", + "Bash(fnm exec:*)", + "WebSearch", + "Bash(npm rebuild:*)", + "Bash(grep:*)" + ] + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1bc1dab --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,193 @@ +# DnD Campaign Hub - Agent Guidelines + +## Project Overview + +Monorepo with a Node.js/Express API + Discord bot and React/Vite frontend. + +**Stack:** +- Server: Node.js + TypeScript + Express + SQLite + Discord.js +- Web: React 19 + TypeScript + Vite + React Router +- Testing: Vitest +- Build: npm workspaces + +## Commands + +### Root (Monorepo) +```bash +npm install # Install all dependencies +npm run dev # Run both server and web in dev mode +npm run build # Build server and web +npm run test # Run all tests +``` + +### Server (`apps/server`) +```bash +npm run dev -w @dnd-hub/server # Dev mode with hot reload (tsx) +npm run build -w @dnd-hub/server # Compile TypeScript +npm run start -w @dnd-hub/server # Run compiled server +npm run test -w @dnd-hub/server # Run server tests +npm run migrate -w @dnd-hub/server # Run DB migrations +``` + +### Web (`apps/web`) +```bash +npm run dev -w @dnd-hub/web # Vite dev server (port 5173) +npm run build -w @dnd-hub/web # Type check + Vite build +npm run preview -w @dnd-hub/web # Preview production build +npm run test -w @dnd-hub/web # Run web tests +``` + +### Running a Single Test +```bash +# Run specific test file +npm run test -w @dnd-hub/server -- test/sessionService.test.ts +npm run test -w @dnd-hub/web -- src/smoke.test.ts + +# Run with filter pattern +npm run test -w @dnd-hub/server -- --run --reporter=verbose +``` + +## Code Style + +### TypeScript +- **Strict mode enabled** in both apps (`strict: true`) +- Target ES2022, use ESNext modules +- Server: `module: NodeNext`, `moduleResolution: NodeNext` +- Web: `module: ESNext`, `moduleResolution: Bundler` +- Always use `.ts`/`.tsx` extensions (no `.js` in src) + +### Imports +- Use ES modules (`import`/`export`) +- Include `.js` extension for relative imports in server code +- Group imports: React/hooks first, then libraries, then local imports +- Example: + ```typescript + import { useEffect, useState } from "react"; + import { Link, Route, Routes } from "react-router-dom"; + import { api } from "./api/client"; + ``` + +### Naming Conventions +- **Files**: camelCase for components/services (e.g., `CharacterSheetDrawer.tsx`, `sessionService.ts`) +- **Components**: PascalCase (e.g., `CharacterPage`, `ToastProvider`) +- **Functions/variables**: camelCase +- **Constants**: UPPER_SNAKE_CASE for config values +- **Types/Interfaces**: PascalCase (e.g., `Me`, `Campaign`) +- **Test files**: `*.test.ts` or `*.test.tsx` alongside or near tested code + +### Formatting +- 2-space indentation +- Semicolons required +- Double quotes for strings (single in JSX when needed) +- Trailing commas in multi-line objects/arrays +- Max line length: ~100 chars (flexible) + +### React Conventions +- Functional components with hooks only (no class components) +- Custom hooks prefixed with `use` (e.g., `useToast`, `useDebugMode`) +- Context providers for global state (Toast, Debug, CharacterSheet) +- Arrow functions for event handlers +- TypeScript interfaces for props + +### Error Handling +- Server: Use try/catch with proper HTTP status codes +- Validate inputs with Zod schemas +- Web: Use Toast context for user-facing errors +- Log errors server-side, show friendly messages client-side +- Never expose stack traces to clients + +### API Design +- RESTful endpoints under `/routes/` +- Use Express middleware for auth/validation +- CORS configured per environment +- Health check at `GET /health` + +### Database +- SQLite via better-sqlite3 (synchronous) +- Migrations in `src/db/migrate.ts` +- Path: `./data/dnd_hub.db` + +### Environment Variables +- Load via dotenv from monorepo root `.env` +- Use `config.ts` wrapper with defaults for dev +- Required vars throw on startup if missing +- Never commit `.env` (use `.env.example`) + +### Testing +- Framework: Vitest +- Pattern: `describe`/`it`/`expect` +- Import from `vitest` +- Tests should be isolated and deterministic +- Smoke tests for basic functionality + +## Project Structure + +``` +dnd-hub/ +├── apps/ +│ ├── server/ +│ │ ├── src/ +│ │ │ ├── routes/ # Express route handlers +│ │ │ ├── services/ # Business logic +│ │ │ ├── db/ # Database schema/migrations +│ │ │ ├── discord/ # Discord bot commands +│ │ │ ├── jobs/ # Scheduled cron jobs +│ │ │ └── main.ts # Entry point +│ │ └── test/ # Test files +│ └── web/ +│ ├── src/ +│ │ ├── components/ # React components +│ │ ├── contexts/ # React context providers +│ │ ├── pages/ # Route page components +│ │ ├── api/ # API client +│ │ └── App.tsx # Root component +│ └── vite.config.ts +├── infra/ # Docker, nginx config +├── scripts/ # Utility scripts +├── .env.example # Environment template +└── package.json # Workspace root +``` + +## Key Features + +### Transcript Sessions +- Discord commands: `/session start`, `/session stop`, `/session status` +- API endpoints: `POST /sessions/*`, `GET /sessions/:id/transcript` +- Cloud sync via `CLOUD_API_BASE_URL` with HMAC signatures + +### Discord Integration +- OAuth2 authentication flow +- Role-based permissions (admin/dm/player) +- Game night notifications via node-cron + +### Recap System +- Configurable providers: ollama (local) or claude (cloud) +- Set via `RECAP_MODE` or `RECAP_PROVIDERS` env vars + +## Common Tasks + +### Adding a New Route +1. Create handler in `apps/server/src/routes/` +2. Register in `app.ts` +3. Add TypeScript types if needed + +### Adding a React Component +1. Create in `apps/web/src/components/` +2. Use TypeScript with typed props +3. Follow existing component patterns + +### Database Changes +1. Update schema in `src/db/migrate.ts` +2. Run `npm run migrate -w @dnd-hub/server` + +### Debug Mode +- Web app has debug toggle to simulate player role +- Controlled via DebugContext + +## Gotchas + +- `.env` is at monorepo root, loaded relative to workspace +- Server uses `.js` extensions in imports (NodeNext module resolution) +- Web uses JSX transform (`jsx: react-jsx`) +- Both apps share Vitest but run independently +- Clean up both `.js` and `.ts` files in web/src (legacy JS exists) diff --git a/VOICE_TRANSCRIPT_PLAN.md b/VOICE_TRANSCRIPT_PLAN.md new file mode 100644 index 0000000..6678746 --- /dev/null +++ b/VOICE_TRANSCRIPT_PLAN.md @@ -0,0 +1,413 @@ +# Discord Voice Transcript Implementation Plan + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Discord Voice Channel │ +│ ┌────────┐ ┌────────┐ ┌────────┐ │ +│ │ Player │ │ Player │ │ Player │ │ +│ └───┬────┘ └───┬────┘ └───┬────┘ │ +│ │ │ │ │ +└───────┼───────────┼───────────┼─────────────────────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Discord Bot (discord.js + @discordjs/voice) │ +│ - Joins voice channel on /session start │ +│ - Subscribes to each user's audio stream │ +│ - Saves per-user .webm files to temp storage │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ (on /session stop) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Whisper Service (Docker container on Ubuntu server) │ +│ - whisper.cpp or openai/whisper │ +│ - Receives audio files via HTTP POST │ +│ - Returns JSON with timestamps + transcript │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ DnD Hub Server │ +│ - Maps Discord user ID → character (existing logic) │ +│ - Stores voice segments in transcript_segments table │ +│ - Marks segments with source='voice' │ +│ - AI recap includes both text + voice transcripts │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Phase 1: Database Schema (2-3 hours) + +### Migration: `apps/server/src/db/migrations/010_voice_transcripts.sql` + +```sql +-- Add source column to distinguish text vs voice +ALTER TABLE transcript_segments ADD COLUMN source TEXT DEFAULT 'text'; + +-- Table for voice recording metadata +CREATE TABLE voice_recordings ( + id INTEGER PRIMARY KEY, + session_id INTEGER NOT NULL, + discord_user_id TEXT NOT NULL, + file_path TEXT NOT NULL, + duration_ms INTEGER, + recorded_at TEXT NOT NULL, + processed_at TEXT, + processing_status TEXT DEFAULT 'pending', + FOREIGN KEY (session_id) REFERENCES sessions(id) +); + +-- Index for efficient lookups +CREATE INDEX idx_voice_recordings_session ON voice_recordings(session_id); +CREATE INDEX idx_transcript_source ON transcript_segments(source); +``` + +**Time:** 30 min schema design + 30 min migration + 1 hr testing + +--- + +## Phase 2: Whisper Docker Service (3-4 hours) + +### Option A: whisper.cpp (recommended for performance) +```yaml +# Add to docker-compose.yml +services: + whisper: + image: ghcr.io/ggerganov/whisper.cpp:server + ports: + - "10888:10888" + volumes: + - ./whisper-models:/models + environment: + - WHISPER_MODEL=/models/ggml-large-v3.bin + restart: unless-stopped +``` + +### Option B: OpenAI Whisper (simpler) +```yaml + whisper: + image: ghcr.io/openai/whisper:latest + ports: + - "10888:10888" + volumes: + - ./whisper-audio:/audio +``` + +**Setup steps:** +1. Add whisper service to `docker-compose.yml` +2. Download model (`ggml-large-v3.bin` ~3GB, best accuracy) +3. Test API endpoint: `POST /inference` with audio file +4. Create `apps/server/src/services/whisperClient.ts` + +**whisperClient.ts:** +```typescript +import FormData from 'form-data'; +import fetch from 'node-fetch'; + +const WHISPER_URL = process.env.WHISPER_BASE_URL ?? 'http://whisper:10888'; + +export async function transcribeAudio(audioBuffer: Buffer, speakerId: string) { + const form = new FormData(); + form.append('file', audioBuffer, { filename: `${speakerId}.webm` }); + form.append('model', 'large-v3'); + form.append('response_format', 'verbose_json'); + form.append('word_timestamps', 'true'); + + const res = await fetch(`${WHISPER_URL}/inference`, { + method: 'POST', + body: form + }); + + return res.json(); // { text, segments: [{start, end, text}] } +} +``` + +**Time:** 2 hrs Docker setup + 1-2 hrs client + testing + +--- + +## Phase 3: Voice Recording in Discord Bot (5-7 hours) + +### Update `discordService.ts` with voice intents: + +```typescript +import { joinVoiceChannel, createAudioSubscriber, VoiceConnectionStatus } from '@discordjs/voice'; +import { AudioReceiveStream } from 'discord.js'; + +// Add to intents +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.GuildMessages + ] +}); +``` + +### New service: `apps/server/src/services/voiceRecorder.ts` + +```typescript +import { joinVoiceChannel, createAudioSubscriber } from '@discordjs/voice'; +import { VoiceConnection } from 'discord.js'; +import { db } from '../db/client.js'; +import { writeFileSync, mkdirSync } from 'fs'; +import path from 'path'; + +const RECORDINGS_DIR = path.resolve('./data/voice-recordings'); + +class VoiceRecorder { + private connection: VoiceConnection | null = null; + private recordingStreams = new Map(); + private sessionId: number | null = null; + + async joinChannel(guildId: number, channelId: string, sessionId: number) { + this.connection = joinVoiceChannel({ + channelId, + guildId, + adapterCreator: getAdapter() // discord.js adapter + }); + + this.connection.on(VoiceConnectionStatus.Ready, () => { + console.log(`Voice connected for session ${sessionId}`); + this.sessionId = sessionId; + }); + + // Subscribe to all users + this.connection.receiver.subscriptions.on('entry', (userId, stream) => { + this.startRecording(userId, stream); + }); + } + + private startRecording(userId: string, stream: AudioReceiveStream) { + const filePath = path.join(RECORDINGS_DIR, `${this.sessionId}_${userId}.webm`); + mkdirSync(RECORDINGS_DIR, { recursive: true }); + + const file = createWriteStream(filePath); + stream.pipe(file); + this.recordingStreams.set(userId, file); + + // Track in DB + db.prepare(` + INSERT INTO voice_recordings (session_id, discord_user_id, file_path, recorded_at) + VALUES (?, ?, ?, datetime('now')) + `).run(this.sessionId, userId, filePath); + } + + async stopRecording() { + // Close all file streams + for (const stream of this.recordingStreams.values()) { + stream.end(); + } + this.recordingStreams.clear(); + + // Leave voice channel + this.connection?.destroy(); + this.connection = null; + } + + async processRecordings(sessionId: number) { + const recordings = db.prepare(` + SELECT * FROM voice_recordings + WHERE session_id = ? AND processing_status = 'pending' + `).all(sessionId); + + for (const rec of recordings) { + await this.processSingleRecording(rec); + } + } + + private async processSingleRecording(recording: any) { + const audioBuffer = readFileSync(recording.file_path); + const result = await transcribeAudio(audioBuffer, recording.discord_user_id); + + // Store transcript segments + for (const segment of result.segments) { + sessionService.appendSegment({ + sessionId: this.sessionId!, + guildId: /* get from session */, + discordUserId: recording.discord_user_id, + text: segment.text, + startedAt: segment.start, + endedAt: segment.end, + confidence: segment.confidence, + source: 'voice' + }); + } + + // Mark as processed + db.prepare(` + UPDATE voice_recordings + SET processing_status = 'completed', processed_at = datetime('now') + WHERE id = ? + `).run(recording.id); + } +} + +export const voiceRecorder = new VoiceRecorder(); +``` + +**Time:** 4-5 hrs discord.js voice API + 2 hrs file handling + 1 hr testing + +--- + +## Phase 4: Discord Command Integration (2-3 hours) + +### Update `/session start` command: + +```typescript +// In discordService.ts handleSessionCommand +if (sub === 'start') { + const result = sessionService.startSession(guildId, user.id); + + // Get the voice channel the user is in + const member = await interaction.guild.members.fetch(interaction.user.id); + const voiceChannel = member.voice.channel; + + if (voiceChannel) { + await voiceRecorder.joinChannel( + interaction.guildId!, + voiceChannel.id, + result.sessionId + ); + } + + await interaction.reply({ + content: `Started session #${result.sessionId}. ${voiceChannel ? '🎤 Recording voice' : '📝 Text only'}`, + ephemeral: false + }); +} +``` + +### Update `/session stop` command: + +```typescript +if (sub === 'stop') { + await voiceRecorder.stopRecording(); + await voiceRecorder.processRecordings(sessionId); + sessionService.stopSession(guildId, user.id); + await interaction.reply({ content: `Stopped session #${active.id}. Processing voice transcripts...`, ephemeral: false }); +} +``` + +**Time:** 2 hrs integration + 1 hr testing + +--- + +## Phase 5: UI for Voice Transcripts (4-6 hours) + +### Update `CampaignDetailPage.tsx`: + +```tsx +// Add filter toggle for transcript sources +const [showVoice, setShowVoice] = useState(true); +const [showText, setShowText] = useState(true); + +// Filter segments +const filteredSegments = segments.filter(s => + (s.source === 'voice' && showVoice) || (s.source === 'text' && showText) +); + +// Add audio player for voice segments +{segment.source === 'voice' && ( +