From 2a75237e90237c729a73b23016ea8f8815204085 Mon Sep 17 00:00:00 2001 From: cottongin Date: Sun, 2 Nov 2025 16:06:31 -0500 Subject: [PATCH] IDK, it's working and we're moving on --- API_QUICK_REFERENCE.md | 474 +++++++++++++++ BOT_INTEGRATION.md | 667 ++++++++++++++++++++++ README.md | 55 +- SESSION_END_QUICK_START.md | 105 ++++ SESSION_END_WEBSOCKET.md | 306 ++++++++++ SESSION_START_WEBSOCKET.md | 361 ++++++++++++ WEBSOCKET_FLOW_DIAGRAM.md | 256 +++++++++ WEBSOCKET_SUBSCRIPTION_GUIDE.md | 310 ++++++++++ WEBSOCKET_TESTING.md | 239 ++++++++ backend/database.js | 56 ++ backend/package.json | 3 +- backend/routes/sessions.js | 159 +++++- backend/routes/votes.js | 198 +++++++ backend/routes/webhooks.js | 271 +++++++++ backend/server.js | 16 +- backend/test-websocket.js | 122 ++++ backend/utils/webhooks.js | 151 +++++ backend/utils/websocket-manager.js | 333 +++++++++++ frontend/src/components/RoomCodeModal.jsx | 120 ++++ frontend/src/config/branding.js | 2 +- frontend/src/pages/Home.jsx | 5 + frontend/src/pages/Picker.jsx | 177 ++++-- test-session-end-websocket.js | 146 +++++ test-webhook-simple.sh | 182 ++++++ test-webhook.js | 294 ++++++++++ test-webhook.sh | 268 +++++++++ 26 files changed, 5231 insertions(+), 45 deletions(-) create mode 100644 API_QUICK_REFERENCE.md create mode 100644 BOT_INTEGRATION.md create mode 100644 SESSION_END_QUICK_START.md create mode 100644 SESSION_END_WEBSOCKET.md create mode 100644 SESSION_START_WEBSOCKET.md create mode 100644 WEBSOCKET_FLOW_DIAGRAM.md create mode 100644 WEBSOCKET_SUBSCRIPTION_GUIDE.md create mode 100644 WEBSOCKET_TESTING.md create mode 100644 backend/routes/votes.js create mode 100644 backend/routes/webhooks.js create mode 100644 backend/test-websocket.js create mode 100644 backend/utils/webhooks.js create mode 100644 backend/utils/websocket-manager.js create mode 100644 frontend/src/components/RoomCodeModal.jsx create mode 100755 test-session-end-websocket.js create mode 100755 test-webhook-simple.sh create mode 100644 test-webhook.js create mode 100755 test-webhook.sh diff --git a/API_QUICK_REFERENCE.md b/API_QUICK_REFERENCE.md new file mode 100644 index 0000000..a15b317 --- /dev/null +++ b/API_QUICK_REFERENCE.md @@ -0,0 +1,474 @@ +# API Quick Reference + +Quick reference for Live Voting, WebSocket, and Webhook endpoints. + +## Base URL + +``` +http://localhost:5000/api +``` + +## Authentication + +All endpoints require JWT authentication: + +``` +Authorization: Bearer YOUR_JWT_TOKEN +``` + +Get token via: +```bash +POST /api/auth/login +Body: { "key": "YOUR_ADMIN_KEY" } +``` + +--- + +## WebSocket Events + +### Connect to WebSocket + +``` +ws://localhost:5000/api/sessions/live +``` + +**Message Protocol**: + +```json +// Authenticate +{ "type": "auth", "token": "YOUR_JWT_TOKEN" } + +// Subscribe to session +{ "type": "subscribe", "sessionId": 123 } + +// Unsubscribe +{ "type": "unsubscribe", "sessionId": 123 } + +// Heartbeat +{ "type": "ping" } +``` + +**Server Events**: + +```json +// Auth success +{ "type": "auth_success", "message": "..." } + +// Subscribed +{ "type": "subscribed", "sessionId": 123 } + +// Session started +{ + "type": "session.started", + "timestamp": "2025-11-01T...", + "data": { + "session": { "id": 123, "is_active": 1, "created_at": "...", "notes": "..." } + } +} + +// Game added +{ + "type": "game.added", + "timestamp": "2025-11-01T...", + "data": { + "session": { "id": 123, "is_active": true, "games_played": 5 }, + "game": { "id": 45, "title": "Fibbage 4", ... } + } +} + +// Session ended +{ + "type": "session.ended", + "timestamp": "2025-11-01T...", + "data": { + "session": { "id": 123, "is_active": 0, "games_played": 5 } + } +} + +// Pong +{ "type": "pong" } +``` + +--- + +## Live Voting + +### Submit Live Vote + +```http +POST /api/votes/live +``` + +**Request Body**: +```json +{ + "username": "string", + "vote": "up" | "down", + "timestamp": "2025-11-01T20:30:00Z" +} +``` + +**Success Response (200)**: +```json +{ + "success": true, + "message": "Vote recorded successfully", + "session": { "id": 123, "games_played": 5 }, + "game": { + "id": 45, + "title": "Fibbage 4", + "upvotes": 46, + "downvotes": 3, + "popularity_score": 43 + }, + "vote": { + "username": "TestUser", + "type": "up", + "timestamp": "2025-11-01T20:30:00Z" + } +} +``` + +**Error Responses**: +- `400` - Invalid payload +- `404` - No active session or timestamp doesn't match any game +- `409` - Duplicate vote (within 1 second) + +--- + +## Webhooks + +### List Webhooks + +```http +GET /api/webhooks +``` + +**Response**: +```json +[ + { + "id": 1, + "name": "Kosmi Bot", + "url": "http://bot-url/webhook", + "events": ["game.added"], + "enabled": true, + "created_at": "2025-11-01T20:00:00Z" + } +] +``` + +### Get Single Webhook + +```http +GET /api/webhooks/:id +``` + +### Create Webhook + +```http +POST /api/webhooks +``` + +**Request Body**: +```json +{ + "name": "Kosmi Bot", + "url": "http://bot-url/webhook", + "secret": "your_shared_secret", + "events": ["game.added"] +} +``` + +**Response (201)**: +```json +{ + "id": 1, + "name": "Kosmi Bot", + "url": "http://bot-url/webhook", + "events": ["game.added"], + "enabled": true, + "created_at": "2025-11-01T20:00:00Z", + "message": "Webhook created successfully" +} +``` + +### Update Webhook + +```http +PATCH /api/webhooks/:id +``` + +**Request Body** (all fields optional): +```json +{ + "name": "Updated Name", + "url": "http://new-url/webhook", + "secret": "new_secret", + "events": ["game.added"], + "enabled": false +} +``` + +### Delete Webhook + +```http +DELETE /api/webhooks/:id +``` + +**Response (200)**: +```json +{ + "message": "Webhook deleted successfully", + "webhookId": 1 +} +``` + +### Test Webhook + +```http +POST /api/webhooks/test/:id +``` + +Sends a test `game.added` event to verify webhook is working. + +**Response (200)**: +```json +{ + "message": "Test webhook sent", + "note": "Check webhook_logs table for delivery status" +} +``` + +### Get Webhook Logs + +```http +GET /api/webhooks/:id/logs?limit=50 +``` + +**Response**: +```json +[ + { + "id": 1, + "webhook_id": 1, + "event_type": "game.added", + "payload": { /* full payload */ }, + "response_status": 200, + "error_message": null, + "created_at": "2025-11-01T20:30:00Z" + } +] +``` + +--- + +## Webhook Payloads + +### Event: `session.started` + +Sent when a new session is created. + +**Headers**: +- `Content-Type: application/json` +- `X-Webhook-Signature: sha256=` +- `X-Webhook-Event: session.started` +- `User-Agent: Jackbox-Game-Picker-Webhook/1.0` + +**Payload**: +```json +{ + "event": "session.started", + "timestamp": "2025-11-01T20:00:00Z", + "data": { + "session": { + "id": 123, + "is_active": 1, + "created_at": "2025-11-01T20:00:00Z", + "notes": "Friday Game Night" + } + } +} +``` + +### Event: `game.added` + +Sent when a game is added to an active session. + +**Headers**: +- `Content-Type: application/json` +- `X-Webhook-Signature: sha256=` +- `X-Webhook-Event: game.added` +- `User-Agent: Jackbox-Game-Picker-Webhook/1.0` + +**Payload**: +```json +{ + "event": "game.added", + "timestamp": "2025-11-01T20:30:00Z", + "data": { + "session": { + "id": 123, + "is_active": true, + "games_played": 5 + }, + "game": { + "id": 45, + "title": "Fibbage 4", + "pack_name": "The Jackbox Party Pack 9", + "min_players": 2, + "max_players": 8, + "manually_added": false + } + } +} +``` + +### Event: `session.ended` + +Sent when a session is closed/ended. + +**Headers**: +- `Content-Type: application/json` +- `X-Webhook-Signature: sha256=` +- `X-Webhook-Event: session.ended` +- `User-Agent: Jackbox-Game-Picker-Webhook/1.0` + +**Payload**: +```json +{ + "event": "session.ended", + "timestamp": "2025-11-01T20:30:00Z", + "data": { + "session": { + "id": 123, + "is_active": 0, + "games_played": 5 + } + } +} +``` + +--- + +## cURL Examples + +### Submit Vote + +```bash +curl -X POST "http://localhost:5000/api/votes/live" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "username": "TestUser", + "vote": "up", + "timestamp": "2025-11-01T20:30:00Z" + }' +``` + +### Create Webhook + +```bash +curl -X POST "http://localhost:5000/api/webhooks" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Kosmi Bot", + "url": "http://localhost:3001/webhook/jackbox", + "secret": "test_secret_123", + "events": ["game.added"] + }' +``` + +### Test Webhook + +```bash +curl -X POST "http://localhost:5000/api/webhooks/test/1" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +### View Webhook Logs + +```bash +curl -X GET "http://localhost:5000/api/webhooks/1/logs?limit=10" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +--- + +## Webhook Signature Verification + +**Node.js Example**: + +```javascript +const crypto = require('crypto'); + +function verifyWebhookSignature(signature, payload, secret) { + if (!signature || !signature.startsWith('sha256=')) { + return false; + } + + const expectedSignature = 'sha256=' + crypto + .createHmac('sha256', secret) + .update(JSON.stringify(payload)) + .digest('hex'); + + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); +} + +// In your webhook endpoint: +app.post('/webhook/jackbox', (req, res) => { + const signature = req.headers['x-webhook-signature']; + const secret = process.env.WEBHOOK_SECRET; + + if (!verifyWebhookSignature(signature, req.body, secret)) { + return res.status(401).send('Invalid signature'); + } + + // Process webhook... + res.status(200).send('OK'); +}); +``` + +--- + +## Error Codes + +| Code | Meaning | +|------|---------| +| 200 | Success | +| 201 | Created | +| 400 | Bad Request - Invalid payload | +| 401 | Unauthorized - Invalid JWT or signature | +| 404 | Not Found - Resource doesn't exist | +| 409 | Conflict - Duplicate vote | +| 500 | Internal Server Error | + +--- + +## Rate Limiting + +Currently no rate limiting is implemented. Consider implementing rate limiting in production: +- Per IP address +- Per JWT token +- Per webhook endpoint + +--- + +## Best Practices + +1. **Always verify webhook signatures** before processing +2. **Use HTTPS** for webhook URLs in production +3. **Store secrets securely** in environment variables +4. **Respond quickly** to webhooks (< 5 seconds) +5. **Log webhook activity** for debugging +6. **Handle retries gracefully** if implementing retry logic +7. **Validate timestamps** to prevent replay attacks + +--- + +For detailed documentation, see [BOT_INTEGRATION.md](BOT_INTEGRATION.md) + diff --git a/BOT_INTEGRATION.md b/BOT_INTEGRATION.md new file mode 100644 index 0000000..349a165 --- /dev/null +++ b/BOT_INTEGRATION.md @@ -0,0 +1,667 @@ +# Bot Integration Guide + +This guide explains how to integrate your bot with the Jackbox Game Picker API for live voting and game notifications. + +## Table of Contents + +1. [Live Voting (Bot โ†’ API)](#live-voting-bot--api) +2. [Game Notifications (API โ†’ Bot)](#game-notifications-api--bot) + - [WebSocket Integration (Recommended)](#websocket-integration-recommended) + - [Webhook Integration](#webhook-integration) +3. [Webhook Management](#webhook-management) +4. [Testing](#testing) + +--- + +## Live Voting (Bot โ†’ API) + +Your bot can send real-time votes to the API when it detects "thisgame++" or "thisgame--" in Kosmi chat. + +### Endpoint + +``` +POST /api/votes/live +``` + +### Authentication + +Requires JWT token in Authorization header: + +``` +Authorization: Bearer YOUR_JWT_TOKEN +``` + +### Request Body + +```json +{ + "username": "string", // Username of the voter + "vote": "up" | "down", // "up" for thisgame++, "down" for thisgame-- + "timestamp": "string" // ISO 8601 timestamp (e.g., "2025-11-01T20:30:00Z") +} +``` + +### Response (Success) + +```json +{ + "success": true, + "message": "Vote recorded successfully", + "session": { + "id": 123, + "games_played": 5 + }, + "game": { + "id": 45, + "title": "Fibbage 4", + "upvotes": 46, + "downvotes": 3, + "popularity_score": 43 + }, + "vote": { + "username": "TestUser", + "type": "up", + "timestamp": "2025-11-01T20:30:00Z" + } +} +``` + +### Error Responses + +- **400 Bad Request**: Invalid payload or timestamp format +- **404 Not Found**: No active session or timestamp doesn't match any game +- **409 Conflict**: Duplicate vote (within 1 second of previous vote from same user) +- **500 Internal Server Error**: Server error + +### Example Implementation (Node.js) + +```javascript +// When bot detects "thisgame++" or "thisgame--" in Kosmi chat +async function handleVote(username, message) { + const isUpvote = message.includes('thisgame++'); + const isDownvote = message.includes('thisgame--'); + + if (!isUpvote && !isDownvote) return; + + try { + const response = await fetch('http://your-api-url/api/votes/live', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.JWT_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + username: username, + vote: isUpvote ? 'up' : 'down', + timestamp: new Date().toISOString() + }) + }); + + const data = await response.json(); + + if (response.ok) { + console.log(`Vote recorded for ${data.game.title}: ${data.game.upvotes}๐Ÿ‘ ${data.game.downvotes}๐Ÿ‘Ž`); + } else { + console.error('Vote failed:', data.error); + } + } catch (error) { + console.error('Error sending vote:', error); + } +} +``` + +### Important Notes + +- **Deduplication**: Votes from the same user within 1 second are automatically rejected to prevent spam +- **Timestamp Matching**: The API matches the vote timestamp to the correct game based on when games were played +- **Active Session Required**: Votes can only be recorded when there's an active session with games played + +--- + +## Game Notifications (API โ†’ Bot) + +The API can notify your bot when games are added to a session, allowing you to announce "Coming up next: Game Title!" in Kosmi chat. + +There are two integration methods available: + +1. **WebSocket (Recommended)**: Real-time bidirectional communication, simpler setup, works through firewalls +2. **Webhooks**: Traditional HTTP callbacks, good for serverless/stateless integrations + +### WebSocket Integration (Recommended) + +WebSocket provides real-time event streaming from the API to your bot. This is the recommended approach as it: + +- Works through firewalls and NAT (bot initiates connection) +- No need to expose inbound ports +- Automatic reconnection on disconnect +- Lower latency than webhooks +- Bidirectional communication + +#### Connection Flow + +1. Bot connects to WebSocket endpoint +2. Bot authenticates with JWT token +3. Bot subscribes to active session +4. Bot receives `game.added` events in real-time + +#### WebSocket Endpoint + +``` +wss://your-api-url/api/sessions/live +``` + +#### Message Protocol + +All messages are JSON-formatted. + +**Client โ†’ Server Messages:** + +```json +// 1. Authenticate (first message after connecting) +{ + "type": "auth", + "token": "YOUR_JWT_TOKEN" +} + +// 2. Subscribe to a session +{ + "type": "subscribe", + "sessionId": 123 +} + +// 3. Unsubscribe from a session +{ + "type": "unsubscribe", + "sessionId": 123 +} + +// 4. Heartbeat (keep connection alive) +{ + "type": "ping" +} +``` + +**Server โ†’ Client Messages:** + +```json +// Authentication success +{ + "type": "auth_success", + "message": "Authenticated successfully" +} + +// Authentication failure +{ + "type": "auth_error", + "message": "Invalid or expired token" +} + +// Subscription confirmed +{ + "type": "subscribed", + "sessionId": 123, + "message": "Subscribed to session 123" +} + +// Game added event +{ + "type": "game.added", + "timestamp": "2025-11-01T20:30:00Z", + "data": { + "session": { + "id": 123, + "is_active": true, + "games_played": 5 + }, + "game": { + "id": 45, + "title": "Fibbage 4", + "pack_name": "The Jackbox Party Pack 9", + "min_players": 2, + "max_players": 8, + "manually_added": false + } + } +} + +// Heartbeat response +{ + "type": "pong" +} + +// Error +{ + "type": "error", + "message": "Error description" +} +``` + +#### Example Implementation (Node.js) + +```javascript +const WebSocket = require('ws'); + +class JackboxWebSocketClient { + constructor(apiURL, jwtToken) { + this.apiURL = apiURL.replace(/^http/, 'ws') + '/api/sessions/live'; + this.jwtToken = jwtToken; + this.ws = null; + this.reconnectDelay = 1000; + this.maxReconnectDelay = 30000; + } + + connect() { + this.ws = new WebSocket(this.apiURL); + + this.ws.on('open', () => { + console.log('WebSocket connected'); + this.authenticate(); + this.startHeartbeat(); + }); + + this.ws.on('message', (data) => { + this.handleMessage(JSON.parse(data)); + }); + + this.ws.on('close', () => { + console.log('WebSocket disconnected, reconnecting...'); + this.reconnect(); + }); + + this.ws.on('error', (err) => { + console.error('WebSocket error:', err); + }); + } + + authenticate() { + this.send({ type: 'auth', token: this.jwtToken }); + } + + subscribe(sessionId) { + this.send({ type: 'subscribe', sessionId }); + } + + send(message) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(message)); + } + } + + handleMessage(message) { + switch (message.type) { + case 'auth_success': + console.log('Authenticated successfully'); + // Get active session and subscribe + this.getActiveSessionAndSubscribe(); + break; + + case 'auth_error': + console.error('Authentication failed:', message.message); + break; + + case 'subscribed': + console.log('Subscribed to session:', message.sessionId); + break; + + case 'game.added': + this.handleGameAdded(message.data); + break; + + case 'pong': + // Heartbeat response + break; + + case 'error': + console.error('Server error:', message.message); + break; + } + } + + handleGameAdded(data) { + const { game } = data; + const announcement = `๐ŸŽฎ Coming up next: ${game.title}!`; + + // Send to your chat platform + this.broadcastToChat(announcement); + } + + startHeartbeat() { + setInterval(() => { + this.send({ type: 'ping' }); + }, 30000); // Every 30 seconds + } + + reconnect() { + setTimeout(() => { + this.connect(); + this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay); + }, this.reconnectDelay); + } + + async getActiveSessionAndSubscribe() { + // Fetch active session from REST API + const response = await fetch(`${this.apiURL.replace('/api/sessions/live', '')}/api/sessions/active`, { + headers: { 'Authorization': `Bearer ${this.jwtToken}` } + }); + + if (response.ok) { + const session = await response.json(); + if (session && session.id) { + this.subscribe(session.id); + } + } + } + + broadcastToChat(message) { + // Implement your chat platform integration here + console.log('Broadcasting:', message); + } +} + +// Usage +const client = new JackboxWebSocketClient('https://your-api-url', 'YOUR_JWT_TOKEN'); +client.connect(); +``` + +#### Example Implementation (Go) + +See the reference implementation in `irc-kosmi-relay/bridge/jackbox/websocket_client.go`. + +--- + +### Webhook Integration + +Webhooks are HTTP callbacks sent from the API to your bot when events occur. This is an alternative to WebSocket for bots that prefer stateless integrations. + +#### Webhook Event: `game.added` + +Triggered whenever a game is added to an active session (either via picker or manual selection). + +### Webhook Payload + +```json +{ + "event": "game.added", + "timestamp": "2025-11-01T20:30:00Z", + "data": { + "session": { + "id": 123, + "is_active": true, + "games_played": 5 + }, + "game": { + "id": 45, + "title": "Fibbage 4", + "pack_name": "The Jackbox Party Pack 9", + "min_players": 2, + "max_players": 8, + "manually_added": false + } + } +} +``` + +### Webhook Headers + +The API sends the following headers with each webhook: + +- `Content-Type: application/json` +- `X-Webhook-Signature: sha256=` - HMAC-SHA256 signature for verification +- `X-Webhook-Event: game.added` - Event type +- `User-Agent: Jackbox-Game-Picker-Webhook/1.0` + +### Signature Verification + +**IMPORTANT**: Always verify the webhook signature to ensure the request is authentic. + +```javascript +const crypto = require('crypto'); + +function verifyWebhookSignature(signature, payload, secret) { + if (!signature || !signature.startsWith('sha256=')) { + return false; + } + + const expectedSignature = 'sha256=' + crypto + .createHmac('sha256', secret) + .update(JSON.stringify(payload)) + .digest('hex'); + + // Use timing-safe comparison + try { + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); + } catch (err) { + return false; + } +} +``` + +### Example Implementation (Express.js) + +```javascript +const express = require('express'); +const crypto = require('crypto'); + +const app = express(); + +// IMPORTANT: Use express.json() with verify option to get raw body +app.use(express.json({ + verify: (req, res, buf) => { + req.rawBody = buf.toString('utf8'); + } +})); + +app.post('/webhook/jackbox', (req, res) => { + const signature = req.headers['x-webhook-signature']; + const secret = process.env.WEBHOOK_SECRET; // Your webhook secret + + // Verify signature + if (!signature || !signature.startsWith('sha256=')) { + return res.status(401).send('Missing or invalid signature'); + } + + const expectedSignature = 'sha256=' + crypto + .createHmac('sha256', secret) + .update(req.rawBody) + .digest('hex'); + + // Timing-safe comparison + try { + if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) { + return res.status(401).send('Invalid signature'); + } + } catch (err) { + return res.status(401).send('Invalid signature'); + } + + // Handle the event + if (req.body.event === 'game.added') { + const game = req.body.data.game; + + // Send message to Kosmi chat + sendKosmiMessage(`๐ŸŽฎ Coming up next: ${game.title}!`); + + console.log(`Announced game: ${game.title} from ${game.pack_name}`); + } + + // Always respond with 200 OK + res.status(200).send('OK'); +}); + +function sendKosmiMessage(message) { + // Your Kosmi chat integration here + console.log('Sending to Kosmi:', message); +} + +app.listen(3001, () => { + console.log('Webhook receiver listening on port 3001'); +}); +``` + +--- + +## Webhook Management + +You can manage webhooks through the API using the following endpoints (all require JWT authentication). + +### List All Webhooks + +```bash +GET /api/webhooks +Authorization: Bearer YOUR_JWT_TOKEN +``` + +### Create Webhook + +```bash +POST /api/webhooks +Authorization: Bearer YOUR_JWT_TOKEN +Content-Type: application/json + +{ + "name": "Kosmi Bot", + "url": "http://your-bot-url/webhook/jackbox", + "secret": "your_shared_secret_key", + "events": ["game.added"] +} +``` + +### Update Webhook + +```bash +PATCH /api/webhooks/:id +Authorization: Bearer YOUR_JWT_TOKEN +Content-Type: application/json + +{ + "enabled": false // Disable webhook +} +``` + +### Delete Webhook + +```bash +DELETE /api/webhooks/:id +Authorization: Bearer YOUR_JWT_TOKEN +``` + +### Test Webhook + +```bash +POST /api/webhooks/test/:id +Authorization: Bearer YOUR_JWT_TOKEN +``` + +Sends a test `game.added` event to verify your webhook is working. + +### View Webhook Logs + +```bash +GET /api/webhooks/:id/logs?limit=50 +Authorization: Bearer YOUR_JWT_TOKEN +``` + +Returns recent webhook delivery attempts with status codes and errors. + +--- + +## Testing + +### Test Live Voting + +```bash +# Get your JWT token first +curl -X POST "http://localhost:5000/api/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"apiKey": "YOUR_API_KEY"}' + +# Send a test vote +curl -X POST "http://localhost:5000/api/votes/live" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "username": "TestUser", + "vote": "up", + "timestamp": "2025-11-01T20:30:00Z" + }' +``` + +### Test Webhooks + +```bash +# Create a webhook +curl -X POST "http://localhost:5000/api/webhooks" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Test Webhook", + "url": "http://localhost:3001/webhook/jackbox", + "secret": "test_secret_123", + "events": ["game.added"] + }' + +# Test the webhook +curl -X POST "http://localhost:5000/api/webhooks/test/1" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" + +# Check webhook logs +curl -X GET "http://localhost:5000/api/webhooks/1/logs" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +--- + +## Available Events + +Currently supported webhook events: + +- `game.added` - Triggered when a game is added to an active session + +More events may be added in the future (e.g., `session.started`, `session.ended`, `vote.recorded`). + +--- + +## Security Best Practices + +1. **Always verify webhook signatures** - Never trust webhook payloads without verification +2. **Use HTTPS in production** - Webhook URLs should use HTTPS to prevent man-in-the-middle attacks +3. **Keep secrets secure** - Store webhook secrets in environment variables, never in code +4. **Implement rate limiting** - Protect your webhook endpoints from abuse +5. **Log webhook activity** - Keep logs of webhook deliveries for debugging +6. **Use strong secrets** - Generate cryptographically secure random strings for webhook secrets + +--- + +## Troubleshooting + +### Votes Not Being Recorded + +- Check that there's an active session with games played +- Verify the timestamp is within the timeframe of a played game +- Ensure you're not sending duplicate votes within 1 second +- Check API logs for error messages + +### Webhooks Not Being Received + +- Verify your webhook URL is publicly accessible +- Check webhook logs via `/api/webhooks/:id/logs` +- Test with `ngrok` or similar tool if developing locally +- Ensure your webhook endpoint responds with 200 OK +- Check that webhook is enabled in the database + +### Signature Verification Failing + +- Ensure you're using the raw request body for signature verification +- Check that the secret matches what's stored in the database +- Verify you're using HMAC-SHA256 algorithm +- Make sure to prefix with "sha256=" when comparing + +--- + +## Support + +For issues or questions, contact: cottongin@cottongin.xyz + diff --git a/README.md b/README.md index a7ff006..c1ce688 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,18 @@ A full-stack web application that helps groups pick games to play from various J - Automatically matches votes to games based on timestamps - Updates popularity scores across sessions +- **Live Voting API**: Real-time vote processing from external bots + - Accept live votes via REST API + - Automatic deduplication (1-second window) + - Timestamp-based game matching + - JWT authentication for security + +- **Webhook System**: Notify external services of events + - Send notifications when games are added to sessions + - HMAC-SHA256 signature verification + - Webhook management (CRUD operations) + - Delivery logging and testing + ### Public Features - View active session and games currently being played - Browse session history @@ -174,9 +186,13 @@ The manifest is automatically generated during the build process, so you don't n โ”‚ โ”‚ โ”œโ”€โ”€ games.js # Game CRUD and management โ”‚ โ”‚ โ”œโ”€โ”€ sessions.js # Session management โ”‚ โ”‚ โ”œโ”€โ”€ picker.js # Game picker algorithm -โ”‚ โ”‚ โ””โ”€โ”€ stats.js # Statistics endpoints +โ”‚ โ”‚ โ”œโ”€โ”€ stats.js # Statistics endpoints +โ”‚ โ”‚ โ”œโ”€โ”€ votes.js # Live voting endpoint +โ”‚ โ”‚ โ””โ”€โ”€ webhooks.js # Webhook management โ”‚ โ”œโ”€โ”€ middleware/ # Express middleware โ”‚ โ”‚ โ””โ”€โ”€ auth.js # JWT authentication +โ”‚ โ”œโ”€โ”€ utils/ # Utility functions +โ”‚ โ”‚ โ””โ”€โ”€ webhooks.js # Webhook trigger and signature โ”‚ โ”œโ”€โ”€ database.js # SQLite database setup โ”‚ โ”œโ”€โ”€ bootstrap.js # Database initialization โ”‚ โ”œโ”€โ”€ server.js # Express app entry point @@ -242,6 +258,18 @@ The manifest is automatically generated during the build process, so you don't n ### Statistics - `GET /api/stats` - Get overall statistics +### Live Votes +- `POST /api/votes/live` - Submit real-time vote (admin) + +### Webhooks +- `GET /api/webhooks` - List all webhooks (admin) +- `GET /api/webhooks/:id` - Get single webhook (admin) +- `POST /api/webhooks` - Create webhook (admin) +- `PATCH /api/webhooks/:id` - Update webhook (admin) +- `DELETE /api/webhooks/:id` - Delete webhook (admin) +- `POST /api/webhooks/test/:id` - Test webhook (admin) +- `GET /api/webhooks/:id/logs` - Get webhook logs (admin) + ## Usage Guide ### Starting a Game Session @@ -303,21 +331,40 @@ The system will: 3. Update the game's popularity score (+1 for ++, -1 for --) 4. Store the chat log in the database +## Bot Integration + +For integrating external bots (e.g., for live voting and game notifications), see **[BOT_INTEGRATION.md](BOT_INTEGRATION.md)** for detailed documentation including: + +- Live voting API usage +- **WebSocket integration (recommended)** for real-time game notifications +- Webhook setup and verification (alternative to WebSocket) +- Example implementations in Node.js and Go +- Security best practices + ## Database Schema ### games - id, pack_name, title, min_players, max_players, length_minutes - has_audience, family_friendly, game_type, secondary_type -- play_count, popularity_score, enabled, created_at +- play_count, popularity_score, upvotes, downvotes, enabled, created_at ### sessions - id, created_at, closed_at, is_active, notes ### session_games -- id, session_id, game_id, played_at, manually_added +- id, session_id, game_id, played_at, manually_added, status ### chat_logs -- id, session_id, chatter_name, message, timestamp, parsed_vote +- id, session_id, chatter_name, message, timestamp, parsed_vote, message_hash + +### live_votes +- id, session_id, game_id, username, vote_type, timestamp, created_at + +### webhooks +- id, name, url, secret, events, enabled, created_at + +### webhook_logs +- id, webhook_id, event_type, payload, response_status, error_message, created_at ## Game Selection Algorithm diff --git a/SESSION_END_QUICK_START.md b/SESSION_END_QUICK_START.md new file mode 100644 index 0000000..897ce11 --- /dev/null +++ b/SESSION_END_QUICK_START.md @@ -0,0 +1,105 @@ +# Session End Event - Quick Start Guide + +## ๐Ÿš€ Quick Start + +### Listen for Session End Events + +```javascript +const WebSocket = require('ws'); + +const ws = new WebSocket('ws://localhost:5000/api/sessions/live'); + +ws.on('open', () => { + // 1. Authenticate + ws.send(JSON.stringify({ + type: 'auth', + token: 'YOUR_JWT_TOKEN' + })); +}); + +ws.on('message', (data) => { + const msg = JSON.parse(data.toString()); + + if (msg.type === 'auth_success') { + // 2. Subscribe to session + ws.send(JSON.stringify({ + type: 'subscribe', + sessionId: 17 + })); + } + + if (msg.type === 'session.ended') { + // 3. Handle session end + console.log('Session ended!'); + console.log(`Games played: ${msg.data.session.games_played}`); + // Announce to your users here + } +}); +``` + +## ๐Ÿ“ฆ Event Format + +```json +{ + "type": "session.ended", + "timestamp": "2025-11-01T02:30:45.123Z", + "data": { + "session": { + "id": 17, + "is_active": 0, + "games_played": 5 + } + } +} +``` + +## ๐Ÿงช Test It + +```bash +# Get your JWT token first +curl -X POST http://localhost:5000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"key":"YOUR_ADMIN_KEY"}' + +# Run the test script +node test-session-end-websocket.js 17 YOUR_JWT_TOKEN + +# In another terminal, close the session +curl -X POST http://localhost:5000/api/sessions/17/close \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +## ๐Ÿค– Bot Integration + +When your bot receives `session.ended`: + +```javascript +if (msg.type === 'session.ended') { + const { id, games_played } = msg.data.session; + + // Announce to IRC/Discord/etc + bot.announce(`๐ŸŒ™ Game Night has ended! We played ${games_played} games.`); + bot.announce('Thanks for playing!'); +} +``` + +## ๐Ÿ“š Full Documentation + +See [SESSION_END_WEBSOCKET.md](SESSION_END_WEBSOCKET.md) for complete documentation. + +## โšก Key Points + +- โœ… **Instant** - No polling needed +- โœ… **Reliable** - Broadcast to all subscribers +- โœ… **Simple** - Same format as `game.added` +- โœ… **Tested** - Test script included + +## ๐Ÿ”— Related Events + +| Event | When | +|-------|------| +| `session.started` | Session created | +| `game.added` | Game starts | +| `session.ended` | Session closes | +| `vote.received` | Vote cast | + diff --git a/SESSION_END_WEBSOCKET.md b/SESSION_END_WEBSOCKET.md new file mode 100644 index 0000000..48de1cd --- /dev/null +++ b/SESSION_END_WEBSOCKET.md @@ -0,0 +1,306 @@ +# Session End WebSocket Event + +This document describes the `session.ended` WebSocket event that is broadcast when a game session is closed. + +## ๐Ÿ“‹ Event Overview + +When a session is closed (either manually or through timeout), the backend broadcasts a `session.ended` event to all subscribed WebSocket clients. This allows bots and other integrations to react immediately to session closures. + +## ๐Ÿ”Œ WebSocket Connection + +**Endpoint:** `ws://localhost:5000/api/sessions/live` + +**Authentication:** Required (JWT token) + +## ๐Ÿ“จ Event Format + +### Event Type +``` +session.ended +``` + +### Full Message Structure + +```json +{ + "type": "session.ended", + "timestamp": "2025-11-01T02:30:45.123Z", + "data": { + "session": { + "id": 17, + "is_active": 0, + "games_played": 5 + } + } +} +``` + +### Field Descriptions + +| Field | Type | Description | +|-------|------|-------------| +| `type` | string | Always `"session.ended"` | +| `timestamp` | string | ISO 8601 timestamp when the event was generated | +| `data.session.id` | number | The ID of the session that ended | +| `data.session.is_active` | number | Always `0` (inactive) for ended sessions | +| `data.session.games_played` | number | Total number of games played in the session | + +## ๐Ÿš€ Implementation + +### Backend Implementation + +The `session.ended` event is automatically broadcast when: + +1. **Manual Session Close**: Admin closes a session via `POST /api/sessions/:id/close` +2. **Session Timeout**: (If implemented) When a session times out + +**Code Location:** `backend/routes/sessions.js` - `POST /:id/close` endpoint + +```javascript +// Broadcast session.ended event via WebSocket +const wsManager = getWebSocketManager(); +if (wsManager) { + const eventData = { + session: { + id: closedSession.id, + is_active: 0, + games_played: closedSession.games_played + } + }; + + wsManager.broadcastEvent('session.ended', eventData, parseInt(req.params.id)); +} +``` + +### Client Implementation Example + +#### Node.js with `ws` library + +```javascript +const WebSocket = require('ws'); + +const ws = new WebSocket('ws://localhost:5000/api/sessions/live'); + +ws.on('open', () => { + // Authenticate + ws.send(JSON.stringify({ + type: 'auth', + token: 'your-jwt-token' + })); +}); + +ws.on('message', (data) => { + const message = JSON.parse(data.toString()); + + switch (message.type) { + case 'auth_success': + // Subscribe to session + ws.send(JSON.stringify({ + type: 'subscribe', + sessionId: 17 + })); + break; + + case 'session.ended': + console.log('Session ended!'); + console.log(`Session ID: ${message.data.session.id}`); + console.log(`Games played: ${message.data.session.games_played}`); + // Handle session end (e.g., announce in IRC, Discord, etc.) + break; + } +}); +``` + +#### Python with `websockets` library + +```python +import asyncio +import json +import websockets + +async def listen_for_session_end(): + uri = "ws://localhost:5000/api/sessions/live" + + async with websockets.connect(uri) as websocket: + # Authenticate + await websocket.send(json.dumps({ + "type": "auth", + "token": "your-jwt-token" + })) + + async for message in websocket: + data = json.loads(message) + + if data["type"] == "auth_success": + # Subscribe to session + await websocket.send(json.dumps({ + "type": "subscribe", + "sessionId": 17 + })) + + elif data["type"] == "session.ended": + session = data["data"]["session"] + print(f"Session {session['id']} ended!") + print(f"Games played: {session['games_played']}") + # Handle session end + +asyncio.run(listen_for_session_end()) +``` + +## ๐Ÿงช Testing + +### Using the Test Script + +A test script is provided to verify the `session.ended` event: + +```bash +node test-session-end-websocket.js +``` + +**Example:** +```bash +node test-session-end-websocket.js 17 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +### Manual Testing Steps + +1. **Start the backend server:** + ```bash + cd backend + npm start + ``` + +2. **Run the test script in another terminal:** + ```bash + node test-session-end-websocket.js 17 + ``` + +3. **Close the session in the Picker UI** or via API: + ```bash + curl -X POST http://localhost:5000/api/sessions/17/close \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" + ``` + +4. **Verify the event is received** in the test script output + +### Expected Output + +``` +๐Ÿš€ Testing session.ended WebSocket event +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” +๐Ÿ“ก Connecting to: ws://localhost:5000/api/sessions/live +๐ŸŽฎ Session ID: 17 +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” + +โœ… Connected to WebSocket server + +๐Ÿ” Authenticating... +โœ… Authentication successful + +๐Ÿ“ป Subscribing to session 17... +โœ… Subscribed to session 17 + +๐Ÿ‘‚ Listening for session.ended events... + (Close the session in the Picker to trigger the event) + +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” +๐ŸŽ‰ SESSION.ENDED EVENT RECEIVED! +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” + +๐Ÿ“ฆ Event Data: +{ + "type": "session.ended", + "timestamp": "2025-11-01T02:30:45.123Z", + "data": { + "session": { + "id": 17, + "is_active": 0, + "games_played": 5 + } + } +} + +โœจ Event Details: + Session ID: 17 + Active: No + Games Played: 5 + Timestamp: 2025-11-01T02:30:45.123Z + +โœ… Test successful! The bot should now announce the session end. +``` + +## ๐Ÿค– Bot Integration + +### IRC/Kosmi Bot Example + +When the bot receives a `session.ended` event, it should: + +1. **Announce the final vote counts** for the last game played +2. **Announce that the game night has ended** +3. **Optionally display session statistics** + +Example bot response: +``` +๐Ÿ—ณ๏ธ Final votes for Quiplash 3: 5๐Ÿ‘ 1๐Ÿ‘Ž (Score: +4) +๐ŸŒ™ Game Night has ended! Thanks for playing! +๐Ÿ“Š Session Stats: 5 games played +``` + +### Fallback Behavior + +The bot should also implement **polling detection** as a fallback in case the WebSocket connection fails or the event is not received: + +- Poll `GET /api/sessions/active` every 30 seconds +- If a previously active session becomes inactive, treat it as a session end +- This ensures the bot will always detect session endings, even without WebSocket + +## ๐Ÿ” Debugging + +### Check WebSocket Logs + +The backend logs WebSocket events: + +``` +[WebSocket] Client subscribed to session 17 +[Sessions] Broadcasted session.ended event for session 17 +[WebSocket] Broadcasted session.ended to 1 client(s) for session 17 +``` + +### Common Issues + +1. **Event not received:** + - Verify the client is authenticated (`auth_success` received) + - Verify the client is subscribed to the correct session + - Check backend logs for broadcast confirmation + +2. **Connection drops:** + - Implement ping/pong heartbeat (send `{"type": "ping"}` every 30s) + - Handle reconnection logic in your client + +3. **Multiple events received:** + - This is normal if multiple clients are subscribed + - Each client receives its own copy of the event + +## ๐Ÿ“š Related Documentation + +- [WebSocket Testing Guide](WEBSOCKET_TESTING.md) +- [Bot Integration Guide](BOT_INTEGRATION.md) +- [API Quick Reference](API_QUICK_REFERENCE.md) + +## ๐Ÿ”— Related Events + +| Event Type | Description | When Triggered | +|------------|-------------|----------------| +| `session.started` | A new session was created | When session is created | +| `game.added` | A new game was added to the session | When a game starts | +| `session.ended` | The session has ended | When session is closed | +| `vote.received` | A vote was cast for a game | When a user votes | + +## ๐Ÿ“ Notes + +- The `session.ended` event is broadcast to **all clients subscribed to that session** +- The event includes the final `games_played` count for the session +- The `is_active` field will always be `0` for ended sessions +- The timestamp is in ISO 8601 format with timezone (UTC) + diff --git a/SESSION_START_WEBSOCKET.md b/SESSION_START_WEBSOCKET.md new file mode 100644 index 0000000..5112774 --- /dev/null +++ b/SESSION_START_WEBSOCKET.md @@ -0,0 +1,361 @@ +# Session Start WebSocket Event + +This document describes the `session.started` WebSocket event that is broadcast when a new game session is created. + +## ๐Ÿ“‹ Event Overview + +When a new session is created, the backend broadcasts a `session.started` event to all subscribed WebSocket clients. This allows bots and other integrations to react immediately to new game sessions. + +## ๐Ÿ”Œ WebSocket Connection + +**Endpoint:** `ws://localhost:5000/api/sessions/live` + +**Authentication:** Required (JWT token) + +## ๐Ÿ“จ Event Format + +### Event Type +``` +session.started +``` + +### Full Message Structure + +```json +{ + "type": "session.started", + "timestamp": "2025-11-01T20:00:00.123Z", + "data": { + "session": { + "id": 17, + "is_active": 1, + "created_at": "2025-11-01T20:00:00.123Z", + "notes": "Friday Game Night" + } + } +} +``` + +### Field Descriptions + +| Field | Type | Description | +|-------|------|-------------| +| `type` | string | Always `"session.started"` | +| `timestamp` | string | ISO 8601 timestamp when the event was generated | +| `data.session.id` | number | The ID of the newly created session | +| `data.session.is_active` | number | Always `1` (active) for new sessions | +| `data.session.created_at` | string | ISO 8601 timestamp when the session was created | +| `data.session.notes` | string/null | Optional notes for the session | + +## ๐Ÿš€ Implementation + +### Backend Implementation + +The `session.started` event is automatically broadcast when: + +1. **New Session Created**: Admin creates a session via `POST /api/sessions` + +**Code Location:** `backend/routes/sessions.js` - `POST /` endpoint + +```javascript +// Broadcast session.started event via WebSocket +const wsManager = getWebSocketManager(); +if (wsManager) { + const eventData = { + session: { + id: newSession.id, + is_active: 1, + created_at: newSession.created_at, + notes: newSession.notes + } + }; + + wsManager.broadcastEvent('session.started', eventData, parseInt(newSession.id)); +} +``` + +### Client Implementation Example + +#### Node.js with `ws` library + +```javascript +const WebSocket = require('ws'); + +const ws = new WebSocket('ws://localhost:5000/api/sessions/live'); + +ws.on('open', () => { + // Authenticate + ws.send(JSON.stringify({ + type: 'auth', + token: 'your-jwt-token' + })); +}); + +ws.on('message', (data) => { + const message = JSON.parse(data.toString()); + + switch (message.type) { + case 'auth_success': + // Subscribe to the new session (or subscribe when you receive session.started) + ws.send(JSON.stringify({ + type: 'subscribe', + sessionId: 17 + })); + break; + + case 'session.started': + console.log('New session started!'); + console.log(`Session ID: ${message.data.session.id}`); + console.log(`Created at: ${message.data.session.created_at}`); + if (message.data.session.notes) { + console.log(`Notes: ${message.data.session.notes}`); + } + + // Auto-subscribe to the new session + ws.send(JSON.stringify({ + type: 'subscribe', + sessionId: message.data.session.id + })); + break; + } +}); +``` + +#### Python with `websockets` library + +```python +import asyncio +import json +import websockets + +async def listen_for_session_start(): + uri = "ws://localhost:5000/api/sessions/live" + + async with websockets.connect(uri) as websocket: + # Authenticate + await websocket.send(json.dumps({ + "type": "auth", + "token": "your-jwt-token" + })) + + async for message in websocket: + data = json.loads(message) + + if data["type"] == "auth_success": + print("Authenticated, waiting for sessions...") + + elif data["type"] == "session.started": + session = data["data"]["session"] + print(f"๐ŸŽฎ New session started! ID: {session['id']}") + print(f"๐Ÿ“… Created: {session['created_at']}") + if session.get('notes'): + print(f"๐Ÿ“ Notes: {session['notes']}") + + # Auto-subscribe to the new session + await websocket.send(json.dumps({ + "type": "subscribe", + "sessionId": session["id"] + })) + +asyncio.run(listen_for_session_start()) +``` + +## ๐Ÿงช Testing + +### Manual Testing Steps + +1. **Start the backend server:** + ```bash + cd backend + npm start + ``` + +2. **Connect a WebSocket client** (use the test script or your own): + ```bash + # You can modify test-session-end-websocket.js to listen for session.started + ``` + +3. **Create a new session** in the Picker UI or via API: + ```bash + curl -X POST http://localhost:5000/api/sessions \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"notes": "Friday Game Night"}' + ``` + +4. **Verify the event is received** by your WebSocket client + +### Expected Event + +```json +{ + "type": "session.started", + "timestamp": "2025-11-01T20:00:00.123Z", + "data": { + "session": { + "id": 18, + "is_active": 1, + "created_at": "2025-11-01T20:00:00.123Z", + "notes": "Friday Game Night" + } + } +} +``` + +## ๐Ÿค– Bot Integration + +### IRC/Kosmi Bot Example + +When the bot receives a `session.started` event, it should: + +1. **Announce the new game session** to users +2. **Auto-subscribe to the session** to receive game.added and session.ended events +3. **Optionally display session info** (notes, ID, etc.) + +Example bot response: +``` +๐ŸŽฎ Game Night has started! Session #18 +๐Ÿ“ Friday Game Night +๐Ÿ—ณ๏ธ Vote with thisgame++ or thisgame-- during games! +``` + +### Implementation Example + +```javascript +ws.on('message', (data) => { + const msg = JSON.parse(data.toString()); + + if (msg.type === 'session.started') { + const { id, notes, created_at } = msg.data.session; + + // Announce to IRC/Discord/etc + bot.announce(`๐ŸŽฎ Game Night has started! Session #${id}`); + if (notes) { + bot.announce(`๐Ÿ“ ${notes}`); + } + bot.announce('๐Ÿ—ณ๏ธ Vote with thisgame++ or thisgame-- during games!'); + + // Auto-subscribe to this session + ws.send(JSON.stringify({ + type: 'subscribe', + sessionId: id + })); + } +}); +``` + +## ๐Ÿ” Debugging + +### Check WebSocket Logs + +The backend logs WebSocket events: + +``` +[Sessions] Broadcasted session.started event for session 18 +[WebSocket] Broadcasted session.started to 1 client(s) for session 18 +``` + +### Common Issues + +1. **Event not received:** + - Verify the client is authenticated (`auth_success` received) + - Check backend logs for broadcast confirmation + - **No subscription required** - All authenticated clients automatically receive `session.started` events + - Make sure your WebSocket connection is open and authenticated + +2. **Missing session data:** + - Check if the session was created successfully + - Verify the API response includes all fields + +3. **Duplicate events:** + - Normal if multiple clients are connected + - Each client receives its own copy of the event + +## ๐Ÿ“š Related Documentation + +- [Session End WebSocket Event](SESSION_END_WEBSOCKET.md) +- [WebSocket Testing Guide](WEBSOCKET_TESTING.md) +- [Bot Integration Guide](BOT_INTEGRATION.md) +- [API Quick Reference](API_QUICK_REFERENCE.md) + +## ๐Ÿ”— Session Lifecycle Events + +``` +session.started + โ†“ +game.added (multiple times) + โ†“ +vote.received (during each game) + โ†“ +session.ended +``` + +## ๐Ÿ“ Notes + +- The `session.started` event is broadcast to **all authenticated clients** (not just subscribed ones) +- **No subscription required** - All authenticated clients automatically receive this event +- Clients should auto-subscribe to the new session to receive subsequent `game.added` and `vote.received` events +- The `is_active` field will always be `1` for new sessions +- The `notes` field may be `null` if no notes were provided +- The timestamp is in ISO 8601 format with timezone (UTC) + +## ๐Ÿ’ก Use Cases + +1. **Bot Announcements** - Notify users when game night starts +2. **Auto-Subscription** - Automatically subscribe to new sessions +3. **Session Tracking** - Track all sessions in external systems +4. **Analytics** - Log session creation times and frequency +5. **Notifications** - Send push notifications to users + +## ๐ŸŽฏ Best Practices + +1. **Auto-subscribe** to new sessions when you receive `session.started` +2. **Store the session ID** for later reference +3. **Handle reconnections** gracefully (you might miss the event) +4. **Use polling as fallback** to detect sessions created while disconnected +5. **Validate session data** before processing + +## ๐Ÿ”„ Complete Event Flow Example + +```javascript +const WebSocket = require('ws'); +const ws = new WebSocket('ws://localhost:5000/api/sessions/live'); + +let currentSessionId = null; + +ws.on('message', (data) => { + const msg = JSON.parse(data.toString()); + + switch (msg.type) { + case 'session.started': + currentSessionId = msg.data.session.id; + console.log(`๐ŸŽฎ Session ${currentSessionId} started!`); + + // Auto-subscribe + ws.send(JSON.stringify({ + type: 'subscribe', + sessionId: currentSessionId + })); + break; + + case 'game.added': + console.log(`๐ŸŽฒ New game: ${msg.data.game.title}`); + break; + + case 'vote.received': + console.log(`๐Ÿ—ณ๏ธ Vote: ${msg.data.vote.type}`); + break; + + case 'session.ended': + console.log(`๐ŸŒ™ Session ${msg.data.session.id} ended!`); + console.log(`๐Ÿ“Š Games played: ${msg.data.session.games_played}`); + currentSessionId = null; + break; + } +}); +``` + +## โœจ Conclusion + +The `session.started` WebSocket event provides instant notification when new game sessions are created, allowing bots and integrations to react immediately and provide a seamless user experience. + diff --git a/WEBSOCKET_FLOW_DIAGRAM.md b/WEBSOCKET_FLOW_DIAGRAM.md new file mode 100644 index 0000000..36ec11c --- /dev/null +++ b/WEBSOCKET_FLOW_DIAGRAM.md @@ -0,0 +1,256 @@ +# WebSocket Event Flow Diagram + +## ๐Ÿ”„ Complete Session Lifecycle + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ BOT CONNECTS โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Bot โ†’ Server: { type: "auth", token: "..." } โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Server โ†’ Bot: { type: "auth_success" } โ”‚ +โ”‚ โœ… Bot is now AUTHENTICATED โ”‚ +โ”‚ โณ Bot waits... (no subscription yet) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ ADMIN CREATES SESSION โ”‚ +โ”‚ POST /api/sessions { notes: "Friday Game Night" } โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Server โ†’ ALL AUTHENTICATED CLIENTS: โ”‚ +โ”‚ { โ”‚ +โ”‚ type: "session.started", โ”‚ +โ”‚ data: { โ”‚ +โ”‚ session: { id: 22, is_active: 1, ... } โ”‚ +โ”‚ } โ”‚ +โ”‚ } โ”‚ +โ”‚ ๐Ÿ“ข Broadcast to ALL (no subscription needed) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Bot receives session.started โ”‚ +โ”‚ ๐ŸŽฎ Bot announces: "Game Night #22 has started!" โ”‚ +โ”‚ โ”‚ +โ”‚ Bot โ†’ Server: { type: "subscribe", sessionId: 22 } โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Server โ†’ Bot: { type: "subscribed", sessionId: 22 } โ”‚ +โ”‚ โœ… Bot is now SUBSCRIBED to session 22 โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ ADMIN ADDS GAME โ”‚ +โ”‚ POST /api/sessions/22/games { game_id: 45 } โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Server โ†’ SUBSCRIBED CLIENTS (session 22): โ”‚ +โ”‚ { โ”‚ +โ”‚ type: "game.added", โ”‚ +โ”‚ data: { โ”‚ +โ”‚ game: { title: "Quiplash 3", ... } โ”‚ +โ”‚ } โ”‚ +โ”‚ } โ”‚ +โ”‚ ๐Ÿ“ข Only to subscribers of session 22 โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Bot receives game.added โ”‚ +โ”‚ ๐ŸŽฒ Bot announces: "Now playing: Quiplash 3" โ”‚ +โ”‚ ๐Ÿ—ณ๏ธ Bot announces: "Vote with thisgame++ or thisgame--" โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ USER VOTES โ”‚ +โ”‚ POST /api/votes/live { username: "Alice", vote: "up" } โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Server โ†’ SUBSCRIBED CLIENTS (session 22): โ”‚ +โ”‚ { โ”‚ +โ”‚ type: "vote.received", โ”‚ +โ”‚ data: { โ”‚ +โ”‚ vote: { username: "Alice", type: "up" } โ”‚ +โ”‚ } โ”‚ +โ”‚ } โ”‚ +โ”‚ ๐Ÿ“ข Only to subscribers of session 22 โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Bot receives vote.received โ”‚ +โ”‚ ๐Ÿ—ณ๏ธ Bot tracks vote (may announce later) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ + (more games and votes...) + โ†“ + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ ADMIN CLOSES SESSION โ”‚ +โ”‚ POST /api/sessions/22/close โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Server โ†’ SUBSCRIBED CLIENTS (session 22): โ”‚ +โ”‚ { โ”‚ +โ”‚ type: "session.ended", โ”‚ +โ”‚ data: { โ”‚ +โ”‚ session: { id: 22, is_active: 0, games_played: 5 } โ”‚ +โ”‚ } โ”‚ +โ”‚ } โ”‚ +โ”‚ ๐Ÿ“ข Only to subscribers of session 22 โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Bot receives session.ended โ”‚ +โ”‚ ๐Ÿ—ณ๏ธ Bot announces: "Final votes for Quiplash 3: 5๐Ÿ‘ 1๐Ÿ‘Ž" โ”‚ +โ”‚ ๐ŸŒ™ Bot announces: "Game Night ended! 5 games played" โ”‚ +โ”‚ โณ Bot waits for next session.started... โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## ๐Ÿ“Š Broadcast Scope Comparison + +### session.started (Global Broadcast) + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ SERVER โ”‚ +โ”‚ โ”‚ +โ”‚ broadcastToAll('session.started', data) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ โ†“ โ†“ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ†“ โ†“ โ†“ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Bot A โ”‚ โ”‚ Bot B โ”‚ โ”‚ Bot C โ”‚ + โ”‚ โœ… โ”‚ โ”‚ โœ… โ”‚ โ”‚ โœ… โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + ALL authenticated clients receive it + (no subscription required) +``` + +### game.added, vote.received, session.ended (Session-Specific) + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ SERVER โ”‚ +โ”‚ โ”‚ +โ”‚ broadcastEvent('game.added', data, sessionId: 22) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ†“ โ†“ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Bot A โ”‚ โ”‚ Bot B โ”‚ โ”‚ Bot C โ”‚ + โ”‚ โœ… โ”‚ โ”‚ โŒ โ”‚ โ”‚ โœ… โ”‚ + โ”‚subscr. โ”‚ โ”‚ not โ”‚ โ”‚subscr. โ”‚ + โ”‚sess 22 โ”‚ โ”‚subscr. โ”‚ โ”‚sess 22 โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + ONLY subscribers to session 22 receive it +``` + +## ๐ŸŽฏ Bot State Machine + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ DISCONNECTEDโ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ connect() + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ CONNECTED โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ send auth + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚AUTHENTICATEDโ”‚ โ† Wait here for session.started +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜ (no subscription yet) + โ”‚ + โ”‚ receive session.started + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ WAITING โ”‚ +โ”‚ TO โ”‚ +โ”‚ SUBSCRIBE โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ send subscribe + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ SUBSCRIBED โ”‚ โ† Now receive game.added, vote.received, session.ended +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”‚ receive session.ended + โ†“ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚AUTHENTICATEDโ”‚ โ† Back to waiting for next session.started +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ (still authenticated, but not subscribed) +``` + +## ๐Ÿ” Event Flow by Subscription Status + +### Before Subscription (Just Authenticated) + +``` +Server Events: Bot Receives: +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +session.started โœ… YES (broadcast to all) +game.added โŒ NO (not subscribed yet) +vote.received โŒ NO (not subscribed yet) +session.ended โŒ NO (not subscribed yet) +``` + +### After Subscription (Subscribed to Session 22) + +``` +Server Events: Bot Receives: +โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +session.started โœ… YES (still broadcast to all) +game.added โœ… YES (subscribed to session 22) +vote.received โœ… YES (subscribed to session 22) +session.ended โœ… YES (subscribed to session 22) +``` + +## ๐ŸŽฎ Multiple Sessions Example + +``` +Time Event Bot A (sess 22) Bot B (sess 23) +โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +10:00 session.started (sess 22) โœ… Receives โœ… Receives +10:01 Bot A subscribes to sess 22 โœ… Subscribed โŒ Not subscribed +10:02 game.added (sess 22) โœ… Receives โŒ Doesn't receive +10:05 session.started (sess 23) โœ… Receives โœ… Receives +10:06 Bot B subscribes to sess 23 โœ… Still sess 22 โœ… Subscribed +10:07 game.added (sess 22) โœ… Receives โŒ Doesn't receive +10:08 game.added (sess 23) โŒ Doesn't receive โœ… Receives +10:10 session.ended (sess 22) โœ… Receives โŒ Doesn't receive +10:15 session.ended (sess 23) โŒ Doesn't receive โœ… Receives +``` + +## ๐Ÿ“ Quick Reference + +| Event | Broadcast Method | Scope | Subscription Required? | +|-------|------------------|-------|------------------------| +| `session.started` | `broadcastToAll()` | All authenticated clients | โŒ NO | +| `game.added` | `broadcastEvent()` | Session subscribers only | โœ… YES | +| `vote.received` | `broadcastEvent()` | Session subscribers only | โœ… YES | +| `session.ended` | `broadcastEvent()` | Session subscribers only | โœ… YES | + +## ๐Ÿ”— Related Documentation + +- [WEBSOCKET_SUBSCRIPTION_GUIDE.md](WEBSOCKET_SUBSCRIPTION_GUIDE.md) - Detailed subscription guide +- [SESSION_START_WEBSOCKET.md](SESSION_START_WEBSOCKET.md) - session.started event +- [SESSION_END_WEBSOCKET.md](SESSION_END_WEBSOCKET.md) - session.ended event +- [BOT_INTEGRATION.md](BOT_INTEGRATION.md) - Bot integration guide + diff --git a/WEBSOCKET_SUBSCRIPTION_GUIDE.md b/WEBSOCKET_SUBSCRIPTION_GUIDE.md new file mode 100644 index 0000000..b170c75 --- /dev/null +++ b/WEBSOCKET_SUBSCRIPTION_GUIDE.md @@ -0,0 +1,310 @@ +# WebSocket Subscription Guide + +## ๐Ÿ“‹ Overview + +This guide explains how WebSocket subscriptions work in the Jackbox Game Picker and which events require subscriptions. + +## ๐Ÿ”Œ Connection & Authentication + +### 1. Connect to WebSocket + +```javascript +const ws = new WebSocket('ws://localhost:5000/api/sessions/live'); +``` + +### 2. Authenticate + +```javascript +ws.send(JSON.stringify({ + type: 'auth', + token: 'YOUR_JWT_TOKEN' +})); +``` + +### 3. Wait for Auth Success + +```javascript +ws.on('message', (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type === 'auth_success') { + console.log('Authenticated!'); + // Now you can subscribe to sessions + } +}); +``` + +## ๐Ÿ“จ Event Types & Subscription Requirements + +| Event Type | Requires Subscription? | Broadcast To | When to Subscribe | +|------------|------------------------|--------------|-------------------| +| `session.started` | โŒ **NO** | All authenticated clients | N/A - Automatic | +| `game.added` | โœ… **YES** | Subscribed clients only | After session.started | +| `vote.received` | โœ… **YES** | Subscribed clients only | After session.started | +| `session.ended` | โœ… **YES** | Subscribed clients only | After session.started | + +## ๐ŸŽฏ Subscription Strategy + +### Strategy 1: Auto-Subscribe to New Sessions (Recommended for Bots) + +```javascript +ws.on('message', (data) => { + const msg = JSON.parse(data.toString()); + + // After authentication + if (msg.type === 'auth_success') { + console.log('โœ… Authenticated'); + } + + // Auto-subscribe to new sessions + if (msg.type === 'session.started') { + const sessionId = msg.data.session.id; + console.log(`๐ŸŽฎ New session ${sessionId} started!`); + + // Subscribe to this session + ws.send(JSON.stringify({ + type: 'subscribe', + sessionId: sessionId + })); + } + + // Now you'll receive game.added, vote.received, and session.ended + if (msg.type === 'game.added') { + console.log(`๐ŸŽฒ Game: ${msg.data.game.title}`); + } + + if (msg.type === 'session.ended') { + console.log(`๐ŸŒ™ Session ended! ${msg.data.session.games_played} games played`); + } +}); +``` + +### Strategy 2: Subscribe to Active Session on Connect + +```javascript +ws.on('message', (data) => { + const msg = JSON.parse(data.toString()); + + if (msg.type === 'auth_success') { + console.log('โœ… Authenticated'); + + // Fetch active session from API + fetch('http://localhost:5000/api/sessions/active') + .then(res => res.json()) + .then(session => { + if (session && session.id) { + // Subscribe to active session + ws.send(JSON.stringify({ + type: 'subscribe', + sessionId: session.id + })); + } + }); + } +}); +``` + +### Strategy 3: Subscribe to Specific Session + +```javascript +ws.on('message', (data) => { + const msg = JSON.parse(data.toString()); + + if (msg.type === 'auth_success') { + console.log('โœ… Authenticated'); + + // Subscribe to specific session + const sessionId = 17; + ws.send(JSON.stringify({ + type: 'subscribe', + sessionId: sessionId + })); + } + + if (msg.type === 'subscribed') { + console.log(`โœ… Subscribed to session ${msg.sessionId}`); + } +}); +``` + +## ๐Ÿ”„ Complete Bot Flow + +```javascript +const WebSocket = require('ws'); + +class JackboxBot { + constructor(token) { + this.token = token; + this.ws = null; + this.currentSessionId = null; + } + + connect() { + this.ws = new WebSocket('ws://localhost:5000/api/sessions/live'); + + this.ws.on('open', () => { + console.log('๐Ÿ”Œ Connected to WebSocket'); + this.authenticate(); + }); + + this.ws.on('message', (data) => { + this.handleMessage(JSON.parse(data.toString())); + }); + } + + authenticate() { + this.ws.send(JSON.stringify({ + type: 'auth', + token: this.token + })); + } + + handleMessage(msg) { + switch (msg.type) { + case 'auth_success': + console.log('โœ… Authenticated'); + // Don't subscribe yet - wait for session.started + break; + + case 'session.started': + this.currentSessionId = msg.data.session.id; + console.log(`๐ŸŽฎ Session ${this.currentSessionId} started!`); + + // Auto-subscribe to this session + this.ws.send(JSON.stringify({ + type: 'subscribe', + sessionId: this.currentSessionId + })); + + // Announce to users + this.announce(`Game Night has started! Session #${this.currentSessionId}`); + break; + + case 'subscribed': + console.log(`โœ… Subscribed to session ${msg.sessionId}`); + break; + + case 'game.added': + console.log(`๐ŸŽฒ Game: ${msg.data.game.title}`); + this.announce(`Now playing: ${msg.data.game.title}`); + break; + + case 'vote.received': + console.log(`๐Ÿ—ณ๏ธ Vote: ${msg.data.vote.type}`); + break; + + case 'session.ended': + console.log(`๐ŸŒ™ Session ${msg.data.session.id} ended`); + this.announce(`Game Night ended! ${msg.data.session.games_played} games played`); + this.currentSessionId = null; + break; + } + } + + announce(message) { + // Send to IRC/Discord/Kosmi/etc + console.log(`๐Ÿ“ข ${message}`); + } +} + +// Usage +const bot = new JackboxBot('YOUR_JWT_TOKEN'); +bot.connect(); +``` + +## ๐Ÿ“Š Subscription Lifecycle + +``` +1. Connect to WebSocket + โ†“ +2. Send auth message + โ†“ +3. Receive auth_success + โ†“ +4. Wait for session.started (no subscription needed) + โ†“ +5. Receive session.started + โ†“ +6. Send subscribe message with sessionId + โ†“ +7. Receive subscribed confirmation + โ†“ +8. Now receive: game.added, vote.received, session.ended + โ†“ +9. Receive session.ended + โ†“ +10. Wait for next session.started (repeat from step 4) +``` + +## ๐Ÿ” Debugging + +### Check What You're Subscribed To + +The WebSocket manager tracks subscriptions. Check backend logs: + +``` +[WebSocket] Client subscribed to session 17 +[WebSocket] Client unsubscribed from session 17 +``` + +### Verify Event Reception + +**session.started** - Should receive immediately after authentication (no subscription needed): +``` +[WebSocket] Broadcasted session.started to 2 authenticated client(s) +``` + +**game.added, vote.received, session.ended** - Only after subscribing: +``` +[WebSocket] Broadcasted game.added to 1 client(s) for session 17 +``` + +### Common Issues + +1. **Not receiving session.started:** + - โœ… Are you authenticated? + - โœ… Is your WebSocket connection open? + - โœ… Check backend logs for broadcast confirmation + +2. **Not receiving game.added:** + - โœ… Did you subscribe to the session? + - โœ… Did you receive `subscribed` confirmation? + - โœ… Is the session ID correct? + +3. **Not receiving session.ended:** + - โœ… Are you still subscribed to the session? + - โœ… Did the session actually close? + - โœ… Check backend logs + +## ๐ŸŽฏ Best Practices + +1. **Auto-subscribe to new sessions** when you receive `session.started` +2. **Don't subscribe before session.started** - there's nothing to subscribe to yet +3. **Handle reconnections** - re-authenticate and re-subscribe on reconnect +4. **Use polling as fallback** - poll `/api/sessions/active` every 30s as backup +5. **Unsubscribe when done** - clean up subscriptions when you're done with a session +6. **Validate session IDs** - make sure the session exists before subscribing + +## ๐Ÿ“ Summary + +### โŒ No Subscription Required +- `session.started` - Broadcast to **all authenticated clients** + +### โœ… Subscription Required +- `game.added` - Only to **subscribed clients** +- `vote.received` - Only to **subscribed clients** +- `session.ended` - Only to **subscribed clients** + +### ๐ŸŽฏ Recommended Flow +1. Authenticate +2. Wait for `session.started` (automatic) +3. Subscribe to the session +4. Receive `game.added`, `vote.received`, `session.ended` +5. Repeat from step 2 for next session + +## ๐Ÿ”— Related Documentation + +- [SESSION_START_WEBSOCKET.md](SESSION_START_WEBSOCKET.md) - session.started event details +- [SESSION_END_WEBSOCKET.md](SESSION_END_WEBSOCKET.md) - session.ended event details +- [API_QUICK_REFERENCE.md](API_QUICK_REFERENCE.md) - API reference +- [BOT_INTEGRATION.md](BOT_INTEGRATION.md) - Bot integration guide + diff --git a/WEBSOCKET_TESTING.md b/WEBSOCKET_TESTING.md new file mode 100644 index 0000000..ab151c6 --- /dev/null +++ b/WEBSOCKET_TESTING.md @@ -0,0 +1,239 @@ +# WebSocket Integration Testing Guide + +This guide walks you through testing the WebSocket event system for game notifications. + +## Prerequisites + +1. Backend API running with WebSocket support +2. Valid JWT token for authentication +3. Active session with games (or ability to create one) + +## Testing Steps + +### Step 1: Install Backend Dependencies + +```bash +cd backend +npm install +``` + +This will install the `ws` package that was added to `package.json`. + +### Step 2: Start the Backend + +```bash +cd backend +npm start +``` + +You should see: +``` +Server is running on port 5000 +WebSocket server available at ws://localhost:5000/api/sessions/live +[WebSocket] WebSocket server initialized on /api/sessions/live +``` + +### Step 3: Get JWT Token + +```bash +curl -X POST "http://localhost:5000/api/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"key":"YOUR_ADMIN_KEY"}' +``` + +Save the token from the response. + +### Step 4: Test WebSocket Connection + +Run the test script: + +```bash +cd backend +JWT_TOKEN="your_token_here" node test-websocket.js +``` + +Expected output: +``` +๐Ÿš€ WebSocket Test Client +โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +Connecting to: ws://localhost:5000/api/sessions/live + +โœ… Connected to WebSocket server + +๐Ÿ“ Step 1: Authenticating... +โœ… Authentication successful + +๐Ÿ“ Step 2: Subscribing to session 1... +โœ… Subscribed to session 1 + +๐ŸŽง Listening for events... + Add a game in the Picker page to see events here + Press Ctrl+C to exit +``` + +### Step 5: Test Game Added Event + +1. Keep the WebSocket test client running +2. Open the web app in your browser +3. Go to the Picker page +4. Add a game to the session + +You should see in the test client: +``` +๐ŸŽฎ GAME ADDED EVENT RECEIVED! +โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +Game: Fibbage 4 +Pack: The Jackbox Party Pack 9 +Players: 2-8 +Session ID: 1 +Games Played: 1 +Timestamp: 2025-11-01T... +โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +``` + +### Step 6: Test Bot Integration + +If you're using the `irc-kosmi-relay` bot: + +1. Make sure `UseWebSocket=true` in `matterbridge.toml` +2. Build and run the bot: + ```bash + cd irc-kosmi-relay + go build + ./matterbridge -conf matterbridge.toml + ``` + +3. Look for these log messages: + ``` + INFO Jackbox integration initialized successfully + INFO Connecting to WebSocket: wss://your-api-url/api/sessions/live + INFO WebSocket connected + INFO Authentication successful + INFO Subscribed to session X + ``` + +4. Add a game in the Picker page + +5. The bot should announce in Kosmi/IRC: + ``` + ๐ŸŽฎ Coming up next: Fibbage 4! + ``` + +## Troubleshooting + +### Connection Refused + +**Problem**: `Error: connect ECONNREFUSED` + +**Solution**: Make sure the backend is running on the correct port (default 5000). + +### Authentication Failed + +**Problem**: `Authentication failed: Invalid or expired token` + +**Solution**: +- Get a fresh JWT token +- Make sure you're using the correct admin key +- Check token hasn't expired (tokens expire after 24 hours) + +### No Events Received + +**Problem**: WebSocket connects but no `game.added` events are received + +**Solution**: +- Make sure you're subscribed to the correct session ID +- Verify the session is active +- Check backend logs for errors +- Try adding a game manually via the Picker page + +### Bot Not Connecting + +**Problem**: Bot fails to connect to WebSocket + +**Solution**: +- Check `APIURL` in `matterbridge.toml` is correct +- Verify `UseWebSocket=true` is set +- Check bot has valid JWT token (authentication succeeded) +- Look for error messages in bot logs + +### Reconnection Issues + +**Problem**: WebSocket disconnects and doesn't reconnect + +**Solution**: +- Check network connectivity +- Backend automatically handles reconnection with exponential backoff +- Bot automatically reconnects on disconnect +- Check logs for reconnection attempts + +## Advanced Testing + +### Test Multiple Clients + +You can run multiple test clients simultaneously: + +```bash +# Terminal 1 +JWT_TOKEN="token1" node test-websocket.js + +# Terminal 2 +JWT_TOKEN="token2" node test-websocket.js +``` + +Both should receive the same `game.added` events. + +### Test Heartbeat + +The WebSocket connection sends ping/pong messages every 30 seconds. You should see: + +``` +๐Ÿ’“ Heartbeat +``` + +If you don't see heartbeats, the connection may be stale. + +### Test Reconnection + +1. Start the test client +2. Stop the backend (Ctrl+C) +3. The client should log: `WebSocket disconnected, reconnecting...` +4. Restart the backend +5. The client should reconnect automatically + +## Integration Checklist + +- [ ] Backend WebSocket server starts successfully +- [ ] Test client can connect and authenticate +- [ ] Test client receives `game.added` events +- [ ] Heartbeat keeps connection alive (30s interval) +- [ ] Auto-reconnect works after disconnect +- [ ] Multiple clients can connect simultaneously +- [ ] Invalid JWT is rejected properly +- [ ] Bot connects and authenticates +- [ ] Bot receives events and broadcasts to chat +- [ ] Bot reconnects after network issues + +## Next Steps + +Once testing is complete: + +1. Update your bot configuration to use `UseWebSocket=true` +2. Deploy the updated backend with WebSocket support +3. Restart your bot to connect via WebSocket +4. Monitor logs for any connection issues +5. Webhooks remain available as a fallback option + +## Comparison: WebSocket vs Webhooks + +| Feature | WebSocket | Webhooks | +|---------|-----------|----------| +| Setup Complexity | Simple | Moderate | +| Inbound Ports | Not needed | Required | +| Docker Networking | Simple | Complex | +| Latency | Lower | Higher | +| Connection Type | Persistent | Per-event | +| Reconnection | Automatic | N/A | +| Best For | Real-time bots | Serverless integrations | + +**Recommendation**: Use WebSocket for bot integrations. Use webhooks for serverless/stateless integrations or when WebSocket is not feasible. + diff --git a/backend/database.js b/backend/database.js index 0789b16..4abcedd 100644 --- a/backend/database.js +++ b/backend/database.js @@ -77,6 +77,13 @@ function initializeDatabase() { // Column already exists, ignore error } + // Add room_code column if it doesn't exist (for existing databases) + try { + db.exec(`ALTER TABLE session_games ADD COLUMN room_code TEXT`); + } catch (err) { + // Column already exists, ignore error + } + // Add favor_bias column to games if it doesn't exist try { db.exec(`ALTER TABLE games ADD COLUMN favor_bias INTEGER DEFAULT 0`); @@ -167,6 +174,55 @@ function initializeDatabase() { // Index already exists, ignore error } + // Live votes table for real-time voting + db.exec(` + CREATE TABLE IF NOT EXISTS live_votes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + game_id INTEGER NOT NULL, + username TEXT NOT NULL, + vote_type INTEGER NOT NULL, + timestamp DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE, + FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE + ) + `); + + // Create index for duplicate checking (username + timestamp within 1 second) + try { + db.exec(`CREATE INDEX IF NOT EXISTS idx_live_votes_dedup ON live_votes(username, timestamp)`); + } catch (err) { + // Index already exists, ignore error + } + + // Webhooks table for external integrations + db.exec(` + CREATE TABLE IF NOT EXISTS webhooks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL, + secret TEXT NOT NULL, + events TEXT NOT NULL, + enabled INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Webhook logs table for debugging + db.exec(` + CREATE TABLE IF NOT EXISTS webhook_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + webhook_id INTEGER NOT NULL, + event_type TEXT NOT NULL, + payload TEXT NOT NULL, + response_status INTEGER, + error_message TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE + ) + `); + console.log('Database initialized successfully'); } diff --git a/backend/package.json b/backend/package.json index 23300c9..56fff2e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,7 +17,8 @@ "jsonwebtoken": "^9.0.2", "dotenv": "^16.3.1", "csv-parse": "^5.5.3", - "csv-stringify": "^6.4.5" + "csv-stringify": "^6.4.5", + "ws": "^8.14.0" }, "devDependencies": { "nodemon": "^3.0.2" diff --git a/backend/routes/sessions.js b/backend/routes/sessions.js index b4784e2..7499463 100644 --- a/backend/routes/sessions.js +++ b/backend/routes/sessions.js @@ -2,6 +2,8 @@ const express = require('express'); const crypto = require('crypto'); const { authenticateToken } = require('../middleware/auth'); const db = require('../database'); +const { triggerWebhook } = require('../utils/webhooks'); +const { getWebSocketManager } = require('../utils/websocket-manager'); const router = express.Router(); @@ -103,6 +105,27 @@ router.post('/', authenticateToken, (req, res) => { const result = stmt.run(notes || null); const newSession = db.prepare('SELECT * FROM sessions WHERE id = ?').get(result.lastInsertRowid); + // Broadcast session.started event via WebSocket to all authenticated clients + try { + const wsManager = getWebSocketManager(); + if (wsManager) { + const eventData = { + session: { + id: newSession.id, + is_active: 1, + created_at: newSession.created_at, + notes: newSession.notes + } + }; + + wsManager.broadcastToAll('session.started', eventData); + console.log(`[Sessions] Broadcasted session.started event for session ${newSession.id} to all clients`); + } + } catch (error) { + // Log error but don't fail the request + console.error('Error broadcasting session.started event:', error); + } + res.status(201).json(newSession); } catch (error) { res.status(500).json({ error: error.message }); @@ -139,7 +162,37 @@ router.post('/:id/close', authenticateToken, (req, res) => { stmt.run(notes || null, req.params.id); - const closedSession = db.prepare('SELECT * FROM sessions WHERE id = ?').get(req.params.id); + // Get updated session with games count + const closedSession = db.prepare(` + SELECT + s.*, + COUNT(sg.id) as games_played + FROM sessions s + LEFT JOIN session_games sg ON s.id = sg.session_id + WHERE s.id = ? + GROUP BY s.id + `).get(req.params.id); + + // Broadcast session.ended event via WebSocket + try { + const wsManager = getWebSocketManager(); + if (wsManager) { + const eventData = { + session: { + id: closedSession.id, + is_active: 0, + games_played: closedSession.games_played + } + }; + + wsManager.broadcastEvent('session.ended', eventData, parseInt(req.params.id)); + console.log(`[Sessions] Broadcasted session.ended event for session ${req.params.id}`); + } + } catch (error) { + // Log error but don't fail the request + console.error('Error broadcasting session.ended event:', error); + } + res.json(closedSession); } catch (error) { res.status(500).json({ error: error.message }); @@ -202,7 +255,7 @@ router.get('/:id/games', (req, res) => { // Add game to session (admin only) router.post('/:id/games', authenticateToken, (req, res) => { try { - const { game_id, manually_added } = req.body; + const { game_id, manually_added, room_code } = req.body; if (!game_id) { return res.status(400).json({ error: 'game_id is required' }); @@ -238,11 +291,11 @@ router.post('/:id/games', authenticateToken, (req, res) => { // Add game to session with 'playing' status const stmt = db.prepare(` - INSERT INTO session_games (session_id, game_id, manually_added, status) - VALUES (?, ?, ?, 'playing') + INSERT INTO session_games (session_id, game_id, manually_added, status, room_code) + VALUES (?, ?, ?, 'playing', ?) `); - const result = stmt.run(req.params.id, game_id, manually_added ? 1 : 0); + const result = stmt.run(req.params.id, game_id, manually_added ? 1 : 0, room_code || null); // Increment play count for the game db.prepare('UPDATE games SET play_count = play_count + 1 WHERE id = ?').run(game_id); @@ -252,12 +305,56 @@ router.post('/:id/games', authenticateToken, (req, res) => { sg.*, g.pack_name, g.title, - g.game_type + g.game_type, + g.min_players, + g.max_players FROM session_games sg JOIN games g ON sg.game_id = g.id WHERE sg.id = ? `).get(result.lastInsertRowid); + // Trigger webhook and WebSocket for game.added event + try { + const sessionStats = db.prepare(` + SELECT + s.*, + COUNT(sg.id) as games_played + FROM sessions s + LEFT JOIN session_games sg ON s.id = sg.session_id + WHERE s.id = ? + GROUP BY s.id + `).get(req.params.id); + + const eventData = { + session: { + id: sessionStats.id, + is_active: sessionStats.is_active === 1, + games_played: sessionStats.games_played + }, + game: { + id: game.id, + title: game.title, + pack_name: game.pack_name, + min_players: game.min_players, + max_players: game.max_players, + manually_added: manually_added || false, + room_code: room_code || null + } + }; + + // Trigger webhook (for backwards compatibility) + triggerWebhook('game.added', eventData); + + // Broadcast via WebSocket (new preferred method) + const wsManager = getWebSocketManager(); + if (wsManager) { + wsManager.broadcastEvent('game.added', eventData, parseInt(req.params.id)); + } + } catch (error) { + // Log error but don't fail the request + console.error('Error triggering notifications:', error); + } + res.status(201).json(sessionGame); } catch (error) { res.status(500).json({ error: error.message }); @@ -498,6 +595,56 @@ router.delete('/:sessionId/games/:gameId', authenticateToken, (req, res) => { } }); +// Update room code for a session game (admin only) +router.patch('/:sessionId/games/:gameId/room-code', authenticateToken, (req, res) => { + try { + const { sessionId, gameId } = req.params; + const { room_code } = req.body; + + if (!room_code) { + return res.status(400).json({ error: 'room_code is required' }); + } + + // Validate room code format: 4 characters, A-Z and 0-9 only + const roomCodeRegex = /^[A-Z0-9]{4}$/; + if (!roomCodeRegex.test(room_code)) { + return res.status(400).json({ error: 'room_code must be exactly 4 alphanumeric characters (A-Z, 0-9)' }); + } + + // Update the room code + const result = db.prepare(` + UPDATE session_games + SET room_code = ? + WHERE session_id = ? AND id = ? + `).run(room_code, sessionId, gameId); + + if (result.changes === 0) { + return res.status(404).json({ error: 'Session game not found' }); + } + + // Return updated game data + const updatedGame = db.prepare(` + SELECT + sg.*, + g.pack_name, + g.title, + g.game_type, + g.min_players, + g.max_players, + g.popularity_score, + g.upvotes, + g.downvotes + FROM session_games sg + JOIN games g ON sg.game_id = g.id + WHERE sg.session_id = ? AND sg.id = ? + `).get(sessionId, gameId); + + res.json(updatedGame); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // Export session data (plaintext and JSON) router.get('/:id/export', authenticateToken, (req, res) => { try { diff --git a/backend/routes/votes.js b/backend/routes/votes.js new file mode 100644 index 0000000..5efeeae --- /dev/null +++ b/backend/routes/votes.js @@ -0,0 +1,198 @@ +const express = require('express'); +const { authenticateToken } = require('../middleware/auth'); +const db = require('../database'); + +const router = express.Router(); + +// Live vote endpoint - receives real-time votes from bot +router.post('/live', authenticateToken, (req, res) => { + try { + const { username, vote, timestamp } = req.body; + + // Validate payload + if (!username || !vote || !timestamp) { + return res.status(400).json({ + error: 'Missing required fields: username, vote, timestamp' + }); + } + + if (vote !== 'up' && vote !== 'down') { + return res.status(400).json({ + error: 'vote must be either "up" or "down"' + }); + } + + // Validate timestamp format + const voteTimestamp = new Date(timestamp); + if (isNaN(voteTimestamp.getTime())) { + return res.status(400).json({ + error: 'Invalid timestamp format. Use ISO 8601 format (e.g., 2025-11-01T20:30:00Z)' + }); + } + + // Check for active session + const activeSession = db.prepare(` + SELECT * FROM sessions WHERE is_active = 1 LIMIT 1 + `).get(); + + if (!activeSession) { + return res.status(404).json({ + error: 'No active session found' + }); + } + + // Get all games played in this session with timestamps + const sessionGames = db.prepare(` + SELECT sg.game_id, sg.played_at, g.title, g.upvotes, g.downvotes, g.popularity_score + FROM session_games sg + JOIN games g ON sg.game_id = g.id + WHERE sg.session_id = ? + ORDER BY sg.played_at ASC + `).all(activeSession.id); + + if (sessionGames.length === 0) { + return res.status(404).json({ + error: 'No games have been played in the active session yet' + }); + } + + // Match vote timestamp to the correct game using interval logic + const voteTime = voteTimestamp.getTime(); + let matchedGame = null; + + for (let i = 0; i < sessionGames.length; i++) { + const currentGame = sessionGames[i]; + const nextGame = sessionGames[i + 1]; + + const currentGameTime = new Date(currentGame.played_at).getTime(); + + if (nextGame) { + const nextGameTime = new Date(nextGame.played_at).getTime(); + if (voteTime >= currentGameTime && voteTime < nextGameTime) { + matchedGame = currentGame; + break; + } + } else { + // Last game in session - vote belongs here if timestamp is after this game started + if (voteTime >= currentGameTime) { + matchedGame = currentGame; + break; + } + } + } + + if (!matchedGame) { + return res.status(404).json({ + error: 'Vote timestamp does not match any game in the active session', + debug: { + voteTimestamp: timestamp, + sessionGames: sessionGames.map(g => ({ + title: g.title, + played_at: g.played_at + })) + } + }); + } + + // Check for duplicate vote (within 1 second window) + // Get the most recent vote from this user + const lastVote = db.prepare(` + SELECT timestamp FROM live_votes + WHERE username = ? + ORDER BY created_at DESC + LIMIT 1 + `).get(username); + + if (lastVote) { + const lastVoteTime = new Date(lastVote.timestamp).getTime(); + const currentVoteTime = new Date(timestamp).getTime(); + const timeDiffSeconds = Math.abs(currentVoteTime - lastVoteTime) / 1000; + + if (timeDiffSeconds <= 1) { + return res.status(409).json({ + error: 'Duplicate vote detected (within 1 second of previous vote)', + message: 'Please wait at least 1 second between votes', + timeSinceLastVote: timeDiffSeconds + }); + } + } + + // Process the vote in a transaction + const voteType = vote === 'up' ? 1 : -1; + + const insertVote = db.prepare(` + INSERT INTO live_votes (session_id, game_id, username, vote_type, timestamp) + VALUES (?, ?, ?, ?, ?) + `); + + const updateUpvote = db.prepare(` + UPDATE games + SET upvotes = upvotes + 1, popularity_score = popularity_score + 1 + WHERE id = ? + `); + + const updateDownvote = db.prepare(` + UPDATE games + SET downvotes = downvotes + 1, popularity_score = popularity_score - 1 + WHERE id = ? + `); + + const processVote = db.transaction(() => { + insertVote.run(activeSession.id, matchedGame.game_id, username, voteType, timestamp); + + if (voteType === 1) { + updateUpvote.run(matchedGame.game_id); + } else { + updateDownvote.run(matchedGame.game_id); + } + }); + + processVote(); + + // Get updated game stats + const updatedGame = db.prepare(` + SELECT id, title, upvotes, downvotes, popularity_score + FROM games + WHERE id = ? + `).get(matchedGame.game_id); + + // Get session stats + const sessionStats = db.prepare(` + SELECT + s.*, + COUNT(sg.id) as games_played + FROM sessions s + LEFT JOIN session_games sg ON s.id = sg.session_id + WHERE s.id = ? + GROUP BY s.id + `).get(activeSession.id); + + res.json({ + success: true, + message: 'Vote recorded successfully', + session: { + id: sessionStats.id, + games_played: sessionStats.games_played + }, + game: { + id: updatedGame.id, + title: updatedGame.title, + upvotes: updatedGame.upvotes, + downvotes: updatedGame.downvotes, + popularity_score: updatedGame.popularity_score + }, + vote: { + username: username, + type: vote, + timestamp: timestamp + } + }); + + } catch (error) { + console.error('Error processing live vote:', error); + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; + diff --git a/backend/routes/webhooks.js b/backend/routes/webhooks.js new file mode 100644 index 0000000..e7406dc --- /dev/null +++ b/backend/routes/webhooks.js @@ -0,0 +1,271 @@ +const express = require('express'); +const { authenticateToken } = require('../middleware/auth'); +const db = require('../database'); +const { triggerWebhook } = require('../utils/webhooks'); + +const router = express.Router(); + +// Get all webhooks (admin only) +router.get('/', authenticateToken, (req, res) => { + try { + const webhooks = db.prepare(` + SELECT id, name, url, events, enabled, created_at + FROM webhooks + ORDER BY created_at DESC + `).all(); + + // Parse events JSON for each webhook + const webhooksWithParsedEvents = webhooks.map(webhook => ({ + ...webhook, + events: JSON.parse(webhook.events), + enabled: webhook.enabled === 1 + })); + + res.json(webhooksWithParsedEvents); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Get single webhook by ID (admin only) +router.get('/:id', authenticateToken, (req, res) => { + try { + const webhook = db.prepare(` + SELECT id, name, url, events, enabled, created_at + FROM webhooks + WHERE id = ? + `).get(req.params.id); + + if (!webhook) { + return res.status(404).json({ error: 'Webhook not found' }); + } + + res.json({ + ...webhook, + events: JSON.parse(webhook.events), + enabled: webhook.enabled === 1 + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Create new webhook (admin only) +router.post('/', authenticateToken, (req, res) => { + try { + const { name, url, secret, events } = req.body; + + // Validate required fields + if (!name || !url || !secret || !events) { + return res.status(400).json({ + error: 'Missing required fields: name, url, secret, events' + }); + } + + // Validate events is an array + if (!Array.isArray(events)) { + return res.status(400).json({ + error: 'events must be an array' + }); + } + + // Validate URL format + try { + new URL(url); + } catch (err) { + return res.status(400).json({ error: 'Invalid URL format' }); + } + + // Insert webhook + const stmt = db.prepare(` + INSERT INTO webhooks (name, url, secret, events, enabled) + VALUES (?, ?, ?, ?, 1) + `); + + const result = stmt.run(name, url, secret, JSON.stringify(events)); + + const newWebhook = db.prepare(` + SELECT id, name, url, events, enabled, created_at + FROM webhooks + WHERE id = ? + `).get(result.lastInsertRowid); + + res.status(201).json({ + ...newWebhook, + events: JSON.parse(newWebhook.events), + enabled: newWebhook.enabled === 1, + message: 'Webhook created successfully' + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Update webhook (admin only) +router.patch('/:id', authenticateToken, (req, res) => { + try { + const { name, url, secret, events, enabled } = req.body; + const webhookId = req.params.id; + + // Check if webhook exists + const webhook = db.prepare('SELECT * FROM webhooks WHERE id = ?').get(webhookId); + + if (!webhook) { + return res.status(404).json({ error: 'Webhook not found' }); + } + + // Build update query dynamically based on provided fields + const updates = []; + const params = []; + + if (name !== undefined) { + updates.push('name = ?'); + params.push(name); + } + + if (url !== undefined) { + // Validate URL format + try { + new URL(url); + } catch (err) { + return res.status(400).json({ error: 'Invalid URL format' }); + } + updates.push('url = ?'); + params.push(url); + } + + if (secret !== undefined) { + updates.push('secret = ?'); + params.push(secret); + } + + if (events !== undefined) { + if (!Array.isArray(events)) { + return res.status(400).json({ error: 'events must be an array' }); + } + updates.push('events = ?'); + params.push(JSON.stringify(events)); + } + + if (enabled !== undefined) { + updates.push('enabled = ?'); + params.push(enabled ? 1 : 0); + } + + if (updates.length === 0) { + return res.status(400).json({ error: 'No fields to update' }); + } + + params.push(webhookId); + + const stmt = db.prepare(` + UPDATE webhooks + SET ${updates.join(', ')} + WHERE id = ? + `); + + stmt.run(...params); + + const updatedWebhook = db.prepare(` + SELECT id, name, url, events, enabled, created_at + FROM webhooks + WHERE id = ? + `).get(webhookId); + + res.json({ + ...updatedWebhook, + events: JSON.parse(updatedWebhook.events), + enabled: updatedWebhook.enabled === 1, + message: 'Webhook updated successfully' + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Delete webhook (admin only) +router.delete('/:id', authenticateToken, (req, res) => { + try { + const webhook = db.prepare('SELECT * FROM webhooks WHERE id = ?').get(req.params.id); + + if (!webhook) { + return res.status(404).json({ error: 'Webhook not found' }); + } + + // Delete webhook (logs will be cascade deleted) + db.prepare('DELETE FROM webhooks WHERE id = ?').run(req.params.id); + + res.json({ + message: 'Webhook deleted successfully', + webhookId: parseInt(req.params.id) + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Test webhook (admin only) +router.post('/test/:id', authenticateToken, async (req, res) => { + try { + const webhook = db.prepare('SELECT * FROM webhooks WHERE id = ?').get(req.params.id); + + if (!webhook) { + return res.status(404).json({ error: 'Webhook not found' }); + } + + // Send a test payload + const testData = { + session: { + id: 0, + is_active: true, + games_played: 0 + }, + game: { + id: 0, + title: 'Test Game', + pack_name: 'Test Pack', + min_players: 2, + max_players: 8, + manually_added: false + } + }; + + // Trigger the webhook asynchronously + triggerWebhook('game.added', testData); + + res.json({ + message: 'Test webhook sent', + note: 'Check webhook_logs table for delivery status' + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Get webhook logs (admin only) +router.get('/:id/logs', authenticateToken, (req, res) => { + try { + const { limit = 50 } = req.query; + + const logs = db.prepare(` + SELECT * + FROM webhook_logs + WHERE webhook_id = ? + ORDER BY created_at DESC + LIMIT ? + `).all(req.params.id, parseInt(limit)); + + // Parse payload JSON for each log + const logsWithParsedPayload = logs.map(log => ({ + ...log, + payload: JSON.parse(log.payload) + })); + + res.json(logsWithParsedPayload); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; + diff --git a/backend/server.js b/backend/server.js index 90f5ba8..5080e6f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,7 +1,9 @@ require('dotenv').config(); const express = require('express'); +const http = require('http'); const cors = require('cors'); const { bootstrapGames } = require('./bootstrap'); +const { WebSocketManager, setWebSocketManager } = require('./utils/websocket-manager'); const app = express(); const PORT = process.env.PORT || 5000; @@ -24,12 +26,16 @@ const gamesRoutes = require('./routes/games'); const sessionsRoutes = require('./routes/sessions'); const statsRoutes = require('./routes/stats'); const pickerRoutes = require('./routes/picker'); +const votesRoutes = require('./routes/votes'); +const webhooksRoutes = require('./routes/webhooks'); app.use('/api/auth', authRoutes); app.use('/api/games', gamesRoutes); app.use('/api/sessions', sessionsRoutes); app.use('/api/stats', statsRoutes); app.use('/api', pickerRoutes); +app.use('/api/votes', votesRoutes); +app.use('/api/webhooks', webhooksRoutes); // Error handling middleware app.use((err, req, res, next) => { @@ -37,7 +43,15 @@ app.use((err, req, res, next) => { res.status(500).json({ error: 'Something went wrong!', message: err.message }); }); -app.listen(PORT, '0.0.0.0', () => { +// Create HTTP server and attach WebSocket +const server = http.createServer(app); + +// Initialize WebSocket Manager +const wsManager = new WebSocketManager(server); +setWebSocketManager(wsManager); + +server.listen(PORT, '0.0.0.0', () => { console.log(`Server is running on port ${PORT}`); + console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`); }); diff --git a/backend/test-websocket.js b/backend/test-websocket.js new file mode 100644 index 0000000..79d2db0 --- /dev/null +++ b/backend/test-websocket.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node + +/** + * WebSocket Test Client + * + * Tests the WebSocket event system for the Jackbox Game Picker API + * + * Usage: + * JWT_TOKEN="your_token" node test-websocket.js + */ + +const WebSocket = require('ws'); + +const API_URL = process.env.API_URL || 'ws://localhost:5000'; +const JWT_TOKEN = process.env.JWT_TOKEN || ''; + +if (!JWT_TOKEN) { + console.error('\nโŒ ERROR: JWT_TOKEN not set!'); + console.error('\nGet your token:'); + console.error(' curl -X POST "http://localhost:5000/api/auth/login" \\'); + console.error(' -H "Content-Type: application/json" \\'); + console.error(' -d \'{"key":"YOUR_ADMIN_KEY"}\''); + console.error('\nThen run:'); + console.error(' JWT_TOKEN="your_token" node test-websocket.js\n'); + process.exit(1); +} + +console.log('\n๐Ÿš€ WebSocket Test Client'); +console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n'); +console.log(`Connecting to: ${API_URL}/api/sessions/live`); +console.log(''); + +const ws = new WebSocket(`${API_URL}/api/sessions/live`); + +ws.on('open', () => { + console.log('โœ… Connected to WebSocket server\n'); + + // Step 1: Authenticate + console.log('๐Ÿ“ Step 1: Authenticating...'); + ws.send(JSON.stringify({ + type: 'auth', + token: JWT_TOKEN + })); +}); + +ws.on('message', (data) => { + try { + const message = JSON.parse(data.toString()); + + switch (message.type) { + case 'auth_success': + console.log('โœ… Authentication successful\n'); + + // Step 2: Subscribe to session (you can change this ID) + console.log('๐Ÿ“ Step 2: Subscribing to session 1...'); + ws.send(JSON.stringify({ + type: 'subscribe', + sessionId: 1 + })); + break; + + case 'auth_error': + console.error('โŒ Authentication failed:', message.message); + process.exit(1); + break; + + case 'subscribed': + console.log(`โœ… Subscribed to session ${message.sessionId}\n`); + console.log('๐ŸŽง Listening for events...'); + console.log(' Add a game in the Picker page to see events here'); + console.log(' Press Ctrl+C to exit\n'); + + // Start heartbeat + setInterval(() => { + ws.send(JSON.stringify({ type: 'ping' })); + }, 30000); + break; + + case 'game.added': + console.log('\n๐ŸŽฎ GAME ADDED EVENT RECEIVED!'); + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + console.log('Game:', message.data.game.title); + console.log('Pack:', message.data.game.pack_name); + console.log('Players:', `${message.data.game.min_players}-${message.data.game.max_players}`); + console.log('Session ID:', message.data.session.id); + console.log('Games Played:', message.data.session.games_played); + console.log('Timestamp:', message.timestamp); + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n'); + break; + + case 'pong': + console.log('๐Ÿ’“ Heartbeat'); + break; + + case 'error': + console.error('โŒ Error:', message.message); + break; + + default: + console.log('๐Ÿ“จ Received:', message); + } + } catch (err) { + console.error('Failed to parse message:', err); + } +}); + +ws.on('error', (err) => { + console.error('\nโŒ WebSocket error:', err.message); + process.exit(1); +}); + +ws.on('close', () => { + console.log('\n๐Ÿ‘‹ Connection closed'); + process.exit(0); +}); + +// Handle Ctrl+C +process.on('SIGINT', () => { + console.log('\n\nโš ๏ธ Closing connection...'); + ws.close(); +}); + diff --git a/backend/utils/webhooks.js b/backend/utils/webhooks.js new file mode 100644 index 0000000..f7eaabe --- /dev/null +++ b/backend/utils/webhooks.js @@ -0,0 +1,151 @@ +const crypto = require('crypto'); +const db = require('../database'); + +/** + * Trigger webhooks for a specific event type + * @param {string} eventType - The event type (e.g., 'game.added') + * @param {object} data - The payload data to send + */ +async function triggerWebhook(eventType, data) { + try { + // Get all enabled webhooks that are subscribed to this event + const webhooks = db.prepare(` + SELECT * FROM webhooks + WHERE enabled = 1 + `).all(); + + if (webhooks.length === 0) { + return; // No webhooks configured + } + + // Filter webhooks that are subscribed to this event + const subscribedWebhooks = webhooks.filter(webhook => { + try { + const events = JSON.parse(webhook.events); + return events.includes(eventType); + } catch (err) { + console.error(`Invalid events JSON for webhook ${webhook.id}:`, err); + return false; + } + }); + + if (subscribedWebhooks.length === 0) { + return; // No webhooks subscribed to this event + } + + // Build the payload + const payload = { + event: eventType, + timestamp: new Date().toISOString(), + data: data + }; + + // Send to each webhook asynchronously (non-blocking) + subscribedWebhooks.forEach(webhook => { + sendWebhook(webhook, payload, eventType).catch(err => { + console.error(`Error sending webhook ${webhook.id}:`, err); + }); + }); + + } catch (err) { + console.error('Error triggering webhooks:', err); + } +} + +/** + * Send a webhook to a specific URL + * @param {object} webhook - The webhook configuration + * @param {object} payload - The payload to send + * @param {string} eventType - The event type + */ +async function sendWebhook(webhook, payload, eventType) { + const payloadString = JSON.stringify(payload); + + // Generate HMAC signature + const signature = 'sha256=' + crypto + .createHmac('sha256', webhook.secret) + .update(payloadString) + .digest('hex'); + + const startTime = Date.now(); + let responseStatus = null; + let errorMessage = null; + + try { + // Send the webhook + const response = await fetch(webhook.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Webhook-Signature': signature, + 'X-Webhook-Event': eventType, + 'User-Agent': 'Jackbox-Game-Picker-Webhook/1.0' + }, + body: payloadString, + // Set a timeout of 5 seconds + signal: AbortSignal.timeout(5000) + }); + + responseStatus = response.status; + + if (!response.ok) { + errorMessage = `HTTP ${response.status}: ${response.statusText}`; + } + + } catch (err) { + errorMessage = err.message; + responseStatus = 0; // Indicates connection/network error + } + + // Log the webhook call + try { + db.prepare(` + INSERT INTO webhook_logs (webhook_id, event_type, payload, response_status, error_message) + VALUES (?, ?, ?, ?, ?) + `).run(webhook.id, eventType, payloadString, responseStatus, errorMessage); + } catch (logErr) { + console.error('Error logging webhook call:', logErr); + } + + const duration = Date.now() - startTime; + + if (errorMessage) { + console.error(`Webhook ${webhook.id} (${webhook.name}) failed: ${errorMessage} (${duration}ms)`); + } else { + console.log(`Webhook ${webhook.id} (${webhook.name}) sent successfully: ${responseStatus} (${duration}ms)`); + } +} + +/** + * Verify a webhook signature + * @param {string} signature - The signature from the X-Webhook-Signature header + * @param {string} payload - The raw request body as a string + * @param {string} secret - The webhook secret + * @returns {boolean} - True if signature is valid + */ +function verifyWebhookSignature(signature, payload, secret) { + if (!signature || !signature.startsWith('sha256=')) { + return false; + } + + const expectedSignature = 'sha256=' + 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 (err) { + return false; + } +} + +module.exports = { + triggerWebhook, + verifyWebhookSignature +}; + diff --git a/backend/utils/websocket-manager.js b/backend/utils/websocket-manager.js new file mode 100644 index 0000000..a392a5c --- /dev/null +++ b/backend/utils/websocket-manager.js @@ -0,0 +1,333 @@ +const { WebSocketServer } = require('ws'); +const jwt = require('jsonwebtoken'); +const { JWT_SECRET } = require('../middleware/auth'); + +/** + * WebSocket Manager for handling real-time session events + * Manages client connections, authentication, and event broadcasting + */ +class WebSocketManager { + constructor(server) { + this.wss = new WebSocketServer({ + server, + path: '/api/sessions/live' + }); + + this.clients = new Map(); // Map + this.sessionSubscriptions = new Map(); // Map> + + this.wss.on('connection', (ws, req) => this.handleConnection(ws, req)); + this.startHeartbeat(); + + console.log('[WebSocket] WebSocket server initialized on /api/sessions/live'); + } + + /** + * Handle new WebSocket connection + */ + handleConnection(ws, req) { + console.log('[WebSocket] New connection from', req.socket.remoteAddress); + + // Initialize client info + const clientInfo = { + authenticated: false, + userId: null, + subscribedSessions: new Set(), + lastPing: Date.now() + }; + + this.clients.set(ws, clientInfo); + + // Handle incoming messages + ws.on('message', (data) => { + try { + const message = JSON.parse(data.toString()); + this.handleMessage(ws, message); + } catch (err) { + console.error('[WebSocket] Failed to parse message:', err); + this.sendError(ws, 'Invalid message format'); + } + }); + + // Handle connection close + ws.on('close', () => { + this.removeClient(ws); + }); + + // Handle errors + ws.on('error', (err) => { + console.error('[WebSocket] Client error:', err); + this.removeClient(ws); + }); + } + + /** + * Handle incoming messages from clients + */ + handleMessage(ws, message) { + const clientInfo = this.clients.get(ws); + + if (!clientInfo) { + return; + } + + switch (message.type) { + case 'auth': + this.authenticateClient(ws, message.token); + break; + + case 'subscribe': + if (!clientInfo.authenticated) { + this.sendError(ws, 'Not authenticated'); + return; + } + this.subscribeToSession(ws, message.sessionId); + break; + + case 'unsubscribe': + if (!clientInfo.authenticated) { + this.sendError(ws, 'Not authenticated'); + return; + } + this.unsubscribeFromSession(ws, message.sessionId); + break; + + case 'ping': + clientInfo.lastPing = Date.now(); + this.send(ws, { type: 'pong' }); + break; + + default: + this.sendError(ws, `Unknown message type: ${message.type}`); + } + } + + /** + * Authenticate a client using JWT token + */ + authenticateClient(ws, token) { + if (!token) { + this.sendError(ws, 'Token required', 'auth_error'); + return; + } + + try { + const decoded = jwt.verify(token, JWT_SECRET); + const clientInfo = this.clients.get(ws); + + if (clientInfo) { + clientInfo.authenticated = true; + clientInfo.userId = decoded.role; // 'admin' for now + + this.send(ws, { + type: 'auth_success', + message: 'Authenticated successfully' + }); + + console.log('[WebSocket] Client authenticated:', clientInfo.userId); + } + } catch (err) { + console.error('[WebSocket] Authentication failed:', err.message); + this.sendError(ws, 'Invalid or expired token', 'auth_error'); + } + } + + /** + * Subscribe a client to session events + */ + subscribeToSession(ws, sessionId) { + if (!sessionId) { + this.sendError(ws, 'Session ID required'); + return; + } + + const clientInfo = this.clients.get(ws); + if (!clientInfo) { + return; + } + + // Add to session subscriptions + if (!this.sessionSubscriptions.has(sessionId)) { + this.sessionSubscriptions.set(sessionId, new Set()); + } + + this.sessionSubscriptions.get(sessionId).add(ws); + clientInfo.subscribedSessions.add(sessionId); + + this.send(ws, { + type: 'subscribed', + sessionId: sessionId, + message: `Subscribed to session ${sessionId}` + }); + + console.log(`[WebSocket] Client subscribed to session ${sessionId}`); + } + + /** + * Unsubscribe a client from session events + */ + unsubscribeFromSession(ws, sessionId) { + const clientInfo = this.clients.get(ws); + if (!clientInfo) { + return; + } + + // Remove from session subscriptions + if (this.sessionSubscriptions.has(sessionId)) { + this.sessionSubscriptions.get(sessionId).delete(ws); + + // Clean up empty subscription sets + if (this.sessionSubscriptions.get(sessionId).size === 0) { + this.sessionSubscriptions.delete(sessionId); + } + } + + clientInfo.subscribedSessions.delete(sessionId); + + this.send(ws, { + type: 'unsubscribed', + sessionId: sessionId, + message: `Unsubscribed from session ${sessionId}` + }); + + console.log(`[WebSocket] Client unsubscribed from session ${sessionId}`); + } + + /** + * Broadcast an event to all clients subscribed to a session + */ + broadcastEvent(eventType, data, sessionId) { + const subscribers = this.sessionSubscriptions.get(sessionId); + + if (!subscribers || subscribers.size === 0) { + console.log(`[WebSocket] No subscribers for session ${sessionId}`); + return; + } + + const message = { + type: eventType, + timestamp: new Date().toISOString(), + data: data + }; + + let sentCount = 0; + subscribers.forEach((ws) => { + if (ws.readyState === ws.OPEN) { + this.send(ws, message); + sentCount++; + } + }); + + console.log(`[WebSocket] Broadcasted ${eventType} to ${sentCount} client(s) for session ${sessionId}`); + } + + /** + * Broadcast an event to all authenticated clients (not session-specific) + * Used for session.started and other global events + */ + broadcastToAll(eventType, data) { + const message = { + type: eventType, + timestamp: new Date().toISOString(), + data: data + }; + + let sentCount = 0; + this.clients.forEach((clientInfo, ws) => { + if (clientInfo.authenticated && ws.readyState === ws.OPEN) { + this.send(ws, message); + sentCount++; + } + }); + + console.log(`[WebSocket] Broadcasted ${eventType} to ${sentCount} authenticated client(s)`); + } + + /** + * Send a message to a specific client + */ + send(ws, message) { + if (ws.readyState === ws.OPEN) { + ws.send(JSON.stringify(message)); + } + } + + /** + * Send an error message to a client + */ + sendError(ws, message, type = 'error') { + this.send(ws, { + type: type, + message: message + }); + } + + /** + * Remove a client and clean up subscriptions + */ + removeClient(ws) { + const clientInfo = this.clients.get(ws); + + if (clientInfo) { + // Remove from all session subscriptions + clientInfo.subscribedSessions.forEach((sessionId) => { + if (this.sessionSubscriptions.has(sessionId)) { + this.sessionSubscriptions.get(sessionId).delete(ws); + + // Clean up empty subscription sets + if (this.sessionSubscriptions.get(sessionId).size === 0) { + this.sessionSubscriptions.delete(sessionId); + } + } + }); + + this.clients.delete(ws); + console.log('[WebSocket] Client disconnected and cleaned up'); + } + } + + /** + * Start heartbeat to detect dead connections + */ + startHeartbeat() { + setInterval(() => { + const now = Date.now(); + const timeout = 60000; // 60 seconds + + this.clients.forEach((clientInfo, ws) => { + if (now - clientInfo.lastPing > timeout) { + console.log('[WebSocket] Client timeout, closing connection'); + ws.terminate(); + this.removeClient(ws); + } + }); + }, 30000); // Check every 30 seconds + } + + /** + * Get connection statistics + */ + getStats() { + return { + totalClients: this.clients.size, + authenticatedClients: Array.from(this.clients.values()).filter(c => c.authenticated).length, + totalSubscriptions: this.sessionSubscriptions.size, + subscriptionDetails: Array.from(this.sessionSubscriptions.entries()).map(([sessionId, subs]) => ({ + sessionId, + subscribers: subs.size + })) + }; + } +} + +// Singleton instance +let instance = null; + +module.exports = { + WebSocketManager, + getWebSocketManager: () => instance, + setWebSocketManager: (manager) => { + instance = manager; + } +}; + diff --git a/frontend/src/components/RoomCodeModal.jsx b/frontend/src/components/RoomCodeModal.jsx new file mode 100644 index 0000000..d7d1c46 --- /dev/null +++ b/frontend/src/components/RoomCodeModal.jsx @@ -0,0 +1,120 @@ +import React, { useState, useEffect, useRef } from 'react'; + +function RoomCodeModal({ isOpen, onConfirm, onCancel, gameName }) { + const [roomCode, setRoomCode] = useState(''); + const [error, setError] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + if (isOpen) { + setRoomCode(''); + setError(''); + // Focus input when modal opens + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + } + }, [isOpen]); + + useEffect(() => { + const handleEscape = (e) => { + if (e.key === 'Escape' && isOpen) { + onCancel(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen, onCancel]); + + const handleInputChange = (e) => { + const value = e.target.value.toUpperCase(); + // Only allow A-Z and 0-9, max 4 characters + const filtered = value.replace(/[^A-Z0-9]/g, '').slice(0, 4); + setRoomCode(filtered); + setError(''); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + if (roomCode.length !== 4) { + setError('Room code must be exactly 4 characters'); + return; + } + onConfirm(roomCode); + }; + + const handleOverlayClick = (e) => { + if (e.target === e.currentTarget) { + onCancel(); + } + }; + + if (!isOpen) return null; + + return ( +
+
+

