Compare commits
10 Commits
8f3a12ad76
...
52e00e56f6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52e00e56f6
|
||
|
|
84398ebdd0
|
||
|
|
974f0e4a67
|
||
|
|
5cf5901001
|
||
|
|
f52754ac87
|
||
|
|
140988d01d
|
||
|
|
2a75237e90
|
||
|
|
6308d99d33
|
||
|
|
47db3890e2
|
||
|
|
1a74b4d777
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -15,6 +15,9 @@ node_modules/
|
||||
frontend/dist/
|
||||
frontend/build/
|
||||
|
||||
# Generated files
|
||||
frontend/public/manifest.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
125
README.md
125
README.md
@@ -4,6 +4,12 @@ A full-stack web application that helps groups pick games to play from various J
|
||||
|
||||
## Features
|
||||
|
||||
### Progressive Web App (PWA)
|
||||
- **Installable**: Add to home screen on mobile and desktop devices
|
||||
- **Offline Support**: Service worker provides offline functionality
|
||||
- **Native Experience**: Runs like a native app when installed
|
||||
- **Auto-updates**: Seamlessly updates to new versions
|
||||
|
||||
### Admin Features
|
||||
- **Game Picker**: Randomly select games with intelligent filters
|
||||
- Filter by player count, drawing games, game length, and family-friendly status
|
||||
@@ -28,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
|
||||
@@ -130,6 +148,34 @@ The backend will run on http://localhost:5000
|
||||
|
||||
The frontend will run on http://localhost:3000 and proxy API requests to the backend.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Branding and Metadata
|
||||
|
||||
All app branding, metadata, and PWA configuration is centralized in `frontend/src/config/branding.js`. Edit this file to customize:
|
||||
|
||||
- **App Name** and **Short Name** - Displayed in UI and when installed as PWA
|
||||
- **Description** - Shown in search engines and app stores
|
||||
- **Version** - Current app version
|
||||
- **Theme Color** - Primary color for browser chrome and PWA theme
|
||||
- **Keywords** - SEO metadata
|
||||
- **Author** - Creator/maintainer information
|
||||
- **Links** - GitHub repo, support contact, etc.
|
||||
|
||||
When you update `branding.js`, the following are automatically synchronized:
|
||||
|
||||
1. **PWA Manifest** (`manifest.json`) - Generated at build time via `generate-manifest.js`
|
||||
2. **HTML Meta Tags** - Updated via Vite HTML transformation plugin
|
||||
3. **App UI** - Components import branding directly
|
||||
|
||||
To regenerate the manifest manually:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run generate-manifest
|
||||
```
|
||||
|
||||
The manifest is automatically generated during the build process, so you don't need to edit it directly.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
@@ -140,9 +186,13 @@ The frontend will run on http://localhost:3000 and proxy API requests to the bac
|
||||
│ │ ├── 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
|
||||
@@ -208,6 +258,18 @@ The frontend will run on http://localhost:3000 and proxy API requests to the bac
|
||||
### 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
|
||||
@@ -269,21 +331,76 @@ 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](docs/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
|
||||
|
||||
## Jackbox Player Count Fetcher
|
||||
|
||||
The `scripts/` directory contains utilities for inspecting Jackbox game lobbies:
|
||||
|
||||
- **[get-player-count.go](scripts/get-player-count.go)** - Go + chromedp script (recommended, most reliable)
|
||||
- **[get-player-count.html](scripts/get-player-count.html)** - Browser-based tool (no installation required!)
|
||||
- **[get-jackbox-player-count.js](scripts/get-jackbox-player-count.js)** - Node.js script (limited, may not work)
|
||||
|
||||
See **[scripts/README.md](scripts/README.md)** for detailed usage instructions.
|
||||
|
||||
### Quick Start
|
||||
|
||||
**Go version (recommended for automation):**
|
||||
```bash
|
||||
cd scripts
|
||||
go run get-player-count.go JYET
|
||||
```
|
||||
|
||||
**Browser version (easiest for manual testing):**
|
||||
1. Open `scripts/get-player-count.html` in any browser
|
||||
2. Enter a 4-letter room code
|
||||
3. View real-time player count and lobby status
|
||||
|
||||
**How it works:**
|
||||
- Automates joining jackbox.tv through Chrome/Chromium
|
||||
- Captures WebSocket messages containing player data
|
||||
- Extracts actual player count from lobby state
|
||||
|
||||
These tools retrieve:
|
||||
- ✅ Actual player count (not just max capacity)
|
||||
- ✅ List of current players and their roles (host/player)
|
||||
- ✅ Game state and lobby status
|
||||
- ✅ Audience count
|
||||
|
||||
**Note:** Direct WebSocket connection is not possible without authentication, so the tools join through jackbox.tv to capture the data.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -2,8 +2,20 @@ FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install wget for healthcheck
|
||||
RUN apk add --no-cache wget
|
||||
# Install Chromium, fonts, and dependencies for Puppeteer
|
||||
RUN apk add --no-cache \
|
||||
wget \
|
||||
chromium \
|
||||
nss \
|
||||
freetype \
|
||||
harfbuzz \
|
||||
ca-certificates \
|
||||
ttf-freefont \
|
||||
font-noto-emoji
|
||||
|
||||
# Tell Puppeteer to use the installed Chromium
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
@@ -77,6 +77,27 @@ 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 player_count column if it doesn't exist (for existing databases)
|
||||
try {
|
||||
db.exec(`ALTER TABLE session_games ADD COLUMN player_count INTEGER`);
|
||||
} catch (err) {
|
||||
// Column already exists, ignore error
|
||||
}
|
||||
|
||||
// Add player_count_check_status column if it doesn't exist (for existing databases)
|
||||
try {
|
||||
db.exec(`ALTER TABLE session_games ADD COLUMN player_count_check_status TEXT DEFAULT 'not_started'`);
|
||||
} 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 +188,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');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
if (!process.env.JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET environment variable is required');
|
||||
}
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
|
||||
function authenticateToken(req, res, next) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"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",
|
||||
"puppeteer": "^24.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
|
||||
@@ -4,7 +4,10 @@ const { JWT_SECRET, authenticateToken } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const ADMIN_KEY = process.env.ADMIN_KEY || 'admin123';
|
||||
if (!process.env.ADMIN_KEY) {
|
||||
throw new Error('ADMIN_KEY environment variable is required');
|
||||
}
|
||||
const ADMIN_KEY = process.env.ADMIN_KEY;
|
||||
|
||||
// Login with admin key
|
||||
router.post('/login', (req, res) => {
|
||||
|
||||
@@ -2,6 +2,9 @@ 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 { startPlayerCountCheck, stopPlayerCountCheck } = require('../utils/player-count-checker');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -103,6 +106,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 +163,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 +256,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 +292,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 +306,66 @@ 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);
|
||||
}
|
||||
|
||||
// Automatically start player count check if room code was provided
|
||||
if (room_code) {
|
||||
try {
|
||||
startPlayerCountCheck(req.params.id, result.lastInsertRowid, room_code, game.max_players);
|
||||
} catch (error) {
|
||||
console.error('Error starting player count check:', error);
|
||||
// Don't fail the request if player count check fails
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json(sessionGame);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -472,6 +580,15 @@ router.patch('/:sessionId/games/:gameId/status', authenticateToken, (req, res) =
|
||||
return res.status(404).json({ error: 'Session game not found' });
|
||||
}
|
||||
|
||||
// Stop player count check if game is no longer playing
|
||||
if (status !== 'playing') {
|
||||
try {
|
||||
stopPlayerCountCheck(sessionId, gameId);
|
||||
} catch (error) {
|
||||
console.error('Error stopping player count check:', error);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: 'Status updated successfully', status });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -483,6 +600,13 @@ router.delete('/:sessionId/games/:gameId', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const { sessionId, gameId } = req.params;
|
||||
|
||||
// Stop player count check before deleting
|
||||
try {
|
||||
stopPlayerCountCheck(sessionId, gameId);
|
||||
} catch (error) {
|
||||
console.error('Error stopping player count check:', error);
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
DELETE FROM session_games
|
||||
WHERE session_id = ? AND id = ?
|
||||
@@ -498,6 +622,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 {
|
||||
@@ -631,5 +805,101 @@ router.get('/:id/export', authenticateToken, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Start player count check for a session game (admin only)
|
||||
router.post('/:sessionId/games/:gameId/start-player-check', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const { sessionId, gameId } = req.params;
|
||||
|
||||
// Get the game to verify it exists and has a room code
|
||||
const game = db.prepare(`
|
||||
SELECT sg.*, g.max_players
|
||||
FROM session_games sg
|
||||
JOIN games g ON sg.game_id = g.id
|
||||
WHERE sg.session_id = ? AND sg.id = ?
|
||||
`).get(sessionId, gameId);
|
||||
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Session game not found' });
|
||||
}
|
||||
|
||||
if (!game.room_code) {
|
||||
return res.status(400).json({ error: 'Game does not have a room code' });
|
||||
}
|
||||
|
||||
// Start the check
|
||||
startPlayerCountCheck(sessionId, gameId, game.room_code, game.max_players);
|
||||
|
||||
res.json({
|
||||
message: 'Player count check started',
|
||||
status: 'waiting'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Stop player count check for a session game (admin only)
|
||||
router.post('/:sessionId/games/:gameId/stop-player-check', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const { sessionId, gameId } = req.params;
|
||||
|
||||
// Stop the check
|
||||
stopPlayerCountCheck(sessionId, gameId);
|
||||
|
||||
res.json({
|
||||
message: 'Player count check stopped',
|
||||
status: 'stopped'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Manually update player count for a session game (admin only)
|
||||
router.patch('/:sessionId/games/:gameId/player-count', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const { sessionId, gameId } = req.params;
|
||||
const { player_count } = req.body;
|
||||
|
||||
if (player_count === undefined || player_count === null) {
|
||||
return res.status(400).json({ error: 'player_count is required' });
|
||||
}
|
||||
|
||||
const count = parseInt(player_count);
|
||||
if (isNaN(count) || count < 0) {
|
||||
return res.status(400).json({ error: 'player_count must be a positive number' });
|
||||
}
|
||||
|
||||
// Update the player count
|
||||
const result = db.prepare(`
|
||||
UPDATE session_games
|
||||
SET player_count = ?, player_count_check_status = 'completed'
|
||||
WHERE session_id = ? AND id = ?
|
||||
`).run(count, sessionId, gameId);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: 'Session game not found' });
|
||||
}
|
||||
|
||||
// Broadcast via WebSocket
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
wsManager.broadcastEvent('player-count.updated', {
|
||||
sessionId,
|
||||
gameId,
|
||||
playerCount: count,
|
||||
status: 'completed'
|
||||
}, parseInt(sessionId));
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Player count updated successfully',
|
||||
player_count: count
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
581
backend/utils/player-count-checker.js
Normal file
581
backend/utils/player-count-checker.js
Normal file
@@ -0,0 +1,581 @@
|
||||
const puppeteer = require('puppeteer');
|
||||
const db = require('../database');
|
||||
const { getWebSocketManager } = require('./websocket-manager');
|
||||
|
||||
// Store active check jobs
|
||||
const activeChecks = new Map();
|
||||
|
||||
/**
|
||||
* Check room status via Jackbox API
|
||||
*/
|
||||
async function checkRoomStatus(roomCode) {
|
||||
try {
|
||||
const response = await fetch(`https://ecast.jackboxgames.com/api/v2/rooms/${roomCode}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const roomData = data.body || data;
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
console.log('[API] Room data:', JSON.stringify(roomData, null, 2));
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
locked: roomData.locked || false,
|
||||
full: roomData.full || false,
|
||||
maxPlayers: roomData.maxPlayers || 8,
|
||||
minPlayers: roomData.minPlayers || 0
|
||||
};
|
||||
}
|
||||
return { exists: false };
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[API] Error checking room:', e.message);
|
||||
}
|
||||
return { exists: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch a game from start to finish as audience member
|
||||
* Collects analytics throughout the entire game lifecycle
|
||||
*/
|
||||
async function watchGameAsAudience(sessionId, gameId, roomCode, maxPlayers) {
|
||||
let browser;
|
||||
const checkKey = `${sessionId}-${gameId}`;
|
||||
|
||||
try {
|
||||
console.log(`[Player Count] Opening audience connection for ${checkKey} (max: ${maxPlayers})`);
|
||||
|
||||
browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-accelerated-2d-canvas',
|
||||
'--no-first-run',
|
||||
'--no-zygote',
|
||||
'--disable-gpu'
|
||||
]
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
||||
|
||||
// Track all player counts we've seen
|
||||
const seenPlayerCounts = new Set();
|
||||
let bestPlayerCount = null;
|
||||
let startPlayerCount = null; // Authoritative count from 'start' action
|
||||
let gameEnded = false;
|
||||
let audienceJoined = false; // Track whether we've confirmed audience join
|
||||
let frameCount = 0;
|
||||
|
||||
// Enable CDP and listen for WebSocket frames BEFORE navigating
|
||||
const client = await page.target().createCDPSession();
|
||||
await client.send('Network.enable');
|
||||
|
||||
client.on('Network.webSocketFrameReceived', ({ response }) => {
|
||||
if (response.payloadData && !gameEnded) {
|
||||
frameCount++;
|
||||
try {
|
||||
const data = JSON.parse(response.payloadData);
|
||||
|
||||
if (process.env.DEBUG && frameCount % 10 === 0) {
|
||||
console.log(`[Frame ${frameCount}] opcode: ${data.opcode}`);
|
||||
}
|
||||
|
||||
// Check for bc:room with player count data
|
||||
let roomVal = null;
|
||||
|
||||
if (data.opcode === 'client/welcome' && data.result?.entities?.['bc:room']) {
|
||||
roomVal = data.result.entities['bc:room'][1]?.val;
|
||||
if (process.env.DEBUG) {
|
||||
console.log(`[Frame ${frameCount}] Found bc:room in client/welcome`);
|
||||
}
|
||||
|
||||
// First client/welcome means Jackbox accepted our audience join
|
||||
if (!audienceJoined) {
|
||||
audienceJoined = true;
|
||||
console.log(`[Audience] Successfully joined room ${roomCode} as audience`);
|
||||
|
||||
// Broadcast audience.joined event via WebSocket
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
wsManager.broadcastEvent('audience.joined', {
|
||||
sessionId,
|
||||
gameId,
|
||||
roomCode
|
||||
}, parseInt(sessionId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.opcode === 'object' && data.result?.key === 'bc:room') {
|
||||
roomVal = data.result.val;
|
||||
}
|
||||
|
||||
if (roomVal) {
|
||||
// Check if game has ended
|
||||
if (roomVal.gameResults?.players) {
|
||||
const finalCount = roomVal.gameResults.players.length;
|
||||
if (process.env.DEBUG) {
|
||||
console.log(`[Frame ${frameCount}] 🎉 GAME ENDED - Final count: ${finalCount} players`);
|
||||
|
||||
// Verify it matches start count if we had one
|
||||
if (startPlayerCount !== null && startPlayerCount !== finalCount) {
|
||||
console.log(`[Frame ${frameCount}] ⚠️ WARNING: Start count (${startPlayerCount}) != Final count (${finalCount})`);
|
||||
} else if (startPlayerCount !== null) {
|
||||
console.log(`[Frame ${frameCount}] ✓ Verified: Start count matches final count (${finalCount})`);
|
||||
}
|
||||
}
|
||||
bestPlayerCount = finalCount;
|
||||
gameEnded = true;
|
||||
// Update immediately with final count
|
||||
updatePlayerCount(sessionId, gameId, finalCount, 'completed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract player counts from analytics (game in progress)
|
||||
if (roomVal.analytics && Array.isArray(roomVal.analytics)) {
|
||||
for (const analytic of roomVal.analytics) {
|
||||
// Check for 'start' action - this is authoritative
|
||||
if (analytic.action === 'start' && analytic.value && typeof analytic.value === 'number') {
|
||||
if (startPlayerCount === null) {
|
||||
startPlayerCount = analytic.value;
|
||||
bestPlayerCount = analytic.value;
|
||||
if (process.env.DEBUG) {
|
||||
console.log(`[Frame ${frameCount}] 🎯 Found 'start' action: ${analytic.value} players (authoritative)`);
|
||||
}
|
||||
// Update UI with authoritative start count
|
||||
updatePlayerCount(sessionId, gameId, startPlayerCount, 'checking');
|
||||
}
|
||||
continue; // Skip to next analytic
|
||||
}
|
||||
|
||||
// If we already have start count, we don't need to keep counting
|
||||
if (startPlayerCount !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise, look for any numeric value that could be a player count
|
||||
if (analytic.value && typeof analytic.value === 'number' && analytic.value > 0 && analytic.value <= 100) {
|
||||
seenPlayerCounts.add(analytic.value);
|
||||
|
||||
// Clamp to maxPlayers to avoid cumulative stats inflating count
|
||||
const clampedValue = Math.min(analytic.value, maxPlayers);
|
||||
|
||||
// Update best guess (highest count seen so far, clamped to maxPlayers)
|
||||
if (bestPlayerCount === null || clampedValue > bestPlayerCount) {
|
||||
bestPlayerCount = clampedValue;
|
||||
if (process.env.DEBUG) {
|
||||
if (analytic.value > maxPlayers) {
|
||||
console.log(`[Frame ${frameCount}] 📊 Found player count ${analytic.value} in action '${analytic.action}' (clamped to ${clampedValue})`);
|
||||
} else {
|
||||
console.log(`[Frame ${frameCount}] 📊 Found player count ${analytic.value} in action '${analytic.action}' (best so far)`);
|
||||
}
|
||||
}
|
||||
// Update UI with current best guess
|
||||
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'checking');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if room is no longer locked (game ended another way)
|
||||
if (roomVal.locked === false && bestPlayerCount !== null) {
|
||||
if (process.env.DEBUG) {
|
||||
console.log(`[Frame ${frameCount}] Room unlocked, game likely ended. Final count: ${bestPlayerCount}`);
|
||||
}
|
||||
gameEnded = true;
|
||||
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'completed');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG && frameCount % 50 === 0) {
|
||||
console.log(`[Frame ${frameCount}] Parse error:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Navigate and join audience
|
||||
if (process.env.DEBUG) console.log('[Audience] Navigating to jackbox.tv...');
|
||||
await page.goto('https://jackbox.tv/', { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
|
||||
if (process.env.DEBUG) console.log('[Audience] Waiting for form...');
|
||||
await page.waitForSelector('input#roomcode', { timeout: 10000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
if (process.env.DEBUG) console.log('[Audience] Typing room code:', roomCode);
|
||||
const roomInput = await page.$('input#roomcode');
|
||||
await roomInput.type(roomCode.toUpperCase(), { delay: 50 });
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
if (process.env.DEBUG) console.log('[Audience] Typing name...');
|
||||
const nameInput = await page.$('input#username');
|
||||
await nameInput.type('CountBot', { delay: 30 });
|
||||
|
||||
if (process.env.DEBUG) console.log('[Audience] Waiting for JOIN AUDIENCE button...');
|
||||
await page.waitForFunction(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
return buttons.some(b => b.textContent.toUpperCase().includes('JOIN AUDIENCE') && !b.disabled);
|
||||
}, { timeout: 10000 });
|
||||
|
||||
if (process.env.DEBUG) console.log('[Audience] Clicking JOIN AUDIENCE...');
|
||||
await page.evaluate(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
const btn = buttons.find(b => b.textContent.toUpperCase().includes('JOIN AUDIENCE') && !b.disabled);
|
||||
if (btn) btn.click();
|
||||
});
|
||||
|
||||
if (process.env.DEBUG) console.log('[Audience] 👀 Watching game... (will monitor until game ends)');
|
||||
|
||||
// Keep watching until game ends or we're told to stop
|
||||
// Check every 5 seconds if we should still be watching
|
||||
const checkInterval = setInterval(async () => {
|
||||
// Check if we should stop
|
||||
const game = db.prepare(`
|
||||
SELECT status, player_count_check_status
|
||||
FROM session_games
|
||||
WHERE session_id = ? AND id = ?
|
||||
`).get(sessionId, gameId);
|
||||
|
||||
if (!game || game.status === 'skipped' || game.status === 'played' || game.player_count_check_status === 'stopped') {
|
||||
if (process.env.DEBUG) {
|
||||
console.log(`[Audience] Stopping watch - game status changed`);
|
||||
}
|
||||
clearInterval(checkInterval);
|
||||
gameEnded = true;
|
||||
if (browser) await browser.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if game ended
|
||||
if (gameEnded) {
|
||||
clearInterval(checkInterval);
|
||||
if (browser) await browser.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if room still exists
|
||||
const roomStatus = await checkRoomStatus(roomCode);
|
||||
if (!roomStatus.exists) {
|
||||
if (process.env.DEBUG) {
|
||||
console.log(`[Audience] Room no longer exists - game ended`);
|
||||
}
|
||||
gameEnded = true;
|
||||
clearInterval(checkInterval);
|
||||
if (bestPlayerCount !== null) {
|
||||
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'completed');
|
||||
} else {
|
||||
updatePlayerCount(sessionId, gameId, null, 'failed');
|
||||
}
|
||||
if (browser) await browser.close();
|
||||
return;
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// Store the interval so we can clean it up
|
||||
const check = activeChecks.get(checkKey);
|
||||
if (check) {
|
||||
check.watchInterval = checkInterval;
|
||||
check.browser = browser;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Audience] Error watching game:', error.message);
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
// If we had a best guess, use it; otherwise fail
|
||||
if (bestPlayerCount !== null) {
|
||||
updatePlayerCount(sessionId, gameId, bestPlayerCount, 'completed');
|
||||
} else {
|
||||
updatePlayerCount(sessionId, gameId, null, 'failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast game.started event when room becomes locked
|
||||
*/
|
||||
function broadcastGameStarted(sessionId, gameId, roomCode, maxPlayers) {
|
||||
try {
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
wsManager.broadcastEvent('game.started', {
|
||||
sessionId,
|
||||
gameId,
|
||||
roomCode,
|
||||
maxPlayers
|
||||
}, parseInt(sessionId));
|
||||
}
|
||||
console.log(`[Player Count] Broadcasted game.started for room ${roomCode} (max: ${maxPlayers})`);
|
||||
} catch (error) {
|
||||
console.error('[Player Count] Failed to broadcast game.started:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update player count in database
|
||||
*/
|
||||
function updatePlayerCount(sessionId, gameId, playerCount, status) {
|
||||
try {
|
||||
db.prepare(`
|
||||
UPDATE session_games
|
||||
SET player_count = ?, player_count_check_status = ?
|
||||
WHERE session_id = ? AND id = ?
|
||||
`).run(playerCount, status, sessionId, gameId);
|
||||
|
||||
// Broadcast via WebSocket
|
||||
const wsManager = getWebSocketManager();
|
||||
if (wsManager) {
|
||||
wsManager.broadcastEvent('player-count.updated', {
|
||||
sessionId,
|
||||
gameId,
|
||||
playerCount,
|
||||
status
|
||||
}, parseInt(sessionId));
|
||||
}
|
||||
|
||||
console.log(`[Player Count] Updated game ${gameId}: ${playerCount} players (${status})`);
|
||||
} catch (error) {
|
||||
console.error('[Player Count] Failed to update database:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start checking player count for a game
|
||||
* Strategy:
|
||||
* 1. Wait 10 seconds for initial room setup
|
||||
* 2. Poll every 10 seconds until game is locked (started)
|
||||
* 3. Broadcast game.started event when locked detected
|
||||
* 4. Join audience and watch entire game
|
||||
* 5. Update UI as we learn more
|
||||
* 6. Finalize when game ends
|
||||
*/
|
||||
async function startPlayerCountCheck(sessionId, gameId, roomCode, maxPlayers = 8) {
|
||||
const checkKey = `${sessionId}-${gameId}`;
|
||||
|
||||
// If already checking, don't start again
|
||||
if (activeChecks.has(checkKey)) {
|
||||
console.log(`[Player Count] Already checking ${checkKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already completed (but allow retrying failed checks)
|
||||
const game = db.prepare(`
|
||||
SELECT player_count_check_status
|
||||
FROM session_games
|
||||
WHERE session_id = ? AND id = ?
|
||||
`).get(sessionId, gameId);
|
||||
|
||||
if (game && game.player_count_check_status === 'completed') {
|
||||
console.log(`[Player Count] Check already completed for ${checkKey}, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If retrying a failed check, reset the status
|
||||
if (game && game.player_count_check_status === 'failed') {
|
||||
console.log(`[Player Count] Retrying failed check for ${checkKey}`);
|
||||
}
|
||||
|
||||
console.log(`[Player Count] Starting check for game ${gameId} with room code ${roomCode}`);
|
||||
|
||||
// Update status to waiting
|
||||
db.prepare(`
|
||||
UPDATE session_games
|
||||
SET player_count_check_status = 'waiting'
|
||||
WHERE session_id = ? AND id = ?
|
||||
`).run(sessionId, gameId);
|
||||
|
||||
// Function to check if game is ready (locked)
|
||||
const waitForGameStart = async () => {
|
||||
const roomStatus = await checkRoomStatus(roomCode);
|
||||
|
||||
if (!roomStatus.exists) {
|
||||
console.log(`[Player Count] Room ${roomCode} does not exist`);
|
||||
updatePlayerCount(sessionId, gameId, null, 'failed');
|
||||
stopPlayerCountCheck(sessionId, gameId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If full, we know the count immediately
|
||||
if (roomStatus.full) {
|
||||
console.log(`[Player Count] Room is FULL - ${roomStatus.maxPlayers} players`);
|
||||
updatePlayerCount(sessionId, gameId, roomStatus.maxPlayers, 'completed');
|
||||
stopPlayerCountCheck(sessionId, gameId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If locked, game has started - ready to watch
|
||||
if (roomStatus.locked) {
|
||||
console.log(`[Player Count] Room is LOCKED - game in progress, starting watch`);
|
||||
// Return both status and real maxPlayers from Jackbox
|
||||
return { ready: true, maxPlayers: roomStatus.maxPlayers };
|
||||
}
|
||||
|
||||
// Not ready yet
|
||||
console.log(`[Player Count] Room not ready yet (lobby still open)`);
|
||||
return null;
|
||||
};
|
||||
|
||||
// Wait 10 seconds before first check
|
||||
const initialTimeout = setTimeout(async () => {
|
||||
try {
|
||||
// Update status to checking
|
||||
db.prepare(`
|
||||
UPDATE session_games
|
||||
SET player_count_check_status = 'checking'
|
||||
WHERE session_id = ? AND id = ?
|
||||
`).run(sessionId, gameId);
|
||||
|
||||
console.log(`[Player Count] Initial check after 10s for ${checkKey}`);
|
||||
const result = await waitForGameStart();
|
||||
|
||||
if (result && result.ready === true) {
|
||||
// Game is locked, broadcast game.started and start watching
|
||||
const realMaxPlayers = result.maxPlayers;
|
||||
broadcastGameStarted(sessionId, gameId, roomCode, realMaxPlayers);
|
||||
console.log(`[Player Count] Using real maxPlayers from Jackbox: ${realMaxPlayers} (database had: ${maxPlayers})`);
|
||||
await watchGameAsAudience(sessionId, gameId, roomCode, realMaxPlayers);
|
||||
} else if (result === null) {
|
||||
// Not ready yet, poll every 10 seconds
|
||||
const checkInterval = setInterval(async () => {
|
||||
// Check if we should stop
|
||||
const game = db.prepare(`
|
||||
SELECT status, player_count_check_status
|
||||
FROM session_games
|
||||
WHERE session_id = ? AND id = ?
|
||||
`).get(sessionId, gameId);
|
||||
|
||||
if (!game || game.status === 'skipped' || game.status === 'played' || game.player_count_check_status === 'stopped' || game.player_count_check_status === 'completed') {
|
||||
console.log(`[Player Count] Stopping check for ${checkKey} - game status changed`);
|
||||
stopPlayerCountCheck(sessionId, gameId);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await waitForGameStart();
|
||||
if (result && result.ready === true) {
|
||||
// Game is now locked, stop interval, broadcast game.started, and start watching
|
||||
clearInterval(checkInterval);
|
||||
const check = activeChecks.get(checkKey);
|
||||
if (check) check.interval = null;
|
||||
const realMaxPlayers = result.maxPlayers;
|
||||
broadcastGameStarted(sessionId, gameId, roomCode, realMaxPlayers);
|
||||
console.log(`[Player Count] Using real maxPlayers from Jackbox: ${realMaxPlayers} (database had: ${maxPlayers})`);
|
||||
await watchGameAsAudience(sessionId, gameId, roomCode, realMaxPlayers);
|
||||
} else if (result === false) {
|
||||
// Check failed or completed, stop
|
||||
clearInterval(checkInterval);
|
||||
stopPlayerCountCheck(sessionId, gameId);
|
||||
}
|
||||
}, 10000); // Poll every 10 seconds
|
||||
|
||||
// Store the interval
|
||||
const check = activeChecks.get(checkKey);
|
||||
if (check) check.interval = checkInterval;
|
||||
}
|
||||
// If ready === false, check already stopped/completed
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[Player Count] Error starting check for ${checkKey}:`, error.message);
|
||||
updatePlayerCount(sessionId, gameId, null, 'failed');
|
||||
stopPlayerCountCheck(sessionId, gameId);
|
||||
}
|
||||
}, 10000); // Wait 10 seconds before first check
|
||||
|
||||
// Store the check references
|
||||
activeChecks.set(checkKey, {
|
||||
sessionId,
|
||||
gameId,
|
||||
roomCode,
|
||||
initialTimeout,
|
||||
interval: null,
|
||||
watchInterval: null,
|
||||
browser: null
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop checking player count for a game
|
||||
*/
|
||||
async function stopPlayerCountCheck(sessionId, gameId) {
|
||||
const checkKey = `${sessionId}-${gameId}`;
|
||||
const check = activeChecks.get(checkKey);
|
||||
|
||||
if (check) {
|
||||
if (check.initialTimeout) {
|
||||
clearTimeout(check.initialTimeout);
|
||||
}
|
||||
if (check.interval) {
|
||||
clearInterval(check.interval);
|
||||
}
|
||||
if (check.watchInterval) {
|
||||
clearInterval(check.watchInterval);
|
||||
}
|
||||
if (check.browser) {
|
||||
try {
|
||||
await check.browser.close();
|
||||
} catch (e) {
|
||||
// Ignore errors closing browser
|
||||
}
|
||||
}
|
||||
activeChecks.delete(checkKey);
|
||||
|
||||
// Update status to stopped if not already completed or failed
|
||||
const game = db.prepare(`
|
||||
SELECT player_count_check_status
|
||||
FROM session_games
|
||||
WHERE session_id = ? AND id = ?
|
||||
`).get(sessionId, gameId);
|
||||
|
||||
if (game && game.player_count_check_status !== 'completed' && game.player_count_check_status !== 'failed') {
|
||||
db.prepare(`
|
||||
UPDATE session_games
|
||||
SET player_count_check_status = 'stopped'
|
||||
WHERE session_id = ? AND id = ?
|
||||
`).run(sessionId, gameId);
|
||||
}
|
||||
|
||||
console.log(`[Player Count] Stopped check for ${checkKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all active checks (for graceful shutdown)
|
||||
*/
|
||||
async function cleanupAllChecks() {
|
||||
for (const [checkKey, check] of activeChecks.entries()) {
|
||||
if (check.initialTimeout) {
|
||||
clearTimeout(check.initialTimeout);
|
||||
}
|
||||
if (check.interval) {
|
||||
clearInterval(check.interval);
|
||||
}
|
||||
if (check.watchInterval) {
|
||||
clearInterval(check.watchInterval);
|
||||
}
|
||||
if (check.browser) {
|
||||
try {
|
||||
await check.browser.close();
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
activeChecks.clear();
|
||||
console.log('[Player Count] Cleaned up all active checks');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startPlayerCountCheck,
|
||||
stopPlayerCountCheck,
|
||||
cleanupAllChecks
|
||||
};
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,8 +9,9 @@ services:
|
||||
- PORT=5000
|
||||
- NODE_ENV=production
|
||||
- DB_PATH=/app/data/jackbox.db
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||
- ADMIN_KEY=${ADMIN_KEY:-admin123}
|
||||
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
||||
- ADMIN_KEY=${ADMIN_KEY:?ADMIN_KEY is required}
|
||||
- DEBUG=false
|
||||
volumes:
|
||||
- jackbox-data:/app/data
|
||||
- ./games-list.csv:/app/games-list.csv:ro
|
||||
|
||||
474
docs/API_QUICK_REFERENCE.md
Normal file
474
docs/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)
|
||||
|
||||
746
docs/BOT_INTEGRATION.md
Normal file
746
docs/BOT_INTEGRATION.md
Normal file
@@ -0,0 +1,746 @@
|
||||
# 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)
|
||||
5. [Available Events](#available-events)
|
||||
|
||||
---
|
||||
|
||||
## 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,
|
||||
"room_code": "JYET"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Session started event (broadcast to all authenticated clients)
|
||||
{
|
||||
"type": "session.started",
|
||||
"timestamp": "2025-11-01T20:00:00Z",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": 123,
|
||||
"is_active": true,
|
||||
"created_at": "2025-11-01T20:00:00Z",
|
||||
"notes": "Friday game night"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Session ended event (broadcast to session subscribers)
|
||||
{
|
||||
"type": "session.ended",
|
||||
"timestamp": "2025-11-01T23:00:00Z",
|
||||
"data": {
|
||||
"session": {
|
||||
"id": 123,
|
||||
"is_active": false,
|
||||
"games_played": 8
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Game started event (broadcast to session subscribers)
|
||||
// Fired when the Jackbox room becomes locked, meaning gameplay has begun
|
||||
{
|
||||
"type": "game.started",
|
||||
"timestamp": "2025-11-01T20:31:00Z",
|
||||
"data": {
|
||||
"sessionId": 123,
|
||||
"gameId": 456,
|
||||
"roomCode": "JYET",
|
||||
"maxPlayers": 8
|
||||
}
|
||||
}
|
||||
|
||||
// Audience joined event (broadcast to session subscribers)
|
||||
// Confirms the app successfully joined a Jackbox room as an audience member
|
||||
{
|
||||
"type": "audience.joined",
|
||||
"timestamp": "2025-11-01T20:31:05Z",
|
||||
"data": {
|
||||
"sessionId": 123,
|
||||
"gameId": 456,
|
||||
"roomCode": "JYET"
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Build announcement with room code if available
|
||||
let announcement = `🎮 Coming up next: ${game.title}!`;
|
||||
if (game.room_code) {
|
||||
announcement += ` Join at jackbox.tv with code: ${game.room_code}`;
|
||||
}
|
||||
|
||||
// Send to your chat platform (e.g., Kosmi chat)
|
||||
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,
|
||||
"room_code": "JYET"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** `room_code` is the 4-character Jackbox room code (e.g. `"JYET"`). It will be `null` if no room code was provided when the game was added.
|
||||
|
||||
### 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;
|
||||
|
||||
// Build announcement with room code if available
|
||||
let message = `🎮 Coming up next: ${game.title}!`;
|
||||
if (game.room_code) {
|
||||
message += ` Join at jackbox.tv with code: ${game.room_code}`;
|
||||
}
|
||||
|
||||
// Send message to Kosmi chat
|
||||
sendKosmiMessage(message);
|
||||
|
||||
console.log(`Announced game: ${game.title} from ${game.pack_name} (code: ${game.room_code || 'N/A'})`);
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
### Webhook Events
|
||||
|
||||
- `game.added` - Triggered when a game is added to an active session. Includes `room_code` (the 4-character Jackbox join code) if one was provided.
|
||||
|
||||
### WebSocket Events
|
||||
|
||||
- `game.added` - Triggered when a game is added to an active session. Sent to clients subscribed to that session. Includes `room_code`.
|
||||
- `session.started` - Triggered when a new session is created. Broadcast to **all** authenticated clients (no subscription required).
|
||||
- `session.ended` - Triggered when a session is closed. Sent to clients subscribed to that session.
|
||||
- `game.started` - Triggered when the Jackbox room becomes locked (gameplay has begun). Detected by polling the Jackbox REST API every 10 seconds. Sent to clients subscribed to that session. Includes `roomCode` and `maxPlayers`.
|
||||
- `audience.joined` - Triggered when the app successfully joins a Jackbox room as an audience member. Sent to clients subscribed to that session. This confirms the room code is valid and the game is being monitored.
|
||||
- `player-count.updated` - Triggered when the player count for a game is updated. Sent to clients subscribed to that session.
|
||||
|
||||
> **Tip:** To receive `session.started` events, your bot only needs to authenticate — no subscription is needed. Once you receive a `session.started` event, subscribe to the new session ID to receive `game.added` and `session.ended` events for it.
|
||||
|
||||
More events may be added in the future (e.g., `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
|
||||
|
||||
105
docs/SESSION_END_QUICK_START.md
Normal file
105
docs/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 ../tests/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
docs/SESSION_END_WEBSOCKET.md
Normal file
306
docs/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 ../tests/test-session-end-websocket.js <session_id> <jwt_token>
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
node ../tests/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 ../tests/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
docs/SESSION_START_WEBSOCKET.md
Normal file
361
docs/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 ../tests/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
docs/WEBSOCKET_FLOW_DIAGRAM.md
Normal file
256
docs/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
docs/WEBSOCKET_SUBSCRIPTION_GUIDE.md
Normal file
310
docs/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
docs/WEBSOCKET_TESTING.md
Normal file
239
docs/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.
|
||||
|
||||
93
frontend/ICONS.md
Normal file
93
frontend/ICONS.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Icon Generation Guide
|
||||
|
||||
## Current Icons
|
||||
|
||||
- ✅ `public/favicon.svg` - Primary icon (SVG format)
|
||||
|
||||
## Missing Icons (Optional but Recommended)
|
||||
|
||||
For optimal PWA support, especially on iOS/Safari, you should generate PNG versions:
|
||||
|
||||
- `public/icon-192.png` - 192x192px PNG
|
||||
- `public/icon-512.png` - 512x512px PNG
|
||||
|
||||
## How to Generate PNG Icons
|
||||
|
||||
### Option 1: Online Converter (Easiest)
|
||||
|
||||
1. Go to https://realfavicongenerator.net/ or https://favicon.io/
|
||||
2. Upload `public/favicon.svg`
|
||||
3. Generate and download PNG versions
|
||||
4. Save as `icon-192.png` and `icon-512.png` in `frontend/public/`
|
||||
|
||||
### Option 2: Using ImageMagick (Command Line)
|
||||
|
||||
If you have ImageMagick installed:
|
||||
|
||||
```bash
|
||||
cd frontend/public
|
||||
|
||||
# Generate 192x192
|
||||
convert favicon.svg -resize 192x192 icon-192.png
|
||||
|
||||
# Generate 512x512
|
||||
convert favicon.svg -resize 512x512 icon-512.png
|
||||
```
|
||||
|
||||
### Option 3: Using Node.js Script
|
||||
|
||||
Install sharp library temporarily:
|
||||
|
||||
```bash
|
||||
npm install --save-dev sharp
|
||||
```
|
||||
|
||||
Create and run this script:
|
||||
|
||||
```javascript
|
||||
// generate-icons.js
|
||||
const sharp = require('sharp');
|
||||
const fs = require('fs');
|
||||
|
||||
const sizes = [192, 512];
|
||||
const svgBuffer = fs.readFileSync('./public/favicon.svg');
|
||||
|
||||
sizes.forEach(size => {
|
||||
sharp(svgBuffer)
|
||||
.resize(size, size)
|
||||
.png()
|
||||
.toFile(`./public/icon-${size}.png`)
|
||||
.then(() => console.log(`✅ Generated icon-${size}.png`))
|
||||
.catch(err => console.error(`❌ Failed to generate icon-${size}.png:`, err));
|
||||
});
|
||||
```
|
||||
|
||||
Run it:
|
||||
```bash
|
||||
node generate-icons.js
|
||||
```
|
||||
|
||||
Then uninstall sharp:
|
||||
```bash
|
||||
npm uninstall sharp
|
||||
```
|
||||
|
||||
## What Happens Without PNG Icons?
|
||||
|
||||
The app will still work as a PWA! Modern browsers (Chrome, Edge, Firefox) support SVG icons just fine. However:
|
||||
|
||||
- **iOS Safari** may not display the icon correctly on the home screen
|
||||
- Some older Android devices might show a generic icon
|
||||
- The manifest references PNG files as fallbacks
|
||||
|
||||
The SVG will be used as a fallback, which works on most platforms.
|
||||
|
||||
## Why We Don't Auto-Generate
|
||||
|
||||
PNG generation requires either:
|
||||
- Native image processing libraries (platform-dependent)
|
||||
- External dependencies that bloat the build
|
||||
- Build-time processing that slows down development
|
||||
|
||||
Since the app works fine with SVG on most platforms, we leave PNG generation as an optional step.
|
||||
|
||||
74
frontend/generate-manifest.js
Normal file
74
frontend/generate-manifest.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Import branding config dynamically
|
||||
const brandingModule = await import('./src/config/branding.js');
|
||||
const branding = brandingModule.branding;
|
||||
|
||||
const manifest = {
|
||||
name: branding.app.name,
|
||||
short_name: branding.app.shortName,
|
||||
description: branding.app.description,
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#1f2937",
|
||||
theme_color: branding.meta.themeColor,
|
||||
orientation: "any",
|
||||
scope: "/",
|
||||
icons: [
|
||||
{
|
||||
src: "/favicon.svg",
|
||||
sizes: "any",
|
||||
type: "image/svg+xml"
|
||||
},
|
||||
{
|
||||
src: "/icon-192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any maskable"
|
||||
},
|
||||
{
|
||||
src: "/icon-512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any maskable"
|
||||
},
|
||||
{
|
||||
src: "/favicon.svg",
|
||||
sizes: "512x512",
|
||||
type: "image/svg+xml",
|
||||
purpose: "any"
|
||||
}
|
||||
],
|
||||
screenshots: [],
|
||||
categories: ["entertainment", "games", "utilities"],
|
||||
shortcuts: [
|
||||
{
|
||||
name: "Pick a Game",
|
||||
short_name: "Pick",
|
||||
description: "Go directly to the game picker",
|
||||
url: "/picker",
|
||||
icons: []
|
||||
},
|
||||
{
|
||||
name: "Session History",
|
||||
short_name: "History",
|
||||
description: "View past gaming sessions",
|
||||
url: "/history",
|
||||
icons: []
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Write manifest to public directory
|
||||
const publicDir = path.join(__dirname, 'public');
|
||||
const manifestPath = path.join(publicDir, 'manifest.json');
|
||||
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
|
||||
|
||||
console.log('✅ Generated manifest.json from branding config');
|
||||
|
||||
166
frontend/generate-png-icons.html
Normal file
166
frontend/generate-png-icons.html
Normal file
@@ -0,0 +1,166 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>PNG Icon Generator</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #4f46e5;
|
||||
margin-top: 0;
|
||||
}
|
||||
.preview {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin: 20px 0;
|
||||
align-items: center;
|
||||
}
|
||||
canvas {
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
button {
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
}
|
||||
button:hover {
|
||||
background: #4338ca;
|
||||
}
|
||||
.info {
|
||||
background: #eff6ff;
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding: 12px;
|
||||
margin: 20px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.success {
|
||||
background: #f0fdf4;
|
||||
border-left-color: #22c55e;
|
||||
color: #166534;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎨 PWA Icon Generator</h1>
|
||||
|
||||
<div class="info">
|
||||
<strong>Instructions:</strong> This tool will generate PNG icons from your SVG favicon.
|
||||
Click the buttons below to download the required icon sizes for PWA support.
|
||||
</div>
|
||||
|
||||
<div class="preview">
|
||||
<div>
|
||||
<h3>192x192</h3>
|
||||
<canvas id="canvas192" width="192" height="192"></canvas>
|
||||
<br>
|
||||
<button onclick="downloadIcon(192)">📥 Download 192x192</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>512x512</h3>
|
||||
<canvas id="canvas512" width="512" height="512"></canvas>
|
||||
<br>
|
||||
<button onclick="downloadIcon(512)">📥 Download 512x512</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status"></div>
|
||||
|
||||
<div class="info">
|
||||
<strong>After downloading:</strong>
|
||||
<ol>
|
||||
<li>Save both files to <code>frontend/public/</code></li>
|
||||
<li>Rename them to <code>icon-192.png</code> and <code>icon-512.png</code></li>
|
||||
<li>Rebuild your Docker containers</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#4f46e5;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Dice/Box shape -->
|
||||
<rect x="10" y="10" width="80" height="80" rx="12" fill="url(#grad)"/>
|
||||
|
||||
<!-- Dots representing game selection -->
|
||||
<circle cx="30" cy="30" r="6" fill="white" opacity="0.9"/>
|
||||
<circle cx="50" cy="30" r="6" fill="white" opacity="0.9"/>
|
||||
<circle cx="70" cy="30" r="6" fill="white" opacity="0.9"/>
|
||||
|
||||
<circle cx="30" cy="50" r="6" fill="white" opacity="0.9"/>
|
||||
<circle cx="50" cy="50" r="6" fill="white" opacity="1"/>
|
||||
<circle cx="70" cy="50" r="6" fill="white" opacity="0.9"/>
|
||||
|
||||
<circle cx="30" cy="70" r="6" fill="white" opacity="0.9"/>
|
||||
<circle cx="50" cy="70" r="6" fill="white" opacity="0.9"/>
|
||||
<circle cx="70" cy="70" r="6" fill="white" opacity="0.9"/>
|
||||
</svg>`;
|
||||
|
||||
function drawIcon(size) {
|
||||
const canvas = document.getElementById(`canvas${size}`);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const img = new Image();
|
||||
const blob = new Blob([svgContent], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
img.onload = function() {
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
function downloadIcon(size) {
|
||||
const canvas = document.getElementById(`canvas${size}`);
|
||||
canvas.toBlob(function(blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `icon-${size}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
const status = document.getElementById('status');
|
||||
status.innerHTML = `<div class="info success">✅ Downloaded icon-${size}.png! Save it to frontend/public/</div>`;
|
||||
setTimeout(() => status.innerHTML = '', 3000);
|
||||
});
|
||||
}
|
||||
|
||||
// Draw icons on page load
|
||||
window.addEventListener('load', () => {
|
||||
drawIcon(192);
|
||||
drawIcon(512);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,22 +4,37 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Branding -->
|
||||
<title>Jackbox Game Picker</title>
|
||||
<meta name="description" content="A web app for managing and picking Jackbox Party Pack games" />
|
||||
<meta name="keywords" content="jackbox, party pack, game picker, multiplayer games" />
|
||||
<meta name="author" content="Jackbox Game Picker" />
|
||||
<!-- Branding (populated by vite.config.js from branding.js) -->
|
||||
<title>HSO Jackbox Game Picker</title>
|
||||
<meta name="description" content="Spicing up Hyper Spaceout game nights!" />
|
||||
<meta name="keywords" content="hso, hyper spaceout, jackbox, party pack, game picker, multiplayer games" />
|
||||
<meta name="author" content="cottongin" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
<!-- Theme color -->
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Mobile Web App -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
|
||||
<!-- iOS Safari -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="HSO JGP" />
|
||||
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" sizes="167x167" href="/favicon.svg" />
|
||||
|
||||
<!-- Theme color (populated by vite.config.js from branding.js) -->
|
||||
<meta name="theme-color" content="#4F46E5" />
|
||||
|
||||
<!-- Open Graph / Social Media -->
|
||||
<!-- Open Graph / Social Media (populated by vite.config.js from branding.js) -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Jackbox Game Picker" />
|
||||
<meta property="og:description" content="A web app for managing and picking Jackbox Party Pack games" />
|
||||
<meta property="og:title" content="HSO Jackbox Game Picker" />
|
||||
<meta property="og:description" content="Spicing up Hyper Spaceout game nights!" />
|
||||
|
||||
<!-- Prevent flash of unstyled content in dark mode -->
|
||||
<script>
|
||||
|
||||
@@ -18,6 +18,20 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Service Worker - no caching!
|
||||
location = /sw.js {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
add_header Service-Worker-Allowed "/";
|
||||
}
|
||||
|
||||
# PWA Manifest
|
||||
location = /manifest.json {
|
||||
add_header Cache-Control "public, max-age=86400";
|
||||
add_header Content-Type "application/manifest+json";
|
||||
}
|
||||
|
||||
# React routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
3378
frontend/package-lock.json
generated
Normal file
3378
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
"name": "jackbox-game-picker-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "Frontend for Jackbox Party Pack Game Picker",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
@@ -20,8 +21,9 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"build": "node generate-manifest.js && vite build",
|
||||
"preview": "vite preview",
|
||||
"generate-manifest": "node generate-manifest.js"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
|
||||
BIN
frontend/public/icon-192.png
Normal file
BIN
frontend/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
BIN
frontend/public/icon-512.png
Normal file
BIN
frontend/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
69
frontend/public/sw.js
Normal file
69
frontend/public/sw.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const CACHE_NAME = 'jackbox-picker-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/favicon.ico'
|
||||
];
|
||||
|
||||
// Install service worker
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => cache.addAll(urlsToCache))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate service worker
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
if (cacheName !== CACHE_NAME) {
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch strategy: Network first, fallback to cache
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Skip non-GET requests
|
||||
if (event.request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip chrome-extension and other non-http(s) requests
|
||||
if (!event.request.url.startsWith('http')) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
// Don't cache API responses or non-successful responses
|
||||
if (event.request.url.includes('/api/') || !response || response.status !== 200) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Clone the response
|
||||
const responseToCache = response.clone();
|
||||
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => {
|
||||
cache.put(event.request, responseToCache);
|
||||
});
|
||||
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
// Network failed, try cache
|
||||
return caches.match(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import { ToastProvider } from './components/Toast';
|
||||
import { branding } from './config/branding';
|
||||
import Logo from './components/Logo';
|
||||
import ThemeToggle from './components/ThemeToggle';
|
||||
import InstallPrompt from './components/InstallPrompt';
|
||||
import SafariInstallPrompt from './components/SafariInstallPrompt';
|
||||
import Home from './pages/Home';
|
||||
import Login from './pages/Login';
|
||||
import Picker from './pages/Picker';
|
||||
@@ -177,6 +179,10 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* PWA Install Prompts */}
|
||||
<InstallPrompt />
|
||||
<SafariInstallPrompt />
|
||||
</div>
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
97
frontend/src/components/InstallPrompt.jsx
Normal file
97
frontend/src/components/InstallPrompt.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
function InstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState(null);
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if Safari (which doesn't support beforeinstallprompt)
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
if (isSafari) {
|
||||
return; // Don't show this prompt on Safari, use SafariInstallPrompt instead
|
||||
}
|
||||
|
||||
const handler = (e) => {
|
||||
// Prevent the mini-infobar from appearing on mobile
|
||||
e.preventDefault();
|
||||
// Save the event so it can be triggered later
|
||||
setDeferredPrompt(e);
|
||||
// Show our custom install prompt
|
||||
setShowPrompt(true);
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!deferredPrompt) return;
|
||||
|
||||
// Show the install prompt
|
||||
deferredPrompt.prompt();
|
||||
|
||||
// Wait for the user to respond to the prompt
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
|
||||
console.log(`User response to install prompt: ${outcome}`);
|
||||
|
||||
// Clear the saved prompt
|
||||
setDeferredPrompt(null);
|
||||
setShowPrompt(false);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowPrompt(false);
|
||||
// Remember dismissal for this session
|
||||
sessionStorage.setItem('installPromptDismissed', 'true');
|
||||
};
|
||||
|
||||
// Don't show if already dismissed in this session
|
||||
if (sessionStorage.getItem('installPromptDismissed')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!showPrompt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:max-w-md z-50 animate-slideUp">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 text-3xl">
|
||||
📱
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
Install App
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Install Jackbox Game Picker for quick access and offline support!
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="flex-1 bg-indigo-600 dark:bg-indigo-700 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition font-medium text-sm"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition text-sm"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InstallPrompt;
|
||||
|
||||
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;
|
||||
|
||||
71
frontend/src/components/SafariInstallPrompt.jsx
Normal file
71
frontend/src/components/SafariInstallPrompt.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
function SafariInstallPrompt() {
|
||||
const [showPrompt, setShowPrompt] = useState(false);
|
||||
const [isStandalone, setIsStandalone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if running in standalone mode (already installed)
|
||||
const standalone = window.navigator.standalone || window.matchMedia('(display-mode: standalone)').matches;
|
||||
setIsStandalone(standalone);
|
||||
|
||||
// Check if Safari on iOS or macOS
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
const isMacOS = navigator.platform.includes('Mac') && !isIOS;
|
||||
|
||||
// Show prompt if Safari and not already installed
|
||||
if ((isSafari || isIOS) && !standalone && !sessionStorage.getItem('safariInstallPromptDismissed')) {
|
||||
// Wait a bit before showing to not overwhelm user
|
||||
const timer = setTimeout(() => {
|
||||
setShowPrompt(true);
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowPrompt(false);
|
||||
sessionStorage.setItem('safariInstallPromptDismissed', 'true');
|
||||
};
|
||||
|
||||
// Don't show if already installed
|
||||
if (isStandalone || !showPrompt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:max-w-md z-50 animate-slideUp">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 text-3xl">
|
||||
🍎
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
Install as App
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Tap the Share button <span className="inline-block w-4 h-4 align-middle">
|
||||
<svg viewBox="0 0 50 50" className="fill-current">
|
||||
<path d="M30.3 13.7L25 8.4l-5.3 5.3-1.4-1.4L25 5.6l6.7 6.7z"/>
|
||||
<path d="M24 7h2v21h-2z"/>
|
||||
<path d="M35 40H15c-1.7 0-3-1.3-3-3V19c0-1.7 1.3-3 3-3h7v2h-7c-.6 0-1 .4-1 1v18c0 .6.4 1 1 1h20c.6 0 1-.4 1-1V19c0-.6-.4-1-1-1h-7v-2h7c1.7 0 3 1.3 3 3v18c0 1.7-1.3 3-3 3z"/>
|
||||
</svg>
|
||||
</span> and select "Add to Home Screen"
|
||||
</p>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="w-full text-center px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition text-sm border border-gray-300 dark:border-gray-600 rounded-lg"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SafariInstallPrompt;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export const branding = {
|
||||
app: {
|
||||
name: 'HSO Jackbox Game Picker',
|
||||
shortName: 'HSO JGP',
|
||||
version: '0.3.0',
|
||||
shortName: 'Jackbox Game Picker',
|
||||
version: '0.5.1 - Thode Goes Wild Edition',
|
||||
description: 'Spicing up Hyper Spaceout game nights!',
|
||||
},
|
||||
meta: {
|
||||
|
||||
@@ -2,6 +2,21 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slideUp {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply antialiased;
|
||||
|
||||
@@ -18,3 +18,16 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
// Register service worker for PWA support
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then((registration) => {
|
||||
console.log('Service Worker registered:', registration);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log('Service Worker registration failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useToast } from '../components/Toast';
|
||||
import api from '../api/axios';
|
||||
@@ -17,9 +17,32 @@ function History() {
|
||||
const [showAllSessions, setShowAllSessions] = useState(false);
|
||||
const [deletingSession, setDeletingSession] = useState(null);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get('/sessions');
|
||||
setSessions(response.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load sessions', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshSessionGames = useCallback(async (sessionId, silent = false) => {
|
||||
try {
|
||||
const response = await api.get(`/sessions/${sessionId}/games`);
|
||||
// Reverse chronological order (most recent first) - create new array to avoid mutation
|
||||
setSessionGames([...response.data].reverse());
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
console.error('Failed to load session games', err);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, []);
|
||||
}, [loadSessions]);
|
||||
|
||||
// Auto-select active session if navigating from picker
|
||||
useEffect(() => {
|
||||
@@ -29,9 +52,18 @@ function History() {
|
||||
loadSessionGames(activeSession.id);
|
||||
}
|
||||
}
|
||||
}, [sessions]);
|
||||
}, [sessions, selectedSession]);
|
||||
|
||||
// Poll for updates on active session
|
||||
// Poll for session list updates (to detect when sessions end/start)
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
loadSessions();
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [loadSessions]);
|
||||
|
||||
// Poll for updates on active session games
|
||||
useEffect(() => {
|
||||
if (!selectedSession) return;
|
||||
|
||||
@@ -40,22 +72,11 @@ function History() {
|
||||
|
||||
// Refresh games every 3 seconds for active session
|
||||
const interval = setInterval(() => {
|
||||
loadSessionGames(selectedSession, true); // silent refresh
|
||||
refreshSessionGames(selectedSession, true); // silent refresh
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedSession, sessions]);
|
||||
|
||||
const loadSessions = async () => {
|
||||
try {
|
||||
const response = await api.get('/sessions');
|
||||
setSessions(response.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load sessions', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [selectedSession, sessions, refreshSessionGames]);
|
||||
|
||||
const handleExport = async (sessionId, format) => {
|
||||
try {
|
||||
@@ -83,7 +104,8 @@ function History() {
|
||||
const loadSessionGames = async (sessionId, silent = false) => {
|
||||
try {
|
||||
const response = await api.get(`/sessions/${sessionId}/games`);
|
||||
setSessionGames(response.data);
|
||||
// Reverse chronological order (most recent first) - create new array to avoid mutation
|
||||
setSessionGames([...response.data].reverse());
|
||||
if (!silent) {
|
||||
setSelectedSession(sessionId);
|
||||
}
|
||||
@@ -298,7 +320,7 @@ function History() {
|
||||
Games Played ({sessionGames.length})
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{[...sessionGames].reverse().map((game, index) => (
|
||||
{sessionGames.map((game, index) => (
|
||||
<div key={game.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-gray-700/50">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import api from '../api/axios';
|
||||
import { formatLocalDateTime, formatLocalTime } from '../utils/dateUtils';
|
||||
@@ -7,27 +7,25 @@ import PopularityBadge from '../components/PopularityBadge';
|
||||
|
||||
function Home() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [activeSession, setActiveSession] = useState(null);
|
||||
const [sessionGames, setSessionGames] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadActiveSession();
|
||||
const loadSessionGames = useCallback(async (sessionId, silent = false) => {
|
||||
try {
|
||||
const gamesResponse = await api.get(`/sessions/${sessionId}/games`);
|
||||
// Reverse chronological order (most recent first)
|
||||
setSessionGames(gamesResponse.data.reverse());
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
console.error('Failed to load session games', error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-refresh for active session
|
||||
useEffect(() => {
|
||||
if (!activeSession) return;
|
||||
|
||||
// Refresh games every 3 seconds for active session
|
||||
const interval = setInterval(() => {
|
||||
loadSessionGames(activeSession.id, true); // silent refresh
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [activeSession]);
|
||||
|
||||
const loadActiveSession = async () => {
|
||||
const loadActiveSession = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get('/sessions/active');
|
||||
|
||||
@@ -44,17 +42,32 @@ function Home() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [loadSessionGames]);
|
||||
|
||||
const loadSessionGames = async (sessionId, silent = false) => {
|
||||
useEffect(() => {
|
||||
loadActiveSession();
|
||||
}, [loadActiveSession]);
|
||||
|
||||
// Auto-refresh for active session status and games
|
||||
useEffect(() => {
|
||||
// Poll for session status changes every 3 seconds
|
||||
const interval = setInterval(() => {
|
||||
loadActiveSession();
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [loadActiveSession]);
|
||||
|
||||
const handleCreateSession = async () => {
|
||||
setCreating(true);
|
||||
try {
|
||||
const gamesResponse = await api.get(`/sessions/${sessionId}/games`);
|
||||
// Reverse chronological order (most recent first)
|
||||
setSessionGames(gamesResponse.data.reverse());
|
||||
await api.post('/sessions');
|
||||
// Navigate to picker page after creating session
|
||||
navigate('/picker');
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
console.error('Failed to load session games', error);
|
||||
}
|
||||
console.error('Failed to create session:', error);
|
||||
alert('Failed to create session. Please try again.');
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -131,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}
|
||||
@@ -169,12 +187,13 @@ function Home() {
|
||||
There is currently no game session in progress.
|
||||
</p>
|
||||
{isAuthenticated ? (
|
||||
<Link
|
||||
to="/picker"
|
||||
className="inline-block bg-indigo-600 dark:bg-indigo-700 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition"
|
||||
<button
|
||||
onClick={handleCreateSession}
|
||||
disabled={creating}
|
||||
className="inline-block bg-indigo-600 dark:bg-indigo-700 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Start a New Session
|
||||
</Link>
|
||||
{creating ? 'Creating Session...' : 'Start a New Session'}
|
||||
</button>
|
||||
) : (
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Admin access required to start a new session.
|
||||
@@ -186,10 +205,18 @@ function Home() {
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<Link
|
||||
to="/history"
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 hover:shadow-xl transition"
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 hover:shadow-xl hover:scale-[1.02] transition-all group"
|
||||
>
|
||||
<h3 className="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-2">
|
||||
Session History
|
||||
<h3 className="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-2 flex items-center justify-between">
|
||||
<span>Session History</span>
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-400 dark:text-gray-500 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 group-hover:translate-x-1 transition-all"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
View past gaming sessions and the games that were played
|
||||
@@ -199,10 +226,18 @@ function Home() {
|
||||
{isAuthenticated && (
|
||||
<Link
|
||||
to="/manager"
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 hover:shadow-xl transition"
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 hover:shadow-xl hover:scale-[1.02] transition-all group"
|
||||
>
|
||||
<h3 className="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-2">
|
||||
Game Manager
|
||||
<h3 className="text-xl font-semibold text-gray-800 dark:text-gray-100 mb-2 flex items-center justify-between">
|
||||
<span>Game Manager</span>
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-400 dark:text-gray-500 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 group-hover:translate-x-1 transition-all"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Manage games, packs, and view statistics
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
"./index.html",
|
||||
|
||||
@@ -1,8 +1,46 @@
|
||||
const { defineConfig } = require('vite');
|
||||
const react = require('@vitejs/plugin-react');
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { branding } from './src/config/branding.js';
|
||||
|
||||
module.exports = defineConfig({
|
||||
plugins: [react()],
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
{
|
||||
name: 'html-transform',
|
||||
transformIndexHtml(html) {
|
||||
return html
|
||||
.replace(/<title>.*?<\/title>/, `<title>${branding.app.name}</title>`)
|
||||
.replace(
|
||||
/<meta name="description" content=".*?"\/>/,
|
||||
`<meta name="description" content="${branding.app.description}"/>`
|
||||
)
|
||||
.replace(
|
||||
/<meta name="keywords" content=".*?"\/>/,
|
||||
`<meta name="keywords" content="${branding.meta.keywords}"/>`
|
||||
)
|
||||
.replace(
|
||||
/<meta name="author" content=".*?"\/>/,
|
||||
`<meta name="author" content="${branding.meta.author}"/>`
|
||||
)
|
||||
.replace(
|
||||
/<meta name="theme-color" content=".*?"\/>/,
|
||||
`<meta name="theme-color" content="${branding.meta.themeColor}"/>`
|
||||
)
|
||||
.replace(
|
||||
/<meta name="apple-mobile-web-app-title" content=".*?"\/>/,
|
||||
`<meta name="apple-mobile-web-app-title" content="${branding.app.shortName}"/>`
|
||||
)
|
||||
.replace(
|
||||
/<meta property="og:title" content=".*?"\/>/,
|
||||
`<meta property="og:title" content="${branding.app.name}"/>`
|
||||
)
|
||||
.replace(
|
||||
/<meta property="og:description" content=".*?"\/>/,
|
||||
`<meta property="og:description" content="${branding.app.description}"/>`
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
|
||||
230
scripts/README.md
Normal file
230
scripts/README.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# Jackbox Player Count Fetcher
|
||||
|
||||
Tools to retrieve the actual player count from a Jackbox game room in real-time.
|
||||
|
||||
## Available Implementations
|
||||
|
||||
### 1. Go + chromedp (Recommended) 🚀
|
||||
The most reliable method - automates joining through jackbox.tv to capture WebSocket data.
|
||||
|
||||
### 2. Browser HTML Interface 🌐
|
||||
Quick visual tool for manual testing - no installation required.
|
||||
|
||||
### 3. Node.js Script (Limited)
|
||||
Attempts direct WebSocket connection - may not work due to authentication requirements.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔍 Automatically joins jackbox.tv to capture WebSocket data
|
||||
- 📊 Returns actual player count (not just max capacity)
|
||||
- 👥 Lists all current players and their roles (host/player)
|
||||
- 🎮 Shows game state, lobby state, and audience count
|
||||
- 🎨 Pretty-printed output with colors
|
||||
|
||||
## Installation
|
||||
|
||||
### Go Version (Recommended)
|
||||
|
||||
```bash
|
||||
cd scripts
|
||||
go mod download
|
||||
```
|
||||
|
||||
**Prerequisites:** Go 1.21+ and Chrome/Chromium browser installed
|
||||
|
||||
### Browser Version (No Installation Required!)
|
||||
|
||||
Just open `get-player-count.html` in any web browser - no installation needed!
|
||||
|
||||
### Node.js Version
|
||||
|
||||
```bash
|
||||
cd scripts
|
||||
npm install
|
||||
```
|
||||
|
||||
**Note:** The Node.js version may not work reliably due to Jackbox WebSocket authentication requirements.
|
||||
|
||||
## Usage
|
||||
|
||||
### Go Version (Best) 🚀
|
||||
|
||||
```bash
|
||||
# Navigate to scripts directory
|
||||
cd scripts
|
||||
|
||||
# Run the script
|
||||
go run get-player-count.go JYET
|
||||
|
||||
# Or build and run
|
||||
go build -o get-player-count get-player-count.go
|
||||
./get-player-count JYET
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
1. Opens jackbox.tv in headless Chrome
|
||||
2. Automatically enters room code and joins as "Observer"
|
||||
3. Captures WebSocket messages from the browser
|
||||
4. Extracts player count from `client/welcome` message
|
||||
5. Enriches with data from REST API
|
||||
|
||||
### Browser Version 🌐
|
||||
|
||||
```bash
|
||||
# Just open in browser
|
||||
open get-player-count.html
|
||||
```
|
||||
|
||||
1. Open `get-player-count.html` in your web browser
|
||||
2. Enter a room code (e.g., "JYET")
|
||||
3. Click "Get Player Count"
|
||||
4. View results instantly
|
||||
|
||||
This version runs entirely in the browser and doesn't require any backend!
|
||||
|
||||
### Node.js Version (Limited)
|
||||
|
||||
```bash
|
||||
node get-jackbox-player-count.js JYET
|
||||
```
|
||||
|
||||
**Warning:** May fail due to WebSocket authentication requirements. Use the Go version for reliable results.
|
||||
|
||||
### JSON Output (for scripting)
|
||||
|
||||
```bash
|
||||
JSON_OUTPUT=true node get-jackbox-player-count.js JYET
|
||||
```
|
||||
|
||||
### As a Module
|
||||
|
||||
```javascript
|
||||
const { getRoomInfo, getPlayerCount } = require('./get-jackbox-player-count');
|
||||
|
||||
async function example() {
|
||||
const roomCode = 'JYET';
|
||||
|
||||
// Get room info
|
||||
const roomInfo = await getRoomInfo(roomCode);
|
||||
console.log('Game:', roomInfo.appTag);
|
||||
|
||||
// Get player count
|
||||
const result = await getPlayerCount(roomCode, roomInfo);
|
||||
console.log('Players:', result.playerCount);
|
||||
console.log('Player list:', result.players);
|
||||
}
|
||||
|
||||
example();
|
||||
```
|
||||
|
||||
## Output Example
|
||||
|
||||
```
|
||||
Jackbox Player Count Fetcher
|
||||
Room Code: JYET
|
||||
|
||||
Fetching room information...
|
||||
✓ Room found: triviadeath
|
||||
Max Players: 8
|
||||
|
||||
Connecting to WebSocket...
|
||||
URL: wss://i-007fc4f534bce7898.play.jackboxgames.com/api/v2/rooms/JYET
|
||||
|
||||
✓ WebSocket connected
|
||||
|
||||
═══════════════════════════════════════════
|
||||
Jackbox Room Status
|
||||
═══════════════════════════════════════════
|
||||
|
||||
Room Code: JYET
|
||||
Game: triviadeath
|
||||
Game State: Lobby
|
||||
Lobby State: CanStart
|
||||
Locked: No
|
||||
Full: No
|
||||
|
||||
Players: 3 / 8
|
||||
Audience: 0
|
||||
|
||||
Current Players:
|
||||
1. Host (host)
|
||||
2. E (player)
|
||||
3. F (player)
|
||||
|
||||
═══════════════════════════════════════════
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **REST API Call**: First queries `https://ecast.jackboxgames.com/api/v2/rooms/{ROOM_CODE}` to get room metadata
|
||||
2. **WebSocket Connection**: Establishes a WebSocket connection to the game server
|
||||
3. **Join as Observer**: Sends a `client/connect` message to join as an audience member
|
||||
4. **Parse Response**: Listens for the `client/welcome` message containing the full lobby state
|
||||
5. **Extract Player Count**: Counts the players in the `here` object
|
||||
|
||||
## API Response Structure
|
||||
|
||||
The script returns an object with the following structure:
|
||||
|
||||
```javascript
|
||||
{
|
||||
roomCode: "JYET",
|
||||
appTag: "triviadeath",
|
||||
playerCount: 3, // Actual player count
|
||||
audienceCount: 0, // Number of audience members
|
||||
maxPlayers: 8, // Maximum capacity
|
||||
gameState: "Lobby", // Current game state
|
||||
lobbyState: "CanStart", // Whether game can start
|
||||
locked: false, // Whether lobby is locked
|
||||
full: false, // Whether lobby is full
|
||||
players: [ // List of all players
|
||||
{ id: "1", role: "host", name: "Host" },
|
||||
{ id: "2", role: "player", name: "E" },
|
||||
{ id: "3", role: "player", name: "F" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Your App
|
||||
|
||||
You can integrate this into your Jackbox game picker application:
|
||||
|
||||
```javascript
|
||||
// In your backend API
|
||||
const { getRoomInfo, getPlayerCount } = require('./scripts/get-jackbox-player-count');
|
||||
|
||||
app.get('/api/room-status/:roomCode', async (req, res) => {
|
||||
try {
|
||||
const roomCode = req.params.roomCode.toUpperCase();
|
||||
const roomInfo = await getRoomInfo(roomCode);
|
||||
const playerData = await getPlayerCount(roomCode, roomInfo);
|
||||
res.json(playerData);
|
||||
} catch (error) {
|
||||
res.status(404).json({ error: 'Room not found' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Room not found or invalid"
|
||||
- Double-check the room code is correct
|
||||
- Make sure the game is currently running (room codes expire after games end)
|
||||
|
||||
### "Connection timeout"
|
||||
- The game server might be unavailable
|
||||
- Check your internet connection
|
||||
- The room might have closed
|
||||
|
||||
### WebSocket connection fails
|
||||
- Ensure you have the `ws` package installed: `npm install`
|
||||
- Some networks/firewalls might block WebSocket connections
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `ws` (^8.14.0) - WebSocket client for Node.js
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
184
scripts/TESTING.md
Normal file
184
scripts/TESTING.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Testing the Jackbox Player Count Script
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Go 1.21+ installed
|
||||
2. Chrome or Chromium browser installed
|
||||
3. Active Jackbox lobby with a valid room code
|
||||
|
||||
## Running the Script
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
cd scripts
|
||||
go run get-player-count.go JYET
|
||||
```
|
||||
|
||||
Replace `JYET` with your actual room code.
|
||||
|
||||
### Debug Mode
|
||||
|
||||
If the script isn't capturing data, run with debug output:
|
||||
|
||||
```bash
|
||||
DEBUG=true go run get-player-count.go JYET
|
||||
```
|
||||
|
||||
This will show:
|
||||
- Each WebSocket frame received
|
||||
- Parsed opcodes
|
||||
- Detailed connection info
|
||||
|
||||
## Expected Output
|
||||
|
||||
### Success
|
||||
|
||||
```
|
||||
🎮 Jackbox Player Count Fetcher
|
||||
Room Code: JYET
|
||||
|
||||
⏳ Navigating to jackbox.tv...
|
||||
✓ Loaded jackbox.tv
|
||||
⏳ Joining room JYET...
|
||||
✓ Clicked Play button, waiting for WebSocket data...
|
||||
✓ Captured lobby data from WebSocket
|
||||
|
||||
═══════════════════════════════════════════
|
||||
Jackbox Room Status
|
||||
═══════════════════════════════════════════
|
||||
|
||||
Room Code: JYET
|
||||
Game: triviadeath
|
||||
Game State: Lobby
|
||||
Lobby State: CanStart
|
||||
Locked: false
|
||||
Full: false
|
||||
|
||||
Players: 3 / 8
|
||||
Audience: 0
|
||||
|
||||
Current Players:
|
||||
1. Host (host)
|
||||
2. E (player)
|
||||
3. F (player)
|
||||
|
||||
═══════════════════════════════════════════
|
||||
```
|
||||
|
||||
### If No WebSocket Messages Captured
|
||||
|
||||
```
|
||||
Error: no WebSocket messages captured - connection may have failed
|
||||
Try running with DEBUG=true for more details
|
||||
```
|
||||
|
||||
**Possible causes:**
|
||||
- Room code is invalid
|
||||
- Game lobby is closed
|
||||
- Network connectivity issues
|
||||
|
||||
### If Messages Captured but No Player Data
|
||||
|
||||
```
|
||||
⚠️ Captured 15 WebSocket messages but couldn't find 'client/welcome'
|
||||
|
||||
Message types found:
|
||||
- room/update: 5
|
||||
- audience/count: 3
|
||||
- ping: 7
|
||||
|
||||
Error: could not find player count data in WebSocket messages
|
||||
Room may be invalid, closed, or not in lobby state
|
||||
```
|
||||
|
||||
**Possible causes:**
|
||||
- Game has already started (not in lobby)
|
||||
- Room code exists but game is in different state
|
||||
- WebSocket connected but welcome message not sent
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cookie Errors (Harmless)
|
||||
|
||||
You may see errors like:
|
||||
```
|
||||
ERROR: could not unmarshal event: parse error: expected string near offset 1247 of 'cookiePart...'
|
||||
```
|
||||
|
||||
**These are harmless** and are suppressed in the output. They're chromedp trying to parse cookie data that doesn't follow expected format.
|
||||
|
||||
### "Failed to load jackbox.tv"
|
||||
|
||||
- Check your internet connection
|
||||
- Verify https://jackbox.tv loads in a regular browser
|
||||
- Try running without `headless` mode (edit the Go file)
|
||||
|
||||
### "Failed to enter room code"
|
||||
|
||||
- Verify the room code is valid
|
||||
- Check that the lobby is actually open
|
||||
- Try running with DEBUG=true to see what's happening
|
||||
|
||||
### "Failed to click Play button"
|
||||
|
||||
- The button may still be disabled
|
||||
- Room code validation may have failed
|
||||
- Name field may not be filled
|
||||
|
||||
### No WebSocket Messages at All
|
||||
|
||||
This means the browser never connected to the game's WebSocket server:
|
||||
- Verify the room code is correct
|
||||
- Check that the game lobby is actually open and accepting players
|
||||
- The game may have a full lobby
|
||||
- The room may have expired
|
||||
|
||||
## Testing with Different Game States
|
||||
|
||||
### Lobby (Should Work)
|
||||
When the game is in the lobby waiting for players to join.
|
||||
|
||||
### During Game (May Not Work)
|
||||
Once the game starts, the WebSocket messages change. The `client/welcome` message may not be sent.
|
||||
|
||||
### After Game (Won't Work)
|
||||
Room codes expire after the game session ends.
|
||||
|
||||
## Manual Verification
|
||||
|
||||
You can verify the data by:
|
||||
|
||||
1. Open https://jackbox.tv in a regular browser
|
||||
2. Open Developer Tools (F12)
|
||||
3. Go to Network tab
|
||||
4. Filter by "WS" (WebSocket)
|
||||
5. Join the room with the same code
|
||||
6. Look for `client/welcome` message in WebSocket frames
|
||||
7. Compare the data with what the script outputs
|
||||
|
||||
## Common Room States
|
||||
|
||||
| State | `client/welcome` | Player Count Available |
|
||||
|-------|------------------|------------------------|
|
||||
| Lobby - Waiting | ✅ Yes | ✅ Yes |
|
||||
| Lobby - Full | ✅ Yes | ✅ Yes |
|
||||
| Game Starting | ⚠️ Maybe | ⚠️ Maybe |
|
||||
| Game In Progress | ❌ No | ❌ No |
|
||||
| Game Ended | ❌ No | ❌ No |
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- Takes ~5-10 seconds to complete
|
||||
- Most time is waiting for WebSocket connection
|
||||
- Headless Chrome startup adds ~1-2 seconds
|
||||
- Network latency affects timing
|
||||
|
||||
## Next Steps
|
||||
|
||||
If the script works:
|
||||
1. Extract the function into a library package
|
||||
2. Integrate with your bot
|
||||
3. Set up cron jobs or periodic polling
|
||||
4. Add result caching to reduce load
|
||||
|
||||
262
scripts/get-jackbox-player-count.js
Normal file
262
scripts/get-jackbox-player-count.js
Normal file
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Jackbox Player Count Fetcher
|
||||
*
|
||||
* This script connects to a Jackbox game room and retrieves the actual player count
|
||||
* by establishing a WebSocket connection and listening for game state updates.
|
||||
*
|
||||
* Usage:
|
||||
* node get-jackbox-player-count.js <ROOM_CODE>
|
||||
* node get-jackbox-player-count.js JYET
|
||||
*/
|
||||
|
||||
const https = require('https');
|
||||
|
||||
// Try to load ws from multiple locations
|
||||
let WebSocket;
|
||||
try {
|
||||
WebSocket = require('ws');
|
||||
} catch (e) {
|
||||
try {
|
||||
WebSocket = require('../backend/node_modules/ws');
|
||||
} catch (e2) {
|
||||
console.error('Error: WebSocket library (ws) not found.');
|
||||
console.error('Please run: npm install ws');
|
||||
console.error('Or run this script from the backend directory where ws is already installed.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ANSI color codes for pretty output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
red: '\x1b[31m',
|
||||
cyan: '\x1b[36m'
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches room information from the Jackbox REST API
|
||||
*/
|
||||
async function getRoomInfo(roomCode) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `https://ecast.jackboxgames.com/api/v2/rooms/${roomCode}`;
|
||||
|
||||
https.get(url, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
if (json.ok) {
|
||||
resolve(json.body);
|
||||
} else {
|
||||
reject(new Error('Room not found or invalid'));
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the Jackbox WebSocket and retrieves player count
|
||||
* Note: Direct WebSocket connection requires proper authentication flow.
|
||||
* This uses the ecast endpoint which is designed for external connections.
|
||||
*/
|
||||
async function getPlayerCount(roomCode, roomInfo) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Use the audienceHost (ecast) instead of direct game host
|
||||
const wsUrl = `wss://${roomInfo.audienceHost}/api/v2/audience/${roomCode}/play`;
|
||||
|
||||
console.log(`${colors.blue}Connecting to WebSocket...${colors.reset}`);
|
||||
console.log(`${colors.cyan}URL: ${wsUrl}${colors.reset}\n`);
|
||||
|
||||
const ws = new WebSocket(wsUrl, {
|
||||
headers: {
|
||||
'Origin': 'https://jackbox.tv',
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
let timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error('Connection timeout - room may be closed or unreachable'));
|
||||
}, 15000); // 15 second timeout
|
||||
|
||||
let receivedAnyData = false;
|
||||
|
||||
ws.on('open', () => {
|
||||
console.log(`${colors.green}✓ WebSocket connected${colors.reset}\n`);
|
||||
|
||||
// For audience endpoint, we might not need to send join message
|
||||
// Just listen for messages
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
receivedAnyData = true;
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
console.log(`${colors.yellow}Received message:${colors.reset}`, message.opcode || 'unknown');
|
||||
|
||||
// Look for various message types that might contain player info
|
||||
if (message.opcode === 'client/welcome' && message.result) {
|
||||
clearTimeout(timeout);
|
||||
|
||||
const here = message.result.here || {};
|
||||
const playerCount = Object.keys(here).length;
|
||||
const audienceCount = message.result.entities?.audience?.[1]?.count || 0;
|
||||
const lobbyState = message.result.entities?.['bc:room']?.[1]?.val?.lobbyState || 'Unknown';
|
||||
const gameState = message.result.entities?.['bc:room']?.[1]?.val?.state || 'Unknown';
|
||||
|
||||
// Extract player details
|
||||
const players = [];
|
||||
for (const [id, playerData] of Object.entries(here)) {
|
||||
const roles = playerData.roles || {};
|
||||
if (roles.host) {
|
||||
players.push({ id, role: 'host', name: 'Host' });
|
||||
} else if (roles.player) {
|
||||
players.push({ id, role: 'player', name: roles.player.name || 'Unknown' });
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
roomCode,
|
||||
appTag: roomInfo.appTag,
|
||||
playerCount,
|
||||
audienceCount,
|
||||
maxPlayers: roomInfo.maxPlayers,
|
||||
gameState,
|
||||
lobbyState,
|
||||
locked: roomInfo.locked,
|
||||
full: roomInfo.full,
|
||||
players
|
||||
};
|
||||
|
||||
ws.close();
|
||||
resolve(result);
|
||||
} else if (message.opcode === 'room/count' || message.opcode === 'audience/count-group') {
|
||||
// Audience count updates
|
||||
console.log(`${colors.cyan}Audience count message received${colors.reset}`);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors, might be non-JSON messages
|
||||
console.log(`${colors.yellow}Parse error:${colors.reset}`, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`WebSocket error: ${error.message}\n\n` +
|
||||
`${colors.yellow}Note:${colors.reset} Direct WebSocket access requires joining through jackbox.tv.\n` +
|
||||
`This limitation means we cannot directly query player count without joining the game.`));
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
clearTimeout(timeout);
|
||||
if (!receivedAnyData) {
|
||||
reject(new Error('WebSocket closed without receiving data.\n\n' +
|
||||
`${colors.yellow}Note:${colors.reset} The Jackbox WebSocket API requires authentication that's only\n` +
|
||||
`available when joining through the official jackbox.tv interface.\n\n` +
|
||||
`${colors.cyan}Alternative:${colors.reset} Use the REST API to check if room is full, or join\n` +
|
||||
`through jackbox.tv in a browser to get real-time player counts.`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty prints the results
|
||||
*/
|
||||
function printResults(result) {
|
||||
console.log(`${colors.bright}═══════════════════════════════════════════${colors.reset}`);
|
||||
console.log(`${colors.bright} Jackbox Room Status${colors.reset}`);
|
||||
console.log(`${colors.bright}═══════════════════════════════════════════${colors.reset}\n`);
|
||||
|
||||
console.log(`${colors.cyan}Room Code:${colors.reset} ${result.roomCode}`);
|
||||
console.log(`${colors.cyan}Game:${colors.reset} ${result.appTag}`);
|
||||
console.log(`${colors.cyan}Game State:${colors.reset} ${result.gameState}`);
|
||||
console.log(`${colors.cyan}Lobby State:${colors.reset} ${result.lobbyState}`);
|
||||
console.log(`${colors.cyan}Locked:${colors.reset} ${result.locked ? 'Yes' : 'No'}`);
|
||||
console.log(`${colors.cyan}Full:${colors.reset} ${result.full ? 'Yes' : 'No'}`);
|
||||
console.log();
|
||||
|
||||
console.log(`${colors.bright}${colors.green}Players:${colors.reset} ${colors.bright}${result.playerCount}${colors.reset} / ${result.maxPlayers}`);
|
||||
console.log(`${colors.cyan}Audience:${colors.reset} ${result.audienceCount}`);
|
||||
console.log();
|
||||
|
||||
if (result.players.length > 0) {
|
||||
console.log(`${colors.bright}Current Players:${colors.reset}`);
|
||||
result.players.forEach((player, idx) => {
|
||||
const roleColor = player.role === 'host' ? colors.yellow : colors.green;
|
||||
console.log(` ${idx + 1}. ${roleColor}${player.name}${colors.reset} (${player.role})`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`\n${colors.bright}═══════════════════════════════════════════${colors.reset}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function
|
||||
*/
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.error(`${colors.red}Error: Room code required${colors.reset}`);
|
||||
console.log(`\nUsage: node get-jackbox-player-count.js <ROOM_CODE>`);
|
||||
console.log(`Example: node get-jackbox-player-count.js JYET\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const roomCode = args[0].toUpperCase();
|
||||
|
||||
console.log(`${colors.bright}Jackbox Player Count Fetcher${colors.reset}`);
|
||||
console.log(`${colors.cyan}Room Code: ${roomCode}${colors.reset}\n`);
|
||||
|
||||
try {
|
||||
// Step 1: Get room info from REST API
|
||||
console.log(`${colors.blue}Fetching room information...${colors.reset}`);
|
||||
const roomInfo = await getRoomInfo(roomCode);
|
||||
console.log(`${colors.green}✓ Room found: ${roomInfo.appTag}${colors.reset}`);
|
||||
console.log(`${colors.cyan} Max Players: ${roomInfo.maxPlayers}${colors.reset}\n`);
|
||||
|
||||
// Step 2: Connect to WebSocket and get player count
|
||||
const result = await getPlayerCount(roomCode, roomInfo);
|
||||
|
||||
// Step 3: Print results
|
||||
printResults(result);
|
||||
|
||||
// Return just the player count for scripting purposes
|
||||
if (process.env.JSON_OUTPUT === 'true') {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error(`${colors.red}Error: ${error.message}${colors.reset}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run if executed directly
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
// Export for use as a module
|
||||
module.exports = {
|
||||
getRoomInfo,
|
||||
getPlayerCount
|
||||
};
|
||||
|
||||
394
scripts/get-player-count.go
Normal file
394
scripts/get-player-count.go
Normal file
@@ -0,0 +1,394 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/chromedp"
|
||||
)
|
||||
|
||||
// PlayerInfo represents a player in the lobby
|
||||
type PlayerInfo struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// LobbyStatus contains all information about the current lobby
|
||||
type LobbyStatus struct {
|
||||
RoomCode string `json:"roomCode"`
|
||||
AppTag string `json:"appTag"`
|
||||
PlayerCount int `json:"playerCount"`
|
||||
AudienceCount int `json:"audienceCount"`
|
||||
MaxPlayers int `json:"maxPlayers"`
|
||||
GameState string `json:"gameState"`
|
||||
LobbyState string `json:"lobbyState"`
|
||||
Locked bool `json:"locked"`
|
||||
Full bool `json:"full"`
|
||||
Players []PlayerInfo `json:"players"`
|
||||
}
|
||||
|
||||
// WebSocketMessage represents a parsed WebSocket message
|
||||
type WebSocketMessage struct {
|
||||
PC int `json:"pc"`
|
||||
Opcode string `json:"opcode"`
|
||||
Result map[string]interface{} `json:"result"`
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: go run get-player-count.go <ROOM_CODE>")
|
||||
fmt.Println("Example: go run get-player-count.go JYET")
|
||||
fmt.Println("\nSet DEBUG=true for verbose output:")
|
||||
fmt.Println("DEBUG=true go run get-player-count.go JYET")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
roomCode := strings.ToUpper(strings.TrimSpace(os.Args[1]))
|
||||
|
||||
if len(roomCode) != 4 {
|
||||
fmt.Println("Error: Room code must be exactly 4 characters")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("🎮 Jackbox Player Count Fetcher\n")
|
||||
fmt.Printf("Room Code: %s\n\n", roomCode)
|
||||
|
||||
status, err := getPlayerCount(roomCode)
|
||||
if err != nil {
|
||||
log.Fatalf("Error: %v\n", err)
|
||||
}
|
||||
|
||||
printStatus(status)
|
||||
}
|
||||
|
||||
func getPlayerCount(roomCode string) (*LobbyStatus, error) {
|
||||
// Create chrome context with less verbose logging
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", true),
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
chromedp.Flag("no-sandbox", true),
|
||||
chromedp.Flag("disable-web-security", true),
|
||||
)
|
||||
|
||||
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
defer cancel()
|
||||
|
||||
// Create context without default logging to reduce cookie errors
|
||||
ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(func(s string, i ...interface{}) {
|
||||
// Only log non-cookie errors
|
||||
msg := fmt.Sprintf(s, i...)
|
||||
if !strings.Contains(msg, "cookiePart") && !strings.Contains(msg, "could not unmarshal") {
|
||||
log.Printf(msg)
|
||||
}
|
||||
}))
|
||||
defer cancel()
|
||||
|
||||
// Set timeout
|
||||
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var lobbyStatus *LobbyStatus
|
||||
welcomeMessageFound := false
|
||||
wsMessages := make([]string, 0)
|
||||
|
||||
// Listen for WebSocket frames - this is the most reliable method
|
||||
debugMode := os.Getenv("DEBUG") == "true"
|
||||
|
||||
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
||||
if debugMode {
|
||||
fmt.Printf("[DEBUG] Event type: %T\n", ev)
|
||||
}
|
||||
|
||||
switch ev := ev.(type) {
|
||||
case *network.EventWebSocketCreated:
|
||||
if debugMode {
|
||||
fmt.Printf("[DEBUG] WebSocket Created: %s\n", ev.URL)
|
||||
}
|
||||
case *network.EventWebSocketFrameReceived:
|
||||
// Capture all WebSocket frames
|
||||
wsMessages = append(wsMessages, ev.Response.PayloadData)
|
||||
|
||||
if debugMode {
|
||||
fmt.Printf("[DEBUG] WS Frame received (%d bytes)\n", len(ev.Response.PayloadData))
|
||||
if len(ev.Response.PayloadData) < 200 {
|
||||
fmt.Printf("[DEBUG] Data: %s\n", ev.Response.PayloadData)
|
||||
} else {
|
||||
fmt.Printf("[DEBUG] Data (truncated): %s...\n", ev.Response.PayloadData[:200])
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse immediately
|
||||
var wsMsg WebSocketMessage
|
||||
if err := json.Unmarshal([]byte(ev.Response.PayloadData), &wsMsg); err == nil {
|
||||
if debugMode {
|
||||
fmt.Printf("[DEBUG] Parsed opcode: %s\n", wsMsg.Opcode)
|
||||
}
|
||||
|
||||
if wsMsg.Opcode == "client/welcome" && wsMsg.Result != nil {
|
||||
lobbyStatus = parseWelcomeMessage(&wsMsg)
|
||||
welcomeMessageFound = true
|
||||
fmt.Println("✓ Captured lobby data from WebSocket")
|
||||
}
|
||||
} else if debugMode {
|
||||
fmt.Printf("[DEBUG] Failed to parse JSON: %v\n", err)
|
||||
}
|
||||
case *network.EventWebSocketFrameSent:
|
||||
if debugMode {
|
||||
fmt.Printf("[DEBUG] WS Frame sent: %s\n", ev.Response.PayloadData)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
fmt.Println("⏳ Navigating to jackbox.tv...")
|
||||
|
||||
// Enable network tracking BEFORE navigation
|
||||
if err := chromedp.Run(ctx, network.Enable()); err != nil {
|
||||
return nil, fmt.Errorf("failed to enable network tracking: %w", err)
|
||||
}
|
||||
|
||||
err := chromedp.Run(ctx,
|
||||
chromedp.Navigate("https://jackbox.tv/"),
|
||||
chromedp.WaitVisible(`input[placeholder*="ENTER 4-LETTER CODE"]`, chromedp.ByQuery),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load jackbox.tv: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Loaded jackbox.tv\n")
|
||||
fmt.Printf("⏳ Joining room %s...\n", roomCode)
|
||||
|
||||
// Type room code and press Enter to join
|
||||
err = chromedp.Run(ctx,
|
||||
chromedp.Focus(`input[placeholder*="ENTER 4-LETTER CODE"]`, chromedp.ByQuery),
|
||||
chromedp.SendKeys(`input[placeholder*="ENTER 4-LETTER CODE"]`, roomCode+"\n", chromedp.ByQuery),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to enter room code: %w", err)
|
||||
}
|
||||
|
||||
if debugMode {
|
||||
fmt.Println("[DEBUG] Entered room code and pressed Enter")
|
||||
}
|
||||
|
||||
// Wait for room code validation and page transition
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
fmt.Println("✓ Clicked Play button, waiting for WebSocket data...")
|
||||
|
||||
// Check if we successfully joined (look for typical lobby UI elements)
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
var pageText string
|
||||
err = chromedp.Run(ctx,
|
||||
chromedp.Text("body", &pageText, chromedp.ByQuery),
|
||||
)
|
||||
if err == nil && debugMode {
|
||||
if strings.Contains(pageText, "Sit back") || strings.Contains(pageText, "waiting") {
|
||||
fmt.Println("[DEBUG] Successfully joined lobby (found lobby text)")
|
||||
} else {
|
||||
fmt.Printf("[DEBUG] Page text: %s\n", pageText[:min(300, len(pageText))])
|
||||
}
|
||||
}
|
||||
|
||||
// Wait longer for WebSocket to connect and receive welcome message
|
||||
for i := 0; i < 15 && !welcomeMessageFound; i++ {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if i%4 == 0 {
|
||||
fmt.Printf("⏳ Waiting for lobby data... (%ds)\n", i/2)
|
||||
}
|
||||
}
|
||||
|
||||
// If we still didn't get it from WebSocket frames, try parsing all captured messages
|
||||
if !welcomeMessageFound && len(wsMessages) > 0 {
|
||||
fmt.Printf("⏳ Parsing %d captured WebSocket messages...\n", len(wsMessages))
|
||||
|
||||
for _, msg := range wsMessages {
|
||||
var wsMsg WebSocketMessage
|
||||
if err := json.Unmarshal([]byte(msg), &wsMsg); err == nil {
|
||||
if wsMsg.Opcode == "client/welcome" && wsMsg.Result != nil {
|
||||
lobbyStatus = parseWelcomeMessage(&wsMsg)
|
||||
welcomeMessageFound = true
|
||||
fmt.Println("✓ Found lobby data in captured messages")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lobbyStatus == nil {
|
||||
if len(wsMessages) == 0 {
|
||||
return nil, fmt.Errorf("no WebSocket messages captured - connection may have failed\nTry running with DEBUG=true for more details")
|
||||
}
|
||||
|
||||
// Show what we captured
|
||||
fmt.Printf("\n⚠️ Captured %d WebSocket messages but couldn't find 'client/welcome'\n", len(wsMessages))
|
||||
fmt.Println("\nMessage types found:")
|
||||
opcodes := make(map[string]int)
|
||||
for _, msg := range wsMessages {
|
||||
var wsMsg WebSocketMessage
|
||||
if err := json.Unmarshal([]byte(msg), &wsMsg); err == nil {
|
||||
opcodes[wsMsg.Opcode]++
|
||||
}
|
||||
}
|
||||
for opcode, count := range opcodes {
|
||||
fmt.Printf(" - %s: %d\n", opcode, count)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("could not find player count data in WebSocket messages\nRoom may be invalid, closed, or not in lobby state")
|
||||
}
|
||||
|
||||
lobbyStatus.RoomCode = roomCode
|
||||
|
||||
// Fetch additional room info from REST API
|
||||
if err := enrichWithRestAPI(lobbyStatus); err != nil {
|
||||
fmt.Printf("Warning: Could not fetch additional room info: %v\n", err)
|
||||
}
|
||||
|
||||
return lobbyStatus, nil
|
||||
}
|
||||
|
||||
func parseWelcomeMessage(msg *WebSocketMessage) *LobbyStatus {
|
||||
status := &LobbyStatus{
|
||||
Players: []PlayerInfo{},
|
||||
}
|
||||
|
||||
// Parse "here" object for players
|
||||
if here, ok := msg.Result["here"].(map[string]interface{}); ok {
|
||||
status.PlayerCount = len(here)
|
||||
|
||||
for id, playerData := range here {
|
||||
if pd, ok := playerData.(map[string]interface{}); ok {
|
||||
player := PlayerInfo{ID: id}
|
||||
|
||||
if roles, ok := pd["roles"].(map[string]interface{}); ok {
|
||||
if _, hasHost := roles["host"]; hasHost {
|
||||
player.Role = "host"
|
||||
player.Name = "Host"
|
||||
} else if playerRole, ok := roles["player"].(map[string]interface{}); ok {
|
||||
player.Role = "player"
|
||||
if name, ok := playerRole["name"].(string); ok {
|
||||
player.Name = name
|
||||
} else {
|
||||
player.Name = "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status.Players = append(status.Players, player)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse entities for additional info
|
||||
if entities, ok := msg.Result["entities"].(map[string]interface{}); ok {
|
||||
// Audience count
|
||||
if audience, ok := entities["audience"].([]interface{}); ok && len(audience) > 1 {
|
||||
if audienceData, ok := audience[1].(map[string]interface{}); ok {
|
||||
if count, ok := audienceData["count"].(float64); ok {
|
||||
status.AudienceCount = int(count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Room state
|
||||
if bcRoom, ok := entities["bc:room"].([]interface{}); ok && len(bcRoom) > 1 {
|
||||
if roomData, ok := bcRoom[1].(map[string]interface{}); ok {
|
||||
if val, ok := roomData["val"].(map[string]interface{}); ok {
|
||||
if gameState, ok := val["state"].(string); ok {
|
||||
status.GameState = gameState
|
||||
}
|
||||
if lobbyState, ok := val["lobbyState"].(string); ok {
|
||||
status.LobbyState = lobbyState
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
func enrichWithRestAPI(status *LobbyStatus) error {
|
||||
// Fetch additional room info from REST API
|
||||
url := fmt.Sprintf("https://ecast.jackboxgames.com/api/v2/rooms/%s", status.RoomCode)
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
OK bool `json:"ok"`
|
||||
Body struct {
|
||||
AppTag string `json:"appTag"`
|
||||
MaxPlayers int `json:"maxPlayers"`
|
||||
Locked bool `json:"locked"`
|
||||
Full bool `json:"full"`
|
||||
} `json:"body"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.OK {
|
||||
status.AppTag = result.Body.AppTag
|
||||
status.MaxPlayers = result.Body.MaxPlayers
|
||||
status.Locked = result.Body.Locked
|
||||
status.Full = result.Body.Full
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func printStatus(status *LobbyStatus) {
|
||||
fmt.Println()
|
||||
fmt.Println("═══════════════════════════════════════════")
|
||||
fmt.Println(" Jackbox Room Status")
|
||||
fmt.Println("═══════════════════════════════════════════")
|
||||
fmt.Println()
|
||||
fmt.Printf("Room Code: %s\n", status.RoomCode)
|
||||
fmt.Printf("Game: %s\n", status.AppTag)
|
||||
fmt.Printf("Game State: %s\n", status.GameState)
|
||||
fmt.Printf("Lobby State: %s\n", status.LobbyState)
|
||||
fmt.Printf("Locked: %t\n", status.Locked)
|
||||
fmt.Printf("Full: %t\n", status.Full)
|
||||
fmt.Println()
|
||||
fmt.Printf("Players: %d / %d\n", status.PlayerCount, status.MaxPlayers)
|
||||
fmt.Printf("Audience: %d\n", status.AudienceCount)
|
||||
fmt.Println()
|
||||
|
||||
if len(status.Players) > 0 {
|
||||
fmt.Println("Current Players:")
|
||||
for i, player := range status.Players {
|
||||
fmt.Printf(" %d. %s (%s)\n", i+1, player.Name, player.Role)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Println("═══════════════════════════════════════════")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
468
scripts/get-player-count.html
Normal file
468
scripts/get-player-count.html
Normal file
@@ -0,0 +1,468 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Jackbox Player Count Fetcher</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 6px;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status.loading {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.results {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.results.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-value.highlight {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.players-list {
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.players-list h3 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.player-item {
|
||||
padding: 10px;
|
||||
margin: 5px 0;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.player-role {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.player-role.host {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.player-role.player {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.badge.success {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.badge.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎮 Jackbox Player Count Fetcher</h1>
|
||||
<p class="subtitle">Enter a room code to get real-time player information</p>
|
||||
|
||||
<div class="input-group">
|
||||
<input type="text" id="roomCode" placeholder="Enter room code (e.g., JYET)" maxlength="4">
|
||||
<button id="fetchBtn" onclick="fetchPlayerCount()">Get Player Count</button>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status"></div>
|
||||
|
||||
<div id="results" class="results">
|
||||
<div class="result-card">
|
||||
<div class="stat-grid">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Players</div>
|
||||
<div class="stat-value highlight" id="playerCount">-</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Max Players</div>
|
||||
<div class="stat-value" id="maxPlayers">-</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Audience</div>
|
||||
<div class="stat-value" id="audienceCount">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="info-label">Room Code:</span>
|
||||
<span class="info-value" id="displayRoomCode">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Game:</span>
|
||||
<span class="info-value" id="gameTag">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Game State:</span>
|
||||
<span class="info-value" id="gameState">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Lobby State:</span>
|
||||
<span class="info-value" id="lobbyState">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Locked:</span>
|
||||
<span class="info-value" id="locked">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Full:</span>
|
||||
<span class="info-value" id="full">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="playersContainer" class="players-list" style="display: none;">
|
||||
<h3>Current Players</h3>
|
||||
<div id="playersList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Allow Enter key to trigger fetch
|
||||
document.getElementById('roomCode').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
fetchPlayerCount();
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchPlayerCount() {
|
||||
const roomCode = document.getElementById('roomCode').value.toUpperCase().trim();
|
||||
|
||||
if (!roomCode || roomCode.length !== 4) {
|
||||
showStatus('Please enter a valid 4-letter room code', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus('Fetching room information...', 'loading');
|
||||
document.getElementById('fetchBtn').disabled = true;
|
||||
document.getElementById('results').classList.remove('visible');
|
||||
|
||||
try {
|
||||
// Step 1: Get room info
|
||||
const roomInfo = await getRoomInfo(roomCode);
|
||||
|
||||
showStatus('Connecting to WebSocket...', 'loading');
|
||||
|
||||
// Step 2: Connect to WebSocket and get player count
|
||||
const result = await getPlayerCount(roomCode, roomInfo);
|
||||
|
||||
showStatus('✓ Successfully retrieved player information', 'success');
|
||||
displayResults(result);
|
||||
|
||||
setTimeout(() => {
|
||||
document.getElementById('status').style.display = 'none';
|
||||
}, 3000);
|
||||
|
||||
} catch (error) {
|
||||
showStatus(`Error: ${error.message}`, 'error');
|
||||
} finally {
|
||||
document.getElementById('fetchBtn').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getRoomInfo(roomCode) {
|
||||
const response = await fetch(`https://ecast.jackboxgames.com/api/v2/rooms/${roomCode}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.ok) {
|
||||
throw new Error('Room not found or invalid');
|
||||
}
|
||||
|
||||
return data.body;
|
||||
}
|
||||
|
||||
async function getPlayerCount(roomCode, roomInfo) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wsUrl = `wss://${roomInfo.host}/api/v2/rooms/${roomCode}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error('Connection timeout'));
|
||||
}, 10000);
|
||||
|
||||
ws.onopen = () => {
|
||||
// Join as audience to get lobby state without affecting the game
|
||||
ws.send(JSON.stringify({
|
||||
opcode: 'client/connect',
|
||||
params: {
|
||||
name: 'WebObserver',
|
||||
role: 'audience',
|
||||
format: 'json'
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
if (message.opcode === 'client/welcome' && message.result) {
|
||||
clearTimeout(timeout);
|
||||
|
||||
const here = message.result.here || {};
|
||||
const playerCount = Object.keys(here).length;
|
||||
const audienceCount = message.result.entities?.audience?.[1]?.count || 0;
|
||||
const lobbyState = message.result.entities?.['bc:room']?.[1]?.val?.lobbyState || 'Unknown';
|
||||
const gameState = message.result.entities?.['bc:room']?.[1]?.val?.state || 'Unknown';
|
||||
|
||||
const players = [];
|
||||
for (const [id, playerData] of Object.entries(here)) {
|
||||
const roles = playerData.roles || {};
|
||||
if (roles.host) {
|
||||
players.push({ id, role: 'host', name: 'Host' });
|
||||
} else if (roles.player) {
|
||||
players.push({ id, role: 'player', name: roles.player.name || 'Unknown' });
|
||||
}
|
||||
}
|
||||
|
||||
ws.close();
|
||||
resolve({
|
||||
roomCode,
|
||||
appTag: roomInfo.appTag,
|
||||
playerCount,
|
||||
audienceCount,
|
||||
maxPlayers: roomInfo.maxPlayers,
|
||||
gameState,
|
||||
lobbyState,
|
||||
locked: roomInfo.locked,
|
||||
full: roomInfo.full,
|
||||
players
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('WebSocket connection failed'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function showStatus(message, type) {
|
||||
const status = document.getElementById('status');
|
||||
status.textContent = message;
|
||||
status.className = `status ${type}`;
|
||||
}
|
||||
|
||||
function displayResults(result) {
|
||||
document.getElementById('results').classList.add('visible');
|
||||
|
||||
document.getElementById('playerCount').textContent = result.playerCount;
|
||||
document.getElementById('maxPlayers').textContent = result.maxPlayers;
|
||||
document.getElementById('audienceCount').textContent = result.audienceCount;
|
||||
document.getElementById('displayRoomCode').textContent = result.roomCode;
|
||||
document.getElementById('gameTag').textContent = result.appTag;
|
||||
document.getElementById('gameState').textContent = result.gameState;
|
||||
document.getElementById('lobbyState').textContent = result.lobbyState;
|
||||
|
||||
document.getElementById('locked').innerHTML = result.locked
|
||||
? '<span class="badge error">Yes</span>'
|
||||
: '<span class="badge success">No</span>';
|
||||
|
||||
document.getElementById('full').innerHTML = result.full
|
||||
? '<span class="badge error">Yes</span>'
|
||||
: '<span class="badge success">No</span>';
|
||||
|
||||
if (result.players && result.players.length > 0) {
|
||||
const playersList = document.getElementById('playersList');
|
||||
playersList.innerHTML = result.players.map(player => `
|
||||
<div class="player-item">
|
||||
<span class="player-name">${player.name}</span>
|
||||
<span class="player-role ${player.role}">${player.role}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
document.getElementById('playersContainer').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('playersContainer').style.display = 'none';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
18
scripts/go.mod
Normal file
18
scripts/go.mod
Normal file
@@ -0,0 +1,18 @@
|
||||
module jackbox-player-count
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998
|
||||
github.com/chromedp/chromedp v0.9.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/chromedp/sysutil v1.0.0 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.3.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
)
|
||||
23
scripts/go.sum
Normal file
23
scripts/go.sum
Normal file
@@ -0,0 +1,23 @@
|
||||
github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998 h1:2zipcnjfFdqAjOQa8otCCh0Lk1M7RBzciy3s80YAKHk=
|
||||
github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
|
||||
github.com/chromedp/chromedp v0.9.3 h1:Wq58e0dZOdHsxaj9Owmfcf+ibtpYN1N0FWVbaxa/esg=
|
||||
github.com/chromedp/chromedp v0.9.3/go.mod h1:NipeUkUcuzIdFbBP8eNNvl9upcceOfWzoJn6cRe4ksA=
|
||||
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
|
||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.3.0 h1:sbeU3Y4Qzlb+MOzIe6mQGf7QR4Hkv6ZD0qhGkBFL2O0=
|
||||
github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
184
scripts/jackbox-count-v2.js
Normal file
184
scripts/jackbox-count-v2.js
Normal file
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env node
|
||||
const puppeteer = require('puppeteer');
|
||||
|
||||
async function getPlayerCount(roomCode) {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-web-security']
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
let playerCount = null;
|
||||
|
||||
// Listen for console API calls (this catches console.log with formatting)
|
||||
page.on('console', async msg => {
|
||||
try {
|
||||
// Get all args
|
||||
for (const arg of msg.args()) {
|
||||
const val = await arg.jsonValue();
|
||||
const str = JSON.stringify(val);
|
||||
|
||||
if (process.env.DEBUG) console.error('[CONSOLE]', str.substring(0, 200));
|
||||
|
||||
// Check if this is the welcome message
|
||||
if (str && str.includes('"opcode":"client/welcome"') && str.includes('"here"')) {
|
||||
const data = JSON.parse(str);
|
||||
if (data.result && data.result.here) {
|
||||
playerCount = Object.keys(data.result.here).length;
|
||||
if (process.env.DEBUG) console.error('[FOUND] Player count:', playerCount);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
if (process.env.DEBUG) console.error('[1] Loading page...');
|
||||
await page.goto('https://jackbox.tv/', { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
|
||||
// Clear storage and reload to avoid reconnect
|
||||
if (process.env.DEBUG) console.error('[2] Clearing storage...');
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
await page.reload({ waitUntil: 'networkidle2' });
|
||||
|
||||
await page.waitForSelector('input[placeholder*="ENTER 4-LETTER CODE"]', { timeout: 10000 });
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[2.5] Checking all inputs on page...');
|
||||
const allInputs = await page.evaluate(() => {
|
||||
const inputs = Array.from(document.querySelectorAll('input'));
|
||||
return inputs.map(inp => ({
|
||||
type: inp.type,
|
||||
placeholder: inp.placeholder,
|
||||
value: inp.value,
|
||||
name: inp.name,
|
||||
id: inp.id,
|
||||
visible: inp.offsetParent !== null
|
||||
}));
|
||||
});
|
||||
console.error('[2.5] All inputs:', JSON.stringify(allInputs, null, 2));
|
||||
}
|
||||
|
||||
if (process.env.DEBUG) console.error('[3] Typing room code...');
|
||||
|
||||
// Type room code character by character (with delay to trigger validation)
|
||||
await page.click('input[placeholder*="ENTER 4-LETTER CODE"]');
|
||||
await page.type('input[placeholder*="ENTER 4-LETTER CODE"]', roomCode, { delay: 100 });
|
||||
|
||||
// Wait for room validation to complete (look for loader success message)
|
||||
if (process.env.DEBUG) console.error('[3.5] Waiting for room validation...');
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// Type name character by character - this will enable the Play button
|
||||
if (process.env.DEBUG) console.error('[3.6] Typing name...');
|
||||
await page.click('input[placeholder*="ENTER YOUR NAME"]');
|
||||
await page.type('input[placeholder*="ENTER YOUR NAME"]', 'Observer', { delay: 100 });
|
||||
|
||||
// Wait a moment for button to fully enable
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
const fieldValues = await page.evaluate(() => {
|
||||
const roomInput = document.querySelector('input[placeholder*="ENTER 4-LETTER CODE"]');
|
||||
const nameInput = document.querySelector('input[placeholder*="ENTER YOUR NAME"]');
|
||||
return {
|
||||
roomCode: roomInput ? roomInput.value : 'NOT FOUND',
|
||||
name: nameInput ? nameInput.value : 'NOT FOUND'
|
||||
};
|
||||
});
|
||||
console.error('[3.5] Field values:', fieldValues);
|
||||
}
|
||||
|
||||
// Find the Play or RECONNECT button (case-insensitive, not disabled)
|
||||
if (process.env.DEBUG) {
|
||||
const buttonInfo = await page.evaluate(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
const allButtons = buttons.map(b => ({
|
||||
text: b.textContent.trim(),
|
||||
disabled: b.disabled,
|
||||
visible: b.offsetParent !== null
|
||||
}));
|
||||
const actionBtn = buttons.find(b => {
|
||||
const text = b.textContent.toUpperCase();
|
||||
return (text.includes('PLAY') || text.includes('RECONNECT')) && !b.disabled;
|
||||
});
|
||||
return {
|
||||
allButtons,
|
||||
found: actionBtn ? actionBtn.textContent.trim() : 'NOT FOUND'
|
||||
};
|
||||
});
|
||||
console.error('[4] All buttons:', JSON.stringify(buttonInfo.allButtons, null, 2));
|
||||
console.error('[4] Target button:', buttonInfo.found);
|
||||
}
|
||||
|
||||
if (process.env.DEBUG) console.error('[5] Clicking Play/Reconnect (even if disabled)...');
|
||||
await page.evaluate(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
const actionBtn = buttons.find(b => {
|
||||
const text = b.textContent.toUpperCase();
|
||||
return text.includes('PLAY') || text.includes('RECONNECT');
|
||||
});
|
||||
if (actionBtn) {
|
||||
// Remove disabled attribute and click
|
||||
actionBtn.disabled = false;
|
||||
actionBtn.click();
|
||||
} else {
|
||||
throw new Error('Could not find PLAY or RECONNECT button');
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for navigation/lobby to load
|
||||
if (process.env.DEBUG) console.error('[6] Waiting for lobby (5 seconds)...');
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
// Check if we're in the lobby
|
||||
const pageText = await page.evaluate(() => document.body.innerText);
|
||||
const inLobby = pageText.includes('Sit back') || pageText.includes('relax');
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[7] In lobby:', inLobby);
|
||||
console.error('[7] Page text sample:', pageText.substring(0, 100));
|
||||
console.error('[7] Page URL:', page.url());
|
||||
}
|
||||
|
||||
// Wait for WebSocket message
|
||||
if (process.env.DEBUG) console.error('[8] Waiting for player count...');
|
||||
for (let i = 0; i < 20 && playerCount === null; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
if (process.env.DEBUG && i % 4 === 0) {
|
||||
console.error(`[8.${i}] Still waiting...`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
if (playerCount === null) {
|
||||
throw new Error('Could not get player count');
|
||||
}
|
||||
|
||||
return playerCount;
|
||||
}
|
||||
|
||||
const roomCode = process.argv[2];
|
||||
if (!roomCode) {
|
||||
console.error('Usage: node jackbox-count-v2.js <ROOM_CODE>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
getPlayerCount(roomCode.toUpperCase())
|
||||
.then(count => {
|
||||
console.log(count);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
184
scripts/jackbox-count-v3.js
Executable file
184
scripts/jackbox-count-v3.js
Executable file
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env node
|
||||
const puppeteer = require('puppeteer');
|
||||
|
||||
async function getPlayerCount(roomCode) {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox'
|
||||
]
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Set a realistic user agent to avoid bot detection
|
||||
await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
||||
let playerCount = null;
|
||||
let roomValidated = false;
|
||||
|
||||
// Monitor network requests to see if API call happens
|
||||
page.on('response', async (response) => {
|
||||
const url = response.url();
|
||||
if (url.includes('ecast.jackboxgames.com/api/v2/rooms')) {
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[NETWORK] Room API called:', url, 'Status:', response.status());
|
||||
}
|
||||
if (response.status() === 200) {
|
||||
roomValidated = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for console messages that contain the client/welcome WebSocket message
|
||||
page.on('console', async msg => {
|
||||
try {
|
||||
const args = msg.args();
|
||||
for (const arg of args) {
|
||||
const val = await arg.jsonValue();
|
||||
const str = typeof val === 'object' ? JSON.stringify(val) : String(val);
|
||||
|
||||
// Debug: log all console messages that might be relevant
|
||||
if (process.env.DEBUG && (str.includes('welcome') || str.includes('here') || str.includes('opcode'))) {
|
||||
console.error('[CONSOLE]', str.substring(0, 200));
|
||||
}
|
||||
|
||||
// Look for the client/welcome message with player data
|
||||
if (str.includes('client/welcome')) {
|
||||
try {
|
||||
let data;
|
||||
if (typeof val === 'object') {
|
||||
data = val;
|
||||
} else {
|
||||
// The string might be "recv <- {...}" so extract the JSON part
|
||||
const jsonStart = str.indexOf('{');
|
||||
if (jsonStart !== -1) {
|
||||
const jsonStr = str.substring(jsonStart);
|
||||
data = JSON.parse(jsonStr);
|
||||
}
|
||||
}
|
||||
|
||||
if (data && data.opcode === 'client/welcome' && data.result) {
|
||||
// Look for the "here" object which contains all connected players
|
||||
if (data.result.here) {
|
||||
playerCount = Object.keys(data.result.here).length;
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[SUCCESS] Found player count:', playerCount);
|
||||
}
|
||||
} else if (process.env.DEBUG) {
|
||||
console.error('[DEBUG] client/welcome found but no "here" object. Keys:', Object.keys(data.result));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[PARSE ERROR]', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
if (process.env.DEBUG) console.error('[1] Navigating to jackbox.tv...');
|
||||
await page.goto('https://jackbox.tv/', { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
|
||||
// Wait for the room code input to be ready
|
||||
if (process.env.DEBUG) console.error('[2] Waiting for form...');
|
||||
await page.waitForSelector('input#roomcode', { timeout: 10000 });
|
||||
|
||||
// Type the room code using the input ID (more reliable)
|
||||
// Use the element.type() method which properly triggers React events
|
||||
if (process.env.DEBUG) console.error('[3] Typing room code:', roomCode);
|
||||
const roomInput = await page.$('input#roomcode');
|
||||
await roomInput.type(roomCode.toUpperCase(), { delay: 50 }); // Reduced delay from 100ms to 50ms
|
||||
|
||||
// Wait for room validation (the app info appears after validation)
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[4] Waiting for room validation...');
|
||||
const roomValue = await page.evaluate(() => document.querySelector('input#roomcode').value);
|
||||
console.error('[4] Room code value:', roomValue);
|
||||
}
|
||||
|
||||
// Actually wait for the validation to complete - the game name label appears
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const labels = Array.from(document.querySelectorAll('div, span, label'));
|
||||
return labels.some(el => {
|
||||
const text = el.textContent;
|
||||
return text.includes('Trivia') || text.includes('Party') || text.includes('Quiplash') ||
|
||||
text.includes('Fibbage') || text.includes('Drawful') || text.includes('Murder');
|
||||
});
|
||||
}, { timeout: 5000 });
|
||||
if (process.env.DEBUG) console.error('[4.5] Room validated successfully!');
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG) console.error('[4.5] Room validation timeout - continuing anyway...');
|
||||
}
|
||||
|
||||
// Type the name using the input ID
|
||||
// This will trigger the input event that enables the Play button
|
||||
if (process.env.DEBUG) console.error('[5] Typing name...');
|
||||
const nameInput = await page.$('input#username');
|
||||
await nameInput.type('Observer', { delay: 30 }); // Reduced delay from 100ms to 30ms
|
||||
|
||||
// Wait a moment for the button to enable and click immediately
|
||||
if (process.env.DEBUG) console.error('[6] Waiting for Play button...');
|
||||
await page.waitForFunction(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
const playBtn = buttons.find(b => {
|
||||
const text = b.textContent.toUpperCase();
|
||||
return (text.includes('PLAY') || text.includes('RECONNECT')) && !b.disabled;
|
||||
});
|
||||
return playBtn !== undefined;
|
||||
}, { timeout: 5000 }); // Reduced timeout from 10s to 5s
|
||||
|
||||
if (process.env.DEBUG) console.error('[7] Clicking Play...');
|
||||
await page.evaluate(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
const playBtn = buttons.find(b => {
|
||||
const text = b.textContent.toUpperCase();
|
||||
return (text.includes('PLAY') || text.includes('RECONNECT')) && !b.disabled;
|
||||
});
|
||||
if (playBtn) {
|
||||
playBtn.click();
|
||||
} else {
|
||||
throw new Error('Play button not found or still disabled');
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for the WebSocket player count message (up to 5 seconds)
|
||||
if (process.env.DEBUG) console.error('[8] Waiting for player count message...');
|
||||
for (let i = 0; i < 10 && playerCount === null; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
if (playerCount === null) {
|
||||
throw new Error('Could not get player count from WebSocket');
|
||||
}
|
||||
|
||||
return playerCount;
|
||||
}
|
||||
|
||||
// Main
|
||||
const roomCode = process.argv[2];
|
||||
if (!roomCode) {
|
||||
console.error('Usage: node jackbox-count-v3.js <ROOM_CODE>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
getPlayerCount(roomCode.toUpperCase())
|
||||
.then(count => {
|
||||
console.log(count);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
190
scripts/jackbox-count.js
Executable file
190
scripts/jackbox-count.js
Executable file
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
const puppeteer = require('puppeteer');
|
||||
|
||||
async function checkRoomStatus(roomCode) {
|
||||
try {
|
||||
const response = await fetch(`https://ecast.jackboxgames.com/api/v2/rooms/${roomCode}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const roomData = data.body || data;
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[API] Room data:', JSON.stringify(roomData, null, 2));
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
locked: roomData.locked || false,
|
||||
full: roomData.full || false,
|
||||
maxPlayers: roomData.maxPlayers || 8,
|
||||
minPlayers: roomData.minPlayers || 0
|
||||
};
|
||||
}
|
||||
return { exists: false };
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[API] Error checking room:', e.message);
|
||||
}
|
||||
return { exists: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function getPlayerCountFromAudience(roomCode) {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
||||
|
||||
let playerCount = null;
|
||||
|
||||
// Enable CDP and listen for WebSocket frames BEFORE navigating
|
||||
const client = await page.target().createCDPSession();
|
||||
await client.send('Network.enable');
|
||||
|
||||
client.on('Network.webSocketFrameReceived', ({ response }) => {
|
||||
if (response.payloadData && playerCount === null) {
|
||||
try {
|
||||
const data = JSON.parse(response.payloadData);
|
||||
|
||||
// Check for bc:room with player count data
|
||||
let roomVal = null;
|
||||
|
||||
if (data.opcode === 'client/welcome' && data.result?.entities?.['bc:room']) {
|
||||
roomVal = data.result.entities['bc:room'][1]?.val;
|
||||
}
|
||||
|
||||
if (data.opcode === 'object' && data.result?.key === 'bc:room') {
|
||||
roomVal = data.result.val;
|
||||
}
|
||||
|
||||
if (roomVal) {
|
||||
// Strategy 1: Game ended - use gameResults
|
||||
if (roomVal.gameResults?.players) {
|
||||
playerCount = roomVal.gameResults.players.length;
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[SUCCESS] Found', playerCount, 'players from gameResults');
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Game in progress - use analytics
|
||||
if (playerCount === null && roomVal.analytics) {
|
||||
const startAnalytic = roomVal.analytics.find(a => a.action === 'start');
|
||||
if (startAnalytic?.value) {
|
||||
playerCount = startAnalytic.value;
|
||||
if (process.env.DEBUG) {
|
||||
console.error('[SUCCESS] Found', playerCount, 'players from analytics');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
if (process.env.DEBUG) console.error('[2] Navigating to jackbox.tv...');
|
||||
await page.goto('https://jackbox.tv/', { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
|
||||
if (process.env.DEBUG) console.error('[3] Waiting for form...');
|
||||
await page.waitForSelector('input#roomcode', { timeout: 10000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
if (process.env.DEBUG) console.error('[4] Typing room code:', roomCode);
|
||||
const roomInput = await page.$('input#roomcode');
|
||||
await roomInput.type(roomCode.toUpperCase(), { delay: 50 });
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
if (process.env.DEBUG) console.error('[5] Typing name...');
|
||||
const nameInput = await page.$('input#username');
|
||||
await nameInput.type('CountBot', { delay: 30 });
|
||||
|
||||
if (process.env.DEBUG) console.error('[6] Waiting for JOIN AUDIENCE button...');
|
||||
await page.waitForFunction(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
return buttons.some(b => b.textContent.toUpperCase().includes('JOIN AUDIENCE') && !b.disabled);
|
||||
}, { timeout: 10000 });
|
||||
|
||||
if (process.env.DEBUG) console.error('[7] Clicking JOIN AUDIENCE...');
|
||||
await page.evaluate(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
const btn = buttons.find(b => b.textContent.toUpperCase().includes('JOIN AUDIENCE') && !b.disabled);
|
||||
if (btn) btn.click();
|
||||
});
|
||||
|
||||
// Wait for WebSocket messages
|
||||
if (process.env.DEBUG) console.error('[8] Waiting for player count...');
|
||||
for (let i = 0; i < 20 && playerCount === null; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
}
|
||||
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
return playerCount;
|
||||
}
|
||||
|
||||
async function getPlayerCount(roomCode) {
|
||||
if (process.env.DEBUG) console.error('[1] Checking room status via API...');
|
||||
const roomStatus = await checkRoomStatus(roomCode);
|
||||
|
||||
if (!roomStatus.exists) {
|
||||
if (process.env.DEBUG) console.error('[ERROR] Room does not exist');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// If full, return maxPlayers
|
||||
if (roomStatus.full) {
|
||||
if (process.env.DEBUG) console.error('[1] Room is FULL - returning maxPlayers:', roomStatus.maxPlayers);
|
||||
return roomStatus.maxPlayers;
|
||||
}
|
||||
|
||||
// If locked (game in progress), join as audience to get count
|
||||
if (roomStatus.locked) {
|
||||
if (process.env.DEBUG) console.error('[1] Room is LOCKED - joining as audience...');
|
||||
|
||||
try {
|
||||
const count = await getPlayerCountFromAudience(roomCode);
|
||||
if (count !== null) {
|
||||
return count;
|
||||
}
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG) console.error('[ERROR] Failed to get count:', e.message);
|
||||
}
|
||||
|
||||
// Fallback to maxPlayers if we couldn't get exact count
|
||||
if (process.env.DEBUG) console.error('[1] Could not get exact count, returning maxPlayers:', roomStatus.maxPlayers);
|
||||
return roomStatus.maxPlayers;
|
||||
}
|
||||
|
||||
// Not locked (lobby open) - don't join, return minPlayers
|
||||
if (process.env.DEBUG) console.error('[1] Room is NOT locked (lobby) - returning minPlayers:', roomStatus.minPlayers);
|
||||
return roomStatus.minPlayers;
|
||||
}
|
||||
|
||||
// Main
|
||||
const roomCode = process.argv[2];
|
||||
if (!roomCode) {
|
||||
console.error('Usage: node jackbox-count.js <ROOM_CODE>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
getPlayerCount(roomCode.toUpperCase())
|
||||
.then(count => {
|
||||
console.log(count);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(err => {
|
||||
if (process.env.DEBUG) console.error('Error:', err.message);
|
||||
console.log(0);
|
||||
process.exit(0);
|
||||
});
|
||||
112
scripts/jackbox-player-count.go
Normal file
112
scripts/jackbox-player-count.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: go run jackbox-player-count.go <ROOM_CODE>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
roomCode := strings.ToUpper(os.Args[1])
|
||||
count, err := getPlayerCount(roomCode)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("%d\n", count)
|
||||
}
|
||||
|
||||
func getPlayerCount(roomCode string) (int, error) {
|
||||
// Suppress cookie errors
|
||||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", true),
|
||||
)
|
||||
|
||||
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||
defer cancel()
|
||||
|
||||
ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(func(s string, i ...interface{}) {
|
||||
msg := fmt.Sprintf(s, i...)
|
||||
if !strings.Contains(msg, "cookie") {
|
||||
log.Printf(msg)
|
||||
}
|
||||
}))
|
||||
defer cancel()
|
||||
|
||||
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var playerCount int
|
||||
|
||||
err := chromedp.Run(ctx,
|
||||
chromedp.Navigate("https://jackbox.tv/"),
|
||||
chromedp.WaitVisible(`input[placeholder*="ENTER 4-LETTER CODE"]`),
|
||||
|
||||
// Inject WebSocket hook BEFORE joining
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
return chromedp.Evaluate(`
|
||||
window.__jackboxPlayerCount = null;
|
||||
const OriginalWebSocket = window.WebSocket;
|
||||
window.WebSocket = function(...args) {
|
||||
const ws = new OriginalWebSocket(...args);
|
||||
const originalAddEventListener = ws.addEventListener;
|
||||
ws.addEventListener = function(type, listener, ...rest) {
|
||||
if (type === 'message') {
|
||||
const wrappedListener = function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.opcode === 'client/welcome' && data.result && data.result.here) {
|
||||
window.__jackboxPlayerCount = Object.keys(data.result.here).length;
|
||||
}
|
||||
} catch (e) {}
|
||||
return listener.call(this, event);
|
||||
};
|
||||
return originalAddEventListener.call(this, type, wrappedListener, ...rest);
|
||||
}
|
||||
return originalAddEventListener.call(this, type, listener, ...rest);
|
||||
};
|
||||
return ws;
|
||||
};
|
||||
`, nil).Do(ctx)
|
||||
}),
|
||||
|
||||
// Now join
|
||||
chromedp.SendKeys(`input[placeholder*="ENTER 4-LETTER CODE"]`, roomCode+"\n\n"),
|
||||
|
||||
// Poll for the value
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
for i := 0; i < 60; i++ { // Try for 30 seconds
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
var count int
|
||||
err := chromedp.Evaluate(`window.__jackboxPlayerCount || -1`, &count).Do(ctx)
|
||||
if err == nil && count > 0 {
|
||||
playerCount = count
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("timeout waiting for player count")
|
||||
}),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if playerCount < 0 {
|
||||
return 0, fmt.Errorf("could not find player count")
|
||||
}
|
||||
|
||||
return playerCount, nil
|
||||
}
|
||||
|
||||
1127
scripts/package-lock.json
generated
Normal file
1127
scripts/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
scripts/package.json
Normal file
16
scripts/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "jackbox-scripts",
|
||||
"version": "1.0.0",
|
||||
"description": "Utility scripts for Jackbox game integration",
|
||||
"main": "jackbox-count.js",
|
||||
"scripts": {
|
||||
"count": "node jackbox-count.js"
|
||||
},
|
||||
"keywords": ["jackbox", "websocket", "player-count"],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"puppeteer": "^24.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
146
tests/test-session-end-websocket.js
Executable file
146
tests/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
tests/test-webhook-simple.sh
Executable file
182
tests/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
tests/test-webhook.js
Normal file
294
tests/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
tests/test-webhook.sh
Executable file
268
tests/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