Compare commits

...

10 Commits

Author SHA1 Message Date
cottongin
52e00e56f6 Add audience.joined and game.started WebSocket events, reduce poll interval
Broadcast audience.joined when the audience client receives its first
client/welcome frame. Broadcast game.started when the room lock is
detected. Reduced initial wait and poll interval from 30s to 10s for
faster feedback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 14:09:37 -05:00
cottongin
84398ebdd0 Reorganize project: move docs to docs/ and test scripts to tests/
Moved 9 documentation .md files from root into docs/.
Moved 4 test scripts from root into tests/.
Updated cross-references in README.md and docs to reflect new paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 14:07:39 -05:00
cottongin
974f0e4a67 Harden secret handling: remove weak fallback defaults, fail fast on missing env vars
JWT_SECRET and ADMIN_KEY no longer fall back to insecure defaults.
The app will throw at startup if these env vars are not set.
docker-compose.yml now uses :? syntax to require them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 14:07:09 -05:00
cottongin
5cf5901001 tweaks 2025-11-03 18:38:27 -05:00
cottongin
f52754ac87 done 2025-11-03 17:56:15 -05:00
cottongin
140988d01d initial actual player count implementation 2025-11-03 13:57:26 -05:00
cottongin
2a75237e90 IDK, it's working and we're moving on 2025-11-02 16:06:31 -05:00
cottongin
6308d99d33 first release! 0.3.6 2025-10-30 19:27:23 -04:00
cottongin
47db3890e2 pretty much ready to 'ship'.3 2025-10-30 17:52:44 -04:00
cottongin
1a74b4d777 pretty much ready to 'ship'.2 2025-10-30 17:34:44 -04:00
65 changed files with 14303 additions and 194 deletions

3
.gitignore vendored
View File

@@ -15,6 +15,9 @@ node_modules/
frontend/dist/
frontend/build/
# Generated files
frontend/public/manifest.json
# Logs
*.log
npm-debug.log*

125
README.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

