IDK, it's working and we're moving on

This commit is contained in:
cottongin
2025-11-02 16:06:31 -05:00
parent 6308d99d33
commit 2a75237e90
26 changed files with 5231 additions and 45 deletions

474
API_QUICK_REFERENCE.md Normal file
View File

@@ -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=<hmac_signature>`
- `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=<hmac_signature>`
- `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=<hmac_signature>`
- `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)

667
BOT_INTEGRATION.md Normal file
View File

@@ -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_signature>` - 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

View File

@@ -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 - Automatically matches votes to games based on timestamps
- Updates popularity scores across sessions - 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 ### Public Features
- View active session and games currently being played - View active session and games currently being played
- Browse session history - 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 │ │ ├── games.js # Game CRUD and management
│ │ ├── sessions.js # Session management │ │ ├── sessions.js # Session management
│ │ ├── picker.js # Game picker algorithm │ │ ├── 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 │ ├── middleware/ # Express middleware
│ │ └── auth.js # JWT authentication │ │ └── auth.js # JWT authentication
│ ├── utils/ # Utility functions
│ │ └── webhooks.js # Webhook trigger and signature
│ ├── database.js # SQLite database setup │ ├── database.js # SQLite database setup
│ ├── bootstrap.js # Database initialization │ ├── bootstrap.js # Database initialization
│ ├── server.js # Express app entry point │ ├── 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 ### Statistics
- `GET /api/stats` - Get overall 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 ## Usage Guide
### Starting a Game Session ### Starting a Game Session
@@ -303,21 +331,40 @@ The system will:
3. Update the game's popularity score (+1 for ++, -1 for --) 3. Update the game's popularity score (+1 for ++, -1 for --)
4. Store the chat log in the database 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 ## Database Schema
### games ### games
- id, pack_name, title, min_players, max_players, length_minutes - id, pack_name, title, min_players, max_players, length_minutes
- has_audience, family_friendly, game_type, secondary_type - 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 ### sessions
- id, created_at, closed_at, is_active, notes - id, created_at, closed_at, is_active, notes
### session_games ### session_games
- id, session_id, game_id, played_at, manually_added - id, session_id, game_id, played_at, manually_added, status
### chat_logs ### 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 ## Game Selection Algorithm

105
SESSION_END_QUICK_START.md Normal file
View File

@@ -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 |

306
SESSION_END_WEBSOCKET.md Normal file
View File

@@ -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 <session_id> <jwt_token>
```
**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 <your-jwt-token>
```
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 <your-jwt-token>" \
-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)

361
SESSION_START_WEBSOCKET.md Normal file
View File

@@ -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 <your-jwt-token>" \
-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.

256
WEBSOCKET_FLOW_DIAGRAM.md Normal file
View File

@@ -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

View File

@@ -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

239
WEBSOCKET_TESTING.md Normal file
View File

@@ -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.

View File

@@ -77,6 +77,13 @@ function initializeDatabase() {
// Column already exists, ignore error // 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 // Add favor_bias column to games if it doesn't exist
try { try {
db.exec(`ALTER TABLE games ADD COLUMN favor_bias INTEGER DEFAULT 0`); db.exec(`ALTER TABLE games ADD COLUMN favor_bias INTEGER DEFAULT 0`);
@@ -167,6 +174,55 @@ function initializeDatabase() {
// Index already exists, ignore error // 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'); console.log('Database initialized successfully');
} }

View File

@@ -17,7 +17,8 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"csv-parse": "^5.5.3", "csv-parse": "^5.5.3",
"csv-stringify": "^6.4.5" "csv-stringify": "^6.4.5",
"ws": "^8.14.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.2" "nodemon": "^3.0.2"

View File

@@ -2,6 +2,8 @@ const express = require('express');
const crypto = require('crypto'); const crypto = require('crypto');
const { authenticateToken } = require('../middleware/auth'); const { authenticateToken } = require('../middleware/auth');
const db = require('../database'); const db = require('../database');
const { triggerWebhook } = require('../utils/webhooks');
const { getWebSocketManager } = require('../utils/websocket-manager');
const router = express.Router(); const router = express.Router();
@@ -103,6 +105,27 @@ router.post('/', authenticateToken, (req, res) => {
const result = stmt.run(notes || null); const result = stmt.run(notes || null);
const newSession = db.prepare('SELECT * FROM sessions WHERE id = ?').get(result.lastInsertRowid); 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); res.status(201).json(newSession);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); 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); 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); res.json(closedSession);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
@@ -202,7 +255,7 @@ router.get('/:id/games', (req, res) => {
// Add game to session (admin only) // Add game to session (admin only)
router.post('/:id/games', authenticateToken, (req, res) => { router.post('/:id/games', authenticateToken, (req, res) => {
try { try {
const { game_id, manually_added } = req.body; const { game_id, manually_added, room_code } = req.body;
if (!game_id) { if (!game_id) {
return res.status(400).json({ error: 'game_id is required' }); 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 // Add game to session with 'playing' status
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO session_games (session_id, game_id, manually_added, status) INSERT INTO session_games (session_id, game_id, manually_added, status, room_code)
VALUES (?, ?, ?, 'playing') 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 // Increment play count for the game
db.prepare('UPDATE games SET play_count = play_count + 1 WHERE id = ?').run(game_id); 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.*, sg.*,
g.pack_name, g.pack_name,
g.title, g.title,
g.game_type g.game_type,
g.min_players,
g.max_players
FROM session_games sg FROM session_games sg
JOIN games g ON sg.game_id = g.id JOIN games g ON sg.game_id = g.id
WHERE sg.id = ? WHERE sg.id = ?
`).get(result.lastInsertRowid); `).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); res.status(201).json(sessionGame);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); 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) // Export session data (plaintext and JSON)
router.get('/:id/export', authenticateToken, (req, res) => { router.get('/:id/export', authenticateToken, (req, res) => {
try { try {

198
backend/routes/votes.js Normal file
View File

@@ -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;

271
backend/routes/webhooks.js Normal file
View File

@@ -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;

View File

@@ -1,7 +1,9 @@
require('dotenv').config(); require('dotenv').config();
const express = require('express'); const express = require('express');
const http = require('http');
const cors = require('cors'); const cors = require('cors');
const { bootstrapGames } = require('./bootstrap'); const { bootstrapGames } = require('./bootstrap');
const { WebSocketManager, setWebSocketManager } = require('./utils/websocket-manager');
const app = express(); const app = express();
const PORT = process.env.PORT || 5000; const PORT = process.env.PORT || 5000;
@@ -24,12 +26,16 @@ const gamesRoutes = require('./routes/games');
const sessionsRoutes = require('./routes/sessions'); const sessionsRoutes = require('./routes/sessions');
const statsRoutes = require('./routes/stats'); const statsRoutes = require('./routes/stats');
const pickerRoutes = require('./routes/picker'); const pickerRoutes = require('./routes/picker');
const votesRoutes = require('./routes/votes');
const webhooksRoutes = require('./routes/webhooks');
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/games', gamesRoutes); app.use('/api/games', gamesRoutes);
app.use('/api/sessions', sessionsRoutes); app.use('/api/sessions', sessionsRoutes);
app.use('/api/stats', statsRoutes); app.use('/api/stats', statsRoutes);
app.use('/api', pickerRoutes); app.use('/api', pickerRoutes);
app.use('/api/votes', votesRoutes);
app.use('/api/webhooks', webhooksRoutes);
// Error handling middleware // Error handling middleware
app.use((err, req, res, next) => { 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 }); 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(`Server is running on port ${PORT}`);
console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`);
}); });

