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 }