+ Enter Room Code +

+ {gameName && ( +

+ For: {gameName} +

+ )} + +
+
+ +
+ +
+ {roomCode.length}/4 +
+
+ {error && ( +

+ {error} +

+ )} +
+ +
+ + +
+
+
+
+ ); +} + +export default RoomCodeModal; + diff --git a/frontend/src/config/branding.js b/frontend/src/config/branding.js index c64d897..0316663 100644 --- a/frontend/src/config/branding.js +++ b/frontend/src/config/branding.js @@ -2,7 +2,7 @@ export const branding = { app: { name: 'HSO Jackbox Game Picker', shortName: 'Jackbox Game Picker', - version: '0.3.6 - Safari Walkabout Edition', + version: '0.4.2 - Safari Walkabout Edition', description: 'Spicing up Hyper Spaceout game nights!', }, meta: { diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index bf7968a..bc1e14a 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -144,6 +144,11 @@ function Home() { โญ๏ธ Skipped )} + {game.room_code && ( + + ๐ŸŽฎ {game.room_code} + + )} { try { @@ -194,56 +199,77 @@ function Picker() { const handleAcceptGame = async () => { if (!selectedGame || !activeSession) return; + // Show room code modal + setPendingGameAction({ + type: 'accept', + game: selectedGame + }); + setShowRoomCodeModal(true); + }; + + const handleRoomCodeConfirm = async (roomCode) => { + if (!pendingGameAction || !activeSession) return; + try { - await api.post(`/sessions/${activeSession.id}/games`, { - game_id: selectedGame.id, - manually_added: false - }); + const { type, game, gameId } = pendingGameAction; + + if (type === 'accept' || type === 'version') { + await api.post(`/sessions/${activeSession.id}/games`, { + game_id: gameId || game.id, + manually_added: false, + room_code: roomCode + }); + setSelectedGame(null); + } else if (type === 'manual') { + await api.post(`/sessions/${activeSession.id}/games`, { + game_id: gameId, + manually_added: true, + room_code: roomCode + }); + setManualGameId(''); + setShowManualSelect(false); + } // Trigger games list refresh setGamesUpdateTrigger(prev => prev + 1); - setSelectedGame(null); setError(''); } catch (err) { setError('Failed to add game to session'); + } finally { + setShowRoomCodeModal(false); + setPendingGameAction(null); } }; + const handleRoomCodeCancel = () => { + setShowRoomCodeModal(false); + setPendingGameAction(null); + }; + const handleAddManualGame = async () => { if (!manualGameId || !activeSession) return; - try { - await api.post(`/sessions/${activeSession.id}/games`, { - game_id: parseInt(manualGameId), - manually_added: true - }); - - // Trigger games list refresh - setGamesUpdateTrigger(prev => prev + 1); - setManualGameId(''); - setShowManualSelect(false); - setError(''); - } catch (err) { - setError('Failed to add game to session'); - } + // Show room code modal + const game = allGames.find(g => g.id === parseInt(manualGameId)); + setPendingGameAction({ + type: 'manual', + gameId: parseInt(manualGameId), + game: game + }); + setShowRoomCodeModal(true); }; const handleSelectVersion = async (gameId) => { if (!activeSession) return; - try { - await api.post(`/sessions/${activeSession.id}/games`, { - game_id: gameId, - manually_added: false - }); - - // Trigger games list refresh - setGamesUpdateTrigger(prev => prev + 1); - setSelectedGame(null); - setError(''); - } catch (err) { - setError('Failed to add game to session'); - } + // Show room code modal + const game = allGames.find(g => g.id === gameId); + setPendingGameAction({ + type: 'version', + gameId: gameId, + game: game + }); + setShowRoomCodeModal(true); }; // Find similar versions of a game based on title patterns @@ -572,6 +598,14 @@ function Picker() { /> )} + {/* Room Code Modal */} + + {/* Results Panel */}
{error && ( @@ -730,6 +764,8 @@ function SessionInfo({ sessionId, onGamesUpdate }) { const [loading, setLoading] = useState(true); const [confirmingRemove, setConfirmingRemove] = useState(null); const [showPopularity, setShowPopularity] = useState(true); + const [editingRoomCode, setEditingRoomCode] = useState(null); + const [newRoomCode, setNewRoomCode] = useState(''); const loadGames = useCallback(async () => { try { @@ -788,6 +824,39 @@ function SessionInfo({ sessionId, onGamesUpdate }) { } }; + const handleEditRoomCode = (gameId, currentCode) => { + setEditingRoomCode(gameId); + setNewRoomCode(currentCode || ''); + }; + + const handleRoomCodeChange = (e) => { + const value = e.target.value.toUpperCase(); + const filtered = value.replace(/[^A-Z0-9]/g, '').slice(0, 4); + setNewRoomCode(filtered); + }; + + const handleSaveRoomCode = async (gameId) => { + if (newRoomCode.length !== 4) { + return; + } + + try { + await api.patch(`/sessions/${sessionId}/games/${gameId}/room-code`, { + room_code: newRoomCode + }); + setEditingRoomCode(null); + setNewRoomCode(''); + loadGames(); // Reload to show updated code + } catch (err) { + console.error('Failed to update room code', err); + } + }; + + const handleCancelEditRoomCode = () => { + setEditingRoomCode(null); + setNewRoomCode(''); + }; + const getStatusBadge = (status) => { if (status === 'playing') { return ( @@ -857,6 +926,50 @@ function SessionInfo({ sessionId, onGamesUpdate }) { Manual )} + {game.room_code && ( +
+ {editingRoomCode === game.id ? ( +
+ + + +
+ ) : ( + <> + + ๐ŸŽฎ {game.room_code} + + {isAuthenticated && ( + + )} + + )} +
+ )} {showPopularity && ( + * + * Example: + * node test-session-end-websocket.js 17 your-jwt-token-here + */ + +const WebSocket = require('ws'); + +// Configuration +const WS_URL = process.env.WS_URL || 'ws://localhost:5000/api/sessions/live'; +const SESSION_ID = process.argv[2] || '17'; +const JWT_TOKEN = process.argv[3]; + +if (!JWT_TOKEN) { + console.error('โŒ Error: JWT token is required'); + console.error('Usage: node test-session-end-websocket.js '); + process.exit(1); +} + +console.log('๐Ÿš€ Testing session.ended WebSocket event'); +console.log('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”'); +console.log(`๐Ÿ“ก Connecting to: ${WS_URL}`); +console.log(`๐ŸŽฎ Session ID: ${SESSION_ID}`); +console.log('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n'); + +// Create WebSocket connection +const ws = new WebSocket(WS_URL); + +ws.on('open', () => { + console.log('โœ… Connected to WebSocket server\n'); + + // Step 1: Authenticate + console.log('๐Ÿ” Authenticating...'); + ws.send(JSON.stringify({ + type: 'auth', + token: JWT_TOKEN + })); +}); + +ws.on('message', (data) => { + try { + const message = JSON.parse(data.toString()); + + switch (message.type) { + case 'auth_success': + console.log('โœ… Authentication successful\n'); + + // Step 2: Subscribe to session + console.log(`๐Ÿ“ป Subscribing to session ${SESSION_ID}...`); + ws.send(JSON.stringify({ + type: 'subscribe', + sessionId: parseInt(SESSION_ID) + })); + break; + + case 'subscribed': + console.log(`โœ… Subscribed to session ${message.sessionId}\n`); + console.log('๐Ÿ‘‚ Listening for session.ended events...'); + console.log(' (Close the session in the Picker to trigger the event)\n'); + break; + + case 'session.ended': + console.log('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”'); + console.log('๐ŸŽ‰ SESSION.ENDED EVENT RECEIVED!'); + console.log('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”'); + console.log('\n๐Ÿ“ฆ Event Data:'); + console.log(JSON.stringify(message, null, 2)); + console.log('\nโœจ Event Details:'); + console.log(` Session ID: ${message.data.session.id}`); + console.log(` Active: ${message.data.session.is_active === 1 ? 'Yes' : 'No'}`); + console.log(` Games Played: ${message.data.session.games_played}`); + console.log(` Timestamp: ${message.timestamp}`); + console.log('\nโœ… Test successful! The bot should now announce the session end.\n'); + + // Close connection after receiving the event + setTimeout(() => { + console.log('๐Ÿ‘‹ Closing connection...'); + ws.close(); + }, 1000); + break; + + case 'auth_error': + case 'error': + console.error(`โŒ Error: ${message.message}`); + ws.close(); + process.exit(1); + break; + + case 'pong': + // Ignore pong messages + break; + + default: + console.log(`๐Ÿ“จ Received message: ${message.type}`); + console.log(JSON.stringify(message, null, 2)); + } + } catch (err) { + console.error('โŒ Failed to parse message:', err); + console.error('Raw data:', data.toString()); + } +}); + +ws.on('error', (err) => { + console.error('โŒ WebSocket error:', err.message); + process.exit(1); +}); + +ws.on('close', (code, reason) => { + console.log(`\n๐Ÿ”Œ Connection closed (code: ${code})`); + if (reason) { + console.log(` Reason: ${reason}`); + } + process.exit(0); +}); + +// Handle Ctrl+C gracefully +process.on('SIGINT', () => { + console.log('\n\n๐Ÿ‘‹ Shutting down...'); + ws.close(); + process.exit(0); +}); + +// Send periodic pings to keep connection alive +const pingInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })); + } +}, 30000); + +// Clean up interval on close +ws.on('close', () => { + clearInterval(pingInterval); +}); + diff --git a/test-webhook-simple.sh b/test-webhook-simple.sh new file mode 100755 index 0000000..c13b099 --- /dev/null +++ b/test-webhook-simple.sh @@ -0,0 +1,182 @@ +#!/bin/bash + +# Simple Webhook Test Script for macOS +# Uses webhook.site for easy testing + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +API_URL="${API_URL:-https://hso.cottongin.xyz/api}" + +echo -e "\n${BLUE}โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—${NC}" +echo -e "${BLUE}โ•‘ Webhook Test Script (webhook.site) โ•‘${NC}" +echo -e "${BLUE}โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}\n" + +# Check if JWT_TOKEN is set +if [ -z "$JWT_TOKEN" ]; then + echo -e "${RED}โŒ ERROR: JWT_TOKEN not set!${NC}\n" + echo "Please set your JWT token:" + echo " 1. Get token:" + echo " curl -X POST https://hso.cottongin.xyz/api/auth/login \\" + echo " -H \"Content-Type: application/json\" \\" + echo " -d '{\"key\":\"YOUR_ADMIN_KEY\"}'" + echo "" + echo " 2. Export the token:" + echo " export JWT_TOKEN=\"your_token_here\"" + echo "" + echo " 3. Run this script:" + echo " ./test-webhook-simple.sh" + echo "" + exit 1 +fi + +echo -e "${GREEN}โœ“${NC} JWT_TOKEN is set" +echo -e "${GREEN}โœ“${NC} API URL: $API_URL" +echo "" + +# Get a webhook.site URL +echo -e "${BLUE}๐Ÿ“ก Getting webhook.site URL...${NC}" +WEBHOOK_RESPONSE=$(curl -s -X POST https://webhook.site/token) +WEBHOOK_UUID=$(echo "$WEBHOOK_RESPONSE" | grep -o '"uuid":"[^"]*' | cut -d'"' -f4) + +if [ -z "$WEBHOOK_UUID" ]; then + echo -e "${RED}โŒ Failed to get webhook.site URL${NC}" + exit 1 +fi + +WEBHOOK_URL="https://webhook.site/$WEBHOOK_UUID" +WEBHOOK_SECRET="test_secret_$(date +%s)" + +echo -e "${GREEN}โœ“${NC} Webhook URL: $WEBHOOK_URL" +echo -e "${GREEN}โœ“${NC} View webhooks at: $WEBHOOK_URL" +echo -e "${GREEN}โœ“${NC} Secret: $WEBHOOK_SECRET" +echo "" + +# Cleanup function +cleanup() { + echo "" + echo -e "${YELLOW}๐Ÿงน Cleaning up...${NC}" + + if [ -n "$WEBHOOK_ID" ]; then + echo " Deleting webhook $WEBHOOK_ID..." + DELETE_RESPONSE=$(curl -s -X DELETE \ + "$API_URL/webhooks/$WEBHOOK_ID" \ + -H "Authorization: Bearer $JWT_TOKEN") + + if echo "$DELETE_RESPONSE" | grep -q "deleted successfully"; then + echo -e " ${GREEN}โœ“${NC} Webhook deleted" + else + echo -e " ${YELLOW}โš ${NC} Could not delete webhook (you may need to delete it manually)" + fi + fi + + echo -e " ${GREEN}โœ“${NC} Cleanup complete" + echo "" + echo -e "${BLUE}๐Ÿ‘‹ Goodbye!${NC}\n" + exit 0 +} + +# Trap Ctrl+C +trap cleanup SIGINT SIGTERM + +echo -e "${BLUE}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" +echo -e "${BLUE}Starting Webhook Tests${NC}" +echo -e "${BLUE}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}\n" + +# Test 1: Create webhook +echo -e "${YELLOW}๐Ÿ“ Test 1: Creating webhook...${NC}" +CREATE_RESPONSE=$(curl -s -X POST \ + "$API_URL/webhooks" \ + -H "Authorization: Bearer $JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"Test Webhook\", + \"url\": \"$WEBHOOK_URL\", + \"secret\": \"$WEBHOOK_SECRET\", + \"events\": [\"game.added\"] + }") + +if echo "$CREATE_RESPONSE" | grep -q '"id"'; then + WEBHOOK_ID=$(echo "$CREATE_RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2) + echo -e "${GREEN}โœ“${NC} Webhook created with ID: $WEBHOOK_ID" +else + echo -e "${RED}โœ—${NC} Failed to create webhook" + echo " Response: $CREATE_RESPONSE" + exit 1 +fi +echo "" + +# Test 2: List webhooks +echo -e "${YELLOW}๐Ÿ“ Test 2: Listing webhooks...${NC}" +LIST_RESPONSE=$(curl -s "$API_URL/webhooks" \ + -H "Authorization: Bearer $JWT_TOKEN") + +WEBHOOK_COUNT=$(echo "$LIST_RESPONSE" | grep -o '"id":' | wc -l | tr -d ' ') +echo -e "${GREEN}โœ“${NC} Found $WEBHOOK_COUNT webhook(s)" +echo "" + +# Test 3: Send test webhook +echo -e "${YELLOW}๐Ÿ“ Test 3: Sending test webhook...${NC}" +TEST_RESPONSE=$(curl -s -X POST \ + "$API_URL/webhooks/test/$WEBHOOK_ID" \ + -H "Authorization: Bearer $JWT_TOKEN") + +if echo "$TEST_RESPONSE" | grep -q "Test webhook sent"; then + echo -e "${GREEN}โœ“${NC} Test webhook sent" +else + echo -e "${RED}โœ—${NC} Failed to send test webhook" + echo " Response: $TEST_RESPONSE" +fi +echo "" + +# Wait for webhook delivery +echo -e "${YELLOW}โณ Waiting for webhook delivery (3 seconds)...${NC}" +sleep 3 +echo "" + +# Test 4: Check webhook logs +echo -e "${YELLOW}๐Ÿ“ Test 4: Checking webhook logs...${NC}" +LOGS_RESPONSE=$(curl -s "$API_URL/webhooks/$WEBHOOK_ID/logs?limit=10" \ + -H "Authorization: Bearer $JWT_TOKEN") + +LOG_COUNT=$(echo "$LOGS_RESPONSE" | grep -o '"id":' | wc -l | tr -d ' ') +echo -e "${GREEN}โœ“${NC} Found $LOG_COUNT log entries" + +if [ "$LOG_COUNT" -gt 0 ]; then + echo "" + echo "Recent webhook deliveries:" + echo "$LOGS_RESPONSE" | python3 -c "import sys, json; print(json.dumps(json.load(sys.stdin), indent=2))" 2>/dev/null || echo "$LOGS_RESPONSE" +fi +echo "" + +# Summary +echo -e "${BLUE}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" +echo -e "${BLUE}Test Summary${NC}" +echo -e "${BLUE}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" +echo -e "${GREEN}โœ“${NC} Webhook created: ID $WEBHOOK_ID" +echo -e "${GREEN}โœ“${NC} Test webhook sent" +echo -e "${GREEN}โœ“${NC} Webhook logs: $LOG_COUNT entries" +echo -e "${BLUE}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}\n" + +echo -e "${GREEN}๐ŸŽ‰ All tests completed!${NC}" +echo "" +echo -e "${BLUE}๐Ÿ’ก Next steps:${NC}" +echo " 1. Visit $WEBHOOK_URL to see webhook deliveries" +echo " 2. Add a game to an active session in the Picker page" +echo " 3. Refresh webhook.site to see the real webhook" +echo " 4. Press Ctrl+C to cleanup and exit" +echo "" +echo -e "${YELLOW}โณ Press Ctrl+C when done to cleanup...${NC}" +echo "" + +# Wait for Ctrl+C +while true; do + sleep 1 +done + diff --git a/test-webhook.js b/test-webhook.js new file mode 100644 index 0000000..1d744eb --- /dev/null +++ b/test-webhook.js @@ -0,0 +1,294 @@ +#!/usr/bin/env node + +/** + * Webhook Test Script + * + * This script creates a local webhook receiver and tests the webhook system. + * + * Usage: + * 1. Start your backend server (docker-compose up or npm run dev) + * 2. Run this script: node test-webhook.js + * 3. The script will: + * - Start a local webhook receiver on port 3001 + * - Create a webhook in the API + * - Send a test webhook + * - Wait for incoming webhooks + */ + +const express = require('express'); +const crypto = require('crypto'); +const fetch = require('node-fetch'); + +// Configuration +const API_URL = process.env.API_URL || 'http://localhost:5000'; +const WEBHOOK_PORT = process.env.WEBHOOK_PORT || 3001; +const WEBHOOK_SECRET = 'test_secret_' + Math.random().toString(36).substring(7); + +// You need to set this - get it from: curl -X POST http://localhost:5000/api/auth/login -H "Content-Type: application/json" -d '{"key":"YOUR_ADMIN_KEY"}' +const JWT_TOKEN = process.env.JWT_TOKEN || ''; + +if (!JWT_TOKEN) { + console.error('\nโŒ ERROR: JWT_TOKEN not set!'); + console.error('\nPlease set your JWT token:'); + console.error(' 1. Get token: curl -X POST http://localhost:5000/api/auth/login -H "Content-Type: application/json" -d \'{"key":"YOUR_ADMIN_KEY"}\''); + console.error(' 2. Run: JWT_TOKEN="your_token_here" node test-webhook.js'); + console.error(' OR: export JWT_TOKEN="your_token_here" && node test-webhook.js\n'); + process.exit(1); +} + +let webhookId = null; +let receivedWebhooks = []; + +// Create Express app for webhook receiver +const app = express(); + +// Important: Parse JSON and keep raw body for signature verification +app.use(express.json({ + verify: (req, res, buf) => { + req.rawBody = buf.toString('utf8'); + } +})); + +// Webhook receiver endpoint +app.post('/webhook/jackbox', (req, res) => { + console.log('\n๐Ÿ“จ Webhook received!'); + console.log('Headers:', { + 'x-webhook-signature': req.headers['x-webhook-signature'], + 'x-webhook-event': req.headers['x-webhook-event'], + 'user-agent': req.headers['user-agent'] + }); + + const signature = req.headers['x-webhook-signature']; + + // Verify signature + if (!signature || !signature.startsWith('sha256=')) { + console.log('โŒ Missing or invalid signature format'); + return res.status(401).send('Missing or invalid signature'); + } + + const expectedSignature = 'sha256=' + crypto + .createHmac('sha256', WEBHOOK_SECRET) + .update(req.rawBody) + .digest('hex'); + + // Timing-safe comparison + let isValid = false; + try { + isValid = crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); + } catch (err) { + console.log('โŒ Signature verification failed:', err.message); + return res.status(401).send('Invalid signature'); + } + + if (!isValid) { + console.log('โŒ Signature mismatch!'); + console.log(' Expected:', expectedSignature); + console.log(' Received:', signature); + return res.status(401).send('Invalid signature'); + } + + console.log('โœ… Signature verified!'); + console.log('\nPayload:', JSON.stringify(req.body, null, 2)); + + if (req.body.event === 'game.added') { + const game = req.body.data.game; + console.log(`\n๐ŸŽฎ Game Added: ${game.title} from ${game.pack_name}`); + console.log(` Players: ${game.min_players}-${game.max_players}`); + console.log(` Session ID: ${req.body.data.session.id}`); + console.log(` Games Played: ${req.body.data.session.games_played}`); + } + + receivedWebhooks.push(req.body); + res.status(200).send('OK'); +}); + +// Health check +app.get('/health', (req, res) => { + res.json({ + status: 'ok', + webhooksReceived: receivedWebhooks.length + }); +}); + +// Start webhook receiver +const server = app.listen(WEBHOOK_PORT, async () => { + console.log(`\n๐Ÿš€ Webhook receiver started on http://localhost:${WEBHOOK_PORT}`); + console.log(`๐Ÿ“ Webhook endpoint: http://localhost:${WEBHOOK_PORT}/webhook/jackbox`); + console.log(`๐Ÿ” Secret: ${WEBHOOK_SECRET}\n`); + + // Wait a moment for server to be ready + await new Promise(resolve => setTimeout(resolve, 500)); + + // Run tests + await runTests(); +}); + +async function runTests() { + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + console.log('Starting Webhook Tests'); + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n'); + + try { + // Test 1: Create webhook + console.log('๐Ÿ“ Test 1: Creating webhook...'); + const createResponse = await fetch(`${API_URL}/api/webhooks`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${JWT_TOKEN}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: 'Test Webhook', + url: `http://localhost:${WEBHOOK_PORT}/webhook/jackbox`, + secret: WEBHOOK_SECRET, + events: ['game.added'] + }) + }); + + if (!createResponse.ok) { + const error = await createResponse.text(); + throw new Error(`Failed to create webhook: ${createResponse.status} ${error}`); + } + + const webhook = await createResponse.json(); + webhookId = webhook.id; + console.log(`โœ… Webhook created with ID: ${webhookId}\n`); + + // Test 2: List webhooks + console.log('๐Ÿ“ Test 2: Listing webhooks...'); + const listResponse = await fetch(`${API_URL}/api/webhooks`, { + headers: { + 'Authorization': `Bearer ${JWT_TOKEN}` + } + }); + + if (!listResponse.ok) { + throw new Error(`Failed to list webhooks: ${listResponse.status}`); + } + + const webhooks = await listResponse.json(); + console.log(`โœ… Found ${webhooks.length} webhook(s)\n`); + + // Test 3: Send test webhook + console.log('๐Ÿ“ Test 3: Sending test webhook...'); + const testResponse = await fetch(`${API_URL}/api/webhooks/test/${webhookId}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${JWT_TOKEN}` + } + }); + + if (!testResponse.ok) { + throw new Error(`Failed to send test webhook: ${testResponse.status}`); + } + + console.log('โœ… Test webhook sent\n'); + + // Wait for webhook to be received + console.log('โณ Waiting for webhook delivery (5 seconds)...'); + await new Promise(resolve => setTimeout(resolve, 5000)); + + if (receivedWebhooks.length > 0) { + console.log(`โœ… Received ${receivedWebhooks.length} webhook(s)!\n`); + } else { + console.log('โš ๏ธ No webhooks received yet. Check webhook logs.\n'); + } + + // Test 4: Check webhook logs + console.log('๐Ÿ“ Test 4: Checking webhook logs...'); + const logsResponse = await fetch(`${API_URL}/api/webhooks/${webhookId}/logs?limit=10`, { + headers: { + 'Authorization': `Bearer ${JWT_TOKEN}` + } + }); + + if (!logsResponse.ok) { + throw new Error(`Failed to get webhook logs: ${logsResponse.status}`); + } + + const logs = await logsResponse.json(); + console.log(`โœ… Found ${logs.length} log entries\n`); + + if (logs.length > 0) { + console.log('Recent webhook deliveries:'); + logs.forEach((log, i) => { + console.log(` ${i + 1}. Event: ${log.event_type}, Status: ${log.response_status || 'pending'}, Time: ${log.created_at}`); + if (log.error_message) { + console.log(` Error: ${log.error_message}`); + } + }); + console.log(''); + } + + // Summary + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + console.log('Test Summary'); + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + console.log(`โœ… Webhook created: ID ${webhookId}`); + console.log(`โœ… Webhooks received: ${receivedWebhooks.length}`); + console.log(`โœ… Webhook logs: ${logs.length} entries`); + console.log('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n'); + + console.log('๐ŸŽ‰ All tests completed!'); + console.log('\n๐Ÿ’ก Next steps:'); + console.log(' 1. Add a game to an active session in the Picker page'); + console.log(' 2. Watch for the webhook to be received here'); + console.log(' 3. Press Ctrl+C to cleanup and exit\n'); + + } catch (error) { + console.error('\nโŒ Test failed:', error.message); + console.error('\nMake sure:'); + console.error(' - Backend server is running (http://localhost:5000)'); + console.error(' - JWT_TOKEN is valid'); + console.error(' - Port 3001 is available\n'); + await cleanup(); + process.exit(1); + } +} + +async function cleanup() { + console.log('\n๐Ÿงน Cleaning up...'); + + if (webhookId) { + try { + console.log(` Deleting webhook ${webhookId}...`); + const deleteResponse = await fetch(`${API_URL}/api/webhooks/${webhookId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${JWT_TOKEN}` + } + }); + + if (deleteResponse.ok) { + console.log(' โœ… Webhook deleted'); + } else { + console.log(' โš ๏ธ Could not delete webhook (you may need to delete it manually)'); + } + } catch (err) { + console.log(' โš ๏ธ Error during cleanup:', err.message); + } + } + + console.log(' Stopping webhook receiver...'); + server.close(() => { + console.log(' โœ… Server stopped'); + console.log('\n๐Ÿ‘‹ Goodbye!\n'); + process.exit(0); + }); +} + +// Handle Ctrl+C +process.on('SIGINT', async () => { + console.log('\n\nโš ๏ธ Received interrupt signal'); + await cleanup(); +}); + +// Handle errors +process.on('uncaughtException', async (err) => { + console.error('\nโŒ Uncaught exception:', err); + await cleanup(); +}); + diff --git a/test-webhook.sh b/test-webhook.sh new file mode 100755 index 0000000..91fd36d --- /dev/null +++ b/test-webhook.sh @@ -0,0 +1,268 @@ +#!/bin/bash + +# Webhook Test Script (Bash/curl version) +# This script tests the webhook system using only curl and bash + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +API_URL="${API_URL:-https://hso.cottongin.xyz/api}" +WEBHOOK_PORT="${WEBHOOK_PORT:-3001}" +WEBHOOK_SECRET="test_secret_$(date +%s)" + +echo -e "\n${BLUE}โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—${NC}" +echo -e "${BLUE}โ•‘ Webhook Test Script (curl) โ•‘${NC}" +echo -e "${BLUE}โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}\n" + +# Check if JWT_TOKEN is set +if [ -z "$JWT_TOKEN" ]; then + echo -e "${RED}โŒ ERROR: JWT_TOKEN not set!${NC}\n" + echo "Please set your JWT token:" + echo " 1. Get token:" + echo " curl -X POST https://hso.cottongin.xyz/api/auth/login \\" + echo " -H \"Content-Type: application/json\" \\" + echo " -d '{\"key\":\"YOUR_ADMIN_KEY\"}'" + echo "" + echo " 2. Export the token:" + echo " export JWT_TOKEN=\"your_token_here\"" + echo "" + echo " 3. Run this script:" + echo " ./test-webhook.sh" + echo "" + exit 1 +fi + +# Check if nc (netcat) is available +if ! command -v nc &> /dev/null; then + echo -e "${RED}โŒ ERROR: 'nc' (netcat) command not found!${NC}" + echo "Please install netcat to run this test." + exit 1 +fi + +echo -e "${GREEN}โœ“${NC} JWT_TOKEN is set" +echo -e "${GREEN}โœ“${NC} API URL: $API_URL" +echo -e "${GREEN}โœ“${NC} Webhook Port: $WEBHOOK_PORT" +echo -e "${GREEN}โœ“${NC} Webhook Secret: $WEBHOOK_SECRET" +echo "" + +# Start a simple webhook receiver in the background +echo -e "${BLUE}๐Ÿš€ Starting webhook receiver on port $WEBHOOK_PORT...${NC}" + +# Create a named pipe for communication +FIFO="/tmp/webhook_test_$$" +mkfifo "$FIFO" + +# Simple HTTP server using netcat +( + while true; do + { + # Read the request + read -r REQUEST + + # Read headers until empty line + CONTENT_LENGTH=0 + SIGNATURE="" + EVENT="" + while read -r HEADER; do + HEADER=$(echo "$HEADER" | tr -d '\r') + [ -z "$HEADER" ] && break + + if [[ "$HEADER" =~ ^Content-Length:\ ([0-9]+) ]]; then + CONTENT_LENGTH="${BASH_REMATCH[1]}" + fi + if [[ "$HEADER" =~ ^X-Webhook-Signature:\ (.+) ]]; then + SIGNATURE="${BASH_REMATCH[1]}" + fi + if [[ "$HEADER" =~ ^X-Webhook-Event:\ (.+) ]]; then + EVENT="${BASH_REMATCH[1]}" + fi + done + + # Read body if Content-Length is set + BODY="" + if [ "$CONTENT_LENGTH" -gt 0 ]; then + BODY=$(dd bs=1 count="$CONTENT_LENGTH" 2>/dev/null) + fi + + # Log the webhook + if [ -n "$BODY" ]; then + echo "" >> "$FIFO" + echo "๐Ÿ“จ Webhook received!" >> "$FIFO" + echo " Event: $EVENT" >> "$FIFO" + echo " Signature: $SIGNATURE" >> "$FIFO" + echo " Body: $BODY" >> "$FIFO" + echo "" >> "$FIFO" + fi + + # Send response + echo "HTTP/1.1 200 OK" + echo "Content-Type: text/plain" + echo "Content-Length: 2" + echo "" + echo "OK" + } | nc -l -p "$WEBHOOK_PORT" -q 1 + done +) & + +WEBHOOK_PID=$! + +# Display webhook output in background +tail -f "$FIFO" & +TAIL_PID=$! + +# Give the server a moment to start +sleep 2 + +echo -e "${GREEN}โœ“${NC} Webhook receiver started (PID: $WEBHOOK_PID)" +echo -e "${GREEN}โœ“${NC} Listening on http://localhost:$WEBHOOK_PORT/webhook/jackbox" +echo "" + +# Cleanup function +cleanup() { + echo "" + echo -e "${YELLOW}๐Ÿงน Cleaning up...${NC}" + + if [ -n "$WEBHOOK_ID" ]; then + echo " Deleting webhook $WEBHOOK_ID..." + DELETE_RESPONSE=$(curl -s -w "\n%{http_code}" -X DELETE \ + "$API_URL/api/webhooks/$WEBHOOK_ID" \ + -H "Authorization: Bearer $JWT_TOKEN") + + DELETE_CODE=$(echo "$DELETE_RESPONSE" | tail -n1) + if [ "$DELETE_CODE" = "200" ]; then + echo -e " ${GREEN}โœ“${NC} Webhook deleted" + else + echo -e " ${YELLOW}โš ${NC} Could not delete webhook (you may need to delete it manually)" + fi + fi + + echo " Stopping webhook receiver..." + kill $WEBHOOK_PID 2>/dev/null || true + kill $TAIL_PID 2>/dev/null || true + rm -f "$FIFO" + echo -e " ${GREEN}โœ“${NC} Cleanup complete" + echo "" + echo -e "${BLUE}๐Ÿ‘‹ Goodbye!${NC}\n" + exit 0 +} + +# Trap Ctrl+C +trap cleanup SIGINT SIGTERM + +echo -e "${BLUE}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" +echo -e "${BLUE}Starting Webhook Tests${NC}" +echo -e "${BLUE}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}\n" + +# Test 1: Create webhook +echo -e "${YELLOW}๐Ÿ“ Test 1: Creating webhook...${NC}" +CREATE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ + "$API_URL/api/webhooks" \ + -H "Authorization: Bearer $JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"Test Webhook\", + \"url\": \"http://host.docker.internal:$WEBHOOK_PORT/webhook/jackbox\", + \"secret\": \"$WEBHOOK_SECRET\", + \"events\": [\"game.added\"] + }") + +CREATE_CODE=$(echo "$CREATE_RESPONSE" | tail -n1) +CREATE_BODY=$(echo "$CREATE_RESPONSE" | head -n-1) + +if [ "$CREATE_CODE" = "201" ]; then + WEBHOOK_ID=$(echo "$CREATE_BODY" | grep -o '"id":[0-9]*' | grep -o '[0-9]*') + echo -e "${GREEN}โœ“${NC} Webhook created with ID: $WEBHOOK_ID" + echo " Response: $CREATE_BODY" +else + echo -e "${RED}โœ—${NC} Failed to create webhook (HTTP $CREATE_CODE)" + echo " Response: $CREATE_BODY" + cleanup +fi +echo "" + +# Test 2: List webhooks +echo -e "${YELLOW}๐Ÿ“ Test 2: Listing webhooks...${NC}" +LIST_RESPONSE=$(curl -s -w "\n%{http_code}" \ + "$API_URL/api/webhooks" \ + -H "Authorization: Bearer $JWT_TOKEN") + +LIST_CODE=$(echo "$LIST_RESPONSE" | tail -n1) +LIST_BODY=$(echo "$LIST_RESPONSE" | head -n-1) + +if [ "$LIST_CODE" = "200" ]; then + WEBHOOK_COUNT=$(echo "$LIST_BODY" | grep -o '"id":' | wc -l) + echo -e "${GREEN}โœ“${NC} Found $WEBHOOK_COUNT webhook(s)" +else + echo -e "${RED}โœ—${NC} Failed to list webhooks (HTTP $LIST_CODE)" +fi +echo "" + +# Test 3: Send test webhook +echo -e "${YELLOW}๐Ÿ“ Test 3: Sending test webhook...${NC}" +TEST_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ + "$API_URL/api/webhooks/test/$WEBHOOK_ID" \ + -H "Authorization: Bearer $JWT_TOKEN") + +TEST_CODE=$(echo "$TEST_RESPONSE" | tail -n1) + +if [ "$TEST_CODE" = "200" ]; then + echo -e "${GREEN}โœ“${NC} Test webhook sent" +else + echo -e "${RED}โœ—${NC} Failed to send test webhook (HTTP $TEST_CODE)" +fi +echo "" + +# Wait for webhook delivery +echo -e "${YELLOW}โณ Waiting for webhook delivery (5 seconds)...${NC}" +sleep 5 +echo "" + +# Test 4: Check webhook logs +echo -e "${YELLOW}๐Ÿ“ Test 4: Checking webhook logs...${NC}" +LOGS_RESPONSE=$(curl -s -w "\n%{http_code}" \ + "$API_URL/api/webhooks/$WEBHOOK_ID/logs?limit=10" \ + -H "Authorization: Bearer $JWT_TOKEN") + +LOGS_CODE=$(echo "$LOGS_RESPONSE" | tail -n1) +LOGS_BODY=$(echo "$LOGS_RESPONSE" | head -n-1) + +if [ "$LOGS_CODE" = "200" ]; then + LOG_COUNT=$(echo "$LOGS_BODY" | grep -o '"id":' | wc -l) + echo -e "${GREEN}โœ“${NC} Found $LOG_COUNT log entries" + echo "" + echo "Recent webhook deliveries:" + echo "$LOGS_BODY" | python3 -m json.tool 2>/dev/null || echo "$LOGS_BODY" +else + echo -e "${RED}โœ—${NC} Failed to get webhook logs (HTTP $LOGS_CODE)" +fi +echo "" + +# Summary +echo -e "${BLUE}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" +echo -e "${BLUE}Test Summary${NC}" +echo -e "${BLUE}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}" +echo -e "${GREEN}โœ“${NC} Webhook created: ID $WEBHOOK_ID" +echo -e "${GREEN}โœ“${NC} Test webhook sent" +echo -e "${GREEN}โœ“${NC} Webhook logs: $LOG_COUNT entries" +echo -e "${BLUE}โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•${NC}\n" + +echo -e "${GREEN}๐ŸŽ‰ All tests completed!${NC}" +echo "" +echo -e "${BLUE}๐Ÿ’ก Next steps:${NC}" +echo " 1. Add a game to an active session in the Picker page" +echo " 2. Watch for the webhook to be received above" +echo " 3. Press Ctrl+C to cleanup and exit" +echo "" +echo -e "${YELLOW}โณ Webhook receiver is still running...${NC}" +echo "" + +# Keep running until Ctrl+C +wait $WEBHOOK_PID +