Files
IRC-kosmi-relay/bridge/jackbox/client.go
2025-11-01 10:40:53 -04:00

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