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 voteDebounceTimer *time.Timer voteDebounceDelay time.Duration } // 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"` } // 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, }, voteDebounceDelay: 3 * time.Second, // Wait 3 seconds after last vote before broadcasting } } // 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 // Stop any pending debounce timer if c.voteDebounceTimer != nil { c.voteDebounceTimer.Stop() c.voteDebounceTimer = 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) // Debounce vote broadcasts - wait for activity to settle c.debouncedVoteBroadcast(&voteResp) 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 } // debouncedVoteBroadcast implements debouncing for vote broadcasts // When there's an active session, it stores votes to be announced with the next game // When there's no active session, it uses time-based debouncing (3 seconds) func (c *Client) debouncedVoteBroadcast(voteResp *VoteResponse) { c.mu.Lock() defer c.mu.Unlock() // Store the latest vote response c.lastVoteResponse = voteResp // If there's an active session, just accumulate votes silently // They'll be announced when the next game is picked if c.activeSessionID > 0 { c.log.Debugf("Vote accumulated for %s (session active, will announce with next game)", voteResp.Game.Title) // Cancel any existing timer since we're in session mode if c.voteDebounceTimer != nil { c.voteDebounceTimer.Stop() c.voteDebounceTimer = nil } return } // No active session - use time-based debouncing // If there's an existing timer, stop it if c.voteDebounceTimer != nil { c.voteDebounceTimer.Stop() } // Create a new timer that will fire after the debounce delay c.voteDebounceTimer = time.AfterFunc(c.voteDebounceDelay, func() { c.mu.Lock() lastResp := c.lastVoteResponse c.lastVoteResponse = nil c.mu.Unlock() if lastResp != nil { // Broadcast the final vote result message := fmt.Sprintf("🗳️ Voting complete for %s • %d👍 %d👎 (Score: %d)", lastResp.Game.Title, lastResp.Game.Upvotes, lastResp.Game.Downvotes, lastResp.Game.PopularityScore) c.broadcastMessage(message) c.log.Infof("Broadcast final vote result: %s - %d👍 %d👎", lastResp.Game.Title, lastResp.Game.Upvotes, lastResp.Game.Downvotes) } }) }