400 lines
11 KiB
Go
400 lines
11 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
|
|
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)
|
|
}
|
|
})
|
|
}
|