Files
IRC-kosmi-relay/bridge/jackbox/client.go
cottongin 88cc140087 Add !votes command, fix vote tally timing, and improve Kosmi stability
- Add !votes command (IRC + Kosmi) showing per-session and all-time vote
  breakdowns for the current game via new Jackbox API endpoints
  (GET sessions/{id}/games, sessions/{id}/votes, games/{id})
- Fix vote tally broadcasting: remove debounce timer, announce tallies
  only at game transitions or session end instead of after every vote
- Add !kreconnect IRC command to manually trigger Kosmi reconnection
- Add WebSocket ping/pong keepalive and write mutex to Kosmi client
  for connection stability
- Add watchConnection() auto-reconnect on unexpected Kosmi disconnects
- Remove old 2025-10-31 chat summaries; add votes command design doc

Made-with: Cursor
2026-03-16 20:56:18 -04:00

560 lines
15 KiB
Go

package jackbox
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/sirupsen/logrus"
)
// Client handles communication with the Jackbox Game Picker API
type Client struct {
apiURL string
adminPassword string
token string
tokenExpiry time.Time
mu sync.RWMutex
log *logrus.Entry
httpClient *http.Client
messageCallback func(string)
// Vote tracking
activeSessionID int
lastVoteResponse *VoteResponse
}
// AuthResponse represents the authentication response from the API
type AuthResponse struct {
Token string `json:"token"`
}
// VoteRequest represents a vote submission to the API
type VoteRequest struct {
Username string `json:"username"`
Vote string `json:"vote"` // "up" or "down"
Timestamp string `json:"timestamp"`
}
// VoteResponse represents the API response to a vote submission
type VoteResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Game struct {
Title string `json:"title"`
Upvotes int `json:"upvotes"`
Downvotes int `json:"downvotes"`
PopularityScore int `json:"popularity_score"`
} `json:"game"`
}
// Session represents an active Jackbox session
type Session struct {
ID int `json:"id"`
IsActive int `json:"is_active"` // API returns 1 for active, 0 for inactive
GamesPlayed int `json:"games_played"`
CreatedAt string `json:"created_at"`
}
// SessionResponse represents the API response for session queries
type SessionResponse struct {
Session *Session `json:"session"`
}
// SessionGame represents a game within a session
type SessionGame struct {
ID int `json:"id"`
GameID int `json:"game_id"`
Title string `json:"title"`
PackName string `json:"pack_name"`
Status string `json:"status"`
RoomCode string `json:"room_code"`
}
// SessionVotesResponse represents the per-game vote breakdown for a session
type SessionVotesResponse struct {
SessionID int `json:"session_id"`
Votes []GameVoteSummary `json:"votes"`
}
// GameVoteSummary represents aggregated vote data for a single game in a session
type GameVoteSummary struct {
GameID int `json:"game_id"`
Title string `json:"title"`
PackName string `json:"pack_name"`
Upvotes int `json:"upvotes"`
Downvotes int `json:"downvotes"`
NetScore int `json:"net_score"`
TotalVotes int `json:"total_votes"`
}
// Game represents a game from the catalog
type Game struct {
ID int `json:"id"`
Title string `json:"title"`
PackName string `json:"pack_name"`
PopularityScore int `json:"popularity_score"`
Upvotes int `json:"upvotes"`
Downvotes int `json:"downvotes"`
PlayCount int `json:"play_count"`
}
// NewClient creates a new Jackbox API client
func NewClient(apiURL, adminPassword string, log *logrus.Entry) *Client {
return &Client{
apiURL: apiURL,
adminPassword: adminPassword,
log: log,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// SetMessageCallback sets the callback function for broadcasting messages
func (c *Client) SetMessageCallback(callback func(string)) {
c.mu.Lock()
defer c.mu.Unlock()
c.messageCallback = callback
}
// SetActiveSession sets the active session ID for vote tracking
func (c *Client) SetActiveSession(sessionID int) {
c.mu.Lock()
defer c.mu.Unlock()
c.activeSessionID = sessionID
c.log.Infof("Active session set to %d", sessionID)
}
// GetAndClearLastVoteResponse returns the last vote response and clears it
func (c *Client) GetAndClearLastVoteResponse() *VoteResponse {
c.mu.Lock()
defer c.mu.Unlock()
resp := c.lastVoteResponse
c.lastVoteResponse = nil
return resp
}
// broadcastMessage sends a message via the callback if set
func (c *Client) broadcastMessage(message string) {
c.mu.RLock()
callback := c.messageCallback
c.mu.RUnlock()
if callback != nil {
callback(message)
}
}
// Authenticate obtains a JWT token from the API using admin password
func (c *Client) Authenticate() error {
c.mu.Lock()
defer c.mu.Unlock()
c.log.Debug("Authenticating with Jackbox API...")
// Prepare authentication request
authReq := map[string]string{
"key": c.adminPassword,
}
jsonBody, err := json.Marshal(authReq)
if err != nil {
return fmt.Errorf("failed to marshal auth request: %w", err)
}
// Send authentication request
req, err := http.NewRequest("POST", c.apiURL+"/api/auth/login", bytes.NewReader(jsonBody))
if err != nil {
return fmt.Errorf("failed to create auth request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send auth request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("authentication failed with status %d: %s", resp.StatusCode, string(body))
}
// Parse response
var authResp AuthResponse
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
return fmt.Errorf("failed to parse auth response: %w", err)
}
c.token = authResp.Token
// Assume token is valid for 24 hours (adjust based on actual API behavior)
c.tokenExpiry = time.Now().Add(24 * time.Hour)
c.log.Info("Successfully authenticated with Jackbox API")
return nil
}
// ensureAuthenticated checks if we have a valid token and authenticates if needed
func (c *Client) ensureAuthenticated() error {
c.mu.RLock()
hasValidToken := c.token != "" && time.Now().Before(c.tokenExpiry)
c.mu.RUnlock()
if hasValidToken {
return nil
}
return c.Authenticate()
}
// SendVote sends a vote to the Jackbox API
func (c *Client) SendVote(username, voteType string, timestamp time.Time) error {
// Ensure we're authenticated
if err := c.ensureAuthenticated(); err != nil {
return fmt.Errorf("authentication failed: %w", err)
}
c.mu.RLock()
token := c.token
c.mu.RUnlock()
// Prepare vote request
voteReq := VoteRequest{
Username: username,
Vote: voteType,
Timestamp: timestamp.Format(time.RFC3339),
}
jsonBody, err := json.Marshal(voteReq)
if err != nil {
return fmt.Errorf("failed to marshal vote request: %w", err)
}
// Send vote request
req, err := http.NewRequest("POST", c.apiURL+"/api/votes/live", bytes.NewReader(jsonBody))
if err != nil {
return fmt.Errorf("failed to create vote request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send vote request: %w", err)
}
defer resp.Body.Close()
// Read response body
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read vote response: %w", err)
}
// Check response status
if resp.StatusCode == http.StatusUnauthorized {
// Token expired, try to re-authenticate
c.log.Warn("Token expired, re-authenticating...")
if err := c.Authenticate(); err != nil {
return fmt.Errorf("re-authentication failed: %w", err)
}
// Retry the vote
return c.SendVote(username, voteType, timestamp)
}
if resp.StatusCode == http.StatusConflict {
// Duplicate vote - this is expected, just log it
c.log.Debugf("Duplicate vote from %s (within 1 second)", username)
return nil
}
if resp.StatusCode == http.StatusNotFound {
// No active session or timestamp doesn't match any game
c.log.Debug("Vote rejected: no active session or timestamp doesn't match any game")
return nil
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("vote failed with status %d: %s", resp.StatusCode, string(body))
}
// Parse successful response
var voteResp VoteResponse
if err := json.Unmarshal(body, &voteResp); err != nil {
c.log.Warnf("Failed to parse vote response: %v", err)
return nil // Don't fail if we can't parse the response
}
c.log.Debugf("Vote recorded for %s: %s - %d👍 %d👎",
voteResp.Game.Title, username, voteResp.Game.Upvotes, voteResp.Game.Downvotes)
// Accumulate vote; tally announced at game change or session end
c.storeVoteResponse(&voteResp)
// If local session tracking is stale, sync from the API.
// A successful vote means the API has an active session.
c.mu.RLock()
sessionID := c.activeSessionID
c.mu.RUnlock()
if sessionID == 0 {
go func() {
if session, err := c.GetActiveSession(); err == nil && session != nil {
c.SetActiveSession(session.ID)
}
}()
}
return nil
}
// GetToken returns the current JWT token
func (c *Client) GetToken() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.token
}
// GetActiveSession retrieves the currently active session from the API
func (c *Client) GetActiveSession() (*Session, error) {
// Ensure we're authenticated
if err := c.ensureAuthenticated(); err != nil {
return nil, fmt.Errorf("authentication failed: %w", err)
}
c.mu.RLock()
token := c.token
c.mu.RUnlock()
// Create request to get active session
req, err := http.NewRequest("GET", c.apiURL+"/api/sessions/active", nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
// Read body for debugging
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("failed to read response body: %w", readErr)
}
c.log.Infof("GetActiveSession: GET %s/api/sessions/active returned %d", c.apiURL, resp.StatusCode)
c.log.Infof("GetActiveSession response body: %s", string(body))
// Handle 404 - no active session
if resp.StatusCode == http.StatusNotFound {
c.log.Info("API returned 404 - endpoint may not exist or no active session")
return nil, nil
}
// Handle 401 - token expired
if resp.StatusCode == http.StatusUnauthorized {
c.log.Warn("Token expired, re-authenticating...")
if err := c.Authenticate(); err != nil {
return nil, fmt.Errorf("re-authentication failed: %w", err)
}
// Retry the request
return c.GetActiveSession()
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}
// Try to parse as direct session object first
var session Session
if err := json.Unmarshal(body, &session); err != nil {
c.log.Errorf("Failed to parse session response: %v, body: %s", err, string(body))
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Check if we got a valid session (ID > 0 means it's valid)
if session.ID == 0 {
c.log.Info("No active session (ID is 0)")
return nil, nil
}
c.log.Infof("Parsed session: ID=%d, IsActive=%v, GamesPlayed=%d", session.ID, session.IsActive, session.GamesPlayed)
return &session, nil
}
// storeVoteResponse accumulates the latest vote response silently.
// The tally is announced later by handleGameAdded or AnnounceSessionEnd
// via GetAndClearLastVoteResponse.
func (c *Client) storeVoteResponse(voteResp *VoteResponse) {
c.mu.Lock()
defer c.mu.Unlock()
c.lastVoteResponse = voteResp
c.log.Debugf("Vote accumulated for %s (will announce at game change or session end)", voteResp.Game.Title)
}
// GetSessionGames retrieves the list of games in a session
func (c *Client) GetSessionGames(sessionID int) ([]SessionGame, error) {
if err := c.ensureAuthenticated(); err != nil {
return nil, fmt.Errorf("authentication failed: %w", err)
}
c.mu.RLock()
token := c.token
c.mu.RUnlock()
url := fmt.Sprintf("%s/api/sessions/%d/games", c.apiURL, sessionID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode == http.StatusUnauthorized {
c.log.Warn("Token expired, re-authenticating...")
if err := c.Authenticate(); err != nil {
return nil, fmt.Errorf("re-authentication failed: %w", err)
}
return c.GetSessionGames(sessionID)
}
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}
var games []SessionGame
if err := json.Unmarshal(body, &games); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return games, nil
}
// GetSessionVotes retrieves the per-game vote breakdown for a session
func (c *Client) GetSessionVotes(sessionID int) (*SessionVotesResponse, error) {
if err := c.ensureAuthenticated(); err != nil {
return nil, fmt.Errorf("authentication failed: %w", err)
}
c.mu.RLock()
token := c.token
c.mu.RUnlock()
url := fmt.Sprintf("%s/api/sessions/%d/votes", c.apiURL, sessionID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode == http.StatusUnauthorized {
c.log.Warn("Token expired, re-authenticating...")
if err := c.Authenticate(); err != nil {
return nil, fmt.Errorf("re-authentication failed: %w", err)
}
return c.GetSessionVotes(sessionID)
}
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}
var votesResp SessionVotesResponse
if err := json.Unmarshal(body, &votesResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &votesResp, nil
}
// GetGame retrieves a single game from the catalog by ID
func (c *Client) GetGame(gameID int) (*Game, error) {
if err := c.ensureAuthenticated(); err != nil {
return nil, fmt.Errorf("authentication failed: %w", err)
}
c.mu.RLock()
token := c.token
c.mu.RUnlock()
url := fmt.Sprintf("%s/api/games/%d", c.apiURL, gameID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode == http.StatusUnauthorized {
c.log.Warn("Token expired, re-authenticating...")
if err := c.Authenticate(); err != nil {
return nil, fmt.Errorf("re-authentication failed: %w", err)
}
return c.GetGame(gameID)
}
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}
var game Game
if err := json.Unmarshal(body, &game); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &game, nil
}