@@ -0,0 +1,198 @@
const express = require('express');
const { authenticateToken } = require('../middleware/auth');
const db = require('../database');
const router = express.Router();
// Live vote endpoint - receives real-time votes from bot
router.post('/live', authenticateToken, (req, res) => {
try {
const { username, vote, timestamp } = req.body;
// Validate payload
if (!username || !vote || !timestamp) {
return res.status(400).json({
error: 'Missing required fields: username, vote, timestamp'
});
}
if (vote !== 'up' && vote !== 'down') {
return res.status(400).json({
error: 'vote must be either "up" or "down"'
});
}
// Validate timestamp format
const voteTimestamp = new Date(timestamp);
if (isNaN(voteTimestamp.getTime())) {
return res.status(400).json({
error: 'Invalid timestamp format. Use ISO 8601 format (e.g., 2025-11-01T20:30:00Z)'
});
}
// Check for active session
const activeSession = db.prepare(`
SELECT * FROM sessions WHERE is_active = 1 LIMIT 1
`).get();
if (!activeSession) {
return res.status(404).json({
error: 'No active session found'
});
}
// Get all games played in this session with timestamps
const sessionGames = db.prepare(`
SELECT sg.game_id, sg.played_at, g.title, g.upvotes, g.downvotes, g.popularity_score
FROM session_games sg
JOIN games g ON sg.game_id = g.id
WHERE sg.session_id = ?
ORDER BY sg.played_at ASC
`).all(activeSession.id);
if (sessionGames.length === 0) {
return res.status(404).json({
error: 'No games have been played in the active session yet'
});
}
// Match vote timestamp to the correct game using interval logic
const voteTime = voteTimestamp.getTime();
let matchedGame = null;
for (let i = 0; i < sessionGames.length; i++) {
const currentGame = sessionGames[i];
const nextGame = sessionGames[i + 1];
const currentGameTime = new Date(currentGame.played_at).getTime();
if (nextGame) {
const nextGameTime = new Date(nextGame.played_at).getTime();
if (voteTime >= currentGameTime && voteTime < nextGameTime) {
matchedGame = currentGame;
break;
}
} else {
// Last game in session - vote belongs here if timestamp is after this game started
if (voteTime >= currentGameTime) {
matchedGame = currentGame;
break;
}
}
}
if (!matchedGame) {
return res.status(404).json({
error: 'Vote timestamp does not match any game in the active session',
debug: {
voteTimestamp: timestamp,
sessionGames: sessionGames.map(g => ({
title: g.title,
played_at: g.played_at
}))
}
});
}
// Check for duplicate vote (within 1 second window)
// Get the most recent vote from this user
const lastVote = db.prepare(`
SELECT timestamp FROM live_votes
WHERE username = ?
ORDER BY created_at DESC
LIMIT 1
`).get(username);
if (lastVote) {
const lastVoteTime = new Date(lastVote.timestamp).getTime();
const currentVoteTime = new Date(timestamp).getTime();
const timeDiffSeconds = Math.abs(currentVoteTime - lastVoteTime) / 1000;
if (timeDiffSeconds <= 1) {
return res.status(409).json({
error: 'Duplicate vote detected (within 1 second of previous vote)',
message: 'Please wait at least 1 second between votes',
timeSinceLastVote: timeDiffSeconds
});
}
}
// Process the vote in a transaction
const voteType = vote === 'up' ? 1 : -1;
const insertVote = db.prepare(`
INSERT INTO live_votes (session_id, game_id, username, vote_type, timestamp)
VALUES (?, ?, ?, ?, ?)
`);
const updateUpvote = db.prepare(`
UPDATE games
SET upvotes = upvotes + 1, popularity_score = popularity_score + 1
WHERE id = ?
`);
const updateDownvote = db.prepare(`
UPDATE games
SET downvotes = downvotes + 1, popularity_score = popularity_score - 1
WHERE id = ?
`);
const processVote = db.transaction(() => {
insertVote.run(activeSession.id, matchedGame.game_id, username, voteType, timestamp);
if (voteType === 1) {
updateUpvote.run(matchedGame.game_id);
} else {
updateDownvote.run(matchedGame.game_id);
}
});
processVote();
// Get updated game stats
const updatedGame = db.prepare(`
SELECT id, title, upvotes, downvotes, popularity_score
FROM games
WHERE id = ?
`).get(matchedGame.game_id);
// Get session stats
const sessionStats = db.prepare(`
SELECT
s.*,
COUNT(sg.id) as games_played
FROM sessions s
LEFT JOIN session_games sg ON s.id = sg.session_id
WHERE s.id = ?
GROUP BY s.id
`).get(activeSession.id);
res.json({
success: true,
message: 'Vote recorded successfully',
session: {
id: sessionStats.id,
games_played: sessionStats.games_played
},
game: {
id: updatedGame.id,
title: updatedGame.title,
upvotes: updatedGame.upvotes,
downvotes: updatedGame.downvotes,
popularity_score: updatedGame.popularity_score
},
vote: {
username: username,
type: vote,
timestamp: timestamp
}
});
} catch (error) {
console.error('Error processing live vote:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router;

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

@@ -0,0 +1,271 @@
const express = require('express');
const { authenticateToken } = require('../middleware/auth');
const db = require('../database');
const { triggerWebhook } = require('../utils/webhooks');
const router = express.Router();
// Get all webhooks (admin only)
router.get('/', authenticateToken, (req, res) => {
try {
const webhooks = db.prepare(`
SELECT id, name, url, events, enabled, created_at
FROM webhooks
ORDER BY created_at DESC
`).all();
// Parse events JSON for each webhook
const webhooksWithParsedEvents = webhooks.map(webhook => ({
...webhook,
events: JSON.parse(webhook.events),
enabled: webhook.enabled === 1
}));
res.json(webhooksWithParsedEvents);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get single webhook by ID (admin only)
router.get('/:id', authenticateToken, (req, res) => {
try {
const webhook = db.prepare(`
SELECT id, name, url, events, enabled, created_at
FROM webhooks
WHERE id = ?
`).get(req.params.id);
if (!webhook) {
return res.status(404).json({ error: 'Webhook not found' });
}
res.json({
...webhook,
events: JSON.parse(webhook.events),
enabled: webhook.enabled === 1
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Create new webhook (admin only)
router.post('/', authenticateToken, (req, res) => {
try {
const { name, url, secret, events } = req.body;
// Validate required fields
if (!name || !url || !secret || !events) {
return res.status(400).json({
error: 'Missing required fields: name, url, secret, events'
});
}
// Validate events is an array
if (!Array.isArray(events)) {
return res.status(400).json({
error: 'events must be an array'
});
}
// Validate URL format
try {
new URL(url);
} catch (err) {
return res.status(400).json({ error: 'Invalid URL format' });
}
// Insert webhook
const stmt = db.prepare(`
INSERT INTO webhooks (name, url, secret, events, enabled)
VALUES (?, ?, ?, ?, 1)
`);
const result = stmt.run(name, url, secret, JSON.stringify(events));
const newWebhook = db.prepare(`
SELECT id, name, url, events, enabled, created_at
FROM webhooks
WHERE id = ?
`).get(result.lastInsertRowid);
res.status(201).json({
...newWebhook,
events: JSON.parse(newWebhook.events),
enabled: newWebhook.enabled === 1,
message: 'Webhook created successfully'
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Update webhook (admin only)
router.patch('/:id', authenticateToken, (req, res) => {
try {
const { name, url, secret, events, enabled } = req.body;
const webhookId = req.params.id;
// Check if webhook exists
const webhook = db.prepare('SELECT * FROM webhooks WHERE id = ?').get(webhookId);
if (!webhook) {
return res.status(404).json({ error: 'Webhook not found' });
}
// Build update query dynamically based on provided fields
const updates = [];
const params = [];
if (name !== undefined) {
updates.push('name = ?');
params.push(name);
}
if (url !== undefined) {
// Validate URL format
try {
new URL(url);
} catch (err) {
return res.status(400).json({ error: 'Invalid URL format' });
}
updates.push('url = ?');
params.push(url);
}
if (secret !== undefined) {
updates.push('secret = ?');
params.push(secret);
}
if (events !== undefined) {
if (!Array.isArray(events)) {
return res.status(400).json({ error: 'events must be an array' });
}
updates.push('events = ?');
params.push(JSON.stringify(events));
}
if (enabled !== undefined) {
updates.push('enabled = ?');
params.push(enabled ? 1 : 0);
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
params.push(webhookId);
const stmt = db.prepare(`
UPDATE webhooks
SET ${updates.join(', ')}
WHERE id = ?
`);
stmt.run(...params);
const updatedWebhook = db.prepare(`
SELECT id, name, url, events, enabled, created_at
FROM webhooks
WHERE id = ?
`).get(webhookId);
res.json({
...updatedWebhook,
events: JSON.parse(updatedWebhook.events),
enabled: updatedWebhook.enabled === 1,
message: 'Webhook updated successfully'
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Delete webhook (admin only)
router.delete('/:id', authenticateToken, (req, res) => {
try {
const webhook = db.prepare('SELECT * FROM webhooks WHERE id = ?').get(req.params.id);
if (!webhook) {
return res.status(404).json({ error: 'Webhook not found' });
}
// Delete webhook (logs will be cascade deleted)
db.prepare('DELETE FROM webhooks WHERE id = ?').run(req.params.id);
res.json({
message: 'Webhook deleted successfully',
webhookId: parseInt(req.params.id)
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Test webhook (admin only)
router.post('/test/:id', authenticateToken, async (req, res) => {
try {
const webhook = db.prepare('SELECT * FROM webhooks WHERE id = ?').get(req.params.id);
if (!webhook) {
return res.status(404).json({ error: 'Webhook not found' });
}
// Send a test payload
const testData = {
session: {
id: 0,
is_active: true,
games_played: 0
},
game: {
id: 0,
title: 'Test Game',
pack_name: 'Test Pack',
min_players: 2,
max_players: 8,
manually_added: false
}
};
// Trigger the webhook asynchronously
triggerWebhook('game.added', testData);
res.json({
message: 'Test webhook sent',
note: 'Check webhook_logs table for delivery status'
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get webhook logs (admin only)
router.get('/:id/logs', authenticateToken, (req, res) => {
try {
const { limit = 50 } = req.query;
const logs = db.prepare(`
SELECT *
FROM webhook_logs
WHERE webhook_id = ?
ORDER BY created_at DESC
LIMIT ?
`).all(req.params.id, parseInt(limit));
// Parse payload JSON for each log
const logsWithParsedPayload = logs.map(log => ({
...log,
payload: JSON.parse(log.payload)
}));
res.json(logsWithParsedPayload);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

View File

@@ -1,7 +1,9 @@
require('dotenv').config();
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
View File

@@ -0,0 +1,122 @@
#!/usr/bin/env node
/**
* WebSocket Test Client
*
* Tests the WebSocket event system for the Jackbox Game Picker API
*
* Usage:
* JWT_TOKEN="your_token" node test-websocket.js
*/
const WebSocket = require('ws');
const API_URL = process.env.API_URL || 'ws://localhost:5000';
const JWT_TOKEN = process.env.JWT_TOKEN || '';
if (!JWT_TOKEN) {
console.error('\n❌ ERROR: JWT_TOKEN not set!');
console.error('\nGet your token:');
console.error(' curl -X POST "http://localhost:5000/api/auth/login" \\');
console.error(' -H "Content-Type: application/json" \\');
console.error(' -d \'{"key":"YOUR_ADMIN_KEY"}\'');
console.error('\nThen run:');
console.error(' JWT_TOKEN="your_token" node test-websocket.js\n');
process.exit(1);
}
console.log('\n🚀 WebSocket Test Client');
console.log('═══════════════════════════════════════════════════════\n');
console.log(`Connecting to: ${API_URL}/api/sessions/live`);
console.log('');
const ws = new WebSocket(`${API_URL}/api/sessions/live`);
ws.on('open', () => {
console.log('✅ Connected to WebSocket server\n');
// Step 1: Authenticate
console.log('📝 Step 1: Authenticating...');
ws.send(JSON.stringify({
type: 'auth',
token: JWT_TOKEN
}));
});
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
switch (message.type) {
case 'auth_success':
console.log('✅ Authentication successful\n');
// Step 2: Subscribe to session (you can change this ID)
console.log('📝 Step 2: Subscribing to session 1...');
ws.send(JSON.stringify({
type: 'subscribe',
sessionId: 1
}));
break;
case 'auth_error':
console.error('❌ Authentication failed:', message.message);
process.exit(1);
break;
case 'subscribed':
console.log(`✅ Subscribed to session ${message.sessionId}\n`);
console.log('🎧 Listening for events...');
console.log(' Add a game in the Picker page to see events here');
console.log(' Press Ctrl+C to exit\n');
// Start heartbeat
setInterval(() => {
ws.send(JSON.stringify({ type: 'ping' }));
}, 30000);
break;
case 'game.added':
console.log('\n🎮 GAME ADDED EVENT RECEIVED!');
console.log('═══════════════════════════════════════════════════════');
console.log('Game:', message.data.game.title);
console.log('Pack:', message.data.game.pack_name);
console.log('Players:', `${message.data.game.min_players}-${message.data.game.max_players}`);
console.log('Session ID:', message.data.session.id);
console.log('Games Played:', message.data.session.games_played);
console.log('Timestamp:', message.timestamp);
console.log('═══════════════════════════════════════════════════════\n');
break;
case 'pong':
console.log('💓 Heartbeat');
break;
case 'error':
console.error('❌ Error:', message.message);
break;
default:
console.log('📨 Received:', message);
}
} catch (err) {
console.error('Failed to parse message:', err);
}
});
ws.on('error', (err) => {
console.error('\n❌ WebSocket error:', err.message);
process.exit(1);
});
ws.on('close', () => {
console.log('\n👋 Connection closed');
process.exit(0);
});
// Handle Ctrl+C
process.on('SIGINT', () => {
console.log('\n\n⚠ Closing connection...');
ws.close();
});

View 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
View File

@@ -0,0 +1,151 @@
const crypto = require('crypto');
const db = require('../database');
/**
* Trigger webhooks for a specific event type
* @param {string} eventType - The event type (e.g., 'game.added')
* @param {object} data - The payload data to send
*/
async function triggerWebhook(eventType, data) {
try {
// Get all enabled webhooks that are subscribed to this event
const webhooks = db.prepare(`
SELECT * FROM webhooks
WHERE enabled = 1
`).all();
if (webhooks.length === 0) {
return; // No webhooks configured
}
// Filter webhooks that are subscribed to this event
const subscribedWebhooks = webhooks.filter(webhook => {
try {
const events = JSON.parse(webhook.events);
return events.includes(eventType);
} catch (err) {
console.error(`Invalid events JSON for webhook ${webhook.id}:`, err);
return false;
}
});
if (subscribedWebhooks.length === 0) {
return; // No webhooks subscribed to this event
}
// Build the payload
const payload = {
event: eventType,
timestamp: new Date().toISOString(),
data: data
};
// Send to each webhook asynchronously (non-blocking)
subscribedWebhooks.forEach(webhook => {
sendWebhook(webhook, payload, eventType).catch(err => {
console.error(`Error sending webhook ${webhook.id}:`, err);
});
});
} catch (err) {
console.error('Error triggering webhooks:', err);
}
}
/**
* Send a webhook to a specific URL
* @param {object} webhook - The webhook configuration
* @param {object} payload - The payload to send
* @param {string} eventType - The event type
*/
async function sendWebhook(webhook, payload, eventType) {
const payloadString = JSON.stringify(payload);
// Generate HMAC signature
const signature = 'sha256=' + crypto
.createHmac('sha256', webhook.secret)
.update(payloadString)
.digest('hex');
const startTime = Date.now();
let responseStatus = null;
let errorMessage = null;
try {
// Send the webhook
const response = await fetch(webhook.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
'X-Webhook-Event': eventType,
'User-Agent': 'Jackbox-Game-Picker-Webhook/1.0'
},
body: payloadString,
// Set a timeout of 5 seconds
signal: AbortSignal.timeout(5000)
});
responseStatus = response.status;
if (!response.ok) {
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
}
} catch (err) {
errorMessage = err.message;
responseStatus = 0; // Indicates connection/network error
}
// Log the webhook call
try {
db.prepare(`
INSERT INTO webhook_logs (webhook_id, event_type, payload, response_status, error_message)
VALUES (?, ?, ?, ?, ?)
`).run(webhook.id, eventType, payloadString, responseStatus, errorMessage);
} catch (logErr) {
console.error('Error logging webhook call:', logErr);
}
const duration = Date.now() - startTime;
if (errorMessage) {
console.error(`Webhook ${webhook.id} (${webhook.name}) failed: ${errorMessage} (${duration}ms)`);
} else {
console.log(`Webhook ${webhook.id} (${webhook.name}) sent successfully: ${responseStatus} (${duration}ms)`);
}
}
/**
* Verify a webhook signature
* @param {string} signature - The signature from the X-Webhook-Signature header
* @param {string} payload - The raw request body as a string
* @param {string} secret - The webhook secret
* @returns {boolean} - True if signature is valid
*/
function verifyWebhookSignature(signature, payload, secret) {
if (!signature || !signature.startsWith('sha256=')) {
return false;
}
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// Use timing-safe comparison to prevent timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
} catch (err) {
return false;
}
}
module.exports = {
triggerWebhook,
verifyWebhookSignature
};

View File

@@ -0,0 +1,333 @@
const { WebSocketServer } = require('ws');
const jwt = require('jsonwebtoken');
const { JWT_SECRET } = require('../middleware/auth');
/**
* WebSocket Manager for handling real-time session events
* Manages client connections, authentication, and event broadcasting
*/
class WebSocketManager {
constructor(server) {
this.wss = new WebSocketServer({
server,
path: '/api/sessions/live'
});
this.clients = new Map(); // Map<ws, clientInfo>
this.sessionSubscriptions = new Map(); // Map<sessionId, Set<ws>>
this.wss.on('connection', (ws, req) => this.handleConnection(ws, req));
this.startHeartbeat();
console.log('[WebSocket] WebSocket server initialized on /api/sessions/live');
}
/**
* Handle new WebSocket connection
*/
handleConnection(ws, req) {
console.log('[WebSocket] New connection from', req.socket.remoteAddress);
// Initialize client info
const clientInfo = {
authenticated: false,
userId: null,
subscribedSessions: new Set(),
lastPing: Date.now()
};
this.clients.set(ws, clientInfo);
// Handle incoming messages
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
this.handleMessage(ws, message);
} catch (err) {
console.error('[WebSocket] Failed to parse message:', err);
this.sendError(ws, 'Invalid message format');
}
});
// Handle connection close
ws.on('close', () => {
this.removeClient(ws);
});
// Handle errors
ws.on('error', (err) => {
console.error('[WebSocket] Client error:', err);
this.removeClient(ws);
});
}
/**
* Handle incoming messages from clients
*/
handleMessage(ws, message) {
const clientInfo = this.clients.get(ws);
if (!clientInfo) {
return;
}
switch (message.type) {
case 'auth':
this.authenticateClient(ws, message.token);
break;
case 'subscribe':
if (!clientInfo.authenticated) {
this.sendError(ws, 'Not authenticated');
return;
}
this.subscribeToSession(ws, message.sessionId);
break;
case 'unsubscribe':
if (!clientInfo.authenticated) {
this.sendError(ws, 'Not authenticated');
return;
}
this.unsubscribeFromSession(ws, message.sessionId);
break;
case 'ping':
clientInfo.lastPing = Date.now();
this.send(ws, { type: 'pong' });
break;
default:
this.sendError(ws, `Unknown message type: ${message.type}`);
}
}
/**
* Authenticate a client using JWT token
*/
authenticateClient(ws, token) {
if (!token) {
this.sendError(ws, 'Token required', 'auth_error');
return;
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
const clientInfo = this.clients.get(ws);
if (clientInfo) {
clientInfo.authenticated = true;
clientInfo.userId = decoded.role; // 'admin' for now
this.send(ws, {
type: 'auth_success',
message: 'Authenticated successfully'
});
console.log('[WebSocket] Client authenticated:', clientInfo.userId);
}
} catch (err) {
console.error('[WebSocket] Authentication failed:', err.message);
this.sendError(ws, 'Invalid or expired token', 'auth_error');
}
}
/**
* Subscribe a client to session events
*/
subscribeToSession(ws, sessionId) {
if (!sessionId) {
this.sendError(ws, 'Session ID required');
return;
}
const clientInfo = this.clients.get(ws);
if (!clientInfo) {
return;
}
// Add to session subscriptions
if (!this.sessionSubscriptions.has(sessionId)) {
this.sessionSubscriptions.set(sessionId, new Set());
}
this.sessionSubscriptions.get(sessionId).add(ws);
clientInfo.subscribedSessions.add(sessionId);
this.send(ws, {
type: 'subscribed',
sessionId: sessionId,
message: `Subscribed to session ${sessionId}`
});
console.log(`[WebSocket] Client subscribed to session ${sessionId}`);
}
/**
* Unsubscribe a client from session events
*/
unsubscribeFromSession(ws, sessionId) {
const clientInfo = this.clients.get(ws);
if (!clientInfo) {
return;
}
// Remove from session subscriptions
if (this.sessionSubscriptions.has(sessionId)) {
this.sessionSubscriptions.get(sessionId).delete(ws);
// Clean up empty subscription sets
if (this.sessionSubscriptions.get(sessionId).size === 0) {
this.sessionSubscriptions.delete(sessionId);
}
}
clientInfo.subscribedSessions.delete(sessionId);
this.send(ws, {
type: 'unsubscribed',
sessionId: sessionId,
message: `Unsubscribed from session ${sessionId}`
});
console.log(`[WebSocket] Client unsubscribed from session ${sessionId}`);
}
/**
* Broadcast an event to all clients subscribed to a session
*/
broadcastEvent(eventType, data, sessionId) {
const subscribers = this.sessionSubscriptions.get(sessionId);
if (!subscribers || subscribers.size === 0) {
console.log(`[WebSocket] No subscribers for session ${sessionId}`);
return;
}
const message = {
type: eventType,
timestamp: new Date().toISOString(),
data: data
};
let sentCount = 0;
subscribers.forEach((ws) => {
if (ws.readyState === ws.OPEN) {
this.send(ws, message);
sentCount++;
}
});
console.log(`[WebSocket] Broadcasted ${eventType} to ${sentCount} client(s) for session ${sessionId}`);
}
/**
* Broadcast an event to all authenticated clients (not session-specific)
* Used for session.started and other global events
*/
broadcastToAll(eventType, data) {
const message = {
type: eventType,
timestamp: new Date().toISOString(),
data: data
};
let sentCount = 0;
this.clients.forEach((clientInfo, ws) => {
if (clientInfo.authenticated && ws.readyState === ws.OPEN) {
this.send(ws, message);
sentCount++;
}
});
console.log(`[WebSocket] Broadcasted ${eventType} to ${sentCount} authenticated client(s)`);
}
/**
* Send a message to a specific client
*/
send(ws, message) {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(message));
}
}
/**
* Send an error message to a client
*/
sendError(ws, message, type = 'error') {
this.send(ws, {
type: type,
message: message
});
}
/**
* Remove a client and clean up subscriptions
*/
removeClient(ws) {
const clientInfo = this.clients.get(ws);
if (clientInfo) {
// Remove from all session subscriptions
clientInfo.subscribedSessions.forEach((sessionId) => {
if (this.sessionSubscriptions.has(sessionId)) {
this.sessionSubscriptions.get(sessionId).delete(ws);
// Clean up empty subscription sets
if (this.sessionSubscriptions.get(sessionId).size === 0) {
this.sessionSubscriptions.delete(sessionId);
}
}
});
this.clients.delete(ws);
console.log('[WebSocket] Client disconnected and cleaned up');
}
}
/**
* Start heartbeat to detect dead connections
*/
startHeartbeat() {
setInterval(() => {
const now = Date.now();
const timeout = 60000; // 60 seconds
this.clients.forEach((clientInfo, ws) => {
if (now - clientInfo.lastPing > timeout) {
console.log('[WebSocket] Client timeout, closing connection');
ws.terminate();
this.removeClient(ws);
}
});
}, 30000); // Check every 30 seconds
}
/**
* Get connection statistics
*/
getStats() {
return {
totalClients: this.clients.size,
authenticatedClients: Array.from(this.clients.values()).filter(c => c.authenticated).length,
totalSubscriptions: this.sessionSubscriptions.size,
subscriptionDetails: Array.from(this.sessionSubscriptions.entries()).map(([sessionId, subs]) => ({
sessionId,
subscribers: subs.size
}))
};
}
}
// Singleton instance
let instance = null;
module.exports = {
WebSocketManager,
getWebSocketManager: () => instance,
setWebSocketManager: (manager) => {
instance = manager;
}
};

View File

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

@@ -0,0 +1,474 @@
# API Quick Reference
Quick reference for Live Voting, WebSocket, and Webhook endpoints.
## Base URL
```
http://localhost:5000/api
```
## Authentication
All endpoints require JWT authentication:
```
Authorization: Bearer YOUR_JWT_TOKEN
```
Get token via:
```bash
POST /api/auth/login
Body: { "key": "YOUR_ADMIN_KEY" }
```
---
## WebSocket Events
### Connect to WebSocket
```
ws://localhost:5000/api/sessions/live
```
**Message Protocol**:
```json
// Authenticate
{ "type": "auth", "token": "YOUR_JWT_TOKEN" }
// Subscribe to session
{ "type": "subscribe", "sessionId": 123 }
// Unsubscribe
{ "type": "unsubscribe", "sessionId": 123 }
// Heartbeat
{ "type": "ping" }
```
**Server Events**:
```json
// Auth success
{ "type": "auth_success", "message": "..." }
// Subscribed
{ "type": "subscribed", "sessionId": 123 }
// Session started
{
"type": "session.started",
"timestamp": "2025-11-01T...",
"data": {
"session": { "id": 123, "is_active": 1, "created_at": "...", "notes": "..." }
}
}
// Game added
{
"type": "game.added",
"timestamp": "2025-11-01T...",
"data": {
"session": { "id": 123, "is_active": true, "games_played": 5 },
"game": { "id": 45, "title": "Fibbage 4", ... }
}
}
// Session ended
{
"type": "session.ended",
"timestamp": "2025-11-01T...",
"data": {
"session": { "id": 123, "is_active": 0, "games_played": 5 }
}
}
// Pong
{ "type": "pong" }
```
---
## Live Voting
### Submit Live Vote
```http
POST /api/votes/live
```
**Request Body**:
```json
{
"username": "string",
"vote": "up" | "down",
"timestamp": "2025-11-01T20:30:00Z"
}
```
**Success Response (200)**:
```json
{
"success": true,
"message": "Vote recorded successfully",
"session": { "id": 123, "games_played": 5 },
"game": {
"id": 45,
"title": "Fibbage 4",
"upvotes": 46,
"downvotes": 3,
"popularity_score": 43
},
"vote": {
"username": "TestUser",
"type": "up",
"timestamp": "2025-11-01T20:30:00Z"
}
}
```
**Error Responses**:
- `400` - Invalid payload
- `404` - No active session or timestamp doesn't match any game
- `409` - Duplicate vote (within 1 second)
---
## Webhooks
### List Webhooks
```http
GET /api/webhooks
```
**Response**:
```json
[
{
"id": 1,
"name": "Kosmi Bot",
"url": "http://bot-url/webhook",
"events": ["game.added"],
"enabled": true,
"created_at": "2025-11-01T20:00:00Z"
}
]
```
### Get Single Webhook
```http
GET /api/webhooks/:id
```
### Create Webhook
```http
POST /api/webhooks
```
**Request Body**:
```json
{
"name": "Kosmi Bot",
"url": "http://bot-url/webhook",
"secret": "your_shared_secret",
"events": ["game.added"]
}
```
**Response (201)**:
```json
{
"id": 1,
"name": "Kosmi Bot",
"url": "http://bot-url/webhook",
"events": ["game.added"],
"enabled": true,
"created_at": "2025-11-01T20:00:00Z",
"message": "Webhook created successfully"
}
```
### Update Webhook
```http
PATCH /api/webhooks/:id
```
**Request Body** (all fields optional):
```json
{
"name": "Updated Name",
"url": "http://new-url/webhook",
"secret": "new_secret",
"events": ["game.added"],
"enabled": false
}
```
### Delete Webhook
```http
DELETE /api/webhooks/:id
```
**Response (200)**:
```json
{
"message": "Webhook deleted successfully",
"webhookId": 1
}
```
### Test Webhook
```http
POST /api/webhooks/test/:id
```
Sends a test `game.added` event to verify webhook is working.
**Response (200)**:
```json
{
"message": "Test webhook sent",
"note": "Check webhook_logs table for delivery status"
}
```
### Get Webhook Logs
```http
GET /api/webhooks/:id/logs?limit=50
```
**Response**:
```json
[
{
"id": 1,
"webhook_id": 1,
"event_type": "game.added",
"payload": { /* full payload */ },
"response_status": 200,
"error_message": null,
"created_at": "2025-11-01T20:30:00Z"
}
]
```
---
## Webhook Payloads
### Event: `session.started`
Sent when a new session is created.
**Headers**:
- `Content-Type: application/json`
- `X-Webhook-Signature: sha256=<hmac_signature>`
- `X-Webhook-Event: session.started`
- `User-Agent: Jackbox-Game-Picker-Webhook/1.0`
**Payload**:
```json
{
"event": "session.started",
"timestamp": "2025-11-01T20:00:00Z",
"data": {
"session": {
"id": 123,
"is_active": 1,
"created_at": "2025-11-01T20:00:00Z",
"notes": "Friday Game Night"
}
}
}
```
### Event: `game.added`
Sent when a game is added to an active session.
**Headers**:
- `Content-Type: application/json`
- `X-Webhook-Signature: sha256=<hmac_signature>`
- `X-Webhook-Event: game.added`
- `User-Agent: Jackbox-Game-Picker-Webhook/1.0`
**Payload**:
```json
{
"event": "game.added",
"timestamp": "2025-11-01T20:30:00Z",
"data": {
"session": {
"id": 123,
"is_active": true,
"games_played": 5
},
"game": {
"id": 45,
"title": "Fibbage 4",
"pack_name": "The Jackbox Party Pack 9",
"min_players": 2,
"max_players": 8,
"manually_added": false
}
}
}
```
### Event: `session.ended`
Sent when a session is closed/ended.
**Headers**:
- `Content-Type: application/json`
- `X-Webhook-Signature: sha256=<hmac_signature>`
- `X-Webhook-Event: session.ended`
- `User-Agent: Jackbox-Game-Picker-Webhook/1.0`
**Payload**:
```json
{
"event": "session.ended",
"timestamp": "2025-11-01T20:30:00Z",
"data": {
"session": {
"id": 123,
"is_active": 0,
"games_played": 5
}
}
}
```
---
## cURL Examples
### Submit Vote
```bash
curl -X POST "http://localhost:5000/api/votes/live" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"username": "TestUser",
"vote": "up",
"timestamp": "2025-11-01T20:30:00Z"
}'
```
### Create Webhook
```bash
curl -X POST "http://localhost:5000/api/webhooks" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Kosmi Bot",
"url": "http://localhost:3001/webhook/jackbox",
"secret": "test_secret_123",
"events": ["game.added"]
}'
```
### Test Webhook
```bash
curl -X POST "http://localhost:5000/api/webhooks/test/1" \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
### View Webhook Logs
```bash
curl -X GET "http://localhost:5000/api/webhooks/1/logs?limit=10" \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
---
## Webhook Signature Verification
**Node.js Example**:
```javascript
const crypto = require('crypto');
function verifyWebhookSignature(signature, payload, secret) {
if (!signature || !signature.startsWith('sha256=')) {
return false;
}
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(JSON.stringify(payload))
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// In your webhook endpoint:
app.post('/webhook/jackbox', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const secret = process.env.WEBHOOK_SECRET;
if (!verifyWebhookSignature(signature, req.body, secret)) {
return res.status(401).send('Invalid signature');
}
// Process webhook...
res.status(200).send('OK');
});
```
---
## Error Codes
| Code | Meaning |
|------|---------|
| 200 | Success |
| 201 | Created |
| 400 | Bad Request - Invalid payload |
| 401 | Unauthorized - Invalid JWT or signature |
| 404 | Not Found - Resource doesn't exist |
| 409 | Conflict - Duplicate vote |
| 500 | Internal Server Error |
---
## Rate Limiting
Currently no rate limiting is implemented. Consider implementing rate limiting in production:
- Per IP address
- Per JWT token
- Per webhook endpoint
---
## Best Practices
1. **Always verify webhook signatures** before processing
2. **Use HTTPS** for webhook URLs in production
3. **Store secrets securely** in environment variables
4. **Respond quickly** to webhooks (< 5 seconds)
5. **Log webhook activity** for debugging
6. **Handle retries gracefully** if implementing retry logic
7. **Validate timestamps** to prevent replay attacks
---
For detailed documentation, see [BOT_INTEGRATION.md](BOT_INTEGRATION.md)

746
docs/BOT_INTEGRATION.md Normal file
View 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

View File

@@ -0,0 +1,105 @@
# Session End Event - Quick Start Guide
## 🚀 Quick Start
### Listen for Session End Events
```javascript
const WebSocket = require('ws');
const ws = new WebSocket('ws://localhost:5000/api/sessions/live');
ws.on('open', () => {
// 1. Authenticate
ws.send(JSON.stringify({
type: 'auth',
token: 'YOUR_JWT_TOKEN'
}));
});
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'auth_success') {
// 2. Subscribe to session
ws.send(JSON.stringify({
type: 'subscribe',
sessionId: 17
}));
}
if (msg.type === 'session.ended') {
// 3. Handle session end
console.log('Session ended!');
console.log(`Games played: ${msg.data.session.games_played}`);
// Announce to your users here
}
});
```
## 📦 Event Format
```json
{
"type": "session.ended",
"timestamp": "2025-11-01T02:30:45.123Z",
"data": {
"session": {
"id": 17,
"is_active": 0,
"games_played": 5
}
}
}
```
## 🧪 Test It
```bash
# Get your JWT token first
curl -X POST http://localhost:5000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"key":"YOUR_ADMIN_KEY"}'
# Run the test script
node ../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 |

View File

@@ -0,0 +1,306 @@
# Session End WebSocket Event
This document describes the `session.ended` WebSocket event that is broadcast when a game session is closed.
## 📋 Event Overview
When a session is closed (either manually or through timeout), the backend broadcasts a `session.ended` event to all subscribed WebSocket clients. This allows bots and other integrations to react immediately to session closures.
## 🔌 WebSocket Connection
**Endpoint:** `ws://localhost:5000/api/sessions/live`
**Authentication:** Required (JWT token)
## 📨 Event Format
### Event Type
```
session.ended
```
### Full Message Structure
```json
{
"type": "session.ended",
"timestamp": "2025-11-01T02:30:45.123Z",
"data": {
"session": {
"id": 17,
"is_active": 0,
"games_played": 5
}
}
}
```
### Field Descriptions
| Field | Type | Description |
|-------|------|-------------|
| `type` | string | Always `"session.ended"` |
| `timestamp` | string | ISO 8601 timestamp when the event was generated |
| `data.session.id` | number | The ID of the session that ended |
| `data.session.is_active` | number | Always `0` (inactive) for ended sessions |
| `data.session.games_played` | number | Total number of games played in the session |
## 🚀 Implementation
### Backend Implementation
The `session.ended` event is automatically broadcast when:
1. **Manual Session Close**: Admin closes a session via `POST /api/sessions/:id/close`
2. **Session Timeout**: (If implemented) When a session times out
**Code Location:** `backend/routes/sessions.js` - `POST /:id/close` endpoint
```javascript
// Broadcast session.ended event via WebSocket
const wsManager = getWebSocketManager();
if (wsManager) {
const eventData = {
session: {
id: closedSession.id,
is_active: 0,
games_played: closedSession.games_played
}
};
wsManager.broadcastEvent('session.ended', eventData, parseInt(req.params.id));
}
```
### Client Implementation Example
#### Node.js with `ws` library
```javascript
const WebSocket = require('ws');
const ws = new WebSocket('ws://localhost:5000/api/sessions/live');
ws.on('open', () => {
// Authenticate
ws.send(JSON.stringify({
type: 'auth',
token: 'your-jwt-token'
}));
});
ws.on('message', (data) => {
const message = JSON.parse(data.toString());
switch (message.type) {
case 'auth_success':
// Subscribe to session
ws.send(JSON.stringify({
type: 'subscribe',
sessionId: 17
}));
break;
case 'session.ended':
console.log('Session ended!');
console.log(`Session ID: ${message.data.session.id}`);
console.log(`Games played: ${message.data.session.games_played}`);
// Handle session end (e.g., announce in IRC, Discord, etc.)
break;
}
});
```
#### Python with `websockets` library
```python
import asyncio
import json
import websockets
async def listen_for_session_end():
uri = "ws://localhost:5000/api/sessions/live"
async with websockets.connect(uri) as websocket:
# Authenticate
await websocket.send(json.dumps({
"type": "auth",
"token": "your-jwt-token"
}))
async for message in websocket:
data = json.loads(message)
if data["type"] == "auth_success":
# Subscribe to session
await websocket.send(json.dumps({
"type": "subscribe",
"sessionId": 17
}))
elif data["type"] == "session.ended":
session = data["data"]["session"]
print(f"Session {session['id']} ended!")
print(f"Games played: {session['games_played']}")
# Handle session end
asyncio.run(listen_for_session_end())
```
## 🧪 Testing
### Using the Test Script
A test script is provided to verify the `session.ended` event:
```bash
node ../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)

View File

@@ -0,0 +1,361 @@
# Session Start WebSocket Event
This document describes the `session.started` WebSocket event that is broadcast when a new game session is created.
## 📋 Event Overview
When a new session is created, the backend broadcasts a `session.started` event to all subscribed WebSocket clients. This allows bots and other integrations to react immediately to new game sessions.
## 🔌 WebSocket Connection
**Endpoint:** `ws://localhost:5000/api/sessions/live`
**Authentication:** Required (JWT token)
## 📨 Event Format
### Event Type
```
session.started
```
### Full Message Structure
```json
{
"type": "session.started",
"timestamp": "2025-11-01T20:00:00.123Z",
"data": {
"session": {
"id": 17,
"is_active": 1,
"created_at": "2025-11-01T20:00:00.123Z",
"notes": "Friday Game Night"
}
}
}
```
### Field Descriptions
| Field | Type | Description |
|-------|------|-------------|
| `type` | string | Always `"session.started"` |
| `timestamp` | string | ISO 8601 timestamp when the event was generated |
| `data.session.id` | number | The ID of the newly created session |
| `data.session.is_active` | number | Always `1` (active) for new sessions |
| `data.session.created_at` | string | ISO 8601 timestamp when the session was created |
| `data.session.notes` | string/null | Optional notes for the session |
## 🚀 Implementation
### Backend Implementation
The `session.started` event is automatically broadcast when:
1. **New Session Created**: Admin creates a session via `POST /api/sessions`
**Code Location:** `backend/routes/sessions.js` - `POST /` endpoint
```javascript
// Broadcast session.started event via WebSocket
const wsManager = getWebSocketManager();
if (wsManager) {
const eventData = {
session: {
id: newSession.id,
is_active: 1,
created_at: newSession.created_at,
notes: newSession.notes
}
};
wsManager.broadcastEvent('session.started', eventData, parseInt(newSession.id));
}
```
### Client Implementation Example
#### Node.js with `ws` library
```javascript
const WebSocket = require('ws');
const ws = new WebSocket('ws://localhost:5000/api/sessions/live');
ws.on('open', () => {
// Authenticate
ws.send(JSON.stringify({
type: 'auth',
token: 'your-jwt-token'
}));
});
ws.on('message', (data) => {
const message = JSON.parse(data.toString());
switch (message.type) {
case 'auth_success':
// Subscribe to the new session (or subscribe when you receive session.started)
ws.send(JSON.stringify({
type: 'subscribe',
sessionId: 17
}));
break;
case 'session.started':
console.log('New session started!');
console.log(`Session ID: ${message.data.session.id}`);
console.log(`Created at: ${message.data.session.created_at}`);
if (message.data.session.notes) {
console.log(`Notes: ${message.data.session.notes}`);
}
// Auto-subscribe to the new session
ws.send(JSON.stringify({
type: 'subscribe',
sessionId: message.data.session.id
}));
break;
}
});
```
#### Python with `websockets` library
```python
import asyncio
import json
import websockets
async def listen_for_session_start():
uri = "ws://localhost:5000/api/sessions/live"
async with websockets.connect(uri) as websocket:
# Authenticate
await websocket.send(json.dumps({
"type": "auth",
"token": "your-jwt-token"
}))
async for message in websocket:
data = json.loads(message)
if data["type"] == "auth_success":
print("Authenticated, waiting for sessions...")
elif data["type"] == "session.started":
session = data["data"]["session"]
print(f"🎮 New session started! ID: {session['id']}")
print(f"📅 Created: {session['created_at']}")
if session.get('notes'):
print(f"📝 Notes: {session['notes']}")
# Auto-subscribe to the new session
await websocket.send(json.dumps({
"type": "subscribe",
"sessionId": session["id"]
}))
asyncio.run(listen_for_session_start())
```
## 🧪 Testing
### Manual Testing Steps
1. **Start the backend server:**
```bash
cd backend
npm start
```
2. **Connect a WebSocket client** (use the test script or your own):
```bash
# You can modify ../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.

View File

@@ -0,0 +1,256 @@
# WebSocket Event Flow Diagram
## 🔄 Complete Session Lifecycle
```
┌─────────────────────────────────────────────────────────────────┐
│ BOT CONNECTS │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Bot → Server: { type: "auth", token: "..." } │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Server → Bot: { type: "auth_success" } │
│ ✅ Bot is now AUTHENTICATED │
│ ⏳ Bot waits... (no subscription yet) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ ADMIN CREATES SESSION │
│ POST /api/sessions { notes: "Friday Game Night" } │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Server → ALL AUTHENTICATED CLIENTS: │
│ { │
│ type: "session.started", │
│ data: { │
│ session: { id: 22, is_active: 1, ... } │
│ } │
│ } │
│ 📢 Broadcast to ALL (no subscription needed) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Bot receives session.started │
│ 🎮 Bot announces: "Game Night #22 has started!" │
│ │
│ Bot → Server: { type: "subscribe", sessionId: 22 } │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Server → Bot: { type: "subscribed", sessionId: 22 } │
│ ✅ Bot is now SUBSCRIBED to session 22 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ ADMIN ADDS GAME │
│ POST /api/sessions/22/games { game_id: 45 } │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Server → SUBSCRIBED CLIENTS (session 22): │
│ { │
│ type: "game.added", │
│ data: { │
│ game: { title: "Quiplash 3", ... } │
│ } │
│ } │
│ 📢 Only to subscribers of session 22 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Bot receives game.added │
│ 🎲 Bot announces: "Now playing: Quiplash 3" │
│ 🗳️ Bot announces: "Vote with thisgame++ or thisgame--" │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ USER VOTES │
│ POST /api/votes/live { username: "Alice", vote: "up" } │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Server → SUBSCRIBED CLIENTS (session 22): │
│ { │
│ type: "vote.received", │
│ data: { │
│ vote: { username: "Alice", type: "up" } │
│ } │
│ } │
│ 📢 Only to subscribers of session 22 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Bot receives vote.received │
│ 🗳️ Bot tracks vote (may announce later) │
└─────────────────────────────────────────────────────────────────┘
(more games and votes...)
┌─────────────────────────────────────────────────────────────────┐
│ ADMIN CLOSES SESSION │
│ POST /api/sessions/22/close │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Server → SUBSCRIBED CLIENTS (session 22): │
│ { │
│ type: "session.ended", │
│ data: { │
│ session: { id: 22, is_active: 0, games_played: 5 } │
│ } │
│ } │
│ 📢 Only to subscribers of session 22 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Bot receives session.ended │
│ 🗳️ Bot announces: "Final votes for Quiplash 3: 5👍 1👎" │
│ 🌙 Bot announces: "Game Night ended! 5 games played" │
│ ⏳ Bot waits for next session.started... │
└─────────────────────────────────────────────────────────────────┘
```
## 📊 Broadcast Scope Comparison
### session.started (Global Broadcast)
```
┌─────────────────────────────────────────────────────────────────┐
│ SERVER │
│ │
│ broadcastToAll('session.started', data) │
└─────────────────────────────────────────────────────────────────┘
↓ ↓ ↓
┌──────────┴───────────┴───────────┴──────────┐
↓ ↓ ↓
┌────────┐ ┌────────┐ ┌────────┐
│ Bot A │ │ Bot B │ │ Bot C │
│ ✅ │ │ ✅ │ │ ✅ │
└────────┘ └────────┘ └────────┘
ALL authenticated clients receive it
(no subscription required)
```
### game.added, vote.received, session.ended (Session-Specific)
```
┌─────────────────────────────────────────────────────────────────┐
│ SERVER │
│ │
│ broadcastEvent('game.added', data, sessionId: 22) │
└─────────────────────────────────────────────────────────────────┘
┌─────────┴─────────┐
↓ ↓
┌────────┐ ┌────────┐ ┌────────┐
│ Bot A │ │ Bot B │ │ Bot C │
│ ✅ │ │ ❌ │ │ ✅ │
│subscr. │ │ not │ │subscr. │
│sess 22 │ │subscr. │ │sess 22 │
└────────┘ └────────┘ └────────┘
ONLY subscribers to session 22 receive it
```
## 🎯 Bot State Machine
```
┌─────────────┐
│ DISCONNECTED│
└──────┬──────┘
│ connect()
┌─────────────┐
│ CONNECTED │
└──────┬──────┘
│ send auth
┌─────────────┐
│AUTHENTICATED│ ← Wait here for session.started
└──────┬──────┘ (no subscription yet)
│ receive session.started
┌─────────────┐
│ WAITING │
│ TO │
│ SUBSCRIBE │
└──────┬──────┘
│ send subscribe
┌─────────────┐
│ SUBSCRIBED │ ← Now receive game.added, vote.received, session.ended
└──────┬──────┘
│ receive session.ended
┌─────────────┐
│AUTHENTICATED│ ← Back to waiting for next session.started
└─────────────┘ (still authenticated, but not subscribed)
```
## 🔍 Event Flow by Subscription Status
### Before Subscription (Just Authenticated)
```
Server Events: Bot Receives:
───────────── ─────────────
session.started ✅ YES (broadcast to all)
game.added ❌ NO (not subscribed yet)
vote.received ❌ NO (not subscribed yet)
session.ended ❌ NO (not subscribed yet)
```
### After Subscription (Subscribed to Session 22)
```
Server Events: Bot Receives:
───────────── ─────────────
session.started ✅ YES (still broadcast to all)
game.added ✅ YES (subscribed to session 22)
vote.received ✅ YES (subscribed to session 22)
session.ended ✅ YES (subscribed to session 22)
```
## 🎮 Multiple Sessions Example
```
Time Event Bot A (sess 22) Bot B (sess 23)
──── ───── ─────────────── ───────────────
10:00 session.started (sess 22) ✅ Receives ✅ Receives
10:01 Bot A subscribes to sess 22 ✅ Subscribed ❌ Not subscribed
10:02 game.added (sess 22) ✅ Receives ❌ Doesn't receive
10:05 session.started (sess 23) ✅ Receives ✅ Receives
10:06 Bot B subscribes to sess 23 ✅ Still sess 22 ✅ Subscribed
10:07 game.added (sess 22) ✅ Receives ❌ Doesn't receive
10:08 game.added (sess 23) ❌ Doesn't receive ✅ Receives
10:10 session.ended (sess 22) ✅ Receives ❌ Doesn't receive
10:15 session.ended (sess 23) ❌ Doesn't receive ✅ Receives
```
## 📝 Quick Reference
| Event | Broadcast Method | Scope | Subscription Required? |
|-------|------------------|-------|------------------------|
| `session.started` | `broadcastToAll()` | All authenticated clients | ❌ NO |
| `game.added` | `broadcastEvent()` | Session subscribers only | ✅ YES |
| `vote.received` | `broadcastEvent()` | Session subscribers only | ✅ YES |
| `session.ended` | `broadcastEvent()` | Session subscribers only | ✅ YES |
## 🔗 Related Documentation
- [WEBSOCKET_SUBSCRIPTION_GUIDE.md](WEBSOCKET_SUBSCRIPTION_GUIDE.md) - Detailed subscription guide
- [SESSION_START_WEBSOCKET.md](SESSION_START_WEBSOCKET.md) - session.started event
- [SESSION_END_WEBSOCKET.md](SESSION_END_WEBSOCKET.md) - session.ended event
- [BOT_INTEGRATION.md](BOT_INTEGRATION.md) - Bot integration guide

View File

@@ -0,0 +1,310 @@
# WebSocket Subscription Guide
## 📋 Overview
This guide explains how WebSocket subscriptions work in the Jackbox Game Picker and which events require subscriptions.
## 🔌 Connection & Authentication
### 1. Connect to WebSocket
```javascript
const ws = new WebSocket('ws://localhost:5000/api/sessions/live');
```
### 2. Authenticate
```javascript
ws.send(JSON.stringify({
type: 'auth',
token: 'YOUR_JWT_TOKEN'
}));
```
### 3. Wait for Auth Success
```javascript
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'auth_success') {
console.log('Authenticated!');
// Now you can subscribe to sessions
}
});
```
## 📨 Event Types & Subscription Requirements
| Event Type | Requires Subscription? | Broadcast To | When to Subscribe |
|------------|------------------------|--------------|-------------------|
| `session.started` | ❌ **NO** | All authenticated clients | N/A - Automatic |
| `game.added` | ✅ **YES** | Subscribed clients only | After session.started |
| `vote.received` | ✅ **YES** | Subscribed clients only | After session.started |
| `session.ended` | ✅ **YES** | Subscribed clients only | After session.started |
## 🎯 Subscription Strategy
### Strategy 1: Auto-Subscribe to New Sessions (Recommended for Bots)
```javascript
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
// After authentication
if (msg.type === 'auth_success') {
console.log('✅ Authenticated');
}
// Auto-subscribe to new sessions
if (msg.type === 'session.started') {
const sessionId = msg.data.session.id;
console.log(`🎮 New session ${sessionId} started!`);
// Subscribe to this session
ws.send(JSON.stringify({
type: 'subscribe',
sessionId: sessionId
}));
}
// Now you'll receive game.added, vote.received, and session.ended
if (msg.type === 'game.added') {
console.log(`🎲 Game: ${msg.data.game.title}`);
}
if (msg.type === 'session.ended') {
console.log(`🌙 Session ended! ${msg.data.session.games_played} games played`);
}
});
```
### Strategy 2: Subscribe to Active Session on Connect
```javascript
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'auth_success') {
console.log('✅ Authenticated');
// Fetch active session from API
fetch('http://localhost:5000/api/sessions/active')
.then(res => res.json())
.then(session => {
if (session && session.id) {
// Subscribe to active session
ws.send(JSON.stringify({
type: 'subscribe',
sessionId: session.id
}));
}
});
}
});
```
### Strategy 3: Subscribe to Specific Session
```javascript
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'auth_success') {
console.log('✅ Authenticated');
// Subscribe to specific session
const sessionId = 17;
ws.send(JSON.stringify({
type: 'subscribe',
sessionId: sessionId
}));
}
if (msg.type === 'subscribed') {
console.log(`✅ Subscribed to session ${msg.sessionId}`);
}
});
```
## 🔄 Complete Bot Flow
```javascript
const WebSocket = require('ws');
class JackboxBot {
constructor(token) {
this.token = token;
this.ws = null;
this.currentSessionId = null;
}
connect() {
this.ws = new WebSocket('ws://localhost:5000/api/sessions/live');
this.ws.on('open', () => {
console.log('🔌 Connected to WebSocket');
this.authenticate();
});
this.ws.on('message', (data) => {
this.handleMessage(JSON.parse(data.toString()));
});
}
authenticate() {
this.ws.send(JSON.stringify({
type: 'auth',
token: this.token
}));
}
handleMessage(msg) {
switch (msg.type) {
case 'auth_success':
console.log('✅ Authenticated');
// Don't subscribe yet - wait for session.started
break;
case 'session.started':
this.currentSessionId = msg.data.session.id;
console.log(`🎮 Session ${this.currentSessionId} started!`);
// Auto-subscribe to this session
this.ws.send(JSON.stringify({
type: 'subscribe',
sessionId: this.currentSessionId
}));
// Announce to users
this.announce(`Game Night has started! Session #${this.currentSessionId}`);
break;
case 'subscribed':
console.log(`✅ Subscribed to session ${msg.sessionId}`);
break;
case 'game.added':
console.log(`🎲 Game: ${msg.data.game.title}`);
this.announce(`Now playing: ${msg.data.game.title}`);
break;
case 'vote.received':
console.log(`🗳️ Vote: ${msg.data.vote.type}`);
break;
case 'session.ended':
console.log(`🌙 Session ${msg.data.session.id} ended`);
this.announce(`Game Night ended! ${msg.data.session.games_played} games played`);
this.currentSessionId = null;
break;
}
}
announce(message) {
// Send to IRC/Discord/Kosmi/etc
console.log(`📢 ${message}`);
}
}
// Usage
const bot = new JackboxBot('YOUR_JWT_TOKEN');
bot.connect();
```
## 📊 Subscription Lifecycle
```
1. Connect to WebSocket
2. Send auth message
3. Receive auth_success
4. Wait for session.started (no subscription needed)
5. Receive session.started
6. Send subscribe message with sessionId
7. Receive subscribed confirmation
8. Now receive: game.added, vote.received, session.ended
9. Receive session.ended
10. Wait for next session.started (repeat from step 4)
```
## 🔍 Debugging
### Check What You're Subscribed To
The WebSocket manager tracks subscriptions. Check backend logs:
```
[WebSocket] Client subscribed to session 17
[WebSocket] Client unsubscribed from session 17
```
### Verify Event Reception
**session.started** - Should receive immediately after authentication (no subscription needed):
```
[WebSocket] Broadcasted session.started to 2 authenticated client(s)
```
**game.added, vote.received, session.ended** - Only after subscribing:
```
[WebSocket] Broadcasted game.added to 1 client(s) for session 17
```
### Common Issues
1. **Not receiving session.started:**
- ✅ Are you authenticated?
- ✅ Is your WebSocket connection open?
- ✅ Check backend logs for broadcast confirmation
2. **Not receiving game.added:**
- ✅ Did you subscribe to the session?
- ✅ Did you receive `subscribed` confirmation?
- ✅ Is the session ID correct?
3. **Not receiving session.ended:**
- ✅ Are you still subscribed to the session?
- ✅ Did the session actually close?
- ✅ Check backend logs
## 🎯 Best Practices
1. **Auto-subscribe to new sessions** when you receive `session.started`
2. **Don't subscribe before session.started** - there's nothing to subscribe to yet
3. **Handle reconnections** - re-authenticate and re-subscribe on reconnect
4. **Use polling as fallback** - poll `/api/sessions/active` every 30s as backup
5. **Unsubscribe when done** - clean up subscriptions when you're done with a session
6. **Validate session IDs** - make sure the session exists before subscribing
## 📝 Summary
### ❌ No Subscription Required
- `session.started` - Broadcast to **all authenticated clients**
### ✅ Subscription Required
- `game.added` - Only to **subscribed clients**
- `vote.received` - Only to **subscribed clients**
- `session.ended` - Only to **subscribed clients**
### 🎯 Recommended Flow
1. Authenticate
2. Wait for `session.started` (automatic)
3. Subscribe to the session
4. Receive `game.added`, `vote.received`, `session.ended`
5. Repeat from step 2 for next session
## 🔗 Related Documentation
- [SESSION_START_WEBSOCKET.md](SESSION_START_WEBSOCKET.md) - session.started event details
- [SESSION_END_WEBSOCKET.md](SESSION_END_WEBSOCKET.md) - session.ended event details
- [API_QUICK_REFERENCE.md](API_QUICK_REFERENCE.md) - API reference
- [BOT_INTEGRATION.md](BOT_INTEGRATION.md) - Bot integration guide

239
docs/WEBSOCKET_TESTING.md Normal file
View File

@@ -0,0 +1,239 @@
# WebSocket Integration Testing Guide
This guide walks you through testing the WebSocket event system for game notifications.
## Prerequisites
1. Backend API running with WebSocket support
2. Valid JWT token for authentication
3. Active session with games (or ability to create one)
## Testing Steps
### Step 1: Install Backend Dependencies
```bash
cd backend
npm install
```
This will install the `ws` package that was added to `package.json`.
### Step 2: Start the Backend
```bash
cd backend
npm start
```
You should see:
```
Server is running on port 5000
WebSocket server available at ws://localhost:5000/api/sessions/live
[WebSocket] WebSocket server initialized on /api/sessions/live
```
### Step 3: Get JWT Token
```bash
curl -X POST "http://localhost:5000/api/auth/login" \
-H "Content-Type: application/json" \
-d '{"key":"YOUR_ADMIN_KEY"}'
```
Save the token from the response.
### Step 4: Test WebSocket Connection
Run the test script:
```bash
cd backend
JWT_TOKEN="your_token_here" node test-websocket.js
```
Expected output:
```
🚀 WebSocket Test Client
═══════════════════════════════════════════════════════
Connecting to: ws://localhost:5000/api/sessions/live
✅ Connected to WebSocket server
📝 Step 1: Authenticating...
✅ Authentication successful
📝 Step 2: Subscribing to session 1...
✅ Subscribed to session 1
🎧 Listening for events...
Add a game in the Picker page to see events here
Press Ctrl+C to exit
```
### Step 5: Test Game Added Event
1. Keep the WebSocket test client running
2. Open the web app in your browser
3. Go to the Picker page
4. Add a game to the session
You should see in the test client:
```
🎮 GAME ADDED EVENT RECEIVED!
═══════════════════════════════════════════════════════
Game: Fibbage 4
Pack: The Jackbox Party Pack 9
Players: 2-8
Session ID: 1
Games Played: 1
Timestamp: 2025-11-01T...
═══════════════════════════════════════════════════════
```
### Step 6: Test Bot Integration
If you're using the `irc-kosmi-relay` bot:
1. Make sure `UseWebSocket=true` in `matterbridge.toml`
2. Build and run the bot:
```bash
cd irc-kosmi-relay
go build
./matterbridge -conf matterbridge.toml
```
3. Look for these log messages:
```
INFO Jackbox integration initialized successfully
INFO Connecting to WebSocket: wss://your-api-url/api/sessions/live
INFO WebSocket connected
INFO Authentication successful
INFO Subscribed to session X
```
4. Add a game in the Picker page
5. The bot should announce in Kosmi/IRC:
```
🎮 Coming up next: Fibbage 4!
```
## Troubleshooting
### Connection Refused
**Problem**: `Error: connect ECONNREFUSED`
**Solution**: Make sure the backend is running on the correct port (default 5000).
### Authentication Failed
**Problem**: `Authentication failed: Invalid or expired token`
**Solution**:
- Get a fresh JWT token
- Make sure you're using the correct admin key
- Check token hasn't expired (tokens expire after 24 hours)
### No Events Received
**Problem**: WebSocket connects but no `game.added` events are received
**Solution**:
- Make sure you're subscribed to the correct session ID
- Verify the session is active
- Check backend logs for errors
- Try adding a game manually via the Picker page
### Bot Not Connecting
**Problem**: Bot fails to connect to WebSocket
**Solution**:
- Check `APIURL` in `matterbridge.toml` is correct
- Verify `UseWebSocket=true` is set
- Check bot has valid JWT token (authentication succeeded)
- Look for error messages in bot logs
### Reconnection Issues
**Problem**: WebSocket disconnects and doesn't reconnect
**Solution**:
- Check network connectivity
- Backend automatically handles reconnection with exponential backoff
- Bot automatically reconnects on disconnect
- Check logs for reconnection attempts
## Advanced Testing
### Test Multiple Clients
You can run multiple test clients simultaneously:
```bash
# Terminal 1
JWT_TOKEN="token1" node test-websocket.js
# Terminal 2
JWT_TOKEN="token2" node test-websocket.js
```
Both should receive the same `game.added` events.
### Test Heartbeat
The WebSocket connection sends ping/pong messages every 30 seconds. You should see:
```
💓 Heartbeat
```
If you don't see heartbeats, the connection may be stale.
### Test Reconnection
1. Start the test client
2. Stop the backend (Ctrl+C)
3. The client should log: `WebSocket disconnected, reconnecting...`
4. Restart the backend
5. The client should reconnect automatically
## Integration Checklist
- [ ] Backend WebSocket server starts successfully
- [ ] Test client can connect and authenticate
- [ ] Test client receives `game.added` events
- [ ] Heartbeat keeps connection alive (30s interval)
- [ ] Auto-reconnect works after disconnect
- [ ] Multiple clients can connect simultaneously
- [ ] Invalid JWT is rejected properly
- [ ] Bot connects and authenticates
- [ ] Bot receives events and broadcasts to chat
- [ ] Bot reconnects after network issues
## Next Steps
Once testing is complete:
1. Update your bot configuration to use `UseWebSocket=true`
2. Deploy the updated backend with WebSocket support
3. Restart your bot to connect via WebSocket
4. Monitor logs for any connection issues
5. Webhooks remain available as a fallback option
## Comparison: WebSocket vs Webhooks
| Feature | WebSocket | Webhooks |
|---------|-----------|----------|
| Setup Complexity | Simple | Moderate |
| Inbound Ports | Not needed | Required |
| Docker Networking | Simple | Complex |
| Latency | Lower | Higher |
| Connection Type | Persistent | Per-event |
| Reconnection | Automatic | N/A |
| Best For | Real-time bots | Serverless integrations |
**Recommendation**: Use WebSocket for bot integrations. Use webhooks for serverless/stateless integrations or when WebSocket is not feasible.

93
frontend/ICONS.md Normal file
View 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.

View 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');

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,4 +1,4 @@
module.exports = {
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

69
frontend/public/sw.js Normal file
View 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);
})
);
});

View File

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

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

View File

@@ -0,0 +1,120 @@
import React, { useState, useEffect, useRef } from 'react';
function RoomCodeModal({ isOpen, onConfirm, onCancel, gameName }) {
const [roomCode, setRoomCode] = useState('');
const [error, setError] = useState('');
const inputRef = useRef(null);
useEffect(() => {
if (isOpen) {
setRoomCode('');
setError('');
// Focus input when modal opens
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
}, [isOpen]);
useEffect(() => {
const handleEscape = (e) => {
if (e.key === 'Escape' && isOpen) {
onCancel();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onCancel]);
const handleInputChange = (e) => {
const value = e.target.value.toUpperCase();
// Only allow A-Z and 0-9, max 4 characters
const filtered = value.replace(/[^A-Z0-9]/g, '').slice(0, 4);
setRoomCode(filtered);
setError('');
};
const handleSubmit = (e) => {
e.preventDefault();
if (roomCode.length !== 4) {
setError('Room code must be exactly 4 characters');
return;
}
onConfirm(roomCode);
};
const handleOverlayClick = (e) => {
if (e.target === e.currentTarget) {
onCancel();
}
};
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
onClick={handleOverlayClick}
>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-md w-full p-6 animate-fade-in">
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-2">
Enter Room Code
</h2>
{gameName && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
For: <span className="font-semibold">{gameName}</span>
</p>
)}
<form onSubmit={handleSubmit}>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
4-Character Room Code (A-Z, 0-9)
</label>
<div className="relative">
<input
ref={inputRef}
type="text"
value={roomCode}
onChange={handleInputChange}
placeholder="ABCD"
className="w-full px-4 py-3 text-center text-2xl font-mono font-bold tracking-widest border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 uppercase"
maxLength={4}
autoComplete="off"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-500 dark:text-gray-400 font-mono">
{roomCode.length}/4
</div>
</div>
{error && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
</div>
<div className="flex gap-3">
<button
type="button"
onClick={onCancel}
className="flex-1 px-4 py-3 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition font-semibold"
>
Cancel
</button>
<button
type="submit"
disabled={roomCode.length !== 4}
className="flex-1 px-4 py-3 bg-indigo-600 dark:bg-indigo-700 text-white rounded-lg hover:bg-indigo-700 dark:hover:bg-indigo-800 transition font-semibold disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-indigo-600 dark:disabled:hover:bg-indigo-700"
>
Confirm
</button>
</div>
</form>
</div>
</div>
);
}
export default RoomCodeModal;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import api from '../api/axios';
import GamePoolModal from '../components/GamePoolModal';
import RoomCodeModal from '../components/RoomCodeModal';
import { formatLocalTime } from '../utils/dateUtils';
import PopularityBadge from '../components/PopularityBadge';
@@ -13,10 +14,12 @@ function Picker() {
const [activeSession, setActiveSession] = useState(null);
const [allGames, setAllGames] = useState([]);
const [selectedGame, setSelectedGame] = useState(null);
const [playingGame, setPlayingGame] = useState(null); // Currently playing game
const [loading, setLoading] = useState(true);
const [picking, setPicking] = useState(false);
const [error, setError] = useState('');
const [showPopularity, setShowPopularity] = useState(true);
const [sessionEnded, setSessionEnded] = useState(false);
// Filters
const [playerCount, setPlayerCount] = useState('');
@@ -27,6 +30,9 @@ function Picker() {
// Manual game selection
const [showManualSelect, setShowManualSelect] = useState(false);
const [manualGameId, setManualGameId] = useState('');
const [manualSearchQuery, setManualSearchQuery] = useState('');
const [showManualDropdown, setShowManualDropdown] = useState(false);
const [filteredManualGames, setFilteredManualGames] = useState([]);
// Game pool viewer
const [showGamePool, setShowGamePool] = useState(false);
@@ -41,6 +47,72 @@ function Picker() {
// Exclude previously played games
const [excludePlayedGames, setExcludePlayedGames] = useState(false);
// Room code modal
const [showRoomCodeModal, setShowRoomCodeModal] = useState(false);
const [pendingGameAction, setPendingGameAction] = useState(null);
const checkActiveSession = useCallback(async () => {
try {
const sessionResponse = await api.get('/sessions/active');
const session = sessionResponse.data?.session !== undefined
? sessionResponse.data.session
: sessionResponse.data;
// Check if session status changed
setActiveSession(prevSession => {
// If we had a session but now don't, mark it as ended
if (prevSession && (!session || !session.id)) {
setSessionEnded(true);
return null;
} else if (session && session.id) {
setSessionEnded(false);
return session;
}
return prevSession;
});
} catch (err) {
console.error('Failed to check session status', err);
}
}, []);
const loadData = useCallback(async () => {
try {
// Load active session
const sessionResponse = await api.get('/sessions/active');
// Handle new format { session: null } or old format (direct session object)
let session = sessionResponse.data?.session !== undefined
? sessionResponse.data.session
: sessionResponse.data;
// Don't auto-create session - let user create it explicitly
setActiveSession(session);
// Load all enabled games for manual selection
const gamesResponse = await api.get('/games?enabled=true');
setAllGames(gamesResponse.data);
// Load currently playing game if session exists
if (session && session.id) {
try {
const sessionGamesResponse = await api.get(`/sessions/${session.id}/games`);
const playingGameEntry = sessionGamesResponse.data.find(g => g.status === 'playing');
if (playingGameEntry) {
setPlayingGame(playingGameEntry);
} else {
setPlayingGame(null);
}
} catch (err) {
console.error('Failed to load playing game', err);
}
}
} catch (err) {
setError('Failed to load session data');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// Wait for auth to finish loading before checking authentication
if (authLoading) return;
@@ -50,33 +122,39 @@ function Picker() {
return;
}
loadData();
}, [isAuthenticated, authLoading, navigate]);
}, [isAuthenticated, authLoading, navigate, loadData]);
const loadData = async () => {
try {
// Load active session or create one
const sessionResponse = await api.get('/sessions/active');
// Poll for active session status changes
useEffect(() => {
if (!isAuthenticated || authLoading) return;
// Handle new format { session: null } or old format (direct session object)
let session = sessionResponse.data?.session !== undefined
? sessionResponse.data.session
: sessionResponse.data;
const interval = setInterval(() => {
checkActiveSession();
}, 3000);
// If no active session, create one
if (!session || !session.id) {
const newSession = await api.post('/sessions', {});
session = newSession.data;
return () => clearInterval(interval);
}, [isAuthenticated, authLoading, checkActiveSession]);
// Close manual game dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (showManualDropdown && !event.target.closest('.manual-search-container')) {
setShowManualDropdown(false);
}
};
setActiveSession(session);
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showManualDropdown]);
// Load all games for manual selection
const gamesResponse = await api.get('/games');
setAllGames(gamesResponse.data);
const handleCreateSession = async () => {
try {
const newSession = await api.post('/sessions', {});
setActiveSession(newSession.data);
setSessionEnded(false);
setError('');
} catch (err) {
setError('Failed to load session data');
} finally {
setLoading(false);
setError('Failed to create session');
}
};
@@ -152,56 +230,119 @@ function Picker() {
const handleAcceptGame = async () => {
if (!selectedGame || !activeSession) return;
try {
await api.post(`/sessions/${activeSession.id}/games`, {
game_id: selectedGame.id,
manually_added: false
// Show room code modal
setPendingGameAction({
type: 'accept',
game: selectedGame
});
setShowRoomCodeModal(true);
};
const handleRoomCodeConfirm = async (roomCode) => {
if (!pendingGameAction || !activeSession) return;
try {
const { type, game, gameId } = pendingGameAction;
if (type === 'accept' || type === 'version') {
const response = await api.post(`/sessions/${activeSession.id}/games`, {
game_id: gameId || game.id,
manually_added: false,
room_code: roomCode
});
// Set the newly added game as playing
setPlayingGame(response.data);
} else if (type === 'manual') {
const response = await api.post(`/sessions/${activeSession.id}/games`, {
game_id: gameId,
manually_added: true,
room_code: roomCode
});
setManualGameId('');
setShowManualSelect(false);
// Set the newly added game as playing
setPlayingGame(response.data);
}
// Close all modals and clear selected game after adding to session
setSelectedGame(null);
setShowGamePool(false);
// Trigger games list refresh
setGamesUpdateTrigger(prev => prev + 1);
setSelectedGame(null);
setError('');
} catch (err) {
setError('Failed to add game to session');
} finally {
setShowRoomCodeModal(false);
setPendingGameAction(null);
}
};
const handleRoomCodeCancel = () => {
setShowRoomCodeModal(false);
setPendingGameAction(null);
};
const handleAddManualGame = async () => {
if (!manualGameId || !activeSession) return;
try {
await api.post(`/sessions/${activeSession.id}/games`, {
game_id: parseInt(manualGameId),
manually_added: true
// Show room code modal
const game = allGames.find(g => g.id === parseInt(manualGameId));
setPendingGameAction({
type: 'manual',
gameId: parseInt(manualGameId),
game: game
});
setShowRoomCodeModal(true);
// Trigger games list refresh
setGamesUpdateTrigger(prev => prev + 1);
// Reset search
setManualSearchQuery('');
setShowManualDropdown(false);
setManualGameId('');
setShowManualSelect(false);
setError('');
} catch (err) {
setError('Failed to add game to session');
}
};
// Handle manual search input with filtering
const handleManualSearchChange = useCallback((e) => {
const query = e.target.value;
setManualSearchQuery(query);
if (query.trim().length === 0) {
setFilteredManualGames([]);
setShowManualDropdown(false);
setManualGameId('');
return;
}
// Filter games by query (non-blocking)
const lowerQuery = query.toLowerCase();
const filtered = allGames.filter(game =>
game.title.toLowerCase().includes(lowerQuery) ||
game.pack_name.toLowerCase().includes(lowerQuery)
).slice(0, 50); // Limit to 50 results for performance
setFilteredManualGames(filtered);
setShowManualDropdown(filtered.length > 0);
}, [allGames]);
// Handle selecting a game from the dropdown
const handleSelectManualGame = useCallback((game) => {
setManualGameId(game.id.toString());
setManualSearchQuery(`${game.title} (${game.pack_name})`);
setShowManualDropdown(false);
}, []);
const handleSelectVersion = async (gameId) => {
if (!activeSession) return;
try {
await api.post(`/sessions/${activeSession.id}/games`, {
game_id: gameId,
manually_added: false
// Show room code modal
const game = allGames.find(g => g.id === gameId);
setPendingGameAction({
type: 'version',
gameId: gameId,
game: game
});
// Trigger games list refresh
setGamesUpdateTrigger(prev => prev + 1);
setSelectedGame(null);
setError('');
} catch (err) {
setError('Failed to add game to session');
}
setShowRoomCodeModal(true);
};
// Find similar versions of a game based on title patterns
@@ -246,8 +387,38 @@ function Picker() {
if (!activeSession) {
return (
<div className="max-w-4xl mx-auto">
<div className="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-200 p-4 rounded">
Failed to load or create session. Please try again.
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
{sessionEnded ? (
<>
<h2 className="text-2xl font-bold text-orange-600 dark:text-orange-400 mb-4">
Session Ended
</h2>
<p className="text-gray-700 dark:text-gray-300 mb-4">
The active session has been ended. To continue picking games, you'll need to create a new session.
</p>
<button
onClick={handleCreateSession}
className="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 font-semibold"
>
Create New Session
</button>
</>
) : (
<>
<h2 className="text-2xl font-bold text-gray-700 dark:text-gray-200 mb-4">
No Active Session
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
There is no active game session. Create a new session to start picking games.
</p>
<button
onClick={handleCreateSession}
className="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 font-semibold"
>
Create New Session
</button>
</>
)}
</div>
</div>
);
@@ -500,6 +671,14 @@ function Picker() {
/>
)}
{/* Room Code Modal */}
<RoomCodeModal
isOpen={showRoomCodeModal}
onConfirm={handleRoomCodeConfirm}
onCancel={handleRoomCodeCancel}
gameName={pendingGameAction?.game?.title}
/>
{/* Results Panel */}
<div className="md:col-span-2">
{error && (
@@ -508,9 +687,75 @@ function Picker() {
</div>
)}
{selectedGame && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-8 mb-6">
{/* Currently Playing Game Card */}
{playingGame && (
<div className="bg-green-50 dark:bg-green-900/20 border-2 border-green-500 dark:border-green-700 rounded-lg shadow-lg p-4 sm:p-8 mb-6">
<div className="flex items-center gap-2 mb-4">
<span className="inline-flex items-center gap-1 text-sm bg-green-600 dark:bg-green-700 text-white px-3 py-1 rounded-full font-semibold">
🎮 Playing Now
</span>
</div>
<h2 className="text-2xl sm:text-3xl font-bold mb-4 text-gray-800 dark:text-gray-100">
{playingGame.title}
</h2>
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-400 mb-4">{playingGame.pack_name}</p>
<div className="grid grid-cols-2 gap-3 sm:gap-4 mb-6 text-sm sm:text-base">
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Players:</span>
<span className="ml-2 text-gray-600 dark:text-gray-400">
{playingGame.min_players}-{playingGame.max_players}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Length:</span>
<span className="ml-2 text-gray-600 dark:text-gray-400">
{playingGame.length_minutes ? `${playingGame.length_minutes} min` : 'Unknown'}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Type:</span>
<span className="ml-2 text-gray-600 dark:text-gray-400">
{playingGame.game_type || 'N/A'}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Room Code:</span>
<span className="ml-2 text-gray-600 dark:text-gray-400 font-mono font-bold">
{playingGame.room_code || 'N/A'}
</span>
</div>
</div>
<div className="flex gap-4">
<button
disabled
className="flex-1 bg-green-600 dark:bg-green-700 text-white py-3 rounded-lg opacity-70 cursor-not-allowed font-semibold"
>
✓ Playing
</button>
<button
onClick={handlePickGame}
className="flex-1 bg-yellow-600 dark:bg-yellow-700 text-white py-3 rounded-lg hover:bg-yellow-700 dark:hover:bg-yellow-800 transition font-semibold"
>
🎲 Pick New Game
</button>
</div>
</div>
)}
{/* Selected Game Card (from dice roll) */}
{selectedGame && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-8 mb-6 relative">
{/* Close/Dismiss Button */}
<button
onClick={() => setSelectedGame(null)}
className="absolute top-2 right-2 sm:top-4 sm:right-4 w-8 h-8 flex items-center justify-center text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition"
title="Dismiss"
>
</button>
<h2 className="text-2xl sm:text-3xl font-bold mb-4 text-gray-800 dark:text-gray-100 pr-8">
{selectedGame.title}
</h2>
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-400 mb-4">{selectedGame.pack_name}</p>
@@ -576,6 +821,13 @@ function Picker() {
>
🎲 Re-roll
</button>
<button
onClick={() => setSelectedGame(null)}
className="bg-gray-500 dark:bg-gray-600 text-white px-4 py-3 rounded-lg hover:bg-gray-600 dark:hover:bg-gray-700 transition font-semibold"
title="Cancel"
>
Cancel
</button>
</div>
{/* Other Versions Suggestion */}
@@ -621,18 +873,47 @@ function Picker() {
Manual Game Selection
</h3>
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
<select
value={manualGameId}
onChange={(e) => setManualGameId(e.target.value)}
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
<div className="flex-1 relative manual-search-container">
<input
type="text"
value={manualSearchQuery}
onChange={handleManualSearchChange}
onFocus={() => {
if (filteredManualGames.length > 0) {
setShowManualDropdown(true);
}
}}
placeholder="Type to search games..."
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400"
/>
{/* Autocomplete dropdown - above on mobile, below on desktop */}
{showManualDropdown && filteredManualGames.length > 0 && (
<div className="absolute z-50 w-full bottom-full mb-1 sm:bottom-auto sm:top-full sm:mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{filteredManualGames.map((game) => (
<button
key={game.id}
onClick={() => handleSelectManualGame(game)}
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors border-b border-gray-200 dark:border-gray-600 last:border-b-0"
>
<option value="">Select a game...</option>
{allGames.map((game) => (
<option key={game.id} value={game.id}>
{game.title} ({game.pack_name})
</option>
<div className="font-semibold text-gray-800 dark:text-gray-100">
{game.title}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-0.5">
{game.pack_name} • {game.min_players}-{game.max_players} players
</div>
</button>
))}
</select>
</div>
)}
{/* No results message - above on mobile, below on desktop */}
{manualSearchQuery.trim() && filteredManualGames.length === 0 && !showManualDropdown && (
<div className="absolute z-50 w-full bottom-full mb-1 sm:bottom-auto sm:top-full sm:mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg px-4 py-3 text-gray-600 dark:text-gray-400 text-sm">
No games found matching "{manualSearchQuery}"
</div>
)}
</div>
<button
onClick={handleAddManualGame}
disabled={!manualGameId}
@@ -645,25 +926,33 @@ function Picker() {
)}
{/* Session info and games */}
<SessionInfo sessionId={activeSession.id} onGamesUpdate={gamesUpdateTrigger} />
<SessionInfo
sessionId={activeSession.id}
onGamesUpdate={gamesUpdateTrigger}
playingGame={playingGame}
setPlayingGame={setPlayingGame}
/>
</div>
</div>
</div>
);
}
function SessionInfo({ sessionId, onGamesUpdate }) {
function SessionInfo({ sessionId, onGamesUpdate, playingGame, setPlayingGame }) {
const { isAuthenticated } = useAuth();
const [games, setGames] = useState([]);
const [loading, setLoading] = useState(true);
const [confirmingRemove, setConfirmingRemove] = useState(null);
const [showPopularity, setShowPopularity] = useState(true);
const [editingRoomCode, setEditingRoomCode] = useState(null);
const [newRoomCode, setNewRoomCode] = useState('');
const [showRepeatRoomCodeModal, setShowRepeatRoomCodeModal] = useState(false);
const [repeatGameData, setRepeatGameData] = useState(null);
const [wsConnection, setWsConnection] = useState(null);
const [editingPlayerCount, setEditingPlayerCount] = useState(null);
const [newPlayerCount, setNewPlayerCount] = useState('');
useEffect(() => {
loadGames();
}, [sessionId, onGamesUpdate]);
const loadGames = async () => {
const loadGames = useCallback(async () => {
try {
const response = await api.get(`/sessions/${sessionId}/games`);
// Reverse chronological order (most recent first)
@@ -673,11 +962,80 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
} finally {
setLoading(false);
}
}, [sessionId]);
useEffect(() => {
loadGames();
}, [sessionId, onGamesUpdate, loadGames]);
// Auto-refresh games list every 3 seconds
useEffect(() => {
const interval = setInterval(() => {
loadGames();
}, 3000);
return () => clearInterval(interval);
}, [loadGames]);
// Setup WebSocket connection for real-time player count updates
useEffect(() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.hostname}:${window.location.port || (window.location.protocol === 'https:' ? 443 : 80)}/api/sessions/live`;
try {
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('[WebSocket] Connected for player count updates');
// Subscribe to session events
ws.send(JSON.stringify({
type: 'subscribe',
sessionId: parseInt(sessionId)
}));
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
// Handle player count updates
if (message.event === 'player-count.updated') {
console.log('[WebSocket] Player count updated:', message.data);
// Reload games to get updated player counts
loadGames();
}
} catch (error) {
console.error('[WebSocket] Error parsing message:', error);
}
};
ws.onerror = (error) => {
console.error('[WebSocket] Error:', error);
};
ws.onclose = () => {
console.log('[WebSocket] Disconnected');
};
setWsConnection(ws);
return () => {
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
};
} catch (error) {
console.error('[WebSocket] Failed to connect:', error);
}
}, [sessionId, loadGames]);
const handleUpdateStatus = async (gameId, newStatus) => {
try {
await api.patch(`/sessions/${sessionId}/games/${gameId}/status`, { status: newStatus });
// If we're changing the playing game's status, clear it from the playing card
if (playingGame && playingGame.id === gameId && newStatus !== 'playing') {
setPlayingGame(null);
}
loadGames(); // Reload to get updated statuses
} catch (err) {
console.error('Failed to update game status', err);
@@ -699,6 +1057,10 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
const handleRemoveGame = async (gameId) => {
try {
await api.delete(`/sessions/${sessionId}/games/${gameId}`);
// If we're removing the playing game, clear it from the playing card
if (playingGame && playingGame.id === gameId) {
setPlayingGame(null);
}
setConfirmingRemove(null);
loadGames(); // Reload after deletion
} catch (err) {
@@ -707,6 +1069,117 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
}
};
const handleEditRoomCode = (gameId, currentCode) => {
setEditingRoomCode(gameId);
setNewRoomCode(currentCode || '');
};
const handleRoomCodeChange = (e) => {
const value = e.target.value.toUpperCase();
const filtered = value.replace(/[^A-Z0-9]/g, '').slice(0, 4);
setNewRoomCode(filtered);
};
const handleSaveRoomCode = async (gameId) => {
if (newRoomCode.length !== 4) {
return;
}
try {
await api.patch(`/sessions/${sessionId}/games/${gameId}/room-code`, {
room_code: newRoomCode
});
setEditingRoomCode(null);
setNewRoomCode('');
loadGames(); // Reload to show updated code
} catch (err) {
console.error('Failed to update room code', err);
}
};
const handleCancelEditRoomCode = () => {
setEditingRoomCode(null);
setNewRoomCode('');
};
const handleRepeatGame = (game) => {
// Store the game data and open the room code modal
setRepeatGameData(game);
setShowRepeatRoomCodeModal(true);
};
const handleRepeatRoomCodeConfirm = async (roomCode) => {
if (!repeatGameData) return;
try {
const response = await api.post(`/sessions/${sessionId}/games`, {
game_id: repeatGameData.game_id,
manually_added: false,
room_code: roomCode
});
// Set the newly added game as playing
setPlayingGame(response.data);
setShowRepeatRoomCodeModal(false);
setRepeatGameData(null);
loadGames(); // Reload to show the new game
} catch (err) {
console.error('Failed to repeat game', err);
}
};
const handleRepeatRoomCodeCancel = () => {
setShowRepeatRoomCodeModal(false);
setRepeatGameData(null);
};
const handleStopPlayerCountCheck = async (gameId) => {
try {
await api.post(`/sessions/${sessionId}/games/${gameId}/stop-player-check`);
loadGames(); // Reload to show updated status
} catch (err) {
console.error('Failed to stop player count check', err);
}
};
const handleRetryPlayerCount = async (gameId, roomCode) => {
if (!roomCode) return;
try {
await api.post(`/sessions/${sessionId}/games/${gameId}/start-player-check`);
loadGames(); // Reload to show checking status
} catch (err) {
console.error('Failed to start player count check', err);
}
};
const handleEditPlayerCount = (gameId, currentCount) => {
setEditingPlayerCount(gameId);
setNewPlayerCount(currentCount?.toString() || '');
};
const handleSavePlayerCount = async (gameId) => {
const count = parseInt(newPlayerCount);
if (isNaN(count) || count < 0) {
return;
}
try {
await api.patch(`/sessions/${sessionId}/games/${gameId}/player-count`, {
player_count: count
});
setEditingPlayerCount(null);
setNewPlayerCount('');
loadGames(); // Reload to show updated count
} catch (err) {
console.error('Failed to update player count', err);
}
};
const handleCancelEditPlayerCount = () => {
setEditingPlayerCount(null);
setNewPlayerCount('');
};
const getStatusBadge = (status) => {
if (status === 'playing') {
return (
@@ -726,6 +1199,15 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
};
return (
<>
{/* Room Code Modal for Repeat Game */}
<RoomCodeModal
isOpen={showRepeatRoomCodeModal}
onConfirm={handleRepeatRoomCodeConfirm}
onCancel={handleRepeatRoomCodeCancel}
gameName={repeatGameData?.title}
/>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 sm:p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg sm:text-xl font-semibold text-gray-800 dark:text-gray-100">
@@ -747,8 +1229,8 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
<p className="text-gray-500 dark:text-gray-400">No games played yet. Pick a game to get started!</p>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{games.map((game) => {
const index = games.length - games.indexOf(game);
{games.map((game, index) => {
const displayNumber = games.length - index;
return (
<div
key={game.id}
@@ -768,7 +1250,7 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
? 'text-gray-500 dark:text-gray-500 line-through'
: 'text-gray-700 dark:text-gray-200'
}`}>
{index + 1}. {game.title}
{displayNumber}. {game.title}
</span>
{getStatusBadge(game.status)}
{game.manually_added === 1 && (
@@ -776,6 +1258,167 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
Manual
</span>
)}
{game.room_code && (
<div className="flex items-center gap-1 flex-wrap">
{editingRoomCode === game.id ? (
<div className="flex items-center gap-1">
<input
type="text"
value={newRoomCode}
onChange={handleRoomCodeChange}
className="w-16 px-2 py-1 text-xs font-mono font-bold text-center border border-indigo-400 dark:border-indigo-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 uppercase focus:outline-none focus:ring-1 focus:ring-indigo-500"
maxLength={4}
autoFocus
/>
<button
onClick={() => handleSaveRoomCode(game.id)}
disabled={newRoomCode.length !== 4}
className="text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<button
onClick={handleCancelEditRoomCode}
className="text-xs px-2 py-1 bg-gray-500 text-white rounded hover:bg-gray-600"
>
</button>
</div>
) : (
<>
<span className="inline-flex items-center gap-1 text-xs bg-indigo-600 dark:bg-indigo-700 text-white px-2 py-1 rounded font-mono font-bold">
🎮 {game.room_code}
</span>
{isAuthenticated && (
<button
onClick={() => handleEditRoomCode(game.id, game.room_code)}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400"
title="Edit room code"
>
</button>
)}
</>
)}
{/* Player Count Display */}
{game.player_count_check_status && game.player_count_check_status !== 'not_started' && (
<div className="flex items-center gap-1">
{game.player_count_check_status === 'waiting' && (
<span className="inline-flex items-center gap-1 text-xs bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 px-2 py-1 rounded">
Waiting...
</span>
)}
{game.player_count_check_status === 'checking' && (
<span className="inline-flex items-center gap-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded">
🔍 {game.player_count ? `${game.player_count} players (checking...)` : 'Checking...'}
</span>
)}
{game.player_count_check_status === 'completed' && game.player_count && (
<>
{editingPlayerCount === game.id ? (
<div className="flex items-center gap-1">
<input
type="number"
value={newPlayerCount}
onChange={(e) => setNewPlayerCount(e.target.value)}
className="w-12 px-2 py-1 text-xs text-center border border-green-400 dark:border-green-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-green-500"
min="0"
autoFocus
/>
<button
onClick={() => handleSavePlayerCount(game.id)}
disabled={!newPlayerCount || parseInt(newPlayerCount) < 0}
className="text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<button
onClick={handleCancelEditPlayerCount}
className="text-xs px-2 py-1 bg-gray-500 text-white rounded hover:bg-gray-600"
>
</button>
</div>
) : (
<>
<span className="inline-flex items-center gap-1 text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded font-semibold">
{game.player_count} players
</span>
{isAuthenticated && (
<button
onClick={() => handleEditPlayerCount(game.id, game.player_count)}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-green-600 dark:hover:text-green-400"
title="Edit player count"
>
</button>
)}
</>
)}
</>
)}
{game.player_count_check_status === 'failed' && (
<>
{editingPlayerCount === game.id ? (
<div className="flex items-center gap-1">
<input
type="number"
value={newPlayerCount}
onChange={(e) => setNewPlayerCount(e.target.value)}
className="w-12 px-2 py-1 text-xs text-center border border-orange-400 dark:border-orange-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-orange-500"
min="0"
autoFocus
/>
<button
onClick={() => handleSavePlayerCount(game.id)}
disabled={!newPlayerCount || parseInt(newPlayerCount) < 0}
className="text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<button
onClick={handleCancelEditPlayerCount}
className="text-xs px-2 py-1 bg-gray-500 text-white rounded hover:bg-gray-600"
>
</button>
</div>
) : (
<>
<button
onClick={() => handleRetryPlayerCount(game.id, game.room_code)}
className="inline-flex items-center gap-1 text-xs bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200 px-2 py-1 rounded hover:bg-orange-200 dark:hover:bg-orange-800 transition cursor-pointer"
title="Click to retry detection"
>
Unknown
</button>
{isAuthenticated && (
<button
onClick={() => handleEditPlayerCount(game.id, null)}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-orange-600 dark:hover:text-orange-400"
title="Set player count manually"
>
</button>
)}
</>
)}
</>
)}
{/* Stop button for active checks */}
{isAuthenticated && (game.player_count_check_status === 'waiting' || game.player_count_check_status === 'checking') && (
<button
onClick={() => handleStopPlayerCountCheck(game.id)}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400"
title="Stop checking player count"
>
</button>
)}
</div>
)}
</div>
)}
{showPopularity && (
<PopularityBadge
upvotes={game.upvotes || 0}
@@ -797,6 +1440,13 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
{/* Action buttons for admins */}
{isAuthenticated && (
<div className="flex flex-wrap gap-2">
<button
onClick={() => handleRepeatGame(game)}
className="text-xs px-3 py-1 bg-purple-600 dark:bg-purple-700 text-white rounded hover:bg-purple-700 dark:hover:bg-purple-800 transition"
title="Play this game again"
>
🔁 Repeat
</button>
{game.status !== 'playing' && (
<button
onClick={() => handleUpdateStatus(game.id, 'playing')}
@@ -839,6 +1489,7 @@ function SessionInfo({ sessionId, onGamesUpdate }) {
</div>
)}
</div>
</>
);
}

View File

@@ -1,5 +1,5 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
export default {
darkMode: 'class',
content: [
"./index.html",

View File

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

View 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
View 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()
}

View 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
View 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
View 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
View 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
View 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
View 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);
});

View 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

File diff suppressed because it is too large Load Diff

16
scripts/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,146 @@
#!/usr/bin/env node
/**
* Test script for session.ended WebSocket event
*
* This script:
* 1. Connects to the WebSocket server
* 2. Authenticates with a JWT token
* 3. Subscribes to a session
* 4. Listens for session.ended events
*
* Usage:
* node test-session-end-websocket.js <session_id> <jwt_token>
*
* Example:
* node test-session-end-websocket.js 17 your-jwt-token-here
*/
const WebSocket = require('ws');
// Configuration
const WS_URL = process.env.WS_URL || 'ws://localhost:5000/api/sessions/live';
const SESSION_ID = process.argv[2] || '17';
const JWT_TOKEN = process.argv[3];
if (!JWT_TOKEN) {
console.error('❌ Error: JWT token is required');
console.error('Usage: node test-session-end-websocket.js <session_id> <jwt_token>');
process.exit(1);
}
console.log('🚀 Testing session.ended WebSocket event');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(`📡 Connecting to: ${WS_URL}`);
console.log(`🎮 Session ID: ${SESSION_ID}`);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
// Create WebSocket connection
const ws = new WebSocket(WS_URL);
ws.on('open', () => {
console.log('✅ Connected to WebSocket server\n');
// Step 1: Authenticate
console.log('🔐 Authenticating...');
ws.send(JSON.stringify({
type: 'auth',
token: JWT_TOKEN
}));
});
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
switch (message.type) {
case 'auth_success':
console.log('✅ Authentication successful\n');
// Step 2: Subscribe to session
console.log(`📻 Subscribing to session ${SESSION_ID}...`);
ws.send(JSON.stringify({
type: 'subscribe',
sessionId: parseInt(SESSION_ID)
}));
break;
case 'subscribed':
console.log(`✅ Subscribed to session ${message.sessionId}\n`);
console.log('👂 Listening for session.ended events...');
console.log(' (Close the session in the Picker to trigger the event)\n');
break;
case 'session.ended':
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('🎉 SESSION.ENDED EVENT RECEIVED!');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('\n📦 Event Data:');
console.log(JSON.stringify(message, null, 2));
console.log('\n✨ Event Details:');
console.log(` Session ID: ${message.data.session.id}`);
console.log(` Active: ${message.data.session.is_active === 1 ? 'Yes' : 'No'}`);
console.log(` Games Played: ${message.data.session.games_played}`);
console.log(` Timestamp: ${message.timestamp}`);
console.log('\n✅ Test successful! The bot should now announce the session end.\n');
// Close connection after receiving the event
setTimeout(() => {
console.log('👋 Closing connection...');
ws.close();
}, 1000);
break;
case 'auth_error':
case 'error':
console.error(`❌ Error: ${message.message}`);
ws.close();
process.exit(1);
break;
case 'pong':
// Ignore pong messages
break;
default:
console.log(`📨 Received message: ${message.type}`);
console.log(JSON.stringify(message, null, 2));
}
} catch (err) {
console.error('❌ Failed to parse message:', err);
console.error('Raw data:', data.toString());
}
});
ws.on('error', (err) => {
console.error('❌ WebSocket error:', err.message);
process.exit(1);
});
ws.on('close', (code, reason) => {
console.log(`\n🔌 Connection closed (code: ${code})`);
if (reason) {
console.log(` Reason: ${reason}`);
}
process.exit(0);
});
// Handle Ctrl+C gracefully
process.on('SIGINT', () => {
console.log('\n\n👋 Shutting down...');
ws.close();
process.exit(0);
});
// Send periodic pings to keep connection alive
const pingInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
// Clean up interval on close
ws.on('close', () => {
clearInterval(pingInterval);
});

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

@@ -0,0 +1,182 @@
#!/bin/bash
# Simple Webhook Test Script for macOS
# Uses webhook.site for easy testing
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
API_URL="${API_URL:-https://hso.cottongin.xyz/api}"
echo -e "\n${BLUE}╔════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Webhook Test Script (webhook.site) ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}\n"
# Check if JWT_TOKEN is set
if [ -z "$JWT_TOKEN" ]; then
echo -e "${RED}❌ ERROR: JWT_TOKEN not set!${NC}\n"
echo "Please set your JWT token:"
echo " 1. Get token:"
echo " curl -X POST https://hso.cottongin.xyz/api/auth/login \\"
echo " -H \"Content-Type: application/json\" \\"
echo " -d '{\"key\":\"YOUR_ADMIN_KEY\"}'"
echo ""
echo " 2. Export the token:"
echo " export JWT_TOKEN=\"your_token_here\""
echo ""
echo " 3. Run this script:"
echo " ./test-webhook-simple.sh"
echo ""
exit 1
fi
echo -e "${GREEN}${NC} JWT_TOKEN is set"
echo -e "${GREEN}${NC} API URL: $API_URL"
echo ""
# Get a webhook.site URL
echo -e "${BLUE}📡 Getting webhook.site URL...${NC}"
WEBHOOK_RESPONSE=$(curl -s -X POST https://webhook.site/token)
WEBHOOK_UUID=$(echo "$WEBHOOK_RESPONSE" | grep -o '"uuid":"[^"]*' | cut -d'"' -f4)
if [ -z "$WEBHOOK_UUID" ]; then
echo -e "${RED}❌ Failed to get webhook.site URL${NC}"
exit 1
fi
WEBHOOK_URL="https://webhook.site/$WEBHOOK_UUID"
WEBHOOK_SECRET="test_secret_$(date +%s)"
echo -e "${GREEN}${NC} Webhook URL: $WEBHOOK_URL"
echo -e "${GREEN}${NC} View webhooks at: $WEBHOOK_URL"
echo -e "${GREEN}${NC} Secret: $WEBHOOK_SECRET"
echo ""
# Cleanup function
cleanup() {
echo ""
echo -e "${YELLOW}🧹 Cleaning up...${NC}"
if [ -n "$WEBHOOK_ID" ]; then
echo " Deleting webhook $WEBHOOK_ID..."
DELETE_RESPONSE=$(curl -s -X DELETE \
"$API_URL/webhooks/$WEBHOOK_ID" \
-H "Authorization: Bearer $JWT_TOKEN")
if echo "$DELETE_RESPONSE" | grep -q "deleted successfully"; then
echo -e " ${GREEN}${NC} Webhook deleted"
else
echo -e " ${YELLOW}${NC} Could not delete webhook (you may need to delete it manually)"
fi
fi
echo -e " ${GREEN}${NC} Cleanup complete"
echo ""
echo -e "${BLUE}👋 Goodbye!${NC}\n"
exit 0
}
# Trap Ctrl+C
trap cleanup SIGINT SIGTERM
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE}Starting Webhook Tests${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}\n"
# Test 1: Create webhook
echo -e "${YELLOW}📝 Test 1: Creating webhook...${NC}"
CREATE_RESPONSE=$(curl -s -X POST \
"$API_URL/webhooks" \
-H "Authorization: Bearer $JWT_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"Test Webhook\",
\"url\": \"$WEBHOOK_URL\",
\"secret\": \"$WEBHOOK_SECRET\",
\"events\": [\"game.added\"]
}")
if echo "$CREATE_RESPONSE" | grep -q '"id"'; then
WEBHOOK_ID=$(echo "$CREATE_RESPONSE" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
echo -e "${GREEN}${NC} Webhook created with ID: $WEBHOOK_ID"
else
echo -e "${RED}${NC} Failed to create webhook"
echo " Response: $CREATE_RESPONSE"
exit 1
fi
echo ""
# Test 2: List webhooks
echo -e "${YELLOW}📝 Test 2: Listing webhooks...${NC}"
LIST_RESPONSE=$(curl -s "$API_URL/webhooks" \
-H "Authorization: Bearer $JWT_TOKEN")
WEBHOOK_COUNT=$(echo "$LIST_RESPONSE" | grep -o '"id":' | wc -l | tr -d ' ')
echo -e "${GREEN}${NC} Found $WEBHOOK_COUNT webhook(s)"
echo ""
# Test 3: Send test webhook
echo -e "${YELLOW}📝 Test 3: Sending test webhook...${NC}"
TEST_RESPONSE=$(curl -s -X POST \
"$API_URL/webhooks/test/$WEBHOOK_ID" \
-H "Authorization: Bearer $JWT_TOKEN")
if echo "$TEST_RESPONSE" | grep -q "Test webhook sent"; then
echo -e "${GREEN}${NC} Test webhook sent"
else
echo -e "${RED}${NC} Failed to send test webhook"
echo " Response: $TEST_RESPONSE"
fi
echo ""
# Wait for webhook delivery
echo -e "${YELLOW}⏳ Waiting for webhook delivery (3 seconds)...${NC}"
sleep 3
echo ""
# Test 4: Check webhook logs
echo -e "${YELLOW}📝 Test 4: Checking webhook logs...${NC}"
LOGS_RESPONSE=$(curl -s "$API_URL/webhooks/$WEBHOOK_ID/logs?limit=10" \
-H "Authorization: Bearer $JWT_TOKEN")
LOG_COUNT=$(echo "$LOGS_RESPONSE" | grep -o '"id":' | wc -l | tr -d ' ')
echo -e "${GREEN}${NC} Found $LOG_COUNT log entries"
if [ "$LOG_COUNT" -gt 0 ]; then
echo ""
echo "Recent webhook deliveries:"
echo "$LOGS_RESPONSE" | python3 -c "import sys, json; print(json.dumps(json.load(sys.stdin), indent=2))" 2>/dev/null || echo "$LOGS_RESPONSE"
fi
echo ""
# Summary
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE}Test Summary${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${GREEN}${NC} Webhook created: ID $WEBHOOK_ID"
echo -e "${GREEN}${NC} Test webhook sent"
echo -e "${GREEN}${NC} Webhook logs: $LOG_COUNT entries"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}\n"
echo -e "${GREEN}🎉 All tests completed!${NC}"
echo ""
echo -e "${BLUE}💡 Next steps:${NC}"
echo " 1. Visit $WEBHOOK_URL to see webhook deliveries"
echo " 2. Add a game to an active session in the Picker page"
echo " 3. Refresh webhook.site to see the real webhook"
echo " 4. Press Ctrl+C to cleanup and exit"
echo ""
echo -e "${YELLOW}⏳ Press Ctrl+C when done to cleanup...${NC}"
echo ""
# Wait for Ctrl+C
while true; do
sleep 1
done

294
tests/test-webhook.js Normal file
View File

@@ -0,0 +1,294 @@
#!/usr/bin/env node
/**
* Webhook Test Script
*
* This script creates a local webhook receiver and tests the webhook system.
*
* Usage:
* 1. Start your backend server (docker-compose up or npm run dev)
* 2. Run this script: node test-webhook.js
* 3. The script will:
* - Start a local webhook receiver on port 3001
* - Create a webhook in the API
* - Send a test webhook
* - Wait for incoming webhooks
*/
const express = require('express');
const crypto = require('crypto');
const fetch = require('node-fetch');
// Configuration
const API_URL = process.env.API_URL || 'http://localhost:5000';
const WEBHOOK_PORT = process.env.WEBHOOK_PORT || 3001;
const WEBHOOK_SECRET = 'test_secret_' + Math.random().toString(36).substring(7);
// You need to set this - get it from: curl -X POST http://localhost:5000/api/auth/login -H "Content-Type: application/json" -d '{"key":"YOUR_ADMIN_KEY"}'
const JWT_TOKEN = process.env.JWT_TOKEN || '';
if (!JWT_TOKEN) {
console.error('\n❌ ERROR: JWT_TOKEN not set!');
console.error('\nPlease set your JWT token:');
console.error(' 1. Get token: curl -X POST http://localhost:5000/api/auth/login -H "Content-Type: application/json" -d \'{"key":"YOUR_ADMIN_KEY"}\'');
console.error(' 2. Run: JWT_TOKEN="your_token_here" node test-webhook.js');
console.error(' OR: export JWT_TOKEN="your_token_here" && node test-webhook.js\n');
process.exit(1);
}
let webhookId = null;
let receivedWebhooks = [];
// Create Express app for webhook receiver
const app = express();
// Important: Parse JSON and keep raw body for signature verification
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString('utf8');
}
}));
// Webhook receiver endpoint
app.post('/webhook/jackbox', (req, res) => {
console.log('\n📨 Webhook received!');
console.log('Headers:', {
'x-webhook-signature': req.headers['x-webhook-signature'],
'x-webhook-event': req.headers['x-webhook-event'],
'user-agent': req.headers['user-agent']
});
const signature = req.headers['x-webhook-signature'];
// Verify signature
if (!signature || !signature.startsWith('sha256=')) {
console.log('❌ Missing or invalid signature format');
return res.status(401).send('Missing or invalid signature');
}
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(req.rawBody)
.digest('hex');
// Timing-safe comparison
let isValid = false;
try {
isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
} catch (err) {
console.log('❌ Signature verification failed:', err.message);
return res.status(401).send('Invalid signature');
}
if (!isValid) {
console.log('❌ Signature mismatch!');
console.log(' Expected:', expectedSignature);
console.log(' Received:', signature);
return res.status(401).send('Invalid signature');
}
console.log('✅ Signature verified!');
console.log('\nPayload:', JSON.stringify(req.body, null, 2));
if (req.body.event === 'game.added') {
const game = req.body.data.game;
console.log(`\n🎮 Game Added: ${game.title} from ${game.pack_name}`);
console.log(` Players: ${game.min_players}-${game.max_players}`);
console.log(` Session ID: ${req.body.data.session.id}`);
console.log(` Games Played: ${req.body.data.session.games_played}`);
}
receivedWebhooks.push(req.body);
res.status(200).send('OK');
});
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'ok',
webhooksReceived: receivedWebhooks.length
});
});
// Start webhook receiver
const server = app.listen(WEBHOOK_PORT, async () => {
console.log(`\n🚀 Webhook receiver started on http://localhost:${WEBHOOK_PORT}`);
console.log(`📍 Webhook endpoint: http://localhost:${WEBHOOK_PORT}/webhook/jackbox`);
console.log(`🔐 Secret: ${WEBHOOK_SECRET}\n`);
// Wait a moment for server to be ready
await new Promise(resolve => setTimeout(resolve, 500));
// Run tests
await runTests();
});
async function runTests() {
console.log('═══════════════════════════════════════════════════════');
console.log('Starting Webhook Tests');
console.log('═══════════════════════════════════════════════════════\n');
try {
// Test 1: Create webhook
console.log('📝 Test 1: Creating webhook...');
const createResponse = await fetch(`${API_URL}/api/webhooks`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${JWT_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'Test Webhook',
url: `http://localhost:${WEBHOOK_PORT}/webhook/jackbox`,
secret: WEBHOOK_SECRET,
events: ['game.added']
})
});
if (!createResponse.ok) {
const error = await createResponse.text();
throw new Error(`Failed to create webhook: ${createResponse.status} ${error}`);
}
const webhook = await createResponse.json();
webhookId = webhook.id;
console.log(`✅ Webhook created with ID: ${webhookId}\n`);
// Test 2: List webhooks
console.log('📝 Test 2: Listing webhooks...');
const listResponse = await fetch(`${API_URL}/api/webhooks`, {
headers: {
'Authorization': `Bearer ${JWT_TOKEN}`
}
});
if (!listResponse.ok) {
throw new Error(`Failed to list webhooks: ${listResponse.status}`);
}
const webhooks = await listResponse.json();
console.log(`✅ Found ${webhooks.length} webhook(s)\n`);
// Test 3: Send test webhook
console.log('📝 Test 3: Sending test webhook...');
const testResponse = await fetch(`${API_URL}/api/webhooks/test/${webhookId}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${JWT_TOKEN}`
}
});
if (!testResponse.ok) {
throw new Error(`Failed to send test webhook: ${testResponse.status}`);
}
console.log('✅ Test webhook sent\n');
// Wait for webhook to be received
console.log('⏳ Waiting for webhook delivery (5 seconds)...');
await new Promise(resolve => setTimeout(resolve, 5000));
if (receivedWebhooks.length > 0) {
console.log(`✅ Received ${receivedWebhooks.length} webhook(s)!\n`);
} else {
console.log('⚠️ No webhooks received yet. Check webhook logs.\n');
}
// Test 4: Check webhook logs
console.log('📝 Test 4: Checking webhook logs...');
const logsResponse = await fetch(`${API_URL}/api/webhooks/${webhookId}/logs?limit=10`, {
headers: {
'Authorization': `Bearer ${JWT_TOKEN}`
}
});
if (!logsResponse.ok) {
throw new Error(`Failed to get webhook logs: ${logsResponse.status}`);
}
const logs = await logsResponse.json();
console.log(`✅ Found ${logs.length} log entries\n`);
if (logs.length > 0) {
console.log('Recent webhook deliveries:');
logs.forEach((log, i) => {
console.log(` ${i + 1}. Event: ${log.event_type}, Status: ${log.response_status || 'pending'}, Time: ${log.created_at}`);
if (log.error_message) {
console.log(` Error: ${log.error_message}`);
}
});
console.log('');
}
// Summary
console.log('═══════════════════════════════════════════════════════');
console.log('Test Summary');
console.log('═══════════════════════════════════════════════════════');
console.log(`✅ Webhook created: ID ${webhookId}`);
console.log(`✅ Webhooks received: ${receivedWebhooks.length}`);
console.log(`✅ Webhook logs: ${logs.length} entries`);
console.log('═══════════════════════════════════════════════════════\n');
console.log('🎉 All tests completed!');
console.log('\n💡 Next steps:');
console.log(' 1. Add a game to an active session in the Picker page');
console.log(' 2. Watch for the webhook to be received here');
console.log(' 3. Press Ctrl+C to cleanup and exit\n');
} catch (error) {
console.error('\n❌ Test failed:', error.message);
console.error('\nMake sure:');
console.error(' - Backend server is running (http://localhost:5000)');
console.error(' - JWT_TOKEN is valid');
console.error(' - Port 3001 is available\n');
await cleanup();
process.exit(1);
}
}
async function cleanup() {
console.log('\n🧹 Cleaning up...');
if (webhookId) {
try {
console.log(` Deleting webhook ${webhookId}...`);
const deleteResponse = await fetch(`${API_URL}/api/webhooks/${webhookId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${JWT_TOKEN}`
}
});
if (deleteResponse.ok) {
console.log(' ✅ Webhook deleted');
} else {
console.log(' ⚠️ Could not delete webhook (you may need to delete it manually)');
}
} catch (err) {
console.log(' ⚠️ Error during cleanup:', err.message);
}
}
console.log(' Stopping webhook receiver...');
server.close(() => {
console.log(' ✅ Server stopped');
console.log('\n👋 Goodbye!\n');
process.exit(0);
});
}
// Handle Ctrl+C
process.on('SIGINT', async () => {
console.log('\n\n⚠ Received interrupt signal');
await cleanup();
});
// Handle errors
process.on('uncaughtException', async (err) => {
console.error('\n❌ Uncaught exception:', err);
await cleanup();
});

268
tests/test-webhook.sh Executable file
View File

@@ -0,0 +1,268 @@
#!/bin/bash
# Webhook Test Script (Bash/curl version)
# This script tests the webhook system using only curl and bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
API_URL="${API_URL:-https://hso.cottongin.xyz/api}"
WEBHOOK_PORT="${WEBHOOK_PORT:-3001}"
WEBHOOK_SECRET="test_secret_$(date +%s)"
echo -e "\n${BLUE}╔════════════════════════════════════════════════════╗${NC}"
echo -e "${BLUE}║ Webhook Test Script (curl) ║${NC}"
echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}\n"
# Check if JWT_TOKEN is set
if [ -z "$JWT_TOKEN" ]; then
echo -e "${RED}❌ ERROR: JWT_TOKEN not set!${NC}\n"
echo "Please set your JWT token:"
echo " 1. Get token:"
echo " curl -X POST https://hso.cottongin.xyz/api/auth/login \\"
echo " -H \"Content-Type: application/json\" \\"
echo " -d '{\"key\":\"YOUR_ADMIN_KEY\"}'"
echo ""
echo " 2. Export the token:"
echo " export JWT_TOKEN=\"your_token_here\""
echo ""
echo " 3. Run this script:"
echo " ./test-webhook.sh"
echo ""
exit 1
fi
# Check if nc (netcat) is available
if ! command -v nc &> /dev/null; then
echo -e "${RED}❌ ERROR: 'nc' (netcat) command not found!${NC}"
echo "Please install netcat to run this test."
exit 1
fi
echo -e "${GREEN}${NC} JWT_TOKEN is set"
echo -e "${GREEN}${NC} API URL: $API_URL"
echo -e "${GREEN}${NC} Webhook Port: $WEBHOOK_PORT"
echo -e "${GREEN}${NC} Webhook Secret: $WEBHOOK_SECRET"
echo ""
# Start a simple webhook receiver in the background
echo -e "${BLUE}🚀 Starting webhook receiver on port $WEBHOOK_PORT...${NC}"
# Create a named pipe for communication
FIFO="/tmp/webhook_test_$$"
mkfifo "$FIFO"
# Simple HTTP server using netcat
(
while true; do
{
# Read the request
read -r REQUEST
# Read headers until empty line
CONTENT_LENGTH=0
SIGNATURE=""
EVENT=""
while read -r HEADER; do
HEADER=$(echo "$HEADER" | tr -d '\r')
[ -z "$HEADER" ] && break
if [[ "$HEADER" =~ ^Content-Length:\ ([0-9]+) ]]; then
CONTENT_LENGTH="${BASH_REMATCH[1]}"
fi
if [[ "$HEADER" =~ ^X-Webhook-Signature:\ (.+) ]]; then
SIGNATURE="${BASH_REMATCH[1]}"
fi
if [[ "$HEADER" =~ ^X-Webhook-Event:\ (.+) ]]; then
EVENT="${BASH_REMATCH[1]}"
fi
done
# Read body if Content-Length is set
BODY=""
if [ "$CONTENT_LENGTH" -gt 0 ]; then
BODY=$(dd bs=1 count="$CONTENT_LENGTH" 2>/dev/null)
fi
# Log the webhook
if [ -n "$BODY" ]; then
echo "" >> "$FIFO"
echo "📨 Webhook received!" >> "$FIFO"
echo " Event: $EVENT" >> "$FIFO"
echo " Signature: $SIGNATURE" >> "$FIFO"
echo " Body: $BODY" >> "$FIFO"
echo "" >> "$FIFO"
fi
# Send response
echo "HTTP/1.1 200 OK"
echo "Content-Type: text/plain"
echo "Content-Length: 2"
echo ""
echo "OK"
} | nc -l -p "$WEBHOOK_PORT" -q 1
done
) &
WEBHOOK_PID=$!
# Display webhook output in background
tail -f "$FIFO" &
TAIL_PID=$!
# Give the server a moment to start
sleep 2
echo -e "${GREEN}${NC} Webhook receiver started (PID: $WEBHOOK_PID)"
echo -e "${GREEN}${NC} Listening on http://localhost:$WEBHOOK_PORT/webhook/jackbox"
echo ""
# Cleanup function
cleanup() {
echo ""
echo -e "${YELLOW}🧹 Cleaning up...${NC}"
if [ -n "$WEBHOOK_ID" ]; then
echo " Deleting webhook $WEBHOOK_ID..."
DELETE_RESPONSE=$(curl -s -w "\n%{http_code}" -X DELETE \
"$API_URL/api/webhooks/$WEBHOOK_ID" \
-H "Authorization: Bearer $JWT_TOKEN")
DELETE_CODE=$(echo "$DELETE_RESPONSE" | tail -n1)
if [ "$DELETE_CODE" = "200" ]; then
echo -e " ${GREEN}${NC} Webhook deleted"
else
echo -e " ${YELLOW}${NC} Could not delete webhook (you may need to delete it manually)"
fi
fi
echo " Stopping webhook receiver..."
kill $WEBHOOK_PID 2>/dev/null || true
kill $TAIL_PID 2>/dev/null || true
rm -f "$FIFO"
echo -e " ${GREEN}${NC} Cleanup complete"
echo ""
echo -e "${BLUE}👋 Goodbye!${NC}\n"
exit 0
}
# Trap Ctrl+C
trap cleanup SIGINT SIGTERM
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE}Starting Webhook Tests${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}\n"
# Test 1: Create webhook
echo -e "${YELLOW}📝 Test 1: Creating webhook...${NC}"
CREATE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
"$API_URL/api/webhooks" \
-H "Authorization: Bearer $JWT_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"Test Webhook\",
\"url\": \"http://host.docker.internal:$WEBHOOK_PORT/webhook/jackbox\",
\"secret\": \"$WEBHOOK_SECRET\",
\"events\": [\"game.added\"]
}")
CREATE_CODE=$(echo "$CREATE_RESPONSE" | tail -n1)
CREATE_BODY=$(echo "$CREATE_RESPONSE" | head -n-1)
if [ "$CREATE_CODE" = "201" ]; then
WEBHOOK_ID=$(echo "$CREATE_BODY" | grep -o '"id":[0-9]*' | grep -o '[0-9]*')
echo -e "${GREEN}${NC} Webhook created with ID: $WEBHOOK_ID"
echo " Response: $CREATE_BODY"
else
echo -e "${RED}${NC} Failed to create webhook (HTTP $CREATE_CODE)"
echo " Response: $CREATE_BODY"
cleanup
fi
echo ""
# Test 2: List webhooks
echo -e "${YELLOW}📝 Test 2: Listing webhooks...${NC}"
LIST_RESPONSE=$(curl -s -w "\n%{http_code}" \
"$API_URL/api/webhooks" \
-H "Authorization: Bearer $JWT_TOKEN")
LIST_CODE=$(echo "$LIST_RESPONSE" | tail -n1)
LIST_BODY=$(echo "$LIST_RESPONSE" | head -n-1)
if [ "$LIST_CODE" = "200" ]; then
WEBHOOK_COUNT=$(echo "$LIST_BODY" | grep -o '"id":' | wc -l)
echo -e "${GREEN}${NC} Found $WEBHOOK_COUNT webhook(s)"
else
echo -e "${RED}${NC} Failed to list webhooks (HTTP $LIST_CODE)"
fi
echo ""
# Test 3: Send test webhook
echo -e "${YELLOW}📝 Test 3: Sending test webhook...${NC}"
TEST_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
"$API_URL/api/webhooks/test/$WEBHOOK_ID" \
-H "Authorization: Bearer $JWT_TOKEN")
TEST_CODE=$(echo "$TEST_RESPONSE" | tail -n1)
if [ "$TEST_CODE" = "200" ]; then
echo -e "${GREEN}${NC} Test webhook sent"
else
echo -e "${RED}${NC} Failed to send test webhook (HTTP $TEST_CODE)"
fi
echo ""
# Wait for webhook delivery
echo -e "${YELLOW}⏳ Waiting for webhook delivery (5 seconds)...${NC}"
sleep 5
echo ""
# Test 4: Check webhook logs
echo -e "${YELLOW}📝 Test 4: Checking webhook logs...${NC}"
LOGS_RESPONSE=$(curl -s -w "\n%{http_code}" \
"$API_URL/api/webhooks/$WEBHOOK_ID/logs?limit=10" \
-H "Authorization: Bearer $JWT_TOKEN")
LOGS_CODE=$(echo "$LOGS_RESPONSE" | tail -n1)
LOGS_BODY=$(echo "$LOGS_RESPONSE" | head -n-1)
if [ "$LOGS_CODE" = "200" ]; then
LOG_COUNT=$(echo "$LOGS_BODY" | grep -o '"id":' | wc -l)
echo -e "${GREEN}${NC} Found $LOG_COUNT log entries"
echo ""
echo "Recent webhook deliveries:"
echo "$LOGS_BODY" | python3 -m json.tool 2>/dev/null || echo "$LOGS_BODY"
else
echo -e "${RED}${NC} Failed to get webhook logs (HTTP $LOGS_CODE)"
fi
echo ""
# Summary
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${BLUE}Test Summary${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}"
echo -e "${GREEN}${NC} Webhook created: ID $WEBHOOK_ID"
echo -e "${GREEN}${NC} Test webhook sent"
echo -e "${GREEN}${NC} Webhook logs: $LOG_COUNT entries"
echo -e "${BLUE}═══════════════════════════════════════════════════════${NC}\n"
echo -e "${GREEN}🎉 All tests completed!${NC}"
echo ""
echo -e "${BLUE}💡 Next steps:${NC}"
echo " 1. Add a game to an active session in the Picker page"
echo " 2. Watch for the webhook to be received above"
echo " 3. Press Ctrl+C to cleanup and exit"
echo ""
echo -e "${YELLOW}⏳ Webhook receiver is still running...${NC}"
echo ""
# Keep running until Ctrl+C
wait $WEBHOOK_PID