IDK, it's working and we're moving on
This commit is contained in:
474
API_QUICK_REFERENCE.md
Normal file
474
API_QUICK_REFERENCE.md
Normal 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
667
BOT_INTEGRATION.md
Normal 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
|
||||
|
||||
55
README.md
55
README.md
@@ -34,6 +34,18 @@ A full-stack web application that helps groups pick games to play from various J
|
||||
- Automatically matches votes to games based on timestamps
|
||||
- Updates popularity scores across sessions
|
||||
|
||||
- **Live Voting API**: Real-time vote processing from external bots
|
||||
- Accept live votes via REST API
|
||||
- Automatic deduplication (1-second window)
|
||||
- Timestamp-based game matching
|
||||
- JWT authentication for security
|
||||
|
||||
- **Webhook System**: Notify external services of events
|
||||
- Send notifications when games are added to sessions
|
||||
- HMAC-SHA256 signature verification
|
||||
- Webhook management (CRUD operations)
|
||||
- Delivery logging and testing
|
||||
|
||||
### Public Features
|
||||
- View active session and games currently being played
|
||||
- Browse session history
|
||||
@@ -174,9 +186,13 @@ The manifest is automatically generated during the build process, so you don't n
|
||||
│ │ ├── games.js # Game CRUD and management
|
||||
│ │ ├── sessions.js # Session management
|
||||
│ │ ├── picker.js # Game picker algorithm
|
||||
│ │ └── stats.js # Statistics endpoints
|
||||
│ │ ├── stats.js # Statistics endpoints
|
||||
│ │ ├── votes.js # Live voting endpoint
|
||||
│ │ └── webhooks.js # Webhook management
|
||||
│ ├── middleware/ # Express middleware
|
||||
│ │ └── auth.js # JWT authentication
|
||||
│ ├── utils/ # Utility functions
|
||||
│ │ └── webhooks.js # Webhook trigger and signature
|
||||
│ ├── database.js # SQLite database setup
|
||||
│ ├── bootstrap.js # Database initialization
|
||||
│ ├── server.js # Express app entry point
|
||||
@@ -242,6 +258,18 @@ The manifest is automatically generated during the build process, so you don't n
|
||||
### Statistics
|
||||
- `GET /api/stats` - Get overall statistics
|
||||
|
||||
### Live Votes
|
||||
- `POST /api/votes/live` - Submit real-time vote (admin)
|
||||
|
||||
### Webhooks
|
||||
- `GET /api/webhooks` - List all webhooks (admin)
|
||||
- `GET /api/webhooks/:id` - Get single webhook (admin)
|
||||
- `POST /api/webhooks` - Create webhook (admin)
|
||||
- `PATCH /api/webhooks/:id` - Update webhook (admin)
|
||||
- `DELETE /api/webhooks/:id` - Delete webhook (admin)
|
||||
- `POST /api/webhooks/test/:id` - Test webhook (admin)
|
||||
- `GET /api/webhooks/:id/logs` - Get webhook logs (admin)
|
||||
|
||||
## Usage Guide
|
||||
|
||||
### Starting a Game Session
|
||||
@@ -303,21 +331,40 @@ The system will:
|
||||
3. Update the game's popularity score (+1 for ++, -1 for --)
|
||||
4. Store the chat log in the database
|
||||
|
||||
## Bot Integration
|
||||
|
||||
For integrating external bots (e.g., for live voting and game notifications), see **[BOT_INTEGRATION.md](BOT_INTEGRATION.md)** for detailed documentation including:
|
||||
|
||||
- Live voting API usage
|
||||
- **WebSocket integration (recommended)** for real-time game notifications
|
||||
- Webhook setup and verification (alternative to WebSocket)
|
||||
- Example implementations in Node.js and Go
|
||||
- Security best practices
|
||||
|
||||
## Database Schema
|
||||
|
||||
### games
|
||||
- id, pack_name, title, min_players, max_players, length_minutes
|
||||
- has_audience, family_friendly, game_type, secondary_type
|
||||
- play_count, popularity_score, enabled, created_at
|
||||
- play_count, popularity_score, upvotes, downvotes, enabled, created_at
|
||||
|
||||
### sessions
|
||||
- id, created_at, closed_at, is_active, notes
|
||||
|
||||
### session_games
|
||||
- id, session_id, game_id, played_at, manually_added
|
||||
- id, session_id, game_id, played_at, manually_added, status
|
||||
|
||||
### chat_logs
|
||||
- id, session_id, chatter_name, message, timestamp, parsed_vote
|
||||
- id, session_id, chatter_name, message, timestamp, parsed_vote, message_hash
|
||||
|
||||
### live_votes
|
||||
- id, session_id, game_id, username, vote_type, timestamp, created_at
|
||||
|
||||
### webhooks
|
||||
- id, name, url, secret, events, enabled, created_at
|
||||
|
||||
### webhook_logs
|
||||
- id, webhook_id, event_type, payload, response_status, error_message, created_at
|
||||
|
||||
## Game Selection Algorithm
|
||||
|
||||
|
||||
105
SESSION_END_QUICK_START.md
Normal file
105
SESSION_END_QUICK_START.md
Normal 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
306
SESSION_END_WEBSOCKET.md
Normal 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
361
SESSION_START_WEBSOCKET.md
Normal 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
256
WEBSOCKET_FLOW_DIAGRAM.md
Normal 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
|
||||
|
||||
310
WEBSOCKET_SUBSCRIPTION_GUIDE.md
Normal file
310
WEBSOCKET_SUBSCRIPTION_GUIDE.md
Normal 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
239
WEBSOCKET_TESTING.md
Normal 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.
|
||||
|
||||
@@ -77,6 +77,13 @@ function initializeDatabase() {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
// Add room_code column if it doesn't exist (for existing databases)
|
||||
try {
|
||||
db.exec(`ALTER TABLE session_games ADD COLUMN room_code TEXT`);
|
||||
} catch (err) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
// Add favor_bias column to games if it doesn't exist
|
||||
try {
|
||||
db.exec(`ALTER TABLE games ADD COLUMN favor_bias INTEGER DEFAULT 0`);
|
||||
@@ -167,6 +174,55 @@ function initializeDatabase() {
|
||||
// Index already exists, ignore error
|
||||
}
|
||||
|
||||
// Live votes table for real-time voting
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS live_votes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
game_id INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
vote_type INTEGER NOT NULL,
|
||||
timestamp DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Create index for duplicate checking (username + timestamp within 1 second)
|
||||
try {
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_live_votes_dedup ON live_votes(username, timestamp)`);
|
||||
} catch (err) {
|
||||
// Index already exists, ignore error
|
||||
}
|
||||
|
||||
// Webhooks table for external integrations
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS webhooks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
secret TEXT NOT NULL,
|
||||
events TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Webhook logs table for debugging
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS webhook_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
webhook_id INTEGER NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
response_status INTEGER,
|
||||
error_message TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
console.log('Database initialized successfully');
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"csv-parse": "^5.5.3",
|
||||
"csv-stringify": "^6.4.5"
|
||||
"csv-stringify": "^6.4.5",
|
||||
"ws": "^8.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
|
||||
@@ -2,6 +2,8 @@ const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const db = require('../database');
|
||||
const { triggerWebhook } = require('../utils/webhooks');
|
||||
const { getWebSocketManager } = require('../utils/websocket-manager');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -103,6 +105,27 @@ router.post('/', authenticateToken, (req, res) => {
|
||||
const result = stmt.run(notes || null);
|
||||
const newSession = db.prepare('SELECT * FROM sessions WHERE id = ?').get(result.lastInsertRowid);
|
||||
|
||||
// Broadcast session.started event via WebSocket to all authenticated clients
|
||||
try {
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
const eventData = {
|
||||
session: {
|
||||
id: newSession.id,
|
||||
is_active: 1,
|
||||
created_at: newSession.created_at,
|
||||
notes: newSession.notes
|
||||
}
|
||||
};
|
||||
|
||||
wsManager.broadcastToAll('session.started', eventData);
|
||||
console.log(`[Sessions] Broadcasted session.started event for session ${newSession.id} to all clients`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't fail the request
|
||||
console.error('Error broadcasting session.started event:', error);
|
||||
}
|
||||
|
||||
res.status(201).json(newSession);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -139,7 +162,37 @@ router.post('/:id/close', authenticateToken, (req, res) => {
|
||||
|
||||
stmt.run(notes || null, req.params.id);
|
||||
|
||||
const closedSession = db.prepare('SELECT * FROM sessions WHERE id = ?').get(req.params.id);
|
||||
// Get updated session with games count
|
||||
const closedSession = db.prepare(`
|
||||
SELECT
|
||||
s.*,
|
||||
COUNT(sg.id) as games_played
|
||||
FROM sessions s
|
||||
LEFT JOIN session_games sg ON s.id = sg.session_id
|
||||
WHERE s.id = ?
|
||||
GROUP BY s.id
|
||||
`).get(req.params.id);
|
||||
|
||||
// Broadcast session.ended event via WebSocket
|
||||
try {
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
const eventData = {
|
||||
session: {
|
||||
id: closedSession.id,
|
||||
is_active: 0,
|
||||
games_played: closedSession.games_played
|
||||
}
|
||||
};
|
||||
|
||||
wsManager.broadcastEvent('session.ended', eventData, parseInt(req.params.id));
|
||||
console.log(`[Sessions] Broadcasted session.ended event for session ${req.params.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't fail the request
|
||||
console.error('Error broadcasting session.ended event:', error);
|
||||
}
|
||||
|
||||
res.json(closedSession);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -202,7 +255,7 @@ router.get('/:id/games', (req, res) => {
|
||||
// Add game to session (admin only)
|
||||
router.post('/:id/games', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const { game_id, manually_added } = req.body;
|
||||
const { game_id, manually_added, room_code } = req.body;
|
||||
|
||||
if (!game_id) {
|
||||
return res.status(400).json({ error: 'game_id is required' });
|
||||
@@ -238,11 +291,11 @@ router.post('/:id/games', authenticateToken, (req, res) => {
|
||||
|
||||
// Add game to session with 'playing' status
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO session_games (session_id, game_id, manually_added, status)
|
||||
VALUES (?, ?, ?, 'playing')
|
||||
INSERT INTO session_games (session_id, game_id, manually_added, status, room_code)
|
||||
VALUES (?, ?, ?, 'playing', ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(req.params.id, game_id, manually_added ? 1 : 0);
|
||||
const result = stmt.run(req.params.id, game_id, manually_added ? 1 : 0, room_code || null);
|
||||
|
||||
// Increment play count for the game
|
||||
db.prepare('UPDATE games SET play_count = play_count + 1 WHERE id = ?').run(game_id);
|
||||
@@ -252,12 +305,56 @@ router.post('/:id/games', authenticateToken, (req, res) => {
|
||||
sg.*,
|
||||
g.pack_name,
|
||||
g.title,
|
||||
g.game_type
|
||||
g.game_type,
|
||||
g.min_players,
|
||||
g.max_players
|
||||
FROM session_games sg
|
||||
JOIN games g ON sg.game_id = g.id
|
||||
WHERE sg.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
// Trigger webhook and WebSocket for game.added event
|
||||
try {
|
||||
const sessionStats = db.prepare(`
|
||||
SELECT
|
||||
s.*,
|
||||
COUNT(sg.id) as games_played
|
||||
FROM sessions s
|
||||
LEFT JOIN session_games sg ON s.id = sg.session_id
|
||||
WHERE s.id = ?
|
||||
GROUP BY s.id
|
||||
`).get(req.params.id);
|
||||
|
||||
const eventData = {
|
||||
session: {
|
||||
id: sessionStats.id,
|
||||
is_active: sessionStats.is_active === 1,
|
||||
games_played: sessionStats.games_played
|
||||
},
|
||||
game: {
|
||||
id: game.id,
|
||||
title: game.title,
|
||||
pack_name: game.pack_name,
|
||||
min_players: game.min_players,
|
||||
max_players: game.max_players,
|
||||
manually_added: manually_added || false,
|
||||
room_code: room_code || null
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger webhook (for backwards compatibility)
|
||||
triggerWebhook('game.added', eventData);
|
||||
|
||||
// Broadcast via WebSocket (new preferred method)
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
wsManager.broadcastEvent('game.added', eventData, parseInt(req.params.id));
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't fail the request
|
||||
console.error('Error triggering notifications:', error);
|
||||
}
|
||||
|
||||
res.status(201).json(sessionGame);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -498,6 +595,56 @@ router.delete('/:sessionId/games/:gameId', authenticateToken, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update room code for a session game (admin only)
|
||||
router.patch('/:sessionId/games/:gameId/room-code', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const { sessionId, gameId } = req.params;
|
||||
const { room_code } = req.body;
|
||||
|
||||
if (!room_code) {
|
||||
return res.status(400).json({ error: 'room_code is required' });
|
||||
}
|
||||
|
||||
// Validate room code format: 4 characters, A-Z and 0-9 only
|
||||
const roomCodeRegex = /^[A-Z0-9]{4}$/;
|
||||
if (!roomCodeRegex.test(room_code)) {
|
||||
return res.status(400).json({ error: 'room_code must be exactly 4 alphanumeric characters (A-Z, 0-9)' });
|
||||
}
|
||||
|
||||
// Update the room code
|
||||
const result = db.prepare(`
|
||||
UPDATE session_games
|
||||
SET room_code = ?
|
||||
WHERE session_id = ? AND id = ?
|
||||
`).run(room_code, sessionId, gameId);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: 'Session game not found' });
|
||||
}
|
||||
|
||||
// Return updated game data
|
||||
const updatedGame = db.prepare(`
|
||||
SELECT
|
||||
sg.*,
|
||||
g.pack_name,
|
||||
g.title,
|
||||
g.game_type,
|
||||
g.min_players,
|
||||
g.max_players,
|
||||
g.popularity_score,
|
||||
g.upvotes,
|
||||
g.downvotes
|
||||
FROM session_games sg
|
||||
JOIN games g ON sg.game_id = g.id
|
||||
WHERE sg.session_id = ? AND sg.id = ?
|
||||
`).get(sessionId, gameId);
|
||||
|
||||
res.json(updatedGame);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Export session data (plaintext and JSON)
|
||||
router.get('/:id/export', authenticateToken, (req, res) => {
|
||||
try {
|
||||
|
||||
198
backend/routes/votes.js
Normal file
198
backend/routes/votes.js
Normal 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
271
backend/routes/webhooks.js
Normal 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;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const cors = require('cors');
|
||||
const { bootstrapGames } = require('./bootstrap');
|
||||
const { WebSocketManager, setWebSocketManager } = require('./utils/websocket-manager');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 5000;
|
||||
@@ -24,12 +26,16 @@ const gamesRoutes = require('./routes/games');
|
||||
const sessionsRoutes = require('./routes/sessions');
|
||||
const statsRoutes = require('./routes/stats');
|
||||
const pickerRoutes = require('./routes/picker');
|
||||
const votesRoutes = require('./routes/votes');
|
||||
const webhooksRoutes = require('./routes/webhooks');
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/games', gamesRoutes);
|
||||
app.use('/api/sessions', sessionsRoutes);
|
||||
app.use('/api/stats', statsRoutes);
|
||||
app.use('/api', pickerRoutes);
|
||||
app.use('/api/votes', votesRoutes);
|
||||
app.use('/api/webhooks', webhooksRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
@@ -37,7 +43,15 @@ app.use((err, req, res, next) => {
|
||||
res.status(500).json({ error: 'Something went wrong!', message: err.message });
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
// Create HTTP server and attach WebSocket
|
||||
const server = http.createServer(app);
|
||||
|
||||
// Initialize WebSocket Manager
|
||||
const wsManager = new WebSocketManager(server);
|
||||
setWebSocketManager(wsManager);
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Server is running on port ${PORT}`);
|
||||
console.log(`WebSocket server available at ws://localhost:${PORT}/api/sessions/live`);
|
||||
});
|
||||
|
||||
|
||||
122
backend/test-websocket.js
Normal file
122
backend/test-websocket.js
Normal 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
151
backend/utils/webhooks.js
Normal 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
|
||||
};
|
||||
|
||||
333
backend/utils/websocket-manager.js
Normal file
333
backend/utils/websocket-manager.js
Normal 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;
|
||||
}
|
||||
};
|
||||
|
||||
120
frontend/src/components/RoomCodeModal.jsx
Normal file
120
frontend/src/components/RoomCodeModal.jsx
Normal 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;
|
||||
|
||||
@@ -2,7 +2,7 @@ export const branding = {
|
||||
app: {
|
||||
name: 'HSO Jackbox Game Picker',
|
||||
shortName: 'Jackbox Game Picker',
|
||||
version: '0.3.6 - Safari Walkabout Edition',
|
||||
version: '0.4.2 - Safari Walkabout Edition',
|
||||
description: 'Spicing up Hyper Spaceout game nights!',
|
||||
},
|
||||
meta: {
|
||||
|
||||
@@ -144,6 +144,11 @@ function Home() {
|
||||
⏭️ Skipped
|
||||
</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
|
||||
upvotes={game.upvotes || 0}
|
||||
downvotes={game.downvotes || 0}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import api from '../api/axios';
|
||||
import GamePoolModal from '../components/GamePoolModal';
|
||||
import RoomCodeModal from '../components/RoomCodeModal';
|
||||
import { formatLocalTime } from '../utils/dateUtils';
|
||||
import PopularityBadge from '../components/PopularityBadge';
|
||||
|
||||
@@ -42,6 +43,10 @@ function Picker() {
|
||||
// Exclude previously played games
|
||||
const [excludePlayedGames, setExcludePlayedGames] = useState(false);
|
||||
|
||||
// Room code modal
|
||||
const [showRoomCodeModal, setShowRoomCodeModal] = useState(false);
|
||||
const [pendingGameAction, setPendingGameAction] = useState(null);
|
||||
|
||||
const checkActiveSession = useCallback(async () => {
|
||||
try {
|
||||
const sessionResponse = await api.get('/sessions/active');
|
||||
@@ -194,56 +199,77 @@ function Picker() {
|
||||
const handleAcceptGame = async () => {
|
||||
if (!selectedGame || !activeSession) return;
|
||||
|
||||
// Show room code modal
|
||||
setPendingGameAction({
|
||||
type: 'accept',
|
||||
game: selectedGame
|
||||
});
|
||||
setShowRoomCodeModal(true);
|
||||
};
|
||||
|
||||
const handleRoomCodeConfirm = async (roomCode) => {
|
||||
if (!pendingGameAction || !activeSession) return;
|
||||
|
||||
try {
|
||||
await api.post(`/sessions/${activeSession.id}/games`, {
|
||||
game_id: selectedGame.id,
|
||||
manually_added: false
|
||||
});
|
||||
const { type, game, gameId } = pendingGameAction;
|
||||
|
||||
if (type === 'accept' || type === 'version') {
|
||||
await api.post(`/sessions/${activeSession.id}/games`, {
|
||||
game_id: gameId || game.id,
|
||||
manually_added: false,
|
||||
room_code: roomCode
|
||||
});
|
||||
setSelectedGame(null);
|
||||
} else if (type === 'manual') {
|
||||
await api.post(`/sessions/${activeSession.id}/games`, {
|
||||
game_id: gameId,
|
||||
manually_added: true,
|
||||
room_code: roomCode
|
||||
});
|
||||
setManualGameId('');
|
||||
setShowManualSelect(false);
|
||||
}
|
||||
|
||||
// Trigger games list refresh
|
||||
setGamesUpdateTrigger(prev => prev + 1);
|
||||
setSelectedGame(null);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError('Failed to add game to session');
|
||||
} finally {
|
||||
setShowRoomCodeModal(false);
|
||||
setPendingGameAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoomCodeCancel = () => {
|
||||
setShowRoomCodeModal(false);
|
||||
setPendingGameAction(null);
|
||||
};
|
||||
|
||||
const handleAddManualGame = async () => {
|
||||
if (!manualGameId || !activeSession) return;
|
||||
|
||||
try {
|
||||
await api.post(`/sessions/${activeSession.id}/games`, {
|
||||
game_id: parseInt(manualGameId),
|
||||
manually_added: true
|
||||
});
|
||||
|
||||
// Trigger games list refresh
|
||||
setGamesUpdateTrigger(prev => prev + 1);
|
||||
setManualGameId('');
|
||||
setShowManualSelect(false);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError('Failed to add game to session');
|
||||
}
|
||||
// Show room code modal
|
||||
const game = allGames.find(g => g.id === parseInt(manualGameId));
|
||||
setPendingGameAction({
|
||||
type: 'manual',
|
||||
gameId: parseInt(manualGameId),
|
||||
game: game
|
||||
});
|
||||
setShowRoomCodeModal(true);
|
||||
};
|
||||
|
||||
const handleSelectVersion = async (gameId) => {
|
||||
if (!activeSession) return;
|
||||
|
||||
try {
|
||||
await api.post(`/sessions/${activeSession.id}/games`, {
|
||||
game_id: gameId,
|
||||
manually_added: false
|
||||
});
|
||||
|
||||
// Trigger games list refresh
|
||||
setGamesUpdateTrigger(prev => prev + 1);
|
||||
setSelectedGame(null);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError('Failed to add game to session');
|
||||
}
|
||||
// Show room code modal
|
||||
const game = allGames.find(g => g.id === gameId);
|
||||
setPendingGameAction({
|
||||
type: 'version',
|
||||
gameId: gameId,
|
||||
game: game
|
||||
});
|
||||
setShowRoomCodeModal(true);
|
||||
};
|
||||
|
||||
// Find similar versions of a game based on title patterns
|
||||
@@ -572,6 +598,14 @@ function Picker() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Room Code Modal */}
|
||||
<RoomCodeModal
|
||||
isOpen={showRoomCodeModal}
|
||||
onConfirm={handleRoomCodeConfirm}
|
||||
onCancel={handleRoomCodeCancel}
|
||||
gameName={pendingGameAction?.game?.title}
|
||||
/>
|
||||
|
||||
{/* Results Panel */}
|
||||
<div className="md:col-span-2">
|
||||
{error && (
|
||||
@@ -730,6 +764,8 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [confirmingRemove, setConfirmingRemove] = useState(null);
|
||||
const [showPopularity, setShowPopularity] = useState(true);
|
||||
const [editingRoomCode, setEditingRoomCode] = useState(null);
|
||||
const [newRoomCode, setNewRoomCode] = useState('');
|
||||
|
||||
const loadGames = useCallback(async () => {
|
||||
try {
|
||||
@@ -788,6 +824,39 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditRoomCode = (gameId, currentCode) => {
|
||||
setEditingRoomCode(gameId);
|
||||
setNewRoomCode(currentCode || '');
|
||||
};
|
||||
|
||||
const handleRoomCodeChange = (e) => {
|
||||
const value = e.target.value.toUpperCase();
|
||||
const filtered = value.replace(/[^A-Z0-9]/g, '').slice(0, 4);
|
||||
setNewRoomCode(filtered);
|
||||
};
|
||||
|
||||
const handleSaveRoomCode = async (gameId) => {
|
||||
if (newRoomCode.length !== 4) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.patch(`/sessions/${sessionId}/games/${gameId}/room-code`, {
|
||||
room_code: newRoomCode
|
||||
});
|
||||
setEditingRoomCode(null);
|
||||
setNewRoomCode('');
|
||||
loadGames(); // Reload to show updated code
|
||||
} catch (err) {
|
||||
console.error('Failed to update room code', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEditRoomCode = () => {
|
||||
setEditingRoomCode(null);
|
||||
setNewRoomCode('');
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
if (status === 'playing') {
|
||||
return (
|
||||
@@ -857,6 +926,50 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
|
||||
Manual
|
||||
</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 && (
|
||||
<PopularityBadge
|
||||
upvotes={game.upvotes || 0}
|
||||
|
||||
146
test-session-end-websocket.js
Executable file
146
test-session-end-websocket.js
Executable 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
182
test-webhook-simple.sh
Executable 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
294
test-webhook.js
Normal 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
268
test-webhook.sh
Executable 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
|
||||
|
||||
Reference in New Issue
Block a user