122
backend/test-websocket.js Normal file
View File

@@ -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();
});

151
backend/utils/webhooks.js Normal file
View File

@@ -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
};

View File

@@ -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<ws, clientInfo>
this.sessionSubscriptions = new Map(); // Map<sessionId, Set<ws>>
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;
}
};

View File

@@ -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 (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
onClick={handleOverlayClick}
>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-md w-full p-6 animate-fade-in">
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-2">
Enter Room Code
</h2>
{gameName && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
For: <span className="font-semibold">{gameName}</span>
</p>
)}
<form onSubmit={handleSubmit}>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
4-Character Room Code (A-Z, 0-9)
</label>
<div className="relative">
<input
ref={inputRef}
type="text"
value={roomCode}
onChange={handleInputChange}
placeholder="ABCD"
className="w-full px-4 py-3 text-center text-2xl font-mono font-bold tracking-widest border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 uppercase"
maxLength={4}
autoComplete="off"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-500 dark:text-gray-400 font-mono">
{roomCode.length}/4
</div>
</div>
{error && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
</div>
<div className="flex gap-3">
<button
type="button"
onClick={onCancel}
className="flex-1 px-4 py-3 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition font-semibold"
>
Cancel
</button>
<button
type="submit"
disabled={roomCode.length !== 4}
className="flex-1 px-4 py-3 bg-indigo-600 dark:bg-indigo-700 text-white rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition font-semibold disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600 dark:disabled:hover:bg-indigo-700"
>
Confirm
</button>
</div>
</form>
</div>
</div>
);
}
export default RoomCodeModal;

View File

@@ -2,7 +2,7 @@ export const branding = {
app: { app: {
name: 'HSO Jackbox Game Picker', name: 'HSO Jackbox Game Picker',
shortName: '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!', description: 'Spicing up Hyper Spaceout game nights!',
}, },
meta: { meta: {

View File

@@ -144,6 +144,11 @@ function Home() {
Skipped Skipped
</span> </span>
)} )}
{game.room_code && (
<span className="inline-flex items-center gap-1 text-xs bg-indigo-600 dark:bg-indigo-700 text-white px-2 py-1 rounded font-mono font-bold">
🎮 {game.room_code}
</span>
)}
<PopularityBadge <PopularityBadge
upvotes={game.upvotes || 0} upvotes={game.upvotes || 0}
downvotes={game.downvotes || 0} downvotes={game.downvotes || 0}

View File

@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import api from '../api/axios'; import api from '../api/axios';
import GamePoolModal from '../components/GamePoolModal'; import GamePoolModal from '../components/GamePoolModal';
import RoomCodeModal from '../components/RoomCodeModal';
import { formatLocalTime } from '../utils/dateUtils'; import { formatLocalTime } from '../utils/dateUtils';
import PopularityBadge from '../components/PopularityBadge'; import PopularityBadge from '../components/PopularityBadge';
@@ -41,6 +42,10 @@ function Picker() {
// Exclude previously played games // Exclude previously played games
const [excludePlayedGames, setExcludePlayedGames] = useState(false); const [excludePlayedGames, setExcludePlayedGames] = useState(false);
// Room code modal
const [showRoomCodeModal, setShowRoomCodeModal] = useState(false);
const [pendingGameAction, setPendingGameAction] = useState(null);
const checkActiveSession = useCallback(async () => { const checkActiveSession = useCallback(async () => {
try { try {
@@ -194,56 +199,77 @@ function Picker() {
const handleAcceptGame = async () => { const handleAcceptGame = async () => {
if (!selectedGame || !activeSession) return; if (!selectedGame || !activeSession) return;
// Show room code modal
setPendingGameAction({
type: 'accept',
game: selectedGame
});
setShowRoomCodeModal(true);
};
const handleRoomCodeConfirm = async (roomCode) => {
if (!pendingGameAction || !activeSession) return;
try { try {
await api.post(`/sessions/${activeSession.id}/games`, { const { type, game, gameId } = pendingGameAction;
game_id: selectedGame.id,
manually_added: false 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 // Trigger games list refresh
setGamesUpdateTrigger(prev => prev + 1); setGamesUpdateTrigger(prev => prev + 1);
setSelectedGame(null);
setError(''); setError('');
} catch (err) { } catch (err) {
setError('Failed to add game to session'); setError('Failed to add game to session');
} finally {
setShowRoomCodeModal(false);
setPendingGameAction(null);
} }
}; };
const handleRoomCodeCancel = () => {
setShowRoomCodeModal(false);
setPendingGameAction(null);
};
const handleAddManualGame = async () => { const handleAddManualGame = async () => {
if (!manualGameId || !activeSession) return; if (!manualGameId || !activeSession) return;
try { // Show room code modal
await api.post(`/sessions/${activeSession.id}/games`, { const game = allGames.find(g => g.id === parseInt(manualGameId));
game_id: parseInt(manualGameId), setPendingGameAction({
manually_added: true type: 'manual',
}); gameId: parseInt(manualGameId),
game: game
// Trigger games list refresh });
setGamesUpdateTrigger(prev => prev + 1); setShowRoomCodeModal(true);
setManualGameId('');
setShowManualSelect(false);
setError('');
} catch (err) {
setError('Failed to add game to session');
}
}; };
const handleSelectVersion = async (gameId) => { const handleSelectVersion = async (gameId) => {
if (!activeSession) return; if (!activeSession) return;
try { // Show room code modal
await api.post(`/sessions/${activeSession.id}/games`, { const game = allGames.find(g => g.id === gameId);
game_id: gameId, setPendingGameAction({
manually_added: false type: 'version',
}); gameId: gameId,
game: game
// Trigger games list refresh });
setGamesUpdateTrigger(prev => prev + 1); setShowRoomCodeModal(true);
setSelectedGame(null);
setError('');
} catch (err) {
setError('Failed to add game to session');
}
}; };
// Find similar versions of a game based on title patterns // Find similar versions of a game based on title patterns
@@ -572,6 +598,14 @@ function Picker() {
/> />
)} )}
{/* Room Code Modal */}
<RoomCodeModal
isOpen={showRoomCodeModal}
onConfirm={handleRoomCodeConfirm}
onCancel={handleRoomCodeCancel}
gameName={pendingGameAction?.game?.title}
/>
{/* Results Panel */} {/* Results Panel */}
<div className="md:col-span-2"> <div className="md:col-span-2">
{error && ( {error && (
@@ -730,6 +764,8 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [confirmingRemove, setConfirmingRemove] = useState(null); const [confirmingRemove, setConfirmingRemove] = useState(null);
const [showPopularity, setShowPopularity] = useState(true); const [showPopularity, setShowPopularity] = useState(true);
const [editingRoomCode, setEditingRoomCode] = useState(null);
const [newRoomCode, setNewRoomCode] = useState('');
const loadGames = useCallback(async () => { const loadGames = useCallback(async () => {
try { 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) => { const getStatusBadge = (status) => {
if (status === 'playing') { if (status === 'playing') {
return ( return (
@@ -857,6 +926,50 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
Manual Manual
</span> </span>
)} )}
{game.room_code && (
<div className="flex items-center gap-1">
{editingRoomCode === game.id ? (
<div className="flex items-center gap-1">
<input
type="text"
value={newRoomCode}
onChange={handleRoomCodeChange}
className="w-16 px-2 py-1 text-xs font-mono font-bold text-center border border-indigo-400 dark:border-indigo-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 uppercase focus:outline-none focus:ring-1 focus:ring-indigo-500"
maxLength={4}
autoFocus
/>
<button
onClick={() => handleSaveRoomCode(game.id)}
disabled={newRoomCode.length !== 4}
className="text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<button
onClick={handleCancelEditRoomCode}
className="text-xs px-2 py-1 bg-gray-500 text-white rounded hover:bg-gray-600"
>
</button>
</div>
) : (
<>
<span className="inline-flex items-center gap-1 text-xs bg-indigo-600 dark:bg-indigo-700 text-white px-2 py-1 rounded font-mono font-bold">
🎮 {game.room_code}
</span>
{isAuthenticated && (
<button
onClick={() => handleEditRoomCode(game.id, game.room_code)}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400"
title="Edit room code"
>
✏️
</button>
)}
</>
)}
</div>
)}
{showPopularity && ( {showPopularity && (
<PopularityBadge <PopularityBadge
upvotes={game.upvotes || 0} upvotes={game.upvotes || 0}

146
test-session-end-websocket.js Executable file
View File

@@ -0,0 +1,146 @@
#!/usr/bin/env node
/**
* Test script for session.ended WebSocket event
*
* This script:
* 1. Connects to the WebSocket server
* 2. Authenticates with a JWT token
* 3. Subscribes to a session
* 4. Listens for session.ended events
*
* Usage:
* node test-session-end-websocket.js <session_id> <jwt_token>
*
* 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 <session_id> <jwt_token>');
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);
});

182
test-webhook-simple.sh Executable file
View File

@@ -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

294
test-webhook.js Normal file
View File

@@ -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();
});

268
test-webhook.sh Executable file
View File

@@ -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