diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..5ff3012 --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,618 @@ +# Implementation Plan: Issues #1-8 + +## Executive Summary + +This document outlines the implementation strategy for GitHub issues #1-8 of the DiscordStats project. The issues fall into two main feature tracks: + +1. **Multi-Server Aggregation** (Issues #1 & #2) - Infrastructure for monitoring multiple Discord servers +2. **RallyRound Integration** (Issues #3-8) - Live session management with Discord voice channels + +--- + +## Current Codebase State + +| Component | Status | +|-----------|--------| +| Frontend | React 19 + Vite + TypeScript, Recharts visualizations | +| Backend | Express + TypeScript + discord.js | +| Database | **None** - currently stateless | +| Authentication | OAuth endpoints exist but not integrated | +| Voice Features | **None** - no @discordjs/voice integration | +| RallyRound | **None** - no integration exists | + +--- + +## Issue Dependency Graph + +``` +Issue #1/#2: Multi-Server Aggregation (Independent Track) + └── Can be developed in parallel with RallyRound features + +Issue #3: Discord OAuth Provider + └── Issue #4: RallyRound API Client + ├── Issue #5: Bot Commands + ├── Issue #6: Voice Channel Presence + │ └── Issue #8: Sound Effect Playback + └── Issue #7: Webhook Receiver + └── Issue #8: Sound Effect Playback +``` + +--- + +## Implementation Phases + +### Phase 0: Foundation (Prerequisites) + +Before implementing any issues, the following foundational work is required: + +#### 0.1 Database Layer +- Add SQLite/PostgreSQL support with an ORM (Drizzle or Prisma) +- Create base schemas for sessions, users, and server configurations +- Set up migrations infrastructure + +#### 0.2 Voice Infrastructure +- Install `@discordjs/voice`, `sodium-native`, `ffmpeg-static` +- Configure Discord bot with voice intents (`GuildVoiceStates`) +- Create audio resource utilities + +#### 0.3 Project Structure Updates +``` +server/src/ +├── db/ +│ ├── schema.ts # Database schemas +│ ├── migrations/ # Migration files +│ └── index.ts # DB connection +├── services/ +│ ├── rallyround/ # RallyRound API client +│ ├── voice/ # Voice connection management +│ └── audio/ # Sound effect playback +├── commands/ # Bot command handlers +├── webhooks/ # Webhook handlers +└── utils/ + └── jwt.ts # JWT utilities +``` + +--- + +### Phase 1: Discord OAuth Provider (Issue #3) + +**Priority:** High | **Complexity:** Medium | **Dependencies:** None + +#### Implementation Tasks + +1. **JWT Infrastructure** + - Install `jsonwebtoken` package + - Create JWT signing/verification utilities + - Configure JWT_SECRET environment variable + +2. **OAuth Endpoints** + ``` + GET /auth/discord - Initiate OAuth flow + GET /auth/discord/callback - Handle Discord callback + GET /auth/validate - Validate bearer tokens + GET /auth/user - Get authenticated user profile + ``` + +3. **Security Requirements** + - CSRF protection via state tokens + - HMAC signature verification + - 24-hour token expiration + - CORS configuration for RallyRound origin + +4. **Files to Create/Modify** + - `server/src/routes/auth.ts` - New auth routes + - `server/src/controllers/authController.ts` - OAuth logic + - `server/src/utils/jwt.ts` - JWT utilities + - `server/src/middleware/auth.ts` - Auth middleware + - `server/src/index.ts` - Mount new routes + +#### Acceptance Criteria +- [ ] OAuth flow redirects to Discord with correct scopes (`identify`, `guilds`) +- [ ] Callback exchanges code for Discord token +- [ ] JWT is generated with user info and 24h expiry +- [ ] `/auth/validate` returns user info for valid tokens +- [ ] Invalid/expired tokens return 401 +- [ ] CORS allows RallyRound origin + +--- + +### Phase 2: RallyRound API Client (Issue #4) + +**Priority:** High | **Complexity:** Medium | **Dependencies:** Issue #3 + +#### Implementation Tasks + +1. **Client Class Structure** + ```typescript + class RallyRoundClient { + // Session Management + createSession(options: CreateSessionOptions): Promise + getSession(sessionId: string): Promise + updateSession(sessionId: string, updates: SessionUpdate): Promise + endSession(sessionId: string): Promise + + // Participants + addParticipant(sessionId: string, participant: Participant): Promise + removeParticipant(sessionId: string, participantId: string): Promise + updateParticipantStatus(sessionId: string, participantId: string, status: ParticipantStatus): Promise + + // Signals + raiseSignal(sessionId: string, participantId: string, signal: SignalType): Promise + clearSignal(sessionId: string, participantId: string): Promise + + // Speaker Queue + advanceSpeaker(sessionId: string): Promise + setSpeaker(sessionId: string, participantId: string): Promise + clearSpeakerQueue(sessionId: string): Promise + + // Agenda + addAgendaItem(sessionId: string, item: AgendaItem): Promise + advanceAgenda(sessionId: string): Promise + completeAgendaItem(sessionId: string, itemId: string): Promise + } + ``` + +2. **Type Definitions** + ```typescript + type SessionState = 'scheduled' | 'active' | 'paused' | 'ended' + type SessionMode = 'structured' | 'unstructured' + type SignalType = 'hand' | 'point_of_order' | 'clarification' | 'information' | 'question' | 'agree' | 'disagree' + type ParticipantStatus = 'active' | 'away' | 'speaking' + ``` + +3. **Session Registry** + - Track active sessions per guild + - Map guild IDs to session IDs + - Store session metadata (voice channel, facilitator, etc.) + +4. **Error Handling** + - Custom `RallyRoundError` class + - Retry logic for transient failures (503, network errors) + - 30-second request timeout + +5. **Files to Create** + - `server/src/services/rallyround/client.ts` - API client + - `server/src/services/rallyround/types.ts` - Type definitions + - `server/src/services/rallyround/errors.ts` - Error classes + - `server/src/services/rallyround/registry.ts` - Session registry + +#### Acceptance Criteria +- [ ] All client methods implemented with type safety +- [ ] Authorization header sent with all requests +- [ ] Webhook URL included in session creation +- [ ] 30-second timeout on all requests +- [ ] Retry logic for 503 errors (max 3 retries with backoff) +- [ ] Session registry tracks guild-to-session mapping + +--- + +### Phase 3: Bot Commands (Issue #5) + +**Priority:** High | **Complexity:** High | **Dependencies:** Issue #4 + +#### Implementation Tasks + +1. **Command Parser** + - Listen for `!rr` prefixed messages + - Parse command, subcommand, and arguments + - Route to appropriate handler + +2. **Command Categories** + + **Session Management (Facilitator Only)** + ``` + !rr start [title] - Create new session + !rr end - End current session + !rr pause - Pause session + !rr resume - Resume session + !rr link - Post dashboard URL + !rr status - Show session status + ``` + + **Mode & Recording** + ``` + !rr mode [structured/unstructured] - Switch mode + !rr record [start/stop] - Toggle recording + ``` + + **Speaker Management (Facilitator Only)** + ``` + !rr next - Advance to next speaker + !rr speaker @user - Set specific speaker + !rr clear - Clear speaker queue + !rr queue - Display current queue + ``` + + **Participant Signals (Voice Channel Members)** + ``` + !rr hand - Toggle raise hand + !rr point [type] - Parliamentary signals + !rr question - Question signal + !rr agree/disagree - Sentiment signals + !rr away/back - Status updates + ``` + + **Agenda Management** + ``` + !rr agenda - Display agenda + !rr agenda add [item] - Add item + !rr agenda next - Advance agenda + !rr agenda done - Mark current complete + ``` + + **Utilities** + ``` + !rr help - Show commands + !rr sfx [on/off/list/name] - Sound controls + ``` + +3. **Permission System** + - Facilitator check: User must be session creator + - Voice channel check: User must be in session's VC + - Mode check: Some signals only in structured mode + +4. **Response Formatting** + - Emoji-enhanced responses + - Embed messages for complex displays (queue, agenda, status) + - Error messages with clear instructions + +5. **Files to Create** + - `server/src/commands/index.ts` - Command router + - `server/src/commands/parser.ts` - Command parser + - `server/src/commands/session.ts` - Session commands + - `server/src/commands/speaker.ts` - Speaker commands + - `server/src/commands/signals.ts` - Signal commands + - `server/src/commands/agenda.ts` - Agenda commands + - `server/src/commands/utils.ts` - Utility commands + - `server/src/commands/permissions.ts` - Permission checks + +#### Acceptance Criteria +- [ ] All commands functional with proper permissions +- [ ] Clear error messages for all failure scenarios +- [ ] Formatted responses with emoji indicators +- [ ] User mentions work in commands +- [ ] Signal commands toggle properly +- [ ] Help command shows categorized documentation + +--- + +### Phase 4: Voice Channel Presence Tracking (Issue #6) + +**Priority:** High | **Complexity:** Medium | **Dependencies:** Issue #4 + +#### Implementation Tasks + +1. **Voice State Event Handler** + - Listen for `voiceStateUpdate` events + - Detect join, leave, and channel moves + - Filter out bot accounts + +2. **Participant Sync** + ```typescript + async function handleVoiceJoin(member: GuildMember, channel: VoiceChannel) + async function handleVoiceLeave(member: GuildMember, channel: VoiceChannel) + async function syncAllMembers(channel: VoiceChannel, sessionId: string) + ``` + +3. **Bot Voice Connection** + - Join voice channel when session starts + - Handle disconnections with exponential backoff (max 3 retries) + - Notify channel on connection issues + +4. **Permission Validation** + ```typescript + function isUserInSessionVC(userId: string, guildId: string): boolean + ``` + +5. **Files to Create/Modify** + - `server/src/services/voice/connection.ts` - Voice connection management + - `server/src/services/voice/presence.ts` - Presence tracking + - `server/src/services/voice/sync.ts` - Member synchronization + - `server/src/index.ts` - Register voice state event handler + +#### Acceptance Criteria +- [ ] Bot joins voice channel on session start +- [ ] Existing members synced as participants on session start +- [ ] New joins trigger `addParticipant` API call +- [ ] Leaves trigger `removeParticipant` API call +- [ ] Channel moves handled correctly +- [ ] Bot accounts filtered out +- [ ] Reconnection with backoff on disconnect +- [ ] Notification posted on connection issues +- [ ] `isUserInSessionVC()` validates command permissions + +--- + +### Phase 5: Webhook Receiver (Issue #7) + +**Priority:** Medium | **Complexity:** Medium | **Dependencies:** Issues #4, #6 + +#### Implementation Tasks + +1. **Webhook Endpoint** + ``` + POST /webhooks/rallyround + ``` + +2. **Security** + - HMAC-SHA256 signature verification + - Timing-safe comparison + - Timestamp validation (reject > 5 minutes old) + - Per-session webhook secrets + +3. **Event Handlers** + | Event | Action | + |-------|--------| + | `signal.raised` | Play sound, post notification with emoji | + | `speaker.changed` | Play transition sound, announce speaker | + | `mode.changed` | Post mode change notification | + | `recording.changed` | Play sound, announce recording status | + | `agenda.advanced` | Display progress, completed/current items | + | `session.ended` | Post summary, cleanup resources | + +4. **Files to Create** + - `server/src/webhooks/rallyround.ts` - Webhook handler + - `server/src/webhooks/security.ts` - Signature verification + - `server/src/webhooks/events/` - Individual event handlers + +#### Acceptance Criteria +- [ ] Endpoint validates HMAC signatures +- [ ] Stale timestamps rejected (> 5 minutes) +- [ ] All 6 event types handled correctly +- [ ] Sound effects triggered appropriately +- [ ] Chat messages posted for all events +- [ ] Session cleanup on `session.ended` +- [ ] Unknown events handled gracefully +- [ ] Per-session secret management + +--- + +### Phase 6: Sound Effect Playback (Issue #8) + +**Priority:** Medium | **Complexity:** Medium | **Dependencies:** Issue #6 + +#### Implementation Tasks + +1. **Audio Infrastructure** + - Install `@discordjs/voice`, `sodium-native`, `ffmpeg-static` + - Create audio player per session + - Implement player cleanup on session end + +2. **Sound Assets** + ``` + assets/sounds/ + ├── hand-raise.mp3 # Chime for hand raises + ├── gavel-tap.mp3 # Session start/end + ├── speaker-change.mp3 # Swoosh for speaker transitions + ├── record-start.mp3 # Recording started + ├── record-stop.mp3 # Recording stopped + ├── agenda-complete.mp3 # Item completion ding + ├── time-warning.mp3 # Time running low + ├── error.mp3 # Error buzzer + └── notification.mp3 # Generic notification + ``` + Specifications: 0.5-3 seconds, 48kHz, 128kbps minimum + +3. **Signal-to-Sound Mapping** + ```typescript + const signalSounds: Record = { + 'hand': 'hand-raise.mp3', + 'point_of_order': 'gavel-tap.mp3', + 'clarification': 'notification.mp3', + 'question': 'notification.mp3', + 'agree': null, // Silent + 'disagree': null, + 'away': null, + 'back': null, + } + ``` + +4. **Playback Controls** + - `!rr sfx on` - Enable sounds + - `!rr sfx off` - Disable sounds + - `!rr sfx list` - Show available sounds + - `!rr sfx [name]` - Play specific sound + +5. **Queue Management** + - Prevent overlapping audio + - Promise-based sequential queue + - Skip if bot not in voice channel + +6. **Files to Create** + - `server/src/services/audio/player.ts` - Audio player management + - `server/src/services/audio/queue.ts` - Playback queue + - `server/src/services/audio/sounds.ts` - Sound mappings + - `server/assets/sounds/` - Audio files + +#### Acceptance Criteria +- [ ] All 9 sound files created meeting specifications +- [ ] `!rr sfx` commands work correctly +- [ ] Sounds play at appropriate events +- [ ] No overlapping audio (queue management) +- [ ] Graceful handling when bot not in voice +- [ ] Per-session enable/disable +- [ ] Cleanup on session end + +--- + +### Phase 7: Multi-Server Aggregation (Issues #1 & #2) + +**Priority:** Medium | **Complexity:** High | **Dependencies:** Phase 0 (Database) + +*Note: This phase can be developed in parallel with Phases 1-6* + +#### Implementation Tasks + +1. **Database Schema** + ```typescript + // Monitored Servers + interface MonitoredServer { + id: string // Guild ID + name: string + icon?: string + botToken?: string // Optional: per-server bot + addedAt: Date + lastSeen: Date + isActive: boolean + settings: { + messageLimit: number + refreshInterval: number + } + } + + // User Preferences + interface UserPreferences { + userId: string + selectedServers: string[] + defaultView: 'aggregated' | 'individual' + savedAt: Date + } + ``` + +2. **Backend API Endpoints** + ``` + GET /api/discord/servers - List monitored servers + POST /api/discord/servers - Add server + DELETE /api/discord/servers/:serverId - Remove server + GET /api/discord/servers/:serverId/health - Check status + + GET /api/discord/stats/multi/guild - Aggregated guild stats + POST /api/discord/stats/multi/messages - Aggregated message stats + GET /api/discord/stats/comparison - Side-by-side comparison + ``` + +3. **Aggregation Engine** + - Combine stats across multiple guilds + - Support per-server breakdowns + - Efficient parallel data fetching + - Caching layer for performance + +4. **Frontend Components** + - Settings page with server selection + - Aggregated dashboard view + - Comparison mode (side-by-side) + - Server health indicators + +5. **Files to Create/Modify** + - `server/src/db/schema.ts` - Database schemas + - `server/src/routes/servers.ts` - Server management routes + - `server/src/controllers/serversController.ts` - CRUD logic + - `server/src/services/aggregation.ts` - Stats aggregation + - `client/src/pages/Settings.tsx` - Settings page + - `client/src/components/ServerSelector.tsx` - Server selection UI + - `client/src/components/AggregatedDashboard.tsx` - Multi-server view + - `client/src/components/ComparisonView.tsx` - Comparison mode + +#### Acceptance Criteria +- [ ] Backend monitors multiple Discord servers +- [ ] API endpoints for server CRUD operations +- [ ] Aggregation endpoints combine stats across servers +- [ ] Server health status trackable +- [ ] Settings page shows available servers +- [ ] Users can select/deselect servers +- [ ] Preferences persist (localStorage minimum) +- [ ] Dashboard displays aggregated statistics +- [ ] Clear visual indication of included servers +- [ ] Toggle between aggregated and individual views + +--- + +## Implementation Order Summary + +| Order | Issue(s) | Phase | Estimated Effort | +|-------|----------|-------|------------------| +| 1 | - | Phase 0: Foundation | Medium | +| 2 | #3 | Phase 1: OAuth Provider | Medium | +| 3 | #4 | Phase 2: RallyRound Client | Medium | +| 4 | #5 | Phase 3: Bot Commands | High | +| 5 | #6 | Phase 4: Voice Presence | Medium | +| 6 | #7 | Phase 5: Webhook Receiver | Medium | +| 7 | #8 | Phase 6: Sound Effects | Medium | +| Parallel | #1, #2 | Phase 7: Multi-Server | High | + +--- + +## New Dependencies Required + +```json +{ + "dependencies": { + "@discordjs/voice": "^0.16.0", + "sodium-native": "^4.0.0", + "ffmpeg-static": "^5.2.0", + "jsonwebtoken": "^9.0.0", + "drizzle-orm": "^0.29.0", + "better-sqlite3": "^9.0.0" + }, + "devDependencies": { + "drizzle-kit": "^0.20.0", + "@types/jsonwebtoken": "^9.0.0", + "@types/better-sqlite3": "^7.6.0" + } +} +``` + +--- + +## Environment Variables (New) + +```env +# Existing +DISCORD_BOT_TOKEN= +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +DISCORD_REDIRECT_URI= +PORT=3002 + +# New - Auth +JWT_SECRET=your-secret-key-here + +# New - RallyRound Integration +RALLYROUND_API_URL=https://api.rallyround.app +RALLYROUND_URL=https://rallyround.app +WEBHOOK_SECRET=your-webhook-secret + +# New - Database +DATABASE_URL=file:./discordstats.db +``` + +--- + +## Risk Assessment + +| Risk | Impact | Mitigation | +|------|--------|------------| +| RallyRound API changes | High | Version API client, maintain documentation | +| Discord rate limits | Medium | Implement rate limiting, caching | +| Voice connection stability | Medium | Robust reconnection with backoff | +| Database migrations | Low | Use ORM with migration support | +| Audio codec issues | Low | Use well-tested ffmpeg-static | + +--- + +## Testing Strategy + +1. **Unit Tests** + - RallyRound client methods + - Command parser + - JWT utilities + - Aggregation logic + +2. **Integration Tests** + - OAuth flow end-to-end + - Webhook signature verification + - Voice connection lifecycle + +3. **Manual Testing** + - All bot commands in real Discord server + - Sound effects in voice channels + - Multi-server dashboard views + +--- + +## Notes + +- Issues #1 and #2 appear to be duplicates - both describe Multi-Server Aggregation +- The RallyRound integration (Issues #3-8) forms a cohesive feature set for live session management +- Phase 7 (Multi-Server) can be developed in parallel with the RallyRound features +- Consider implementing Phase 0 first to establish database infrastructure needed by multiple features diff --git a/server/.env.example b/server/.env.example index 885ba7b..46a5933 100644 --- a/server/.env.example +++ b/server/.env.example @@ -2,8 +2,19 @@ DISCORD_BOT_TOKEN=your_bot_token_here DISCORD_CLIENT_ID=your_client_id_here DISCORD_CLIENT_SECRET=your_client_secret_here -DISCORD_REDIRECT_URI=http://localhost:5173/auth/callback +DISCORD_REDIRECT_URI=http://localhost:3002/auth/discord/callback # Server Configuration PORT=3002 NODE_ENV=development + +# JWT Configuration +JWT_SECRET=your-jwt-secret-change-in-production + +# RallyRound Integration +RALLYROUND_API_URL=http://localhost:8765/api +RALLYROUND_URL=http://localhost:8765 +RALLYROUND_WEBHOOK_SECRET=your-webhook-secret + +# Webhook Configuration (for session creation) +WEBHOOK_BASE_URL=http://localhost:3002 diff --git a/server/package.json b/server/package.json index 78ed9c2..fc7e984 100644 --- a/server/package.json +++ b/server/package.json @@ -6,23 +6,37 @@ "scripts": { "dev": "tsx watch --env-file=.env src/index.ts", "build": "tsc", - "start": "node dist/index.js" + "start": "node dist/index.js", + "register-commands": "tsx --env-file=.env src/commands/register.ts" }, - "keywords": ["discord", "stats", "api"], + "keywords": [ + "discord", + "stats", + "api" + ], "author": "", "license": "ISC", "dependencies": { - "express": "^4.18.2", + "@discordjs/builders": "^1.13.1", + "@discordjs/rest": "^2.6.0", + "@discordjs/voice": "^0.19.0", + "axios": "^1.6.2", + "better-sqlite3": "^12.5.0", "cors": "^2.8.5", - "dotenv": "^16.3.1", "discord.js": "^14.14.1", - "axios": "^1.6.2" + "dotenv": "^16.3.1", + "drizzle-orm": "^0.45.1", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.3" }, "devDependencies": { - "@types/express": "^4.17.21", + "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.10.5", - "typescript": "^5.3.3", - "tsx": "^4.7.0" + "drizzle-kit": "^0.31.8", + "tsx": "^4.7.0", + "typescript": "^5.3.3" } } diff --git a/server/src/commands/definitions.ts b/server/src/commands/definitions.ts new file mode 100644 index 0000000..9afd591 --- /dev/null +++ b/server/src/commands/definitions.ts @@ -0,0 +1,186 @@ +import { SlashCommandBuilder } from 'discord.js'; + +/** + * Define all RallyRound slash commands + */ +export const rrCommand = new SlashCommandBuilder() + .setName('rr') + .setDescription('RallyRound live session commands') + + // Session Management + .addSubcommand((sub) => + sub + .setName('start') + .setDescription('Start a new RallyRound session') + .addStringOption((opt) => + opt.setName('title').setDescription('Session title').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub.setName('end').setDescription('End the current session') + ) + .addSubcommand((sub) => + sub.setName('pause').setDescription('Pause the current session') + ) + .addSubcommand((sub) => + sub.setName('resume').setDescription('Resume the paused session') + ) + .addSubcommand((sub) => + sub.setName('status').setDescription('Show current session status') + ) + .addSubcommand((sub) => + sub.setName('link').setDescription('Post the session dashboard URL') + ) + + // Mode and Recording + .addSubcommand((sub) => + sub + .setName('mode') + .setDescription('Change session mode') + .addStringOption((opt) => + opt + .setName('mode') + .setDescription('Session mode') + .setRequired(true) + .addChoices( + { name: 'Structured', value: 'structured' }, + { name: 'Unstructured', value: 'unstructured' } + ) + ) + ) + .addSubcommand((sub) => + sub + .setName('record') + .setDescription('Control session recording') + .addStringOption((opt) => + opt + .setName('action') + .setDescription('Recording action') + .setRequired(true) + .addChoices( + { name: 'Start Recording', value: 'start' }, + { name: 'Stop Recording', value: 'stop' } + ) + ) + ) + + // Speaker Management + .addSubcommand((sub) => + sub.setName('next').setDescription('Move to the next speaker in queue') + ) + .addSubcommand((sub) => + sub + .setName('speaker') + .setDescription('Set a specific user as the current speaker') + .addUserOption((opt) => + opt.setName('user').setDescription('User to set as speaker').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub.setName('clear').setDescription('Clear the current speaker') + ) + .addSubcommand((sub) => + sub.setName('queue').setDescription('Display the speaker queue') + ) + + // Signals + .addSubcommand((sub) => + sub.setName('hand').setDescription('Raise or lower your hand') + ) + .addSubcommand((sub) => + sub + .setName('point') + .setDescription('Raise a parliamentary point') + .addStringOption((opt) => + opt + .setName('type') + .setDescription('Type of point') + .setRequired(true) + .addChoices( + { name: 'Point of Order', value: 'order' }, + { name: 'Point of Clarification', value: 'clarify' }, + { name: 'Point of Information', value: 'info' } + ) + ) + ) + .addSubcommand((sub) => + sub.setName('question').setDescription('Signal that you have a question') + ) + .addSubcommand((sub) => + sub.setName('agree').setDescription('Signal agreement') + ) + .addSubcommand((sub) => + sub.setName('disagree').setDescription('Signal disagreement') + ) + .addSubcommand((sub) => + sub.setName('away').setDescription('Mark yourself as away') + ) + .addSubcommand((sub) => + sub.setName('back').setDescription('Mark yourself as back') + ) + + // Agenda + .addSubcommand((sub) => + sub.setName('agenda').setDescription('Display the session agenda') + ) + .addSubcommand((sub) => + sub + .setName('agenda-add') + .setDescription('Add an item to the agenda') + .addStringOption((opt) => + opt.setName('item').setDescription('Agenda item title').setRequired(true) + ) + ) + .addSubcommand((sub) => + sub.setName('agenda-next').setDescription('Advance to the next agenda item') + ) + .addSubcommand((sub) => + sub.setName('agenda-done').setDescription('Mark the current agenda item as complete') + ) + .addSubcommand((sub) => + sub.setName('agenda-skip').setDescription('Skip the current agenda item') + ) + + // Sound Effects + .addSubcommand((sub) => + sub + .setName('sfx') + .setDescription('Control sound effects') + .addStringOption((opt) => + opt + .setName('action') + .setDescription('Sound effect action') + .setRequired(true) + .addChoices( + { name: 'Enable', value: 'on' }, + { name: 'Disable', value: 'off' }, + { name: 'List Sounds', value: 'list' } + ) + ) + ) + .addSubcommand((sub) => + sub + .setName('sfx-play') + .setDescription('Play a specific sound effect') + .addStringOption((opt) => + opt + .setName('sound') + .setDescription('Sound to play') + .setRequired(true) + .addChoices( + { name: 'Chime', value: 'chime' }, + { name: 'Gavel', value: 'gavel' }, + { name: 'Swoosh', value: 'swoosh' }, + { name: 'Ding', value: 'ding' }, + { name: 'Bell', value: 'bell' }, + { name: 'Notification', value: 'notification' } + ) + ) + ); + +/** + * Get all commands as JSON for registration + */ +export function getCommandsJSON() { + return [rrCommand.toJSON()]; +} diff --git a/server/src/commands/handlers/agenda.ts b/server/src/commands/handlers/agenda.ts new file mode 100644 index 0000000..e44151b --- /dev/null +++ b/server/src/commands/handlers/agenda.ts @@ -0,0 +1,194 @@ +import { ChatInputCommandInteraction, TextChannel } from 'discord.js'; +import { sessionRegistry } from '../../services/rallyround'; +import { successMessage, errorMessage, formatAgenda, Emoji } from '../responses'; + +/** + * Check if user is facilitator + */ +function checkFacilitator( + interaction: ChatInputCommandInteraction +): { allowed: boolean; reason?: string } { + const guildId = interaction.guildId; + if (!guildId) { + return { allowed: false, reason: 'This command can only be used in a server.' }; + } + + const session = sessionRegistry.get(guildId); + if (!session) { + return { allowed: false, reason: 'No active session in this server.' }; + } + + if (session.facilitatorId !== interaction.user.id) { + return { allowed: false, reason: 'Only the facilitator can use this command.' }; + } + + return { allowed: true }; +} + +/** + * /rr agenda - Display agenda + */ +export async function handleAgenda(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId; + + if (!guildId) { + await interaction.reply({ content: errorMessage('This command can only be used in a server.'), ephemeral: true }); + return; + } + + const session = sessionRegistry.get(guildId); + if (!session) { + await interaction.reply({ content: errorMessage('No active session in this server.'), ephemeral: true }); + return; + } + + await interaction.deferReply(); + + try { + const agenda = await session.client.getAgenda(session.sessionId); + const embed = formatAgenda(agenda); + await interaction.editReply({ embeds: [embed] }); + } catch (error: any) { + console.error('Failed to get agenda:', error); + await interaction.editReply(errorMessage(`Failed to get agenda: ${error.message}`)); + } +} + +/** + * /rr agenda-add item:"Title" + */ +export async function handleAgendaAdd(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId; + + const permission = checkFacilitator(interaction); + if (!permission.allowed) { + await interaction.reply({ content: errorMessage(permission.reason!), ephemeral: true }); + return; + } + + const title = interaction.options.getString('item', true); + const session = sessionRegistry.get(guildId!)!; + + try { + const item = await session.client.addAgendaItem(session.sessionId, { title }); + await interaction.reply(successMessage(`Added agenda item: **${item.title}**`)); + } catch (error: any) { + console.error('Failed to add agenda item:', error); + await interaction.reply({ content: errorMessage(`Failed to add agenda item: ${error.message}`), ephemeral: true }); + } +} + +/** + * /rr agenda-next + */ +export async function handleAgendaNext(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId; + + const permission = checkFacilitator(interaction); + if (!permission.allowed) { + await interaction.reply({ content: errorMessage(permission.reason!), ephemeral: true }); + return; + } + + const session = sessionRegistry.get(guildId!)!; + + try { + const item = await session.client.advanceAgenda(session.sessionId); + await interaction.reply(`${Emoji.active} Now discussing: **${item.title}**`); + } catch (error: any) { + console.error('Failed to advance agenda:', error); + if (error.code === 'NOT_FOUND') { + await interaction.reply({ content: errorMessage('No more pending agenda items.'), ephemeral: true }); + } else { + await interaction.reply({ content: errorMessage(`Failed to advance agenda: ${error.message}`), ephemeral: true }); + } + } +} + +/** + * /rr agenda-done + */ +export async function handleAgendaDone(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId; + + const permission = checkFacilitator(interaction); + if (!permission.allowed) { + await interaction.reply({ content: errorMessage(permission.reason!), ephemeral: true }); + return; + } + + const session = sessionRegistry.get(guildId!)!; + + await interaction.deferReply(); + + try { + const agenda = await session.client.getAgenda(session.sessionId); + const activeItem = agenda.find((item) => item.status === 'active'); + + if (!activeItem) { + await interaction.editReply(errorMessage('No active agenda item to complete.')); + return; + } + + await session.client.updateAgendaItem(session.sessionId, activeItem.id, { + status: 'completed', + }); + + let response = `${Emoji.completed} Completed: **${activeItem.title}**`; + + const pendingItems = agenda.filter((item) => item.status === 'pending'); + if (pendingItems.length > 0) { + const nextItem = await session.client.advanceAgenda(session.sessionId); + response += `\n${Emoji.active} Next up: **${nextItem.title}**`; + } + + await interaction.editReply(response); + } catch (error: any) { + console.error('Failed to complete agenda item:', error); + await interaction.editReply(errorMessage(`Failed to complete agenda item: ${error.message}`)); + } +} + +/** + * /rr agenda-skip + */ +export async function handleAgendaSkip(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId; + + const permission = checkFacilitator(interaction); + if (!permission.allowed) { + await interaction.reply({ content: errorMessage(permission.reason!), ephemeral: true }); + return; + } + + const session = sessionRegistry.get(guildId!)!; + + await interaction.deferReply(); + + try { + const agenda = await session.client.getAgenda(session.sessionId); + const activeItem = agenda.find((item) => item.status === 'active'); + + if (!activeItem) { + await interaction.editReply(errorMessage('No active agenda item to skip.')); + return; + } + + await session.client.updateAgendaItem(session.sessionId, activeItem.id, { + status: 'skipped', + }); + + let response = `${Emoji.skipped} Skipped: **${activeItem.title}**`; + + const pendingItems = agenda.filter((item) => item.status === 'pending'); + if (pendingItems.length > 0) { + const nextItem = await session.client.advanceAgenda(session.sessionId); + response += `\n${Emoji.active} Next up: **${nextItem.title}**`; + } + + await interaction.editReply(response); + } catch (error: any) { + console.error('Failed to skip agenda item:', error); + await interaction.editReply(errorMessage(`Failed to skip agenda item: ${error.message}`)); + } +} diff --git a/server/src/commands/handlers/index.ts b/server/src/commands/handlers/index.ts new file mode 100644 index 0000000..fa0a7e8 --- /dev/null +++ b/server/src/commands/handlers/index.ts @@ -0,0 +1,5 @@ +export * from './session'; +export * from './speaker'; +export * from './signals'; +export * from './agenda'; +export * from './sfx'; diff --git a/server/src/commands/handlers/session.ts b/server/src/commands/handlers/session.ts new file mode 100644 index 0000000..3ffb2d7 --- /dev/null +++ b/server/src/commands/handlers/session.ts @@ -0,0 +1,302 @@ +import { ChatInputCommandInteraction, VoiceChannel, GuildMember } from 'discord.js'; +import crypto from 'crypto'; +import { RallyRoundClient, sessionRegistry, SessionMode } from '../../services/rallyround'; +import { + joinSessionVoiceChannel, + leaveSessionVoiceChannel, + syncVoiceChannelMembers, +} from '../../services/voice'; +import { clearSessionSignals } from './signals'; +import { + successMessage, + errorMessage, + formatSessionStatus, + Emoji, +} from '../responses'; + +const RALLYROUND_API_URL = process.env.RALLYROUND_API_URL || 'http://localhost:8765/api'; +const RALLYROUND_URL = process.env.RALLYROUND_URL || 'http://localhost:8765'; +const WEBHOOK_BASE_URL = process.env.WEBHOOK_BASE_URL || 'http://localhost:3002'; + +/** + * Check if user is facilitator + */ +function checkFacilitator( + interaction: ChatInputCommandInteraction +): { allowed: boolean; reason?: string } { + const guildId = interaction.guildId; + if (!guildId) { + return { allowed: false, reason: 'This command can only be used in a server.' }; + } + + const session = sessionRegistry.get(guildId); + if (!session) { + return { allowed: false, reason: 'No active session in this server.' }; + } + + if (session.facilitatorId !== interaction.user.id) { + return { allowed: false, reason: 'Only the facilitator can use this command.' }; + } + + return { allowed: true }; +} + +/** + * /rr start title:"Session Title" + */ +export async function handleStart(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId; + const member = interaction.member as GuildMember; + + if (!guildId || !member) { + await interaction.reply({ content: errorMessage('This command can only be used in a server.'), ephemeral: true }); + return; + } + + if (sessionRegistry.has(guildId)) { + await interaction.reply({ content: errorMessage('A session is already active in this server.'), ephemeral: true }); + return; + } + + const voiceChannel = member.voice.channel as VoiceChannel | null; + if (!voiceChannel) { + await interaction.reply({ content: errorMessage('You must be in a voice channel to start a session.'), ephemeral: true }); + return; + } + + const title = interaction.options.getString('title', true); + const webhookSecret = crypto.randomBytes(32).toString('hex'); + + await interaction.deferReply(); + + try { + const client = new RallyRoundClient(RALLYROUND_API_URL, 'bot-token'); + + const session = await client.createSession({ + title, + guildId, + channelId: interaction.channelId, + voiceChannelId: voiceChannel.id, + facilitatorId: member.id, + webhookUrl: `${WEBHOOK_BASE_URL}/webhooks/rallyround`, + webhookSecret, + config: { + soundEffectsEnabled: true, + selfQueueEnabled: true, + facilitatorApprovalRequired: false, + }, + }); + + sessionRegistry.register( + guildId, + session.id, + interaction.channelId, + voiceChannel.id, + member.id, + webhookSecret, + session.config, + client + ); + + await joinSessionVoiceChannel(guildId, voiceChannel.id, interaction.channelId); + await syncVoiceChannelMembers(guildId, voiceChannel.id); + + const dashboardUrl = `${RALLYROUND_URL}/session/${session.id}`; + await interaction.editReply( + `${successMessage(`Session "${title}" started!`)}\n` + + `${Emoji.speaker} Voice Channel: ${voiceChannel.name}\n` + + `${Emoji.info} Dashboard: ${dashboardUrl}` + ); + } catch (error: any) { + console.error('Failed to start session:', error); + await interaction.editReply(errorMessage(`Failed to start session: ${error.message}`)); + } +} + +/** + * /rr end + */ +export async function handleEnd(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId; + + const permission = checkFacilitator(interaction); + if (!permission.allowed) { + await interaction.reply({ content: errorMessage(permission.reason!), ephemeral: true }); + return; + } + + const session = sessionRegistry.get(guildId!)!; + + await interaction.deferReply(); + + try { + const stats = await session.client.endSession(session.sessionId); + + clearSessionSignals(guildId!); + leaveSessionVoiceChannel(guildId!); + sessionRegistry.remove(guildId!); + + await interaction.editReply( + `${successMessage('Session ended!')}\n` + + `Duration: ${Math.round(stats.duration / 60)} minutes\n` + + `Participants: ${stats.participantCount}\n` + + `Total Signals: ${stats.totalSignals}` + ); + } catch (error: any) { + console.error('Failed to end session:', error); + await interaction.editReply(errorMessage(`Failed to end session: ${error.message}`)); + } +} + +/** + * /rr pause + */ +export async function handlePause(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId; + + const permission = checkFacilitator(interaction); + if (!permission.allowed) { + await interaction.reply({ content: errorMessage(permission.reason!), ephemeral: true }); + return; + } + + const session = sessionRegistry.get(guildId!)!; + + try { + await session.client.updateSession(session.sessionId, { status: 'paused' }); + await interaction.reply(`${Emoji.pause} Session paused.`); + } catch (error: any) { + console.error('Failed to pause session:', error); + await interaction.reply({ content: errorMessage(`Failed to pause session: ${error.message}`), ephemeral: true }); + } +} + +/** + * /rr resume + */ +export async function handleResume(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId; + + const permission = checkFacilitator(interaction); + if (!permission.allowed) { + await interaction.reply({ content: errorMessage(permission.reason!), ephemeral: true }); + return; + } + + const session = sessionRegistry.get(guildId!)!; + + try { + await session.client.updateSession(session.sessionId, { status: 'active' }); + await interaction.reply(`${Emoji.play} Session resumed.`); + } catch (error: any) { + console.error('Failed to resume session:', error); + await interaction.reply({ content: errorMessage(`Failed to resume session: ${error.message}`), ephemeral: true }); + } +} + +/** + * /rr status + */ +export async function handleStatus(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId; + + if (!guildId) { + await interaction.reply({ content: errorMessage('This command can only be used in a server.'), ephemeral: true }); + return; + } + + const activeSession = sessionRegistry.get(guildId); + if (!activeSession) { + await interaction.reply({ content: errorMessage('No active session in this server.'), ephemeral: true }); + return; + } + + await interaction.deferReply(); + + try { + const session = await activeSession.client.getSession(activeSession.sessionId, [ + 'participants', + 'queue', + 'agenda', + ]); + const embed = formatSessionStatus(session); + await interaction.editReply({ embeds: [embed] }); + } catch (error: any) { + console.error('Failed to get session status:', error); + await interaction.editReply(errorMessage(`Failed to get session status: ${error.message}`)); + } +} + +/** + * /rr link + */ +export async function handleLink(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId; + + if (!guildId) { + await interaction.reply({ content: errorMessage('This command can only be used in a server.'), ephemeral: true }); + return; + } + + const session = sessionRegistry.get(guildId); + if (!session) { + await interaction.reply({ content: errorMessage('No active session in this server.'), ephemeral: true }); + return; + } + + const dashboardUrl = `${RALLYROUND_URL}/session/${session.sessionId}`; + await interaction.reply(`${Emoji.info} **Dashboard:** ${dashboardUrl}`); +} + +/** + * /rr mode mode:structured|unstructured + */ +export async function handleMode(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId; + + const permission = checkFacilitator(interaction); + if (!permission.allowed) { + await interaction.reply({ content: errorMessage(permission.reason!), ephemeral: true }); + return; + } + + const mode = interaction.options.getString('mode', true) as SessionMode; + const session = sessionRegistry.get(guildId!)!; + + try { + await session.client.updateSession(session.sessionId, { mode }); + await interaction.reply(`${Emoji.mode} Session mode changed to **${mode}**.`); + } catch (error: any) { + console.error('Failed to change mode:', error); + await interaction.reply({ content: errorMessage(`Failed to change mode: ${error.message}`), ephemeral: true }); + } +} + +/** + * /rr record action:start|stop + */ +export async function handleRecord(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId; + + const permission = checkFacilitator(interaction); + if (!permission.allowed) { + await interaction.reply({ content: errorMessage(permission.reason!), ephemeral: true }); + return; + } + + const action = interaction.options.getString('action', true); + const session = sessionRegistry.get(guildId!)!; + const isRecording = action === 'start'; + + try { + await session.client.updateSession(session.sessionId, { isRecording }); + if (isRecording) { + await interaction.reply(`${Emoji.recording} Recording started.`); + } else { + await interaction.reply(`${Emoji.stop} Recording stopped.`); + } + } catch (error: any) { + console.error('Failed to toggle recording:', error); + await interaction.reply({ content: errorMessage(`Failed to toggle recording: ${error.message}`), ephemeral: true }); + } +} diff --git a/server/src/commands/handlers/sfx.ts b/server/src/commands/handlers/sfx.ts new file mode 100644 index 0000000..27f4b1c --- /dev/null +++ b/server/src/commands/handlers/sfx.ts @@ -0,0 +1,135 @@ +import { ChatInputCommandInteraction } from 'discord.js'; +import { sessionRegistry } from '../../services/rallyround'; +import { successMessage, errorMessage, Emoji } from '../responses'; + +// Available sounds +export const AVAILABLE_SOUNDS = [ + { name: 'chime', description: 'Hand raise notification' }, + { name: 'gavel', description: 'Point of order / session control' }, + { name: 'swoosh', description: 'Speaker transition' }, + { name: 'ding', description: 'Agenda item complete' }, + { name: 'bell', description: 'Time warning' }, + { name: 'notification', description: 'Generic notification' }, +] as const; + +export type SoundName = (typeof AVAILABLE_SOUNDS)[number]['name']; + +/** + * Check if user is facilitator + */ +function checkFacilitator( + interaction: ChatInputCommandInteraction +): { allowed: boolean; reason?: string } { + const guildId = interaction.guildId; + if (!guildId) { + return { allowed: false, reason: 'This command can only be used in a server.' }; + } + + const session = sessionRegistry.get(guildId); + if (!session) { + return { allowed: false, reason: 'No active session in this server.' }; + } + + if (session.facilitatorId !== interaction.user.id) { + return { allowed: false, reason: 'Only the facilitator can use this command.' }; + } + + return { allowed: true }; +} + +/** + * /rr sfx action:on|off|list + */ +export async function handleSfx(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId; + const action = interaction.options.getString('action', true); + + if (!guildId) { + await interaction.reply({ content: errorMessage('This command can only be used in a server.'), ephemeral: true }); + return; + } + + const session = sessionRegistry.get(guildId); + if (!session) { + await interaction.reply({ content: errorMessage('No active session in this server.'), ephemeral: true }); + return; + } + + switch (action) { + case 'on': { + const permission = checkFacilitator(interaction); + if (!permission.allowed) { + await interaction.reply({ content: errorMessage(permission.reason!), ephemeral: true }); + return; + } + sessionRegistry.setSoundEffects(guildId, true); + await interaction.reply(`${Emoji.sound} Sound effects enabled.`); + break; + } + + case 'off': { + const permission = checkFacilitator(interaction); + if (!permission.allowed) { + await interaction.reply({ content: errorMessage(permission.reason!), ephemeral: true }); + return; + } + sessionRegistry.setSoundEffects(guildId, false); + await interaction.reply(`${Emoji.mute} Sound effects disabled.`); + break; + } + + case 'list': { + const lines = AVAILABLE_SOUNDS.map( + (sound) => `\`${sound.name}\` - ${sound.description}` + ); + await interaction.reply({ + content: `${Emoji.sound} **Available Sounds:**\n${lines.join('\n')}\n\nUse \`/rr sfx-play sound:[name]\` to play a sound.`, + ephemeral: true, + }); + break; + } + } +} + +/** + * /rr sfx-play sound:[name] + */ +export async function handleSfxPlay(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId; + const soundName = interaction.options.getString('sound', true); + + if (!guildId) { + await interaction.reply({ content: errorMessage('This command can only be used in a server.'), ephemeral: true }); + return; + } + + const session = sessionRegistry.get(guildId); + if (!session) { + await interaction.reply({ content: errorMessage('No active session in this server.'), ephemeral: true }); + return; + } + + if (!session.soundEffectsEnabled) { + await interaction.reply({ content: errorMessage('Sound effects are disabled. Use `/rr sfx action:on` to enable.'), ephemeral: true }); + return; + } + + // Validate sound name + const sound = AVAILABLE_SOUNDS.find((s) => s.name === soundName); + if (!sound) { + await interaction.reply({ content: errorMessage(`Unknown sound: \`${soundName}\``), ephemeral: true }); + return; + } + + // TODO: Actually play the sound via audio service + // audioService.playSound(guildId, soundName); + + await interaction.reply(`${Emoji.sound} Playing: \`${soundName}\``); +} + +/** + * Check if a sound name is valid + */ +export function isValidSound(name: string): name is SoundName { + return AVAILABLE_SOUNDS.some((s) => s.name === name); +} diff --git a/server/src/commands/handlers/signals.ts b/server/src/commands/handlers/signals.ts new file mode 100644 index 0000000..3e6dc6d --- /dev/null +++ b/server/src/commands/handlers/signals.ts @@ -0,0 +1,260 @@ +import { ChatInputCommandInteraction, GuildMember } from 'discord.js'; +import { sessionRegistry, SignalType } from '../../services/rallyround'; +import { successMessage, errorMessage, getSignalEmoji } from '../responses'; + +// Map of point subcommands to signal types +const pointSignals: Record = { + order: 'point_of_order', + clarify: 'clarification', + info: 'information', +}; + +// Track active signals per user per session for toggling +const userSignals = new Map(); + +function getUserKey(guildId: string, odId: string): string { + return `${guildId}:${odId}`; +} + +/** + * Check if user is in session voice channel + */ +function checkInSessionVC( + interaction: ChatInputCommandInteraction +): { allowed: boolean; reason?: string } { + const guildId = interaction.guildId; + if (!guildId) { + return { allowed: false, reason: 'This command can only be used in a server.' }; + } + + const session = sessionRegistry.get(guildId); + if (!session) { + return { allowed: false, reason: 'No active session in this server.' }; + } + + const member = interaction.member as GuildMember; + const voiceChannel = member.voice.channel; + + if (!voiceChannel) { + return { allowed: false, reason: 'You must be in a voice channel to use this command.' }; + } + + if (voiceChannel.id !== session.voiceChannelId) { + return { allowed: false, reason: 'You must be in the session voice channel to use this command.' }; + } + + return { allowed: true }; +} + +/** + * /rr hand - Toggle raise/lower hand + */ +export async function handleHand(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId!; + const userId = interaction.user.id; + + const permission = checkInSessionVC(interaction); + if (!permission.allowed) { + await interaction.reply({ content: errorMessage(permission.reason!), ephemeral: true }); + return; + } + + const session = sessionRegistry.get(guildId)!; + const userKey = getUserKey(guildId, userId); + const currentSignal = userSignals.get(userKey); + + try { + if (currentSignal === 'hand') { + await session.client.clearSignal(session.sessionId, userId); + userSignals.delete(userKey); + await interaction.reply({ content: `${getSignalEmoji('hand')} Hand lowered.`, ephemeral: true }); + } else { + if (currentSignal) { + await session.client.clearSignal(session.sessionId, userId); + } + await session.client.raiseSignal(session.sessionId, { + discordId: userId, + signal: 'hand', + }); + userSignals.set(userKey, 'hand'); + await interaction.reply(`${getSignalEmoji('hand')} **${interaction.user.displayName}** raised their hand.`); + } + } catch (error: any) { + console.error('Failed to toggle hand:', error); + await interaction.reply({ content: errorMessage(`Failed to toggle hand: ${error.message}`), ephemeral: true }); + } +} + +/** + * /rr point type:order|clarify|info - Raise parliamentary signal + */ +export async function handlePoint(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId!; + const userId = interaction.user.id; + + const permission = checkInSessionVC(interaction); + if (!permission.allowed) { + await interaction.reply({ content: errorMessage(permission.reason!), ephemeral: true }); + return; + } + + const pointType = interaction.options.getString('type', true); + const signal = pointSignals[pointType]; + + if (!signal) { + await interaction.reply({ content: errorMessage('Invalid point type.'), ephemeral: true }); + return; + } + + const session = sessionRegistry.get(guildId)!; + const userKey = getUserKey(guildId, userId); + const currentSignal = userSignals.get(userKey); + + try { + if (currentSignal === signal) { + await session.client.clearSignal(session.sessionId, userId); + userSignals.delete(userKey); + await interaction.reply({ content: `${getSignalEmoji(signal)} Signal cleared.`, ephemeral: true }); + } else { + if (currentSignal) { + await session.client.clearSignal(session.sessionId, userId); + } + await session.client.raiseSignal(session.sessionId, { + discordId: userId, + signal, + }); + userSignals.set(userKey, signal); + await interaction.reply(`${getSignalEmoji(signal)} **${interaction.user.displayName}** raised ${signal.replace('_', ' ')}.`); + } + } catch (error: any) { + console.error('Failed to raise signal:', error); + await interaction.reply({ content: errorMessage(`Failed to raise signal: ${error.message}`), ephemeral: true }); + } +} + +/** + * /rr question - Question signal + */ +export async function handleQuestion(interaction: ChatInputCommandInteraction): Promise { + await handleSignal(interaction, 'question'); +} + +/** + * /rr agree - Agreement signal + */ +export async function handleAgree(interaction: ChatInputCommandInteraction): Promise { + await handleSignal(interaction, 'agree'); +} + +/** + * /rr disagree - Disagreement signal + */ +export async function handleDisagree(interaction: ChatInputCommandInteraction): Promise { + await handleSignal(interaction, 'disagree'); +} + +/** + * /rr away - Mark as away + */ +export async function handleAway(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId!; + const userId = interaction.user.id; + + const permission = checkInSessionVC(interaction); + if (!permission.allowed) { + await interaction.reply({ content: errorMessage(permission.reason!), ephemeral: true }); + return; + } + + const session = sessionRegistry.get(guildId)!; + + try { + await session.client.updateParticipantStatus(session.sessionId, userId, 'away'); + await interaction.reply({ content: ':zzz: Marked as away.', ephemeral: true }); + } catch (error: any) { + console.error('Failed to update status:', error); + await interaction.reply({ content: errorMessage(`Failed to update status: ${error.message}`), ephemeral: true }); + } +} + +/** + * /rr back - Mark as back + */ +export async function handleBack(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId!; + const userId = interaction.user.id; + + const permission = checkInSessionVC(interaction); + if (!permission.allowed) { + await interaction.reply({ content: errorMessage(permission.reason!), ephemeral: true }); + return; + } + + const session = sessionRegistry.get(guildId)!; + + try { + await session.client.updateParticipantStatus(session.sessionId, userId, 'ready'); + await interaction.reply({ content: ':wave: Welcome back!', ephemeral: true }); + } catch (error: any) { + console.error('Failed to update status:', error); + await interaction.reply({ content: errorMessage(`Failed to update status: ${error.message}`), ephemeral: true }); + } +} + +/** + * Generic signal handler + */ +async function handleSignal(interaction: ChatInputCommandInteraction, signal: SignalType): Promise { + const guildId = interaction.guildId!; + const userId = interaction.user.id; + + const permission = checkInSessionVC(interaction); + if (!permission.allowed) { + await interaction.reply({ content: errorMessage(permission.reason!), ephemeral: true }); + return; + } + + const session = sessionRegistry.get(guildId)!; + const userKey = getUserKey(guildId, userId); + const currentSignal = userSignals.get(userKey); + + try { + if (currentSignal === signal) { + await session.client.clearSignal(session.sessionId, userId); + userSignals.delete(userKey); + await interaction.reply({ content: `${getSignalEmoji(signal)} Signal cleared.`, ephemeral: true }); + } else { + if (currentSignal) { + await session.client.clearSignal(session.sessionId, userId); + } + await session.client.raiseSignal(session.sessionId, { + discordId: userId, + signal, + }); + userSignals.set(userKey, signal); + await interaction.reply(`${getSignalEmoji(signal)} **${interaction.user.displayName}** signaled ${signal}.`); + } + } catch (error: any) { + console.error('Failed to handle signal:', error); + await interaction.reply({ content: errorMessage(`Failed to handle signal: ${error.message}`), ephemeral: true }); + } +} + +/** + * Clear user's signal state + */ +export function clearUserSignal(guildId: string, odId: string): void { + const userKey = getUserKey(guildId, odId); + userSignals.delete(userKey); +} + +/** + * Clear all signals for a session + */ +export function clearSessionSignals(guildId: string): void { + for (const key of userSignals.keys()) { + if (key.startsWith(`${guildId}:`)) { + userSignals.delete(key); + } + } +} diff --git a/server/src/commands/handlers/speaker.ts b/server/src/commands/handlers/speaker.ts new file mode 100644 index 0000000..1418dce --- /dev/null +++ b/server/src/commands/handlers/speaker.ts @@ -0,0 +1,153 @@ +import { ChatInputCommandInteraction, GuildMember } from 'discord.js'; +import { sessionRegistry } from '../../services/rallyround'; +import { successMessage, errorMessage, formatQueue, Emoji } from '../responses'; + +/** + * Check if user is facilitator + */ +function checkFacilitator( + interaction: ChatInputCommandInteraction +): { allowed: boolean; reason?: string } { + const guildId = interaction.guildId; + if (!guildId) { + return { allowed: false, reason: 'This command can only be used in a server.' }; + } + + const session = sessionRegistry.get(guildId); + if (!session) { + return { allowed: false, reason: 'No active session in this server.' }; + } + + if (session.facilitatorId !== interaction.user.id) { + return { allowed: false, reason: 'Only the facilitator can use this command.' }; + } + + return { allowed: true }; +} + +/** + * /rr next - Move to next speaker + */ +export async function handleNext(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId; + + const permission = checkFacilitator(interaction); + if (!permission.allowed) { + await interaction.reply({ content: errorMessage(permission.reason!), ephemeral: true }); + return; + } + + const session = sessionRegistry.get(guildId!)!; + + await interaction.deferReply(); + + try { + const result = await session.client.nextSpeaker(session.sessionId); + + if (result.currentSpeaker) { + let response = `${Emoji.speaker} **${result.currentSpeaker.displayName}** now has the floor.`; + if (result.previousSpeaker) { + const duration = Math.round(result.previousSpeaker.duration / 60); + response += `\nPrevious speaker: ${duration}m`; + } + response += `\n${result.remainingQueue} remaining in queue.`; + await interaction.editReply(response); + } else { + await interaction.editReply(`${Emoji.info} The queue is empty.`); + } + } catch (error: any) { + console.error('Failed to advance speaker:', error); + await interaction.editReply(errorMessage(`Failed to advance speaker: ${error.message}`)); + } +} + +/** + * /rr speaker user:@user - Set specific speaker + */ +export async function handleSpeaker(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId; + + const permission = checkFacilitator(interaction); + if (!permission.allowed) { + await interaction.reply({ content: errorMessage(permission.reason!), ephemeral: true }); + return; + } + + const targetUser = interaction.options.getUser('user', true); + const targetMember = interaction.guild?.members.cache.get(targetUser.id); + + if (!targetMember) { + await interaction.reply({ content: errorMessage('User not found in this server.'), ephemeral: true }); + return; + } + + const session = sessionRegistry.get(guildId!)!; + + await interaction.deferReply(); + + try { + const result = await session.client.setSpeaker(session.sessionId, { discordId: targetUser.id }); + + let response = `${Emoji.speaker} **${targetMember.displayName}** now has the floor.`; + if (result.previousSpeaker) { + const duration = Math.round(result.previousSpeaker.duration / 60); + response += `\nPrevious speaker: ${duration}m`; + } + await interaction.editReply(response); + } catch (error: any) { + console.error('Failed to set speaker:', error); + await interaction.editReply(errorMessage(`Failed to set speaker: ${error.message}`)); + } +} + +/** + * /rr clear - Clear current speaker + */ +export async function handleClear(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId; + + const permission = checkFacilitator(interaction); + if (!permission.allowed) { + await interaction.reply({ content: errorMessage(permission.reason!), ephemeral: true }); + return; + } + + const session = sessionRegistry.get(guildId!)!; + + try { + await session.client.clearSpeaker(session.sessionId); + await interaction.reply(successMessage('Speaker cleared. The floor is open.')); + } catch (error: any) { + console.error('Failed to clear speaker:', error); + await interaction.reply({ content: errorMessage(`Failed to clear speaker: ${error.message}`), ephemeral: true }); + } +} + +/** + * /rr queue - Display speaker queue + */ +export async function handleQueue(interaction: ChatInputCommandInteraction): Promise { + const guildId = interaction.guildId; + + if (!guildId) { + await interaction.reply({ content: errorMessage('This command can only be used in a server.'), ephemeral: true }); + return; + } + + const session = sessionRegistry.get(guildId); + if (!session) { + await interaction.reply({ content: errorMessage('No active session in this server.'), ephemeral: true }); + return; + } + + await interaction.deferReply(); + + try { + const queue = await session.client.getQueue(session.sessionId); + const embed = formatQueue(queue); + await interaction.editReply({ embeds: [embed] }); + } catch (error: any) { + console.error('Failed to get queue:', error); + await interaction.editReply(errorMessage(`Failed to get queue: ${error.message}`)); + } +} diff --git a/server/src/commands/index.ts b/server/src/commands/index.ts new file mode 100644 index 0000000..788289c --- /dev/null +++ b/server/src/commands/index.ts @@ -0,0 +1,162 @@ +import { ChatInputCommandInteraction } from 'discord.js'; +import { + handleStart, + handleEnd, + handlePause, + handleResume, + handleStatus, + handleLink, + handleMode, + handleRecord, +} from './handlers/session'; +import { + handleNext, + handleSpeaker, + handleClear, + handleQueue, +} from './handlers/speaker'; +import { + handleHand, + handlePoint, + handleQuestion, + handleAgree, + handleDisagree, + handleAway, + handleBack, + clearSessionSignals, + clearUserSignal, +} from './handlers/signals'; +import { + handleAgenda, + handleAgendaAdd, + handleAgendaNext, + handleAgendaDone, + handleAgendaSkip, +} from './handlers/agenda'; +import { handleSfx, handleSfxPlay } from './handlers/sfx'; +import { errorMessage } from './responses'; + +/** + * Route slash command interactions to appropriate handlers + */ +export async function handleSlashCommand(interaction: ChatInputCommandInteraction): Promise { + // Only handle /rr commands + if (interaction.commandName !== 'rr') { + return; + } + + const subcommand = interaction.options.getSubcommand(); + + try { + switch (subcommand) { + // Session commands + case 'start': + await handleStart(interaction); + break; + case 'end': + await handleEnd(interaction); + break; + case 'pause': + await handlePause(interaction); + break; + case 'resume': + await handleResume(interaction); + break; + case 'status': + await handleStatus(interaction); + break; + case 'link': + await handleLink(interaction); + break; + + // Mode and recording + case 'mode': + await handleMode(interaction); + break; + case 'record': + await handleRecord(interaction); + break; + + // Speaker commands + case 'next': + await handleNext(interaction); + break; + case 'speaker': + await handleSpeaker(interaction); + break; + case 'clear': + await handleClear(interaction); + break; + case 'queue': + await handleQueue(interaction); + break; + + // Signal commands + case 'hand': + await handleHand(interaction); + break; + case 'point': + await handlePoint(interaction); + break; + case 'question': + await handleQuestion(interaction); + break; + case 'agree': + await handleAgree(interaction); + break; + case 'disagree': + await handleDisagree(interaction); + break; + case 'away': + await handleAway(interaction); + break; + case 'back': + await handleBack(interaction); + break; + + // Agenda commands + case 'agenda': + await handleAgenda(interaction); + break; + case 'agenda-add': + await handleAgendaAdd(interaction); + break; + case 'agenda-next': + await handleAgendaNext(interaction); + break; + case 'agenda-done': + await handleAgendaDone(interaction); + break; + case 'agenda-skip': + await handleAgendaSkip(interaction); + break; + + // Sound effect commands + case 'sfx': + await handleSfx(interaction); + break; + case 'sfx-play': + await handleSfxPlay(interaction); + break; + + default: + await interaction.reply({ + content: errorMessage(`Unknown command: \`${subcommand}\``), + ephemeral: true, + }); + } + } catch (error) { + console.error('Slash command error:', error); + const errorResponse = errorMessage('An error occurred while processing your command.'); + + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content: errorResponse, ephemeral: true }); + } else { + await interaction.reply({ content: errorResponse, ephemeral: true }); + } + } +} + +// Re-export utilities +export { clearSessionSignals, clearUserSignal }; +export { getCommandsJSON } from './definitions'; diff --git a/server/src/commands/register.ts b/server/src/commands/register.ts new file mode 100644 index 0000000..2d02826 --- /dev/null +++ b/server/src/commands/register.ts @@ -0,0 +1,37 @@ +import { REST, Routes } from 'discord.js'; +import { getCommandsJSON } from './definitions'; + +/** + * Register slash commands with Discord + * Run with: npx tsx src/commands/register.ts + */ +async function registerCommands() { + const token = process.env.DISCORD_BOT_TOKEN; + const clientId = process.env.DISCORD_CLIENT_ID; + + if (!token || !clientId) { + console.error('Missing DISCORD_BOT_TOKEN or DISCORD_CLIENT_ID environment variables'); + process.exit(1); + } + + const rest = new REST({ version: '10' }).setToken(token); + const commands = getCommandsJSON(); + + try { + console.log(`Registering ${commands.length} slash command(s)...`); + + // Register commands globally (available in all servers) + const data = await rest.put(Routes.applicationCommands(clientId), { + body: commands, + }); + + console.log(`Successfully registered ${(data as any[]).length} command(s)`); + console.log('Commands may take up to an hour to appear in all servers.'); + } catch (error) { + console.error('Failed to register commands:', error); + process.exit(1); + } +} + +// Run if executed directly +registerCommands(); diff --git a/server/src/commands/responses.ts b/server/src/commands/responses.ts new file mode 100644 index 0000000..04a7908 --- /dev/null +++ b/server/src/commands/responses.ts @@ -0,0 +1,239 @@ +import { EmbedBuilder } from 'discord.js'; +import { Session, QueueEntry, AgendaItem, AgendaItemStatus } from '../services/rallyround/types'; + +// Emoji constants +export const Emoji = { + // Status + success: ':white_check_mark:', + error: ':x:', + warning: ':warning:', + info: ':information_source:', + + // Signals + hand: ':raised_hand:', + pointOfOrder: ':rotating_light:', + clarification: ':pushpin:', + information: ':information_source:', + question: ':question:', + agree: ':thumbsup:', + disagree: ':thumbsdown:', + away: ':zzz:', + back: ':wave:', + + // Session + speaker: ':speaking_head:', + queue: ':busts_in_silhouette:', + recording: ':red_circle:', + pause: ':pause_button:', + play: ':arrow_forward:', + stop: ':stop_button:', + mode: ':gear:', + + // Agenda + pending: ':white_circle:', + active: ':large_blue_circle:', + completed: ':white_check_mark:', + skipped: ':fast_forward:', + + // Sounds + sound: ':loud_sound:', + mute: ':mute:', +} as const; + +/** + * Format a success message + */ +export function successMessage(text: string): string { + return `${Emoji.success} ${text}`; +} + +/** + * Format an error message + */ +export function errorMessage(text: string): string { + return `${Emoji.error} ${text}`; +} + +/** + * Format session status as an embed + */ +export function formatSessionStatus(session: Session): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle(`${Emoji.info} Session Status`) + .setColor(session.isRecording ? 0xff0000 : 0x5865f2) + .addFields( + { name: 'Title', value: session.title, inline: true }, + { name: 'Mode', value: session.mode, inline: true }, + { name: 'Status', value: session.status, inline: true } + ); + + if (session.isRecording) { + embed.addFields({ name: 'Recording', value: `${Emoji.recording} Active`, inline: true }); + } + + if (session.currentSpeaker) { + embed.addFields({ + name: 'Current Speaker', + value: `${Emoji.speaker} ${session.currentSpeaker.displayName}`, + inline: true, + }); + } + + if (session.queue && session.queue.length > 0) { + embed.addFields({ + name: 'Queue', + value: `${session.queue.length} waiting`, + inline: true, + }); + } + + return embed; +} + +/** + * Format speaker queue as an embed + */ +export function formatQueue(queue: QueueEntry[]): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle(`${Emoji.queue} Speaker Queue`) + .setColor(0x5865f2); + + if (queue.length === 0) { + embed.setDescription('The queue is empty.'); + return embed; + } + + const signalEmoji: Record = { + hand: Emoji.hand, + point_of_order: Emoji.pointOfOrder, + clarification: Emoji.clarification, + information: Emoji.information, + question: Emoji.question, + }; + + const lines = queue.map((entry, index) => { + const emoji = signalEmoji[entry.signal] || Emoji.hand; + const ack = entry.acknowledged ? ' (ack)' : ''; + return `${index + 1}. ${emoji} **${entry.displayName}**${ack}`; + }); + + embed.setDescription(lines.join('\n')); + return embed; +} + +/** + * Format agenda as an embed + */ +export function formatAgenda(agenda: AgendaItem[]): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle(`${Emoji.info} Agenda`) + .setColor(0x5865f2); + + if (agenda.length === 0) { + embed.setDescription('No agenda items.'); + return embed; + } + + const statusEmoji: Record = { + pending: Emoji.pending, + active: Emoji.active, + completed: Emoji.completed, + skipped: Emoji.skipped, + }; + + const lines = agenda.map((item) => { + const emoji = statusEmoji[item.status]; + const duration = item.duration ? ` (${item.duration}m)` : ''; + return `${emoji} ${item.title}${duration}`; + }); + + embed.setDescription(lines.join('\n')); + return embed; +} + +/** + * Format help message + */ +export function formatHelp(): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle('RallyRound Bot Commands') + .setColor(0x5865f2) + .setDescription('Use `!rr ` to interact with live sessions.') + .addFields( + { + name: 'Session (Facilitator)', + value: [ + '`!rr start [title]` - Start a new session', + '`!rr end` - End the current session', + '`!rr pause` - Pause the session', + '`!rr resume` - Resume the session', + '`!rr status` - Show session status', + '`!rr link` - Post dashboard URL', + ].join('\n'), + }, + { + name: 'Mode & Recording (Facilitator)', + value: [ + '`!rr mode structured|unstructured` - Change mode', + '`!rr record start|stop` - Toggle recording', + ].join('\n'), + }, + { + name: 'Speaker (Facilitator)', + value: [ + '`!rr next` - Move to next speaker', + '`!rr speaker @user` - Set specific speaker', + '`!rr clear` - Clear speaker', + '`!rr queue` - Show speaker queue', + ].join('\n'), + }, + { + name: 'Signals (Voice Channel)', + value: [ + '`!rr hand` - Raise/lower hand', + '`!rr point order|clarify|info` - Parliamentary signals', + '`!rr question` - Question signal', + '`!rr agree` / `!rr disagree` - Sentiment signals', + '`!rr away` / `!rr back` - Status updates', + ].join('\n'), + }, + { + name: 'Agenda (Facilitator)', + value: [ + '`!rr agenda` - Show agenda', + '`!rr agenda add [item]` - Add item', + '`!rr agenda next` - Advance to next item', + '`!rr agenda done` - Mark current complete', + '`!rr agenda skip` - Skip current item', + ].join('\n'), + }, + { + name: 'Sound Effects', + value: [ + '`!rr sfx on|off` - Enable/disable sounds', + '`!rr sfx list` - List available sounds', + '`!rr sfx [name]` - Play specific sound', + ].join('\n'), + } + ); + + return embed; +} + +/** + * Get signal emoji + */ +export function getSignalEmoji(signal: string): string { + const map: Record = { + hand: Emoji.hand, + point_of_order: Emoji.pointOfOrder, + clarification: Emoji.clarification, + information: Emoji.information, + question: Emoji.question, + agree: Emoji.agree, + disagree: Emoji.disagree, + away: Emoji.away, + back: Emoji.back, + }; + return map[signal] || Emoji.hand; +} diff --git a/server/src/controllers/authController.ts b/server/src/controllers/authController.ts new file mode 100644 index 0000000..34395f8 --- /dev/null +++ b/server/src/controllers/authController.ts @@ -0,0 +1,229 @@ +import { Request, Response } from 'express'; +import axios from 'axios'; +import crypto from 'crypto'; +import { signToken, verifyToken, getTokenExpiration } from '../utils/jwt'; + +const DISCORD_API_URL = 'https://discord.com/api/v10'; +const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID!; +const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET!; +const DISCORD_REDIRECT_URI = process.env.DISCORD_REDIRECT_URI || 'http://localhost:3002/auth/discord/callback'; + +// Store state tokens for CSRF protection (in production, use Redis or similar) +const stateTokens = new Map(); + +// Clean up expired state tokens periodically +setInterval(() => { + const now = Date.now(); + for (const [token, data] of stateTokens.entries()) { + if (now - data.createdAt > 10 * 60 * 1000) { // 10 minutes + stateTokens.delete(token); + } + } +}, 60 * 1000); + +/** + * GET /auth/discord + * Initiates Discord OAuth flow + */ +export const initiateOAuth = (req: Request, res: Response) => { + const { redirect_uri, state: clientState } = req.query; + + // Generate CSRF state token + const stateToken = crypto.randomBytes(32).toString('hex'); + stateTokens.set(stateToken, { + redirectUri: redirect_uri as string | undefined, + createdAt: Date.now(), + }); + + // Combine our state with client's state if provided + const state = clientState ? `${stateToken}:${clientState}` : stateToken; + + const params = new URLSearchParams({ + client_id: DISCORD_CLIENT_ID, + redirect_uri: DISCORD_REDIRECT_URI, + response_type: 'code', + scope: 'identify guilds email', + state, + }); + + const authUrl = `https://discord.com/api/oauth2/authorize?${params.toString()}`; + res.redirect(authUrl); +}; + +/** + * GET /auth/discord/callback + * Handles Discord OAuth callback, exchanges code for tokens + */ +export const handleCallback = async (req: Request, res: Response) => { + const { code, state, error, error_description } = req.query; + + if (error) { + console.error('OAuth error:', error, error_description); + return res.status(400).json({ + error: 'oauth_error', + message: error_description || 'OAuth authorization failed', + }); + } + + if (!code || !state) { + return res.status(400).json({ + error: 'missing_params', + message: 'Missing authorization code or state', + }); + } + + // Validate state token for CSRF protection + const [stateToken, clientState] = (state as string).split(':'); + const stateData = stateTokens.get(stateToken); + + if (!stateData) { + return res.status(400).json({ + error: 'invalid_state', + message: 'Invalid or expired state token', + }); + } + + stateTokens.delete(stateToken); + + try { + // Exchange code for access token + const tokenResponse = await axios.post( + `${DISCORD_API_URL}/oauth2/token`, + new URLSearchParams({ + client_id: DISCORD_CLIENT_ID, + client_secret: DISCORD_CLIENT_SECRET, + grant_type: 'authorization_code', + code: code as string, + redirect_uri: DISCORD_REDIRECT_URI, + }), + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + } + ); + + const { access_token } = tokenResponse.data; + + // Fetch user info from Discord + const userResponse = await axios.get(`${DISCORD_API_URL}/users/@me`, { + headers: { Authorization: `Bearer ${access_token}` }, + }); + + const discordUser = userResponse.data; + + // Generate JWT + const jwt = signToken({ + sub: discordUser.id, + username: discordUser.username, + discriminator: discordUser.discriminator || '0', + avatar: discordUser.avatar, + email: discordUser.email, + }); + + // If there's a redirect URI, redirect with token + if (stateData.redirectUri) { + const redirectUrl = new URL(stateData.redirectUri); + redirectUrl.searchParams.set('token', jwt); + if (clientState) { + redirectUrl.searchParams.set('state', clientState); + } + return res.redirect(redirectUrl.toString()); + } + + // Otherwise return JSON response + res.json({ + token: jwt, + user: { + id: discordUser.id, + username: discordUser.username, + discriminator: discordUser.discriminator || '0', + avatar: discordUser.avatar, + email: discordUser.email, + }, + expires_at: getTokenExpiration(jwt)?.toISOString(), + }); + } catch (error: any) { + console.error('OAuth callback error:', error.response?.data || error.message); + res.status(500).json({ + error: 'token_exchange_failed', + message: 'Failed to exchange authorization code for token', + }); + } +}; + +/** + * GET /auth/validate + * Validates bearer token and returns user info (used by RallyRound) + */ +export const validateToken = (req: Request, res: Response) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + error: 'missing_token', + message: 'Authorization header with Bearer token required', + }); + } + + const token = authHeader.slice(7); + const decoded = verifyToken(token); + + if (!decoded) { + return res.status(401).json({ + error: 'invalid_token', + message: 'Token is invalid or expired', + }); + } + + res.json({ + valid: true, + user: { + id: decoded.sub, + username: decoded.username, + discriminator: decoded.discriminator, + avatar: decoded.avatar, + email: decoded.email, + }, + expires_at: new Date(decoded.exp * 1000).toISOString(), + }); +}; + +/** + * GET /auth/user + * Returns full authenticated user profile with guilds + */ +export const getUserProfile = async (req: Request, res: Response) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + error: 'missing_token', + message: 'Authorization header with Bearer token required', + }); + } + + const token = authHeader.slice(7); + const decoded = verifyToken(token); + + if (!decoded) { + return res.status(401).json({ + error: 'invalid_token', + message: 'Token is invalid or expired', + }); + } + + // For full profile with guilds, we need to fetch from Discord + // This requires the user to have authorized with 'guilds' scope + // Since we store JWT (not Discord token), we return cached user info + // and note that guilds would require re-authorization + res.json({ + id: decoded.sub, + username: decoded.username, + discriminator: decoded.discriminator, + avatar: decoded.avatar, + email: decoded.email, + // Note: To get guilds, the client should initiate a new OAuth flow + // or we need to store the Discord access token (with security implications) + guilds: [], + _note: 'Guild list requires Discord access token. Re-authenticate if needed.', + }); +}; diff --git a/server/src/controllers/discordController.ts b/server/src/controllers/discordController.ts index 894ca82..565b183 100644 --- a/server/src/controllers/discordController.ts +++ b/server/src/controllers/discordController.ts @@ -1,38 +1,11 @@ import { Request, Response } from 'express'; -import { Client, GatewayIntentBits, Guild, TextChannel } from 'discord.js'; - -const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.GuildMembers, - GatewayIntentBits.MessageContent, - ], -}); - -// Initialize Discord bot -let botReady = false; - -client.on('ready', () => { - console.log(`Discord bot logged in as ${client.user?.tag}`); - botReady = true; -}); - -client.on('error', (error) => { - console.error('Discord client error:', error); -}); - -if (process.env.DISCORD_BOT_TOKEN) { - client.login(process.env.DISCORD_BOT_TOKEN).catch((error) => { - console.error('Failed to login to Discord:', error.message); - }); -} else { - console.error('DISCORD_BOT_TOKEN is not set!'); -} +import { TextChannel } from 'discord.js'; +import { getClient, isBotReady } from '../services/discord'; export const getGuildStats = async (req: Request, res: Response) => { try { - if (!botReady) { + const client = getClient(); + if (!client || !isBotReady()) { return res.status(503).json({ error: 'Discord bot is not ready yet' }); } @@ -68,6 +41,11 @@ export const getGuildStats = async (req: Request, res: Response) => { export const getUserActivity = async (req: Request, res: Response) => { try { + const client = getClient(); + if (!client || !isBotReady()) { + return res.status(503).json({ error: 'Discord bot is not ready yet' }); + } + const { userId } = req.params; const { guildId } = req.query; @@ -100,6 +78,11 @@ export const getUserActivity = async (req: Request, res: Response) => { export const getMessageStats = async (req: Request, res: Response) => { try { + const client = getClient(); + if (!client || !isBotReady()) { + return res.status(503).json({ error: 'Discord bot is not ready yet' }); + } + const { guildId } = req.params; const { limit = '100', channelId } = req.query; diff --git a/server/src/index.ts b/server/src/index.ts index bc434bd..0fe9776 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,22 +5,40 @@ dotenv.config({ path: path.resolve(__dirname, '../.env') }); import express from 'express'; import cors from 'cors'; import discordRoutes from './routes/discord'; +import authRoutes from './routes/auth'; +import webhookRoutes from './webhooks'; +import { initializeBot } from './services/discord'; const app = express(); -const PORT = process.env.PORT || 3001; +const PORT = process.env.PORT || 3002; + +// CORS configuration - allow RallyRound origin and localhost for development +const corsOptions = { + origin: [ + process.env.RALLYROUND_URL || 'http://localhost:8765', + 'http://localhost:5173', + 'http://localhost:3000', + ], + credentials: true, +}; // Middleware -app.use(cors()); +app.use(cors(corsOptions)); app.use(express.json()); // Routes +app.use('/auth', authRoutes); app.use('/api/discord', discordRoutes); +app.use('/webhooks', webhookRoutes); // Health check app.get('/health', (req, res) => { res.json({ status: 'ok', message: 'Discord Stats API is running' }); }); +// Initialize Discord bot +initializeBot(); + app.listen(PORT, () => { - console.log(`🚀 Server running on http://localhost:${PORT}`); + console.log(`Server running on http://localhost:${PORT}`); }); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 0000000..91f7511 --- /dev/null +++ b/server/src/routes/auth.ts @@ -0,0 +1,23 @@ +import { Router } from 'express'; +import { + initiateOAuth, + handleCallback, + validateToken, + getUserProfile, +} from '../controllers/authController'; + +const router = Router(); + +// GET /auth/discord - Initiate OAuth flow +router.get('/discord', initiateOAuth); + +// GET /auth/discord/callback - Handle OAuth callback +router.get('/discord/callback', handleCallback); + +// GET /auth/validate - Validate token (for RallyRound) +router.get('/validate', validateToken); + +// GET /auth/user - Get full user profile +router.get('/user', getUserProfile); + +export default router; diff --git a/server/src/services/audio/index.ts b/server/src/services/audio/index.ts new file mode 100644 index 0000000..cf99dc1 --- /dev/null +++ b/server/src/services/audio/index.ts @@ -0,0 +1,18 @@ +export { + playSound, + playSignalSound, + queueSound, + clearQueue, + stopPlayback, + isCurrentlyPlaying, +} from './player'; + +export { + SoundFiles, + SoundName, + SignalSounds, + getSoundPath, + getSignalSound, + isValidSound, + SOUNDS_DIR, +} from './sounds'; diff --git a/server/src/services/audio/player.ts b/server/src/services/audio/player.ts new file mode 100644 index 0000000..c30e800 --- /dev/null +++ b/server/src/services/audio/player.ts @@ -0,0 +1,177 @@ +import { + AudioPlayer, + AudioPlayerStatus, + createAudioPlayer, + createAudioResource, + NoSubscriberBehavior, +} from '@discordjs/voice'; +import { getVoiceConnectionForGuild } from '../voice'; +import { getSoundPath, SoundName, isValidSound } from './sounds'; +import fs from 'fs'; + +// Store audio players per guild +const audioPlayers = new Map(); + +// Store playback queues per guild +const playbackQueues = new Map(); + +// Store currently playing state +const isPlaying = new Map(); + +/** + * Get or create an audio player for a guild + */ +function getOrCreatePlayer(guildId: string): AudioPlayer { + let player = audioPlayers.get(guildId); + + if (!player) { + player = createAudioPlayer({ + behaviors: { + noSubscriber: NoSubscriberBehavior.Pause, + }, + }); + + // Set up player event handlers + player.on(AudioPlayerStatus.Idle, () => { + isPlaying.set(guildId, false); + processQueue(guildId); + }); + + player.on('error', (error) => { + console.error(`Audio player error for guild ${guildId}:`, error); + isPlaying.set(guildId, false); + processQueue(guildId); + }); + + audioPlayers.set(guildId, player); + + // Subscribe the voice connection to this player + const connection = getVoiceConnectionForGuild(guildId); + if (connection) { + connection.subscribe(player); + } + } + + return player; +} + +/** + * Process the next sound in the queue + */ +function processQueue(guildId: string): void { + const queue = playbackQueues.get(guildId); + + if (!queue || queue.length === 0) { + return; + } + + if (isPlaying.get(guildId)) { + return; + } + + const nextSound = queue.shift(); + if (nextSound) { + playImmediately(guildId, nextSound); + } +} + +/** + * Play a sound immediately (internal use) + */ +function playImmediately(guildId: string, sound: SoundName): void { + const connection = getVoiceConnectionForGuild(guildId); + + if (!connection) { + console.log(`No voice connection for guild ${guildId}, cannot play sound`); + return; + } + + const soundPath = getSoundPath(sound); + + // Check if file exists + if (!fs.existsSync(soundPath)) { + console.error(`Sound file not found: ${soundPath}`); + return; + } + + try { + const player = getOrCreatePlayer(guildId); + const resource = createAudioResource(soundPath); + + isPlaying.set(guildId, true); + player.play(resource); + + // Ensure connection is subscribed + connection.subscribe(player); + + console.log(`Playing sound: ${sound} for guild ${guildId}`); + } catch (error) { + console.error(`Failed to play sound ${sound}:`, error); + isPlaying.set(guildId, false); + } +} + +/** + * Queue a sound for playback + */ +export function queueSound(guildId: string, sound: SoundName): void { + if (!isValidSound(sound)) { + console.error(`Invalid sound name: ${sound}`); + return; + } + + let queue = playbackQueues.get(guildId); + if (!queue) { + queue = []; + playbackQueues.set(guildId, queue); + } + + queue.push(sound); + processQueue(guildId); +} + +/** + * Play a sound (queued to prevent overlap) + */ +export function playSound(guildId: string, sound: SoundName): void { + queueSound(guildId, sound); +} + +/** + * Play a sound for a signal + */ +export function playSignalSound(guildId: string, signal: string): void { + const { getSignalSound } = require('./sounds'); + const sound = getSignalSound(signal); + + if (sound) { + queueSound(guildId, sound); + } +} + +/** + * Clear the playback queue for a guild + */ +export function clearQueue(guildId: string): void { + playbackQueues.set(guildId, []); +} + +/** + * Stop playback and clean up for a guild + */ +export function stopPlayback(guildId: string): void { + const player = audioPlayers.get(guildId); + if (player) { + player.stop(); + audioPlayers.delete(guildId); + } + playbackQueues.delete(guildId); + isPlaying.delete(guildId); +} + +/** + * Check if bot is currently playing audio + */ +export function isCurrentlyPlaying(guildId: string): boolean { + return isPlaying.get(guildId) || false; +} diff --git a/server/src/services/audio/sounds.ts b/server/src/services/audio/sounds.ts new file mode 100644 index 0000000..4955f91 --- /dev/null +++ b/server/src/services/audio/sounds.ts @@ -0,0 +1,70 @@ +import path from 'path'; +import { SignalType } from '../rallyround'; + +// Base path for sound assets +export const SOUNDS_DIR = path.resolve(__dirname, '../../../assets/sounds'); + +// Sound file definitions +export const SoundFiles = { + // Signal sounds + 'hand-raise': 'chime.mp3', + 'point-of-order': 'gavel.mp3', + 'clarification': 'notification.mp3', + 'information': 'notification.mp3', + 'question': 'notification.mp3', + + // Session sounds + 'speaker-change': 'swoosh.mp3', + 'record-start': 'record-start.mp3', + 'record-stop': 'record-stop.mp3', + + // Agenda sounds + 'agenda-complete': 'ding.mp3', + 'time-warning': 'bell.mp3', + + // Utility sounds + 'chime': 'chime.mp3', + 'gavel': 'gavel.mp3', + 'swoosh': 'swoosh.mp3', + 'ding': 'ding.mp3', + 'bell': 'bell.mp3', + 'error': 'error.mp3', + 'notification': 'notification.mp3', +} as const; + +export type SoundName = keyof typeof SoundFiles; + +// Map signals to sounds +export const SignalSounds: Record = { + hand: 'hand-raise', + point_of_order: 'point-of-order', + clarification: 'clarification', + information: 'information', + question: 'question', + agree: null, // Silent + disagree: null, // Silent + away: null, // Silent + back: null, // Silent +}; + +/** + * Get the file path for a sound + */ +export function getSoundPath(sound: SoundName): string { + const filename = SoundFiles[sound]; + return path.join(SOUNDS_DIR, filename); +} + +/** + * Get the sound for a signal (or null if silent) + */ +export function getSignalSound(signal: SignalType): SoundName | null { + return SignalSounds[signal]; +} + +/** + * Check if a sound name is valid + */ +export function isValidSound(name: string): name is SoundName { + return name in SoundFiles; +} diff --git a/server/src/services/discord/bot.ts b/server/src/services/discord/bot.ts new file mode 100644 index 0000000..eba2270 --- /dev/null +++ b/server/src/services/discord/bot.ts @@ -0,0 +1,92 @@ +import { + Client, + GatewayIntentBits, + Events, + VoiceState, + ChatInputCommandInteraction, +} from 'discord.js'; +import { handleSlashCommand } from '../../commands'; +import { handleVoiceStateUpdate } from '../voice'; + +// Discord client singleton +let client: Client | null = null; +let botReady = false; + +/** + * Initialize the Discord bot client + */ +export function initializeBot(): Client { + if (client) { + return client; + } + + client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.MessageContent, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.GuildPresences, + ], + }); + + // Ready event + client.on(Events.ClientReady, (readyClient) => { + console.log(`Discord bot logged in as ${readyClient.user.tag}`); + console.log(`Bot is in ${readyClient.guilds.cache.size} guild(s)`); + botReady = true; + }); + + // Error handling + client.on(Events.Error, (error) => { + console.error('Discord client error:', error); + }); + + // Slash command handling + client.on(Events.InteractionCreate, async (interaction) => { + if (!interaction.isChatInputCommand()) return; + await handleSlashCommand(interaction as ChatInputCommandInteraction); + }); + + // Voice state updates for presence tracking + client.on(Events.VoiceStateUpdate, async (oldState: VoiceState, newState: VoiceState) => { + await handleVoiceStateUpdate(oldState, newState); + }); + + // Login + const token = process.env.DISCORD_BOT_TOKEN; + if (token) { + client.login(token).catch((error) => { + console.error('Failed to login to Discord:', error.message); + }); + } else { + console.error('DISCORD_BOT_TOKEN is not set!'); + } + + return client; +} + +/** + * Get the Discord client instance + */ +export function getClient(): Client | null { + return client; +} + +/** + * Check if the bot is ready + */ +export function isBotReady(): boolean { + return botReady; +} + +/** + * Get a guild by ID + */ +export async function getGuild(guildId: string) { + if (!client || !botReady) { + throw new Error('Discord bot is not ready'); + } + return client.guilds.fetch(guildId); +} diff --git a/server/src/services/discord/index.ts b/server/src/services/discord/index.ts new file mode 100644 index 0000000..4a948ad --- /dev/null +++ b/server/src/services/discord/index.ts @@ -0,0 +1 @@ +export { initializeBot, getClient, isBotReady, getGuild } from './bot'; diff --git a/server/src/services/rallyround/client.ts b/server/src/services/rallyround/client.ts new file mode 100644 index 0000000..a75dada --- /dev/null +++ b/server/src/services/rallyround/client.ts @@ -0,0 +1,204 @@ +import axios, { AxiosInstance, AxiosError } from 'axios'; +import { RallyRoundError } from './errors'; +import { + Session, + CreateSessionOptions, + UpdateSessionOptions, + Participant, + AddParticipantOptions, + ParticipantStatus, + Signal, + RaiseSignalOptions, + SpeakerChangeResult, + SetSpeakerOptions, + AgendaItem, + AddAgendaItemOptions, + UpdateAgendaItemOptions, + SessionStats, + QueueEntry, +} from './types'; + +const REQUEST_TIMEOUT = 30000; // 30 seconds +const MAX_RETRIES = 3; +const RETRY_DELAYS = [1000, 2000, 4000]; // Exponential backoff + +export class RallyRoundClient { + private client: AxiosInstance; + private authToken: string; + + constructor(baseUrl: string, authToken: string) { + this.authToken = authToken; + this.client = axios.create({ + baseURL: baseUrl, + timeout: REQUEST_TIMEOUT, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + }, + }); + } + + private async request( + method: 'get' | 'post' | 'patch' | 'delete', + path: string, + data?: any, + retryCount = 0 + ): Promise { + try { + const response = await this.client.request({ + method, + url: path, + data, + }); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + + // Handle timeout + if (axiosError.code === 'ECONNABORTED') { + if (retryCount < MAX_RETRIES) { + await this.delay(RETRY_DELAYS[retryCount]); + return this.request(method, path, data, retryCount + 1); + } + throw RallyRoundError.timeout(); + } + + // Handle network errors + if (!axiosError.response) { + if (retryCount < MAX_RETRIES) { + await this.delay(RETRY_DELAYS[retryCount]); + return this.request(method, path, data, retryCount + 1); + } + throw RallyRoundError.networkError(axiosError.message); + } + + const { status, data: responseData } = axiosError.response; + + // Retry on 503 Service Unavailable + if (status === 503 && retryCount < MAX_RETRIES) { + await this.delay(RETRY_DELAYS[retryCount]); + return this.request(method, path, data, retryCount + 1); + } + + throw RallyRoundError.fromResponse(status, responseData); + } + + throw error; + } + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + // ==================== Session Management ==================== + + async createSession(options: CreateSessionOptions): Promise { + return this.request('post', '/sessions', options); + } + + async getSession( + sessionId: string, + include?: ('participants' | 'queue' | 'agenda')[] + ): Promise { + const params = include ? `?include=${include.join(',')}` : ''; + return this.request('get', `/sessions/${sessionId}${params}`); + } + + async updateSession(sessionId: string, updates: UpdateSessionOptions): Promise { + return this.request('patch', `/sessions/${sessionId}`, updates); + } + + async endSession(sessionId: string): Promise { + return this.request('delete', `/sessions/${sessionId}`); + } + + // ==================== Participant Management ==================== + + async addParticipant(sessionId: string, options: AddParticipantOptions): Promise { + return this.request('post', `/sessions/${sessionId}/participants`, options); + } + + async removeParticipant(sessionId: string, discordId: string): Promise { + return this.request('delete', `/sessions/${sessionId}/participants/${discordId}`); + } + + async updateParticipantStatus( + sessionId: string, + discordId: string, + status: ParticipantStatus + ): Promise { + return this.request( + 'patch', + `/sessions/${sessionId}/participants/${discordId}`, + { status } + ); + } + + // ==================== Signal Management ==================== + + async raiseSignal(sessionId: string, options: RaiseSignalOptions): Promise { + return this.request('post', `/sessions/${sessionId}/signals`, options); + } + + async clearSignal(sessionId: string, discordId: string): Promise { + return this.request('delete', `/sessions/${sessionId}/signals/${discordId}`); + } + + // ==================== Speaker Queue Management ==================== + + async setSpeaker(sessionId: string, options: SetSpeakerOptions): Promise { + return this.request('post', `/sessions/${sessionId}/speaker`, options); + } + + async nextSpeaker(sessionId: string): Promise { + return this.request('post', `/sessions/${sessionId}/speaker/next`); + } + + async clearSpeaker(sessionId: string): Promise { + return this.request('delete', `/sessions/${sessionId}/speaker`); + } + + async clearSpeakerQueue(sessionId: string): Promise { + return this.request('delete', `/sessions/${sessionId}/speaker/queue`); + } + + async getQueue(sessionId: string): Promise { + const session = await this.getSession(sessionId, ['queue']); + return session.queue || []; + } + + // ==================== Agenda Management ==================== + + async getAgenda(sessionId: string): Promise { + const session = await this.getSession(sessionId, ['agenda']); + return session.agenda || []; + } + + async addAgendaItem(sessionId: string, options: AddAgendaItemOptions): Promise { + return this.request('post', `/sessions/${sessionId}/agenda`, options); + } + + async updateAgendaItem( + sessionId: string, + itemId: string, + options: UpdateAgendaItemOptions + ): Promise { + return this.request('patch', `/sessions/${sessionId}/agenda/${itemId}`, options); + } + + async removeAgendaItem(sessionId: string, itemId: string): Promise { + return this.request('delete', `/sessions/${sessionId}/agenda/${itemId}`); + } + + async advanceAgenda(sessionId: string): Promise { + return this.request('post', `/sessions/${sessionId}/agenda/next`); + } + + async reorderAgenda(sessionId: string, itemIds: string[]): Promise { + return this.request('post', `/sessions/${sessionId}/agenda/reorder`, { + itemIds, + }); + } +} diff --git a/server/src/services/rallyround/errors.ts b/server/src/services/rallyround/errors.ts new file mode 100644 index 0000000..69b84c1 --- /dev/null +++ b/server/src/services/rallyround/errors.ts @@ -0,0 +1,42 @@ +export class RallyRoundError extends Error { + public readonly statusCode: number; + public readonly code: string; + public readonly retryable: boolean; + + constructor(message: string, statusCode: number, code: string, retryable = false) { + super(message); + this.name = 'RallyRoundError'; + this.statusCode = statusCode; + this.code = code; + this.retryable = retryable; + } + + static fromResponse(statusCode: number, body: any): RallyRoundError { + const code = body?.error?.code || 'UNKNOWN_ERROR'; + const message = body?.error?.message || 'An unknown error occurred'; + const retryable = statusCode === 503 || statusCode === 429; + + return new RallyRoundError(message, statusCode, code, retryable); + } + + static networkError(message: string): RallyRoundError { + return new RallyRoundError(message, 0, 'NETWORK_ERROR', true); + } + + static timeout(): RallyRoundError { + return new RallyRoundError('Request timed out', 0, 'TIMEOUT', true); + } +} + +// Error codes from API spec +export const ErrorCodes = { + UNAUTHORIZED: 'UNAUTHORIZED', + FORBIDDEN: 'FORBIDDEN', + NOT_FOUND: 'NOT_FOUND', + VALIDATION_ERROR: 'VALIDATION_ERROR', + MODE_MISMATCH: 'MODE_MISMATCH', + RATE_LIMITED: 'RATE_LIMITED', + INTERNAL_ERROR: 'INTERNAL_ERROR', + NETWORK_ERROR: 'NETWORK_ERROR', + TIMEOUT: 'TIMEOUT', +} as const; diff --git a/server/src/services/rallyround/index.ts b/server/src/services/rallyround/index.ts new file mode 100644 index 0000000..04fb64f --- /dev/null +++ b/server/src/services/rallyround/index.ts @@ -0,0 +1,4 @@ +export { RallyRoundClient } from './client'; +export { RallyRoundError, ErrorCodes } from './errors'; +export { sessionRegistry, type ActiveSession } from './registry'; +export * from './types'; diff --git a/server/src/services/rallyround/registry.ts b/server/src/services/rallyround/registry.ts new file mode 100644 index 0000000..756c877 --- /dev/null +++ b/server/src/services/rallyround/registry.ts @@ -0,0 +1,116 @@ +import { RallyRoundClient } from './client'; +import { Session, SessionConfig } from './types'; + +export interface ActiveSession { + sessionId: string; + guildId: string; + channelId: string; + voiceChannelId: string; + facilitatorId: string; + webhookSecret: string; + soundEffectsEnabled: boolean; + client: RallyRoundClient; + createdAt: Date; +} + +class SessionRegistry { + // Map of guildId -> ActiveSession + private sessions: Map = new Map(); + + /** + * Register a new active session for a guild + */ + register( + guildId: string, + sessionId: string, + channelId: string, + voiceChannelId: string, + facilitatorId: string, + webhookSecret: string, + config: Partial, + client: RallyRoundClient + ): ActiveSession { + const session: ActiveSession = { + sessionId, + guildId, + channelId, + voiceChannelId, + facilitatorId, + webhookSecret, + soundEffectsEnabled: config.soundEffectsEnabled ?? true, + client, + createdAt: new Date(), + }; + + this.sessions.set(guildId, session); + return session; + } + + /** + * Get active session for a guild + */ + get(guildId: string): ActiveSession | undefined { + return this.sessions.get(guildId); + } + + /** + * Get session by session ID + */ + getBySessionId(sessionId: string): ActiveSession | undefined { + for (const session of this.sessions.values()) { + if (session.sessionId === sessionId) { + return session; + } + } + return undefined; + } + + /** + * Check if a guild has an active session + */ + has(guildId: string): boolean { + return this.sessions.has(guildId); + } + + /** + * Remove session for a guild + */ + remove(guildId: string): boolean { + return this.sessions.delete(guildId); + } + + /** + * Get all active sessions + */ + getAll(): ActiveSession[] { + return Array.from(this.sessions.values()); + } + + /** + * Get session count + */ + get count(): number { + return this.sessions.size; + } + + /** + * Update sound effects setting for a session + */ + setSoundEffects(guildId: string, enabled: boolean): void { + const session = this.sessions.get(guildId); + if (session) { + session.soundEffectsEnabled = enabled; + } + } + + /** + * Check if user is the facilitator for a guild's session + */ + isFacilitator(guildId: string, userId: string): boolean { + const session = this.sessions.get(guildId); + return session?.facilitatorId === userId; + } +} + +// Export singleton instance +export const sessionRegistry = new SessionRegistry(); diff --git a/server/src/services/rallyround/types.ts b/server/src/services/rallyround/types.ts new file mode 100644 index 0000000..6e57225 --- /dev/null +++ b/server/src/services/rallyround/types.ts @@ -0,0 +1,172 @@ +// Session types +export type SessionState = 'scheduled' | 'active' | 'paused' | 'ended'; +export type SessionMode = 'structured' | 'unstructured'; + +export interface SessionConfig { + soundEffectsEnabled: boolean; + speakerTimeLimit?: number; + selfQueueEnabled: boolean; + facilitatorApprovalRequired: boolean; +} + +export interface Session { + id: string; + title: string; + guildId: string; + channelId: string; + voiceChannelId: string; + facilitatorId: string; + status: SessionState; + mode: SessionMode; + isRecording: boolean; + currentSpeaker?: Participant; + config: SessionConfig; + createdAt: string; + updatedAt: string; + participants?: Participant[]; + queue?: QueueEntry[]; + agenda?: AgendaItem[]; +} + +export interface CreateSessionOptions { + title: string; + guildId: string; + channelId: string; + voiceChannelId: string; + facilitatorId: string; + webhookUrl: string; + webhookSecret: string; + config?: Partial; +} + +export interface UpdateSessionOptions { + mode?: SessionMode; + isRecording?: boolean; + status?: SessionState; +} + +// Participant types +export type ParticipantStatus = 'ready' | 'away' | 'speaking' | 'disconnected'; +export type ParticipantSource = 'discord' | 'dashboard'; + +export interface Participant { + id: string; + discordId: string; + username: string; + displayName: string; + avatar?: string; + status: ParticipantStatus; + source: ParticipantSource; + joinedAt: string; + speakingTime: number; + activeSignal?: SignalType; +} + +export interface AddParticipantOptions { + discordId: string; + username: string; + displayName: string; + avatar?: string; + source: ParticipantSource; +} + +// Signal types +export type SignalType = + | 'hand' + | 'point_of_order' + | 'clarification' + | 'information' + | 'question' + | 'agree' + | 'disagree' + | 'away' + | 'back'; + +export type SignalPriority = 'interrupt' | 'high' | 'normal' | 'acknowledgment'; + +export interface Signal { + discordId: string; + signal: SignalType; + priority: SignalPriority; + queuePosition?: number; + raisedAt: string; +} + +export interface RaiseSignalOptions { + discordId: string; + signal: SignalType; +} + +// Queue types +export interface QueueEntry { + discordId: string; + username: string; + displayName: string; + signal: SignalType; + priority: SignalPriority; + position: number; + queuedAt: string; + acknowledged: boolean; +} + +// Speaker types +export interface SetSpeakerOptions { + discordId: string; +} + +export interface SpeakerChangeResult { + previousSpeaker?: { + discordId: string; + duration: number; + }; + currentSpeaker?: Participant; + remainingQueue: number; +} + +// Agenda types +export type AgendaItemStatus = 'pending' | 'active' | 'completed' | 'skipped'; + +export interface AgendaItem { + id: string; + title: string; + description?: string; + duration?: number; + status: AgendaItemStatus; + position: number; + createdBy: string; + startedAt?: string; + completedAt?: string; +} + +export interface AddAgendaItemOptions { + title: string; + description?: string; + duration?: number; + position?: number; +} + +export interface UpdateAgendaItemOptions { + title?: string; + description?: string; + duration?: number; + status?: AgendaItemStatus; +} + +// Session statistics +export interface SessionStats { + duration: number; + participantCount: number; + totalSignals: number; + agendaItemsCompleted: number; + agendaItemsTotal: number; +} + +// API response types +export interface ApiResponse { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + }; +} diff --git a/server/src/services/voice/connection.ts b/server/src/services/voice/connection.ts new file mode 100644 index 0000000..5b86b31 --- /dev/null +++ b/server/src/services/voice/connection.ts @@ -0,0 +1,169 @@ +import { + joinVoiceChannel, + VoiceConnection, + VoiceConnectionStatus, + entersState, + getVoiceConnection, +} from '@discordjs/voice'; +import { VoiceChannel, TextChannel } from 'discord.js'; +import { getClient } from '../discord'; +import { sessionRegistry } from '../rallyround'; + +// Store voice connections per guild +const voiceConnections = new Map(); + +// Reconnection settings +const MAX_RECONNECT_ATTEMPTS = 3; +const RECONNECT_DELAYS = [2000, 4000, 8000]; + +/** + * Join a voice channel for a session + */ +export async function joinSessionVoiceChannel( + guildId: string, + voiceChannelId: string, + notifyChannelId: string +): Promise { + const client = getClient(); + if (!client) { + console.error('Discord client not available'); + return null; + } + + try { + const guild = await client.guilds.fetch(guildId); + const voiceChannel = (await guild.channels.fetch(voiceChannelId)) as VoiceChannel; + const textChannel = (await guild.channels.fetch(notifyChannelId)) as TextChannel; + + if (!voiceChannel) { + console.error(`Voice channel ${voiceChannelId} not found`); + return null; + } + + const connection = joinVoiceChannel({ + channelId: voiceChannelId, + guildId: guildId, + adapterCreator: guild.voiceAdapterCreator, + selfDeaf: false, + selfMute: false, + }); + + // Set up connection event handlers + setupConnectionHandlers(connection, guildId, voiceChannelId, textChannel); + + // Wait for the connection to be ready + try { + await entersState(connection, VoiceConnectionStatus.Ready, 20_000); + voiceConnections.set(guildId, connection); + console.log(`Joined voice channel ${voiceChannel.name} in ${guild.name}`); + return connection; + } catch (error) { + console.error('Failed to establish voice connection:', error); + connection.destroy(); + return null; + } + } catch (error) { + console.error('Failed to join voice channel:', error); + return null; + } +} + +/** + * Set up voice connection event handlers + */ +function setupConnectionHandlers( + connection: VoiceConnection, + guildId: string, + voiceChannelId: string, + textChannel: TextChannel +): void { + let reconnectAttempts = 0; + + connection.on(VoiceConnectionStatus.Disconnected, async () => { + console.log(`Voice connection disconnected for guild ${guildId}`); + + // Check if session is still active + const session = sessionRegistry.get(guildId); + if (!session) { + connection.destroy(); + voiceConnections.delete(guildId); + return; + } + + // Attempt to reconnect + while (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + try { + await entersState(connection, VoiceConnectionStatus.Connecting, 5_000); + // Connected, reset attempts + reconnectAttempts = 0; + return; + } catch (error) { + reconnectAttempts++; + console.log(`Reconnect attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); + + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + await delay(RECONNECT_DELAYS[reconnectAttempts - 1]); + } + } + } + + // Failed to reconnect + console.error(`Failed to reconnect after ${MAX_RECONNECT_ATTEMPTS} attempts`); + + // Notify the text channel + try { + await textChannel.send( + ':warning: Lost connection to voice channel. Sound effects temporarily unavailable. ' + + 'Use `!rr sfx on` to attempt reconnection.' + ); + } catch (e) { + console.error('Failed to send disconnect notification:', e); + } + + connection.destroy(); + voiceConnections.delete(guildId); + }); + + connection.on(VoiceConnectionStatus.Destroyed, () => { + console.log(`Voice connection destroyed for guild ${guildId}`); + voiceConnections.delete(guildId); + }); + + connection.on('error', (error) => { + console.error(`Voice connection error for guild ${guildId}:`, error); + }); +} + +/** + * Leave voice channel for a session + */ +export function leaveSessionVoiceChannel(guildId: string): void { + const connection = voiceConnections.get(guildId); + if (connection) { + connection.destroy(); + voiceConnections.delete(guildId); + console.log(`Left voice channel for guild ${guildId}`); + } +} + +/** + * Get voice connection for a guild + */ +export function getVoiceConnectionForGuild(guildId: string): VoiceConnection | undefined { + return voiceConnections.get(guildId) || getVoiceConnection(guildId); +} + +/** + * Check if bot is in voice channel + */ +export function isBotInVoiceChannel(guildId: string): boolean { + const connection = getVoiceConnectionForGuild(guildId); + return connection?.state.status === VoiceConnectionStatus.Ready; +} + +/** + * Delay helper + */ +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/server/src/services/voice/index.ts b/server/src/services/voice/index.ts new file mode 100644 index 0000000..f64f6c9 --- /dev/null +++ b/server/src/services/voice/index.ts @@ -0,0 +1,13 @@ +export { + handleVoiceStateUpdate, + syncVoiceChannelMembers, + isUserInSessionVC, + getSessionTextChannel, +} from './presence'; + +export { + joinSessionVoiceChannel, + leaveSessionVoiceChannel, + getVoiceConnectionForGuild, + isBotInVoiceChannel, +} from './connection'; diff --git a/server/src/services/voice/presence.ts b/server/src/services/voice/presence.ts new file mode 100644 index 0000000..adb0e87 --- /dev/null +++ b/server/src/services/voice/presence.ts @@ -0,0 +1,166 @@ +import { VoiceState, GuildMember, VoiceChannel, TextChannel } from 'discord.js'; +import { sessionRegistry } from '../rallyround'; +import { clearUserSignal } from '../../commands'; +import { getClient } from '../discord'; + +/** + * Handle voice state updates for session participant tracking + */ +export async function handleVoiceStateUpdate( + oldState: VoiceState, + newState: VoiceState +): Promise { + const member = newState.member || oldState.member; + if (!member || member.user.bot) return; + + const guildId = newState.guild.id; + const session = sessionRegistry.get(guildId); + + if (!session) return; + + const oldChannelId = oldState.channelId; + const newChannelId = newState.channelId; + const sessionVCId = session.voiceChannelId; + + // No channel change + if (oldChannelId === newChannelId) return; + + // User joined the session's voice channel + if (newChannelId === sessionVCId && oldChannelId !== sessionVCId) { + await handleUserJoin(session, member); + } + // User left the session's voice channel + else if (oldChannelId === sessionVCId && newChannelId !== sessionVCId) { + await handleUserLeave(session, member); + } +} + +/** + * Handle a user joining the session's voice channel + */ +async function handleUserJoin( + session: ReturnType, + member: GuildMember +): Promise { + if (!session) return; + + try { + await session.client.addParticipant(session.sessionId, { + discordId: member.id, + username: member.user.username, + displayName: member.displayName, + avatar: member.user.avatar || undefined, + source: 'discord', + }); + + console.log(`Added participant ${member.displayName} to session ${session.sessionId}`); + } catch (error) { + console.error('Failed to add participant:', error); + } +} + +/** + * Handle a user leaving the session's voice channel + */ +async function handleUserLeave( + session: ReturnType, + member: GuildMember +): Promise { + if (!session) return; + + try { + await session.client.removeParticipant(session.sessionId, member.id); + + // Clear any active signals for this user + clearUserSignal(session.guildId, member.id); + + console.log(`Removed participant ${member.displayName} from session ${session.sessionId}`); + } catch (error) { + console.error('Failed to remove participant:', error); + } +} + +/** + * Sync all current voice channel members as participants + */ +export async function syncVoiceChannelMembers( + guildId: string, + voiceChannelId: string +): Promise { + const client = getClient(); + if (!client) return; + + const session = sessionRegistry.get(guildId); + if (!session) return; + + try { + const guild = await client.guilds.fetch(guildId); + const voiceChannel = (await guild.channels.fetch(voiceChannelId)) as VoiceChannel; + + if (!voiceChannel) { + console.error(`Voice channel ${voiceChannelId} not found`); + return; + } + + // Add all current members (excluding bots) + for (const [, member] of voiceChannel.members) { + if (member.user.bot) continue; + + try { + await session.client.addParticipant(session.sessionId, { + discordId: member.id, + username: member.user.username, + displayName: member.displayName, + avatar: member.user.avatar || undefined, + source: 'discord', + }); + } catch (error) { + // Participant may already exist + console.log(`Participant ${member.displayName} may already be in session`); + } + } + + console.log(`Synced ${voiceChannel.members.size} members to session`); + } catch (error) { + console.error('Failed to sync voice channel members:', error); + } +} + +/** + * Check if a user is in the session's voice channel + */ +export function isUserInSessionVC(guildId: string, userId: string): boolean { + const client = getClient(); + if (!client) return false; + + const session = sessionRegistry.get(guildId); + if (!session) return false; + + const guild = client.guilds.cache.get(guildId); + if (!guild) return false; + + const member = guild.members.cache.get(userId); + if (!member) return false; + + return member.voice.channelId === session.voiceChannelId; +} + +/** + * Get the text channel for a session + */ +export async function getSessionTextChannel(guildId: string): Promise { + const client = getClient(); + if (!client) return null; + + const session = sessionRegistry.get(guildId); + if (!session) return null; + + try { + const guild = await client.guilds.fetch(guildId); + const channel = await guild.channels.fetch(session.channelId); + return channel as TextChannel; + } catch (error) { + console.error('Failed to get session text channel:', error); + return null; + } +} diff --git a/server/src/utils/jwt.ts b/server/src/utils/jwt.ts new file mode 100644 index 0000000..c4f2c1a --- /dev/null +++ b/server/src/utils/jwt.ts @@ -0,0 +1,45 @@ +import jwt from 'jsonwebtoken'; + +export interface JWTPayload { + sub: string; // Discord user ID + username: string; + discriminator: string; + avatar?: string; + email?: string; + iat?: number; + exp?: number; +} + +export interface DecodedToken extends JWTPayload { + iat: number; + exp: number; +} + +const JWT_SECRET = process.env.JWT_SECRET || 'development-secret-change-in-production'; +const TOKEN_EXPIRY = '24h'; + +export function signToken(payload: Omit): string { + return jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }); +} + +export function verifyToken(token: string): DecodedToken | null { + try { + return jwt.verify(token, JWT_SECRET) as DecodedToken; + } catch (error) { + return null; + } +} + +export function decodeToken(token: string): DecodedToken | null { + try { + return jwt.decode(token) as DecodedToken; + } catch (error) { + return null; + } +} + +export function getTokenExpiration(token: string): Date | null { + const decoded = decodeToken(token); + if (!decoded?.exp) return null; + return new Date(decoded.exp * 1000); +} diff --git a/server/src/webhooks/handlers.ts b/server/src/webhooks/handlers.ts new file mode 100644 index 0000000..11bee48 --- /dev/null +++ b/server/src/webhooks/handlers.ts @@ -0,0 +1,227 @@ +import { TextChannel } from 'discord.js'; +import { ActiveSession, sessionRegistry, SignalType } from '../services/rallyround'; +import { getClient } from '../services/discord'; +import { clearSessionSignals } from '../commands'; +import { leaveSessionVoiceChannel } from '../services/voice'; +import { Emoji } from '../commands/responses'; + +// Event handler type +type EventHandler = (session: ActiveSession, data: any) => Promise; + +// Map of event types to handlers +const eventHandlers: Record = { + signal_raised: handleSignalRaised, + speaker_changed: handleSpeakerChanged, + mode_changed: handleModeChanged, + recording_changed: handleRecordingChanged, + agenda_advanced: handleAgendaAdvanced, + session_ended: handleSessionEnded, +}; + +/** + * Route webhook event to appropriate handler + */ +export async function handleWebhookEvent( + event: string, + session: ActiveSession, + data: any +): Promise { + const handler = eventHandlers[event]; + + if (handler) { + await handler(session, data); + } else { + console.log(`Unknown webhook event: ${event}`); + } +} + +/** + * Get text channel for session + */ +async function getTextChannel(session: ActiveSession): Promise { + const client = getClient(); + if (!client) return null; + + try { + const guild = await client.guilds.fetch(session.guildId); + const channel = await guild.channels.fetch(session.channelId); + return channel as TextChannel; + } catch (error) { + console.error('Failed to get text channel:', error); + return null; + } +} + +/** + * Handle signal_raised event + */ +async function handleSignalRaised(session: ActiveSession, data: any): Promise { + const { discordId, signal, priority, queuePosition } = data; + + const channel = await getTextChannel(session); + if (!channel) return; + + const client = getClient(); + if (!client) return; + + try { + const guild = await client.guilds.fetch(session.guildId); + const member = await guild.members.fetch(discordId); + + const signalEmojis: Record = { + hand: Emoji.hand, + point_of_order: Emoji.pointOfOrder, + clarification: Emoji.clarification, + information: Emoji.information, + question: Emoji.question, + agree: Emoji.agree, + disagree: Emoji.disagree, + away: Emoji.away, + back: Emoji.back, + }; + + const emoji = signalEmojis[signal as SignalType] || Emoji.hand; + const positionText = queuePosition ? ` (Queue position: ${queuePosition})` : ''; + + await channel.send(`${emoji} **${member.displayName}** raised ${signal.replace('_', ' ')}${positionText}`); + + // Play sound effect if enabled + if (session.soundEffectsEnabled) { + // TODO: Play sound via audio service + // audioService.playSignalSound(session.guildId, signal); + } + } catch (error) { + console.error('Failed to handle signal_raised:', error); + } +} + +/** + * Handle speaker_changed event + */ +async function handleSpeakerChanged(session: ActiveSession, data: any): Promise { + const { currentSpeaker, previousSpeaker, remainingQueue } = data; + + const channel = await getTextChannel(session); + if (!channel) return; + + let message = ''; + + if (previousSpeaker) { + const duration = Math.round(previousSpeaker.duration / 60); + message += `Previous speaker finished (${duration}m). `; + } + + if (currentSpeaker) { + message += `${Emoji.speaker} **${currentSpeaker.displayName}** now has the floor.`; + if (remainingQueue > 0) { + message += ` (${remainingQueue} remaining in queue)`; + } + } else { + message += `${Emoji.info} The floor is open.`; + } + + await channel.send(message); + + // Play transition sound if enabled + if (session.soundEffectsEnabled) { + // TODO: Play sound via audio service + // audioService.playSound(session.guildId, 'swoosh'); + } +} + +/** + * Handle mode_changed event + */ +async function handleModeChanged(session: ActiveSession, data: any): Promise { + const { mode, previousMode } = data; + + const channel = await getTextChannel(session); + if (!channel) return; + + await channel.send( + `${Emoji.mode} Session mode changed from **${previousMode}** to **${mode}**` + ); +} + +/** + * Handle recording_changed event + */ +async function handleRecordingChanged(session: ActiveSession, data: any): Promise { + const { isRecording } = data; + + const channel = await getTextChannel(session); + if (!channel) return; + + if (isRecording) { + await channel.send(`${Emoji.recording} **Recording started**`); + } else { + await channel.send(`${Emoji.stop} **Recording stopped**`); + } + + // Play recording sound if enabled + if (session.soundEffectsEnabled) { + // TODO: Play sound via audio service + // const sound = isRecording ? 'record-start' : 'record-stop'; + // audioService.playSound(session.guildId, sound); + } +} + +/** + * Handle agenda_advanced event + */ +async function handleAgendaAdvanced(session: ActiveSession, data: any): Promise { + const { completedItem, currentItem, completedCount, totalCount } = data; + + const channel = await getTextChannel(session); + if (!channel) return; + + let message = ''; + + if (completedItem) { + message += `${Emoji.completed} Completed: **${completedItem.title}**\n`; + } + + if (currentItem) { + message += `${Emoji.active} Now discussing: **${currentItem.title}**`; + if (currentItem.duration) { + message += ` (${currentItem.duration}m)`; + } + } else { + message += `${Emoji.completed} All agenda items completed! (${completedCount}/${totalCount})`; + } + + await channel.send(message); + + // Play completion sound if enabled + if (session.soundEffectsEnabled && completedItem) { + // TODO: Play sound via audio service + // audioService.playSound(session.guildId, 'ding'); + } +} + +/** + * Handle session_ended event + */ +async function handleSessionEnded(session: ActiveSession, data: any): Promise { + const { duration, participantCount, totalSignals, agendaItemsCompleted } = data; + + const channel = await getTextChannel(session); + if (channel) { + const durationMinutes = Math.round(duration / 60); + + await channel.send( + `${Emoji.stop} **Session ended**\n` + + `Duration: ${durationMinutes} minutes\n` + + `Participants: ${participantCount}\n` + + `Total Signals: ${totalSignals}\n` + + `Agenda Items Completed: ${agendaItemsCompleted || 0}` + ); + } + + // Clean up + clearSessionSignals(session.guildId); + leaveSessionVoiceChannel(session.guildId); + sessionRegistry.remove(session.guildId); + + console.log(`Session ${session.sessionId} ended and cleaned up`); +} diff --git a/server/src/webhooks/index.ts b/server/src/webhooks/index.ts new file mode 100644 index 0000000..05975e4 --- /dev/null +++ b/server/src/webhooks/index.ts @@ -0,0 +1,36 @@ +import { Router, Request, Response } from 'express'; +import { webhookAuthMiddleware, WebhookPayload } from './security'; +import { handleWebhookEvent } from './handlers'; +import { ActiveSession } from '../services/rallyround'; + +const router = Router(); + +/** + * POST /webhooks/rallyround + * Receive webhooks from RallyRound + */ +router.post('/rallyround', webhookAuthMiddleware, async (req: Request, res: Response) => { + const payload = req.body as WebhookPayload; + const session = (req as any).session as ActiveSession; + + console.log(`Received webhook: ${payload.event} for session ${payload.sessionId}`); + + try { + await handleWebhookEvent(payload.event, session, payload.data); + + res.json({ + success: true, + message: 'Webhook processed', + }); + } catch (error) { + console.error('Webhook processing error:', error); + + res.status(500).json({ + success: false, + error: 'processing_error', + message: 'Failed to process webhook', + }); + } +}); + +export default router; diff --git a/server/src/webhooks/security.ts b/server/src/webhooks/security.ts new file mode 100644 index 0000000..52736d3 --- /dev/null +++ b/server/src/webhooks/security.ts @@ -0,0 +1,118 @@ +import crypto from 'crypto'; +import { Request, Response, NextFunction } from 'express'; +import { sessionRegistry } from '../services/rallyround'; + +// Maximum age for webhook timestamps (5 minutes) +const MAX_TIMESTAMP_AGE_MS = 5 * 60 * 1000; + +export interface WebhookPayload { + event: string; + sessionId: string; + timestamp: string; + data: any; +} + +/** + * Verify HMAC-SHA256 signature of webhook request + */ +export function verifySignature( + payload: string, + signature: string, + secret: string +): boolean { + const expectedSignature = crypto + .createHmac('sha256', secret) + .update(payload) + .digest('hex'); + + // Use timing-safe comparison to prevent timing attacks + try { + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); + } catch { + return false; + } +} + +/** + * Validate webhook timestamp is within acceptable range + */ +export function isTimestampValid(timestamp: string): boolean { + const webhookTime = new Date(timestamp).getTime(); + const now = Date.now(); + + // Check if timestamp is valid + if (isNaN(webhookTime)) { + return false; + } + + // Check if timestamp is within acceptable range + return Math.abs(now - webhookTime) <= MAX_TIMESTAMP_AGE_MS; +} + +/** + * Express middleware to verify webhook requests + */ +export function webhookAuthMiddleware( + req: Request, + res: Response, + next: NextFunction +): void { + const signature = req.headers['x-rallyround-signature'] as string; + const timestamp = req.headers['x-rallyround-timestamp'] as string; + + if (!signature || !timestamp) { + res.status(401).json({ + error: 'missing_headers', + message: 'Missing required webhook headers', + }); + return; + } + + // Validate timestamp + if (!isTimestampValid(timestamp)) { + res.status(401).json({ + error: 'stale_timestamp', + message: 'Webhook timestamp is too old or invalid', + }); + return; + } + + const payload = req.body as WebhookPayload; + + if (!payload.sessionId) { + res.status(400).json({ + error: 'missing_session_id', + message: 'Session ID is required', + }); + return; + } + + // Get the session to find the webhook secret + const session = sessionRegistry.getBySessionId(payload.sessionId); + + if (!session) { + res.status(404).json({ + error: 'session_not_found', + message: 'Session not found', + }); + return; + } + + // Verify signature + const rawBody = JSON.stringify(req.body); + if (!verifySignature(rawBody, signature, session.webhookSecret)) { + res.status(401).json({ + error: 'invalid_signature', + message: 'Invalid webhook signature', + }); + return; + } + + // Attach session to request for use in handlers + (req as any).session = session; + + next(); +}