wow that took awhile

This commit is contained in:
cottongin
2025-11-01 10:40:53 -04:00
parent 9143a0bc60
commit bd9513b86c
44 changed files with 4484 additions and 76 deletions

Binary file not shown.

399
bridge/jackbox/client.go Normal file
View File

@@ -0,0 +1,399 @@
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)
}
})
}

View File

@@ -0,0 +1,111 @@
package jackbox
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/textproto"
"github.com/sirupsen/logrus"
)
const (
kosmiImageUploadURL = "https://img.kosmi.io/"
)
// ImageUploadResponse represents the response from Kosmi image upload endpoint
type ImageUploadResponse struct {
Filename string `json:"filename"`
}
// UploadImageToKosmi uploads an image to Kosmi's CDN and returns the URL
func UploadImageToKosmi(imageData []byte, filename string) (string, error) {
logrus.WithFields(logrus.Fields{
"filename": filename,
"size": len(imageData),
}).Debug("Uploading image to Kosmi CDN")
// Create multipart form body
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// Add file field with proper MIME type for GIF
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, filename))
h.Set("Content-Type", "image/gif")
logrus.WithFields(logrus.Fields{
"Content-Disposition": h.Get("Content-Disposition"),
"Content-Type": h.Get("Content-Type"),
}).Debug("Creating multipart form with headers")
part, err := writer.CreatePart(h)
if err != nil {
return "", fmt.Errorf("failed to create form file: %w", err)
}
if _, err := part.Write(imageData); err != nil {
return "", fmt.Errorf("failed to write image data: %w", err)
}
logrus.Debugf("Written %d bytes of GIF data to multipart form", len(imageData))
// Close the multipart writer to finalize the body
if err := writer.Close(); err != nil {
return "", fmt.Errorf("failed to close multipart writer: %w", err)
}
// Create HTTP request
req, err := http.NewRequest("POST", kosmiImageUploadURL, body)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
// Set required headers
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Origin", "https://app.kosmi.io")
req.Header.Set("Referer", "https://app.kosmi.io/")
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
// Send request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
// Check status code
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
// Read response body
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
logrus.WithField("response", string(bodyBytes)).Debug("Upload response body")
// Parse response
var result ImageUploadResponse
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return "", fmt.Errorf("failed to parse response: %w (body: %s)", err, string(bodyBytes))
}
if result.Filename == "" {
return "", fmt.Errorf("no filename in response (body: %s)", string(bodyBytes))
}
// Construct the full URL from the filename
imageURL := fmt.Sprintf("https://img.kosmi.io/%s", result.Filename)
logrus.WithField("url", imageURL).Info("Successfully uploaded image to Kosmi CDN")
return imageURL, nil
}

277
bridge/jackbox/manager.go Normal file
View File

@@ -0,0 +1,277 @@
package jackbox
import (
"fmt"
"sync"
"time"
"github.com/42wim/matterbridge/bridge/config"
"github.com/sirupsen/logrus"
)
// Manager handles the Jackbox integration lifecycle
type Manager struct {
client *Client
webhookServer *WebhookServer
wsClient *WebSocketClient
config config.Config
log *logrus.Entry
enabled bool
useWebSocket bool
messageCallback func(string)
muted bool
mu sync.RWMutex
}
// NewManager creates a new Jackbox manager
func NewManager(cfg config.Config, log *logrus.Entry) *Manager {
return &Manager{
config: cfg,
log: log,
}
}
// Initialize sets up the Jackbox client and webhook server or WebSocket client
func (m *Manager) Initialize() error {
// Check if Jackbox integration is enabled
m.enabled = m.config.Viper().GetBool("jackbox.Enabled")
if !m.enabled {
m.log.Info("Jackbox integration is disabled")
return nil
}
m.log.Info("Initializing Jackbox integration...")
// Get configuration values
apiURL := m.config.Viper().GetString("jackbox.APIURL")
adminPassword := m.config.Viper().GetString("jackbox.AdminPassword")
m.useWebSocket = m.config.Viper().GetBool("jackbox.UseWebSocket")
// Validate configuration
if apiURL == "" {
return fmt.Errorf("jackbox.APIURL is required when Jackbox integration is enabled")
}
if adminPassword == "" {
return fmt.Errorf("jackbox.AdminPassword is required when Jackbox integration is enabled")
}
// Create Jackbox API client
m.client = NewClient(apiURL, adminPassword, m.log)
// Authenticate with the API
if err := m.client.Authenticate(); err != nil {
return fmt.Errorf("failed to authenticate with Jackbox API: %w", err)
}
m.log.Info("Jackbox integration initialized successfully")
return nil
}
// StartWebhookServer starts the webhook server with the provided message callback
func (m *Manager) StartWebhookServer(messageCallback func(string)) error {
if !m.enabled {
return nil
}
// Use WebSocket if enabled, otherwise fall back to webhook
if m.useWebSocket {
return m.startWebSocketClient(messageCallback)
}
webhookPort := m.config.Viper().GetInt("jackbox.WebhookPort")
webhookSecret := m.config.Viper().GetString("jackbox.WebhookSecret")
if webhookSecret == "" {
return fmt.Errorf("jackbox.WebhookSecret is required when using webhooks")
}
if webhookPort == 0 {
webhookPort = 3001
}
// Wrap the callback to check mute status
wrappedCallback := func(message string) {
if m.IsMuted() {
m.log.Debugf("Jackbox message suppressed (muted): %s", message)
return
}
messageCallback(message)
}
m.webhookServer = NewWebhookServer(webhookPort, webhookSecret, wrappedCallback, m.log)
return m.webhookServer.Start()
}
// startWebSocketClient starts the WebSocket client connection
func (m *Manager) startWebSocketClient(messageCallback func(string)) error {
apiURL := m.config.Viper().GetString("jackbox.APIURL")
// Store the callback for use in monitoring
m.messageCallback = messageCallback
// Wrap the callback to check mute status
wrappedCallback := func(message string) {
if m.IsMuted() {
m.log.Debugf("Jackbox message suppressed (muted): %s", message)
return
}
messageCallback(message)
}
// Set wrapped callback on client for vote broadcasts
m.client.SetMessageCallback(wrappedCallback)
// Get JWT token from client
token := m.client.GetToken()
if token == "" {
return fmt.Errorf("no JWT token available, authentication may have failed")
}
// Get EnableRoomCodeImage setting from config (defaults to false)
enableRoomCodeImage := m.config.Viper().GetBool("jackbox.EnableRoomCodeImage")
// Create WebSocket client (pass the API client for vote tracking)
m.wsClient = NewWebSocketClient(apiURL, token, wrappedCallback, m.client, enableRoomCodeImage, m.log)
// Connect to WebSocket
if err := m.wsClient.Connect(); err != nil {
return fmt.Errorf("failed to connect WebSocket: %w", err)
}
// Get active session and subscribe
session, err := m.client.GetActiveSession()
if err != nil {
m.log.Warnf("Could not get active session: %v", err)
m.log.Info("WebSocket connected but not subscribed to any session yet")
return nil
}
if session != nil && session.ID > 0 {
if err := m.wsClient.Subscribe(session.ID); err != nil {
m.log.Warnf("Failed to subscribe to session %d: %v", session.ID, err)
}
// Set the active session on the client for vote tracking
m.client.SetActiveSession(session.ID)
// Only announce if this is a NEW session (no games played yet)
// If games have been played, the bot is just reconnecting to an existing session
if session.GamesPlayed == 0 {
announcement := fmt.Sprintf("🎮 Game Night is starting! Session #%d", session.ID)
if messageCallback != nil {
messageCallback(announcement)
}
} else {
m.log.Infof("Reconnected to existing session #%d (%d games already played)", session.ID, session.GamesPlayed)
}
} else {
m.log.Info("No active session found, will subscribe when session becomes active")
// Start a goroutine to periodically check for active sessions
go m.monitorActiveSessions()
}
return nil
}
// monitorActiveSessions periodically checks for active sessions and subscribes
// This is a FALLBACK mechanism - only used when WebSocket events aren't working
// Normally, session.started and session.ended events should handle this
func (m *Manager) monitorActiveSessions() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
wasSubscribed := false
for {
select {
case <-ticker.C:
// Skip polling if WebSocket is disconnected (no point in polling)
if m.wsClient == nil || !m.wsClient.IsConnected() {
m.log.Debug("WebSocket disconnected, skipping session poll")
continue
}
session, err := m.client.GetActiveSession()
if err != nil {
m.log.Debugf("Error checking for active session: %v", err)
continue
}
isSubscribed := m.wsClient.IsSubscribed()
// Check if we need to subscribe to a new session (fallback if session.started wasn't received)
if !isSubscribed && session != nil && session.ID > 0 {
m.log.Warnf("Found active session %d via polling (session.started event may have been missed), subscribing...", session.ID)
if err := m.wsClient.Subscribe(session.ID); err != nil {
m.log.Warnf("Failed to subscribe to session %d: %v", session.ID, err)
} else {
m.client.SetActiveSession(session.ID)
wasSubscribed = true
// Only announce if this is a NEW session (no games played yet)
if session.GamesPlayed == 0 && !m.IsMuted() {
announcement := fmt.Sprintf("🎮 Game Night is starting! Session #%d", session.ID)
if m.messageCallback != nil {
m.messageCallback(announcement)
}
} else if session.GamesPlayed == 0 && m.IsMuted() {
m.log.Debugf("Jackbox message suppressed (muted): 🎮 Game Night is starting! Session #%d", session.ID)
}
}
}
// Check if session ended (fallback if session.ended wasn't received)
if wasSubscribed && (session == nil || session.ID == 0 || session.IsActive == 0) {
m.log.Warn("Active session ended (detected via polling, session.ended event may have been missed)")
if m.wsClient != nil {
m.wsClient.AnnounceSessionEnd()
}
wasSubscribed = false
}
}
}
}
// GetClient returns the Jackbox API client (may be nil if disabled)
func (m *Manager) GetClient() *Client {
return m.client
}
// IsEnabled returns whether Jackbox integration is enabled
func (m *Manager) IsEnabled() bool {
return m.enabled
}
// Shutdown stops the webhook server or WebSocket client
func (m *Manager) Shutdown() error {
if m.wsClient != nil {
if err := m.wsClient.Close(); err != nil {
m.log.Errorf("Error closing WebSocket client: %v", err)
}
}
if m.webhookServer != nil {
return m.webhookServer.Stop()
}
return nil
}
// SetMuted sets the mute state for Jackbox announcements
func (m *Manager) SetMuted(muted bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.muted = muted
}
// ToggleMuted toggles the mute state and returns the new state
func (m *Manager) ToggleMuted() bool {
m.mu.Lock()
defer m.mu.Unlock()
m.muted = !m.muted
return m.muted
}
// IsMuted returns the current mute state
func (m *Manager) IsMuted() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.muted
}

View File

@@ -0,0 +1,453 @@
package jackbox
import (
"bytes"
"embed"
"image"
"image/color"
"image/draw"
"image/gif"
"strings"
"sync"
"time"
"github.com/gonutz/gofont"
)
//go:embed FiraMono-Bold.ttf
var firaMono embed.FS
const (
imageWidth = 300
imageHeight = 300
padding = 15
)
// Color pairs for room codes
var colorPairs = [][2]color.RGBA{
{{0xDD, 0xCC, 0x77, 255}, {0x44, 0xAA, 0x99, 255}}, // a) #DDCC77, #44AA99
{{0xFE, 0xFE, 0x62, 255}, {0xD3, 0x5F, 0xB7, 255}}, // b) #FEFE62, #D35FB7
{{0x64, 0x8F, 0xFF, 255}, {0xFF, 0xB0, 0x00, 255}}, // c) #648FFF, #FFB000
{{229, 212, 232, 255}, {217, 241, 213, 255}}, // d) RGB values
}
// State for color rotation
var (
colorIndex int
letterGetColor bool // true = letters get Color1, false = letters get Color2
colorMutex sync.Mutex
)
func init() {
// Seed with current time to get different starting point each run
now := time.Now()
colorIndex = int(now.Unix()) % len(colorPairs)
letterGetColor = (now.UnixNano() % 2) == 0
}
// GenerateRoomCodeImage creates an animated GIF with the room code and game title
// Black background, colored Fira Mono text
// Colors rotate through predefined pairs and alternate letter/number assignment
func GenerateRoomCodeImage(roomCode, gameTitle string) ([]byte, error) {
// Get and advance color state
colorMutex.Lock()
currentPair := colorPairs[colorIndex]
currentLetterGetColor1 := letterGetColor
// Advance for next call
letterGetColor = !letterGetColor
if !letterGetColor {
// Only advance to next color pair when we've used both orientations
colorIndex = (colorIndex + 1) % len(colorPairs)
}
colorMutex.Unlock()
// Determine which color goes to letters vs numbers
var letterColor, numberColor color.RGBA
if currentLetterGetColor1 {
letterColor = currentPair[0]
numberColor = currentPair[1]
} else {
letterColor = currentPair[1]
numberColor = currentPair[0]
}
// Static text color for game title and labels is always off-white #EEEEEE (hardcoded in drawing code)
// Choose a random color from the pair for the separator
separatorColor := currentPair[0]
if time.Now().UnixNano()%2 == 1 {
separatorColor = currentPair[1]
}
// Load Fira Mono Bold font
fontData, err := firaMono.ReadFile("FiraMono-Bold.ttf")
if err != nil {
return nil, err
}
font, err := gofont.Read(bytes.NewReader(fontData))
if err != nil {
return nil, err
}
black := color.RGBA{0, 0, 0, 255}
// Layout from top to bottom:
// 1. Game title (at top, staticTextColor)
// 2. Room code (center, largest, letterColor/numberColor)
// 3. "Room Code" label (below code, staticTextColor)
// 4. "Jackbox.tv 🎮 coming up next!" (at bottom, staticTextColor)
// Calculate layout from bottom up to maximize room code size
// 4. Bottom text "Jackbox.tv :: coming up next!"
// Split into parts so we can color the separator differently
bottomTextLeft := "Jackbox.tv "
bottomTextSeparator := "::"
bottomTextRight := " coming up next!"
bottomTextSize := 16
font.HeightInPixels = bottomTextSize
// Measure full bottom text for positioning
fullBottomText := bottomTextLeft + bottomTextSeparator + bottomTextRight
bottomTextWidth, bottomTextHeight := font.Measure(fullBottomText)
bottomTextX := (imageWidth - bottomTextWidth) / 2
bottomTextY := imageHeight - 20 - bottomTextHeight
// Calculate positions for each part
leftWidth, _ := font.Measure(bottomTextLeft)
sepWidth, _ := font.Measure(bottomTextSeparator)
// 3. "Room Code" label (above bottom text)
labelText := "^ Room Code ^"
labelSize := 21 // Increased by 5% from 20
font.HeightInPixels = labelSize
labelWidth, labelHeight := font.Measure(labelText)
labelX := (imageWidth - labelWidth) / 2
labelY := bottomTextY - 10 - labelHeight
// 2. Room code in center (largest text)
// Calculate available vertical space for the room code
// We'll reserve space at the top for the game title (calculate after room code)
tempTopMargin := 60 // Temporary estimate for title + spacing
availableTop := tempTopMargin
availableBottom := labelY - 20
availableHeight := availableBottom - availableTop
availableWidth := imageWidth - 60
// Find the largest font size that fits the room code
bestSize := 30
for size := 150; size >= 30; size -= 5 {
font.HeightInPixels = size
width, height := font.Measure(roomCode)
if width <= availableWidth && height <= availableHeight {
bestSize = size
break
}
}
// Calculate actual room code position
font.HeightInPixels = bestSize
_, codeHeight := font.Measure(roomCode)
// Room code is ALWAYS 4 characters, monospace font
// Calculate width of a single character and spread them evenly
singleCharWidth, _ := font.Measure("X") // Use X as reference for monospace width
totalCodeWidth := singleCharWidth * 4
codeX := (imageWidth - totalCodeWidth) / 2
codeY := availableTop + (availableHeight-codeHeight)/2
// 1. Game title at top - find largest font size that fits in remaining space
// Available space is from top of image to top of room code
maxTitleWidth := imageWidth - 40 // Leave 20px padding on each side
maxTitleHeight := codeY - 30 // Space from top (20px) to room code top (with 10px gap)
gameTitleSize := 14
// Helper function to split title into two lines at nearest whitespace to middle
splitTitle := func(title string) (string, string) {
words := strings.Fields(title)
if len(words) <= 1 {
return title, ""
}
// Find the split point closest to the middle
totalLen := len(title)
midPoint := totalLen / 2
bestSplit := 0
bestDist := totalLen
currentLen := 0
for i := 0; i < len(words)-1; i++ {
currentLen += len(words[i]) + 1 // +1 for space
dist := currentLen - midPoint
if dist < 0 {
dist = -dist
}
if dist < bestDist {
bestDist = dist
bestSplit = i + 1
}
}
line1 := strings.Join(words[:bestSplit], " ")
line2 := strings.Join(words[bestSplit:], " ")
return line1, line2
}
// Try single line first, starting at larger size
titleLines := []string{gameTitle}
var gameTitleHeight int
singleLineFits := false
for size := 40; size >= 28; size -= 2 {
font.HeightInPixels = size
titleWidth, titleHeight := font.Measure(gameTitle)
if titleWidth <= maxTitleWidth && titleHeight <= maxTitleHeight {
gameTitleSize = size
gameTitleHeight = titleHeight
singleLineFits = true
break
}
}
// If single line doesn't fit at 28px or larger, try splitting into two lines
if !singleLineFits {
line1, line2 := splitTitle(gameTitle)
if line2 != "" {
titleLines = []string{line1, line2}
// Recalculate from maximum size with two lines - might fit larger now!
gameTitleSize = 14 // Reset to minimum
for size := 40; size >= 14; size -= 2 {
font.HeightInPixels = size
line1Width, line1Height := font.Measure(line1)
line2Width, line2Height := font.Measure(line2)
maxLineWidth := line1Width
if line2Width > maxLineWidth {
maxLineWidth = line2Width
}
totalHeight := line1Height + line2Height + 5 // 5px gap between lines
if maxLineWidth <= maxTitleWidth && totalHeight <= maxTitleHeight {
gameTitleSize = size
gameTitleHeight = totalHeight
break
}
}
} else {
// Single word that's too long, just shrink it
for size := 27; size >= 14; size -= 2 {
font.HeightInPixels = size
titleWidth, titleHeight := font.Measure(gameTitle)
if titleWidth <= maxTitleWidth && titleHeight <= maxTitleHeight {
gameTitleSize = size
gameTitleHeight = titleHeight
break
}
}
}
}
// Calculate Y position (center vertically in available space)
gameTitleY := 20 + (codeY-30-20-gameTitleHeight)/2
// Calculate character positions - evenly spaced for 4 characters
// Room code is ALWAYS 4 characters, monospace font
charPositions := make([]int, 4)
for i := 0; i < 4; i++ {
charPositions[i] = codeX + (i * singleCharWidth)
}
// Create animated GIF frames
var frames []*image.Paletted
var delays []int
// Palette: Must include ALL colors used in the image
// - Black (background)
// - Shades for numberColor (room code animation)
// - Shades for letterColor (room code animation)
// - #EEEEEE (static text)
// - separatorColor (:: separator)
palette := make([]color.Color, 256)
// Index 0: Pure black (background)
palette[0] = color.RGBA{0, 0, 0, 255}
// Index 1: #EEEEEE (static text - game title, labels, bottom text)
palette[1] = color.RGBA{0xEE, 0xEE, 0xEE, 255}
// Index 2: separatorColor (:: separator)
palette[2] = separatorColor
// Indices 3-128: black to numberColor (for number animation)
for i := 0; i < 126; i++ {
progress := float64(i) / 125.0
r := uint8(progress * float64(numberColor.R))
g := uint8(progress * float64(numberColor.G))
b := uint8(progress * float64(numberColor.B))
palette[3+i] = color.RGBA{r, g, b, 255}
}
// Indices 129-255: black to letterColor (for letter animation)
for i := 0; i < 127; i++ {
progress := float64(i) / 126.0
r := uint8(progress * float64(letterColor.R))
g := uint8(progress * float64(letterColor.G))
b := uint8(progress * float64(letterColor.B))
palette[129+i] = color.RGBA{r, g, b, 255}
}
// Helper function to determine if a character is a letter
isLetter := func(ch rune) bool {
return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z')
}
// Animation parameters
initialPauseFrames := 25 // Initial pause before animation starts (2.5 seconds at 10fps)
fadeFrames := 10 // Number of frames for fade-in (1 second at 10fps)
pauseFrames := 30 // Frames to pause between characters (3 seconds at 10fps)
frameDelay := 10 // 10/100 second = 0.1s per frame (10 fps)
// Helper function to draw a frame and convert to paletted
drawFrame := func(charIndex int, fadeProgress float64) *image.Paletted {
// Draw to RGBA first for proper alpha blending
rgba := image.NewRGBA(image.Rect(0, 0, imageWidth, imageHeight))
draw.Draw(rgba, rgba.Bounds(), &image.Uniform{black}, image.Point{}, draw.Src)
// STEP 1: Draw room code FIRST with animation (colored letters/numbers)
font.HeightInPixels = bestSize
// Draw all previous characters (fully visible)
for i := 0; i < charIndex; i++ {
ch := rune(roomCode[i])
if isLetter(ch) {
font.R, font.G, font.B, font.A = letterColor.R, letterColor.G, letterColor.B, 255
} else {
font.R, font.G, font.B, font.A = numberColor.R, numberColor.G, numberColor.B, 255
}
font.Write(rgba, string(roomCode[i]), charPositions[i], codeY)
}
// Draw current character (fading in) using manual alpha blending
if charIndex < len(roomCode) && fadeProgress > 0 {
// Draw the character to a temporary image at full opacity
tempImg := image.NewRGBA(image.Rect(0, 0, imageWidth, imageHeight))
draw.Draw(tempImg, tempImg.Bounds(), &image.Uniform{color.RGBA{0, 0, 0, 0}}, image.Point{}, draw.Src)
ch := rune(roomCode[charIndex])
if isLetter(ch) {
font.R, font.G, font.B, font.A = letterColor.R, letterColor.G, letterColor.B, 255
} else {
font.R, font.G, font.B, font.A = numberColor.R, numberColor.G, numberColor.B, 255
}
font.Write(tempImg, string(roomCode[charIndex]), charPositions[charIndex], codeY)
// Manually blend the character onto the main image with fadeProgress alpha
targetAlpha := uint8(fadeProgress * 255)
for y := 0; y < imageHeight; y++ {
for x := 0; x < imageWidth; x++ {
srcColor := tempImg.RGBAAt(x, y)
if srcColor.A > 0 {
// Apply fade alpha to the source color
dstColor := rgba.RGBAAt(x, y)
// Alpha blending formula
alpha := uint32(srcColor.A) * uint32(targetAlpha) / 255
invAlpha := 255 - alpha
r := (uint32(srcColor.R)*alpha + uint32(dstColor.R)*invAlpha) / 255
g := (uint32(srcColor.G)*alpha + uint32(dstColor.G)*invAlpha) / 255
b := (uint32(srcColor.B)*alpha + uint32(dstColor.B)*invAlpha) / 255
rgba.SetRGBA(x, y, color.RGBA{uint8(r), uint8(g), uint8(b), 255})
}
}
}
}
// STEP 2: Draw static text elements ON TOP (always visible, same on every frame)
// 1. Game title at top (off-white #EEEEEE) - may be 1 or 2 lines
font.HeightInPixels = gameTitleSize
font.R, font.G, font.B, font.A = 0xEE, 0xEE, 0xEE, 255
if len(titleLines) == 1 {
// Single line - use pre-calculated position
lineWidth, _ := font.Measure(titleLines[0])
lineX := (imageWidth - lineWidth) / 2
font.Write(rgba, titleLines[0], lineX, gameTitleY)
} else {
// Two lines
line1Width, line1Height := font.Measure(titleLines[0])
line2Width, _ := font.Measure(titleLines[1])
line1X := (imageWidth - line1Width) / 2
line2X := (imageWidth - line2Width) / 2
font.Write(rgba, titleLines[0], line1X, gameTitleY)
font.Write(rgba, titleLines[1], line2X, gameTitleY+line1Height+5) // 5px gap
}
// 3. "^ Room Code ^" label (off-white #EEEEEE)
font.HeightInPixels = labelSize
font.R, font.G, font.B, font.A = 0xEE, 0xEE, 0xEE, 255
font.Write(rgba, labelText, labelX, labelY)
// 4. Bottom text with colored separator
font.HeightInPixels = bottomTextSize
// Left part (off-white #EEEEEE)
font.R, font.G, font.B, font.A = 0xEE, 0xEE, 0xEE, 255
font.Write(rgba, bottomTextLeft, bottomTextX, bottomTextY)
// Separator (separatorColor - from the color pair)
font.R, font.G, font.B, font.A = separatorColor.R, separatorColor.G, separatorColor.B, 255
font.Write(rgba, bottomTextSeparator, bottomTextX+leftWidth, bottomTextY)
// Right part (off-white #EEEEEE)
font.R, font.G, font.B, font.A = 0xEE, 0xEE, 0xEE, 255
font.Write(rgba, bottomTextRight, bottomTextX+leftWidth+sepWidth, bottomTextY)
// Convert RGBA to paletted
paletted := image.NewPaletted(rgba.Bounds(), palette)
draw.FloydSteinberg.Draw(paletted, rgba.Bounds(), rgba, image.Point{})
return paletted
}
// Generate initial pause frames (just label, no characters)
for i := 0; i < initialPauseFrames; i++ {
frames = append(frames, drawFrame(0, 0))
delays = append(delays, frameDelay)
}
// Generate frames
for charIndex := 0; charIndex < len(roomCode); charIndex++ {
// Fade-in frames for current character
for fadeFrame := 0; fadeFrame < fadeFrames; fadeFrame++ {
fadeProgress := float64(fadeFrame+1) / float64(fadeFrames)
frames = append(frames, drawFrame(charIndex, fadeProgress))
delays = append(delays, frameDelay)
}
// Pause frames (hold current state with character fully visible)
for pauseFrame := 0; pauseFrame < pauseFrames; pauseFrame++ {
frames = append(frames, drawFrame(charIndex+1, 0))
delays = append(delays, frameDelay)
}
}
// Encode as GIF (loop forever since LoopCount is unreliable)
var buf bytes.Buffer
err = gif.EncodeAll(&buf, &gif.GIF{
Image: frames,
Delay: delays,
// Omit LoopCount entirely - let it loop forever (most reliable)
})
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}

29
bridge/jackbox/votes.go Normal file
View File

@@ -0,0 +1,29 @@
package jackbox
import "strings"
// DetectVote checks if a message contains a vote and returns the vote type
// Returns (true, "up") for thisgame++
// Returns (true, "down") for thisgame--
// Returns (false, "") for non-vote messages
func DetectVote(text string) (isVote bool, voteType string) {
lower := strings.ToLower(text)
if strings.Contains(lower, "thisgame++") {
return true, "up"
}
if strings.Contains(lower, "thisgame--") {
return true, "down"
}
return false, ""
}
// IsRelayedMessage checks if a message is relayed from another chat
// Returns true if the message has [irc] or [kosmi] prefix
func IsRelayedMessage(text string) bool {
lower := strings.ToLower(text)
return strings.HasPrefix(lower, "[irc]") || strings.HasPrefix(lower, "[kosmi]")
}

174
bridge/jackbox/webhook.go Normal file
View File

@@ -0,0 +1,174 @@
package jackbox
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/sirupsen/logrus"
)
// WebhookServer handles incoming webhooks from the Jackbox API
type WebhookServer struct {
port int
secret string
messageCallback func(string) // Callback to broadcast messages
log *logrus.Entry
server *http.Server
}
// WebhookPayload represents the webhook event from the API
type WebhookPayload struct {
Event string `json:"event"`
Timestamp string `json:"timestamp"`
Data struct {
Session struct {
ID int `json:"id"`
IsActive bool `json:"is_active"`
GamesPlayed int `json:"games_played"`
} `json:"session"`
Game struct {
ID int `json:"id"`
Title string `json:"title"`
PackName string `json:"pack_name"`
MinPlayers int `json:"min_players"`
MaxPlayers int `json:"max_players"`
ManuallyAdded bool `json:"manually_added"`
} `json:"game"`
} `json:"data"`
}
// NewWebhookServer creates a new webhook server
func NewWebhookServer(port int, secret string, messageCallback func(string), log *logrus.Entry) *WebhookServer {
return &WebhookServer{
port: port,
secret: secret,
messageCallback: messageCallback,
log: log,
}
}
// Start starts the webhook HTTP server
func (w *WebhookServer) Start() error {
mux := http.NewServeMux()
mux.HandleFunc("/webhook/jackbox", w.handleWebhook)
// Health check endpoint
mux.HandleFunc("/health", func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("OK"))
})
w.server = &http.Server{
Addr: fmt.Sprintf(":%d", w.port),
Handler: mux,
}
w.log.Infof("Starting Jackbox webhook server on port %d", w.port)
go func() {
if err := w.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
w.log.Errorf("Webhook server error: %v", err)
}
}()
return nil
}
// Stop stops the webhook server
func (w *WebhookServer) Stop() error {
if w.server != nil {
w.log.Info("Stopping Jackbox webhook server")
return w.server.Close()
}
return nil
}
// handleWebhook processes incoming webhook requests
func (w *WebhookServer) handleWebhook(rw http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Read the raw body for signature verification
body, err := io.ReadAll(r.Body)
if err != nil {
w.log.Errorf("Failed to read webhook body: %v", err)
http.Error(rw, "Failed to read body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// Verify signature
signature := r.Header.Get("X-Webhook-Signature")
if !w.verifySignature(signature, body) {
w.log.Warn("Webhook signature verification failed")
http.Error(rw, "Invalid signature", http.StatusUnauthorized)
return
}
// Parse the webhook payload
var payload WebhookPayload
if err := json.Unmarshal(body, &payload); err != nil {
w.log.Errorf("Failed to parse webhook payload: %v", err)
http.Error(rw, "Invalid payload", http.StatusBadRequest)
return
}
// Handle the event
w.handleEvent(&payload)
// Always respond with 200 OK
rw.WriteHeader(http.StatusOK)
rw.Write([]byte("OK"))
}
// verifySignature verifies the HMAC-SHA256 signature of the webhook
func (w *WebhookServer) verifySignature(signature string, body []byte) bool {
if signature == "" || len(signature) < 7 || signature[:7] != "sha256=" {
return false
}
// Extract the hex signature (after "sha256=")
receivedSig := signature[7:]
// Compute expected signature
mac := hmac.New(sha256.New, []byte(w.secret))
mac.Write(body)
expectedSig := hex.EncodeToString(mac.Sum(nil))
// Timing-safe comparison
return subtle.ConstantTimeCompare([]byte(receivedSig), []byte(expectedSig)) == 1
}
// handleEvent processes webhook events
func (w *WebhookServer) handleEvent(payload *WebhookPayload) {
switch payload.Event {
case "game.added":
w.handleGameAdded(payload)
default:
w.log.Debugf("Unhandled webhook event: %s", payload.Event)
}
}
// handleGameAdded handles the game.added event
func (w *WebhookServer) handleGameAdded(payload *WebhookPayload) {
game := payload.Data.Game
w.log.Infof("Game added: %s from %s", game.Title, game.PackName)
// Format the announcement message
message := fmt.Sprintf("🎮 Coming up next: %s!", game.Title)
// Broadcast to both chats via callback
if w.messageCallback != nil {
w.messageCallback(message)
}
}

View File

@@ -0,0 +1,547 @@
package jackbox
import (
"encoding/json"
"fmt"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/sirupsen/logrus"
)
// WebSocketClient handles WebSocket connection to Jackbox API
type WebSocketClient struct {
apiURL string
token string
conn *websocket.Conn
messageCallback func(string)
apiClient *Client // Reference to API client for vote tracking
log *logrus.Entry
mu sync.Mutex
reconnectDelay time.Duration
maxReconnect time.Duration
stopChan chan struct{}
connected bool
authenticated bool
subscribedSession int
enableRoomCodeImage bool // Whether to upload room code images to Kosmi
}
// WebSocket message types
type WSMessage struct {
Type string `json:"type"`
Token string `json:"token,omitempty"`
SessionID int `json:"sessionId,omitempty"`
Message string `json:"message,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
// SessionStartedData represents the session.started event data
type SessionStartedData struct {
Session struct {
ID int `json:"id"`
IsActive int `json:"is_active"`
CreatedAt string `json:"created_at"`
Notes string `json:"notes"`
} `json:"session"`
}
// GameAddedData represents the game.added event data
type GameAddedData struct {
Session struct {
ID int `json:"id"`
IsActive bool `json:"is_active"`
GamesPlayed int `json:"games_played"`
} `json:"session"`
Game struct {
ID int `json:"id"`
Title string `json:"title"`
PackName string `json:"pack_name"`
MinPlayers int `json:"min_players"`
MaxPlayers int `json:"max_players"`
ManuallyAdded bool `json:"manually_added"`
RoomCode string `json:"room_code"`
} `json:"game"`
}
// NewWebSocketClient creates a new WebSocket client
func NewWebSocketClient(apiURL, token string, messageCallback func(string), apiClient *Client, enableRoomCodeImage bool, log *logrus.Entry) *WebSocketClient {
return &WebSocketClient{
apiURL: apiURL,
token: token,
messageCallback: messageCallback,
apiClient: apiClient,
enableRoomCodeImage: enableRoomCodeImage,
log: log,
reconnectDelay: 1 * time.Second,
maxReconnect: 30 * time.Second,
stopChan: make(chan struct{}),
}
}
// Connect establishes WebSocket connection
func (c *WebSocketClient) Connect() error {
c.mu.Lock()
defer c.mu.Unlock()
// Convert http(s):// to ws(s)://
wsURL := c.apiURL
if len(wsURL) > 7 && wsURL[:7] == "http://" {
wsURL = "ws://" + wsURL[7:]
} else if len(wsURL) > 8 && wsURL[:8] == "https://" {
wsURL = "wss://" + wsURL[8:]
}
wsURL += "/api/sessions/live"
c.log.Infof("Connecting to WebSocket: %s", wsURL)
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
c.conn = conn
c.connected = true
c.log.Info("WebSocket connected")
// Start message listener
go c.listen()
// Authenticate
return c.authenticate()
}
// authenticate sends authentication message
func (c *WebSocketClient) authenticate() error {
msg := WSMessage{
Type: "auth",
Token: c.token,
}
if err := c.sendMessage(msg); err != nil {
return fmt.Errorf("failed to send auth: %w", err)
}
c.log.Debug("Authentication message sent")
return nil
}
// Subscribe subscribes to a session's events
func (c *WebSocketClient) Subscribe(sessionID int) error {
c.mu.Lock()
defer c.mu.Unlock()
if !c.authenticated {
return fmt.Errorf("not authenticated")
}
msg := WSMessage{
Type: "subscribe",
SessionID: sessionID,
}
if err := c.sendMessage(msg); err != nil {
return fmt.Errorf("failed to subscribe: %w", err)
}
c.subscribedSession = sessionID
c.log.Infof("Subscribed to session %d", sessionID)
return nil
}
// Unsubscribe unsubscribes from a session's events
func (c *WebSocketClient) Unsubscribe(sessionID int) error {
c.mu.Lock()
defer c.mu.Unlock()
msg := WSMessage{
Type: "unsubscribe",
SessionID: sessionID,
}
if err := c.sendMessage(msg); err != nil {
return fmt.Errorf("failed to unsubscribe: %w", err)
}
if c.subscribedSession == sessionID {
c.subscribedSession = 0
}
c.log.Infof("Unsubscribed from session %d", sessionID)
return nil
}
// listen handles incoming WebSocket messages
func (c *WebSocketClient) listen() {
defer c.handleDisconnect()
// Start heartbeat
go c.startHeartbeat()
for {
select {
case <-c.stopChan:
return
default:
_, message, err := c.conn.ReadMessage()
if err != nil {
c.log.Errorf("Error reading message: %v", err)
return
}
c.handleMessage(message)
}
}
}
// handleMessage processes incoming messages
func (c *WebSocketClient) handleMessage(data []byte) {
var msg WSMessage
if err := json.Unmarshal(data, &msg); err != nil {
c.log.Errorf("Failed to parse message: %v", err)
return
}
switch msg.Type {
case "auth_success":
c.mu.Lock()
c.authenticated = true
c.mu.Unlock()
c.log.Info("Authentication successful")
// session.started events are automatically broadcast to all authenticated clients
// No need to subscribe - just wait for session.started events
case "auth_error":
c.log.Errorf("Authentication failed: %s", msg.Message)
c.authenticated = false
case "subscribed":
c.log.Infof("Subscription confirmed: %s", msg.Message)
case "unsubscribed":
c.log.Infof("Unsubscription confirmed: %s", msg.Message)
case "session.started":
c.handleSessionStarted(msg.Data)
case "game.added":
c.handleGameAdded(msg.Data)
case "session.ended":
c.handleSessionEnded(msg.Data)
case "pong":
c.log.Debug("Heartbeat pong received")
case "error":
c.log.Errorf("Server error: %s", msg.Message)
default:
c.log.Debugf("Unknown message type: %s", msg.Type)
}
}
// handleSessionStarted processes session.started events
func (c *WebSocketClient) handleSessionStarted(data json.RawMessage) {
var sessionData SessionStartedData
if err := json.Unmarshal(data, &sessionData); err != nil {
c.log.Errorf("Failed to parse session.started data: %v", err)
return
}
sessionID := sessionData.Session.ID
c.log.Infof("Session started: ID=%d", sessionID)
// Subscribe to the new session
if err := c.Subscribe(sessionID); err != nil {
c.log.Errorf("Failed to subscribe to new session %d: %v", sessionID, err)
return
}
// Set the active session on the client for vote tracking
if c.apiClient != nil {
c.apiClient.SetActiveSession(sessionID)
}
// Announce the new session
message := fmt.Sprintf("🎮 Game Night is starting! Session #%d", sessionID)
if c.messageCallback != nil {
c.messageCallback(message)
}
}
// handleGameAdded processes game.added events
func (c *WebSocketClient) handleGameAdded(data json.RawMessage) {
var gameData GameAddedData
if err := json.Unmarshal(data, &gameData); err != nil {
c.log.Errorf("Failed to parse game.added data: %v", err)
return
}
c.log.Infof("Game added: %s from %s (Room Code: %s)", gameData.Game.Title, gameData.Game.PackName, gameData.Game.RoomCode)
// Get and clear the last vote response for the previous game
var message string
if c.apiClient != nil {
lastVote := c.apiClient.GetAndClearLastVoteResponse()
if lastVote != nil {
// Include vote results from the previous game
message = fmt.Sprintf("🗳️ Final votes for %s: %d👍 %d👎 (Score: %d)\n🎮 Coming up next: %s",
lastVote.Game.Title,
lastVote.Game.Upvotes, lastVote.Game.Downvotes, lastVote.Game.PopularityScore,
gameData.Game.Title)
} else {
// No votes for previous game (or first game)
message = fmt.Sprintf("🎮 Coming up next: %s", gameData.Game.Title)
}
} else {
// Fallback if no API client
message = fmt.Sprintf("🎮 Coming up next: %s", gameData.Game.Title)
}
// Handle room code display based on configuration
if gameData.Game.RoomCode != "" {
if c.enableRoomCodeImage {
// Try to upload room code image (for Kosmi) - image contains all info
c.broadcastWithRoomCodeImage(gameData.Game.Title, gameData.Game.RoomCode)
} else {
// Use IRC text formatting (fallback)
roomCodeText := fmt.Sprintf(" - Room Code \x02\x11%s\x0F", gameData.Game.RoomCode)
if c.messageCallback != nil {
c.messageCallback(message + roomCodeText)
}
}
} else {
// No room code, just send the message
if c.messageCallback != nil {
c.messageCallback(message)
}
}
}
// broadcastWithRoomCodeImage generates, uploads, and broadcasts a room code image
// The image contains all the information (game title, room code, etc.)
func (c *WebSocketClient) broadcastWithRoomCodeImage(gameTitle, roomCode string) {
c.log.Infof("🎨 Starting room code image generation and upload for: %s - %s", gameTitle, roomCode)
// Generate room code image (animated GIF) with game title embedded
c.log.Infof("📝 Step 1: Generating image...")
imageData, err := GenerateRoomCodeImage(roomCode, gameTitle)
if err != nil {
c.log.Errorf("❌ Failed to generate room code image: %v", err)
// Fallback to plain text (no IRC formatting codes for Kosmi)
fallbackMessage := fmt.Sprintf("🎮 Coming up next: %s - Room Code %s", gameTitle, roomCode)
if c.messageCallback != nil {
c.messageCallback(fallbackMessage)
}
return
}
c.log.Infof("✅ Step 1 complete: Generated %d bytes of GIF data", len(imageData))
// Upload animated GIF to Kosmi CDN (MUST complete before announcing)
c.log.Infof("📤 Step 2: Uploading to Kosmi CDN...")
filename := fmt.Sprintf("roomcode_%s.gif", roomCode)
imageURL, err := UploadImageToKosmi(imageData, filename)
if err != nil {
c.log.Errorf("❌ Failed to upload room code image: %v", err)
// Fallback to plain text (no IRC formatting codes for Kosmi)
fallbackMessage := fmt.Sprintf("🎮 Coming up next: %s - Room Code %s", gameTitle, roomCode)
if c.messageCallback != nil {
c.messageCallback(fallbackMessage)
}
return
}
c.log.Infof("✅ Step 2 complete: Uploaded to %s", imageURL)
// Now that upload succeeded, send the full announcement with game title and URL
c.log.Infof("📢 Step 3: Broadcasting game announcement with URL...")
fullMessage := fmt.Sprintf("🎮 Coming up next: %s %s", gameTitle, imageURL)
if c.messageCallback != nil {
c.messageCallback(fullMessage)
c.log.Infof("✅ Step 3 complete: Game announcement sent with URL")
} else {
c.log.Error("❌ Step 3 failed: messageCallback is nil")
}
// Send the plaintext room code after 19 seconds (to sync with animation completion)
// Capture callback and logger in closure
callback := c.messageCallback
logger := c.log
plainRoomCode := roomCode // Capture room code for plain text message
c.log.Infof("⏰ Step 4: Starting 19-second timer goroutine for plaintext room code...")
go func() {
logger.Infof("⏰ [Goroutine started] Waiting 19 seconds before sending plaintext room code: %s", plainRoomCode)
time.Sleep(19 * time.Second)
logger.Infof("⏰ [19 seconds elapsed] Now sending plaintext room code...")
if callback != nil {
// Send just the room code in plaintext (for easy copy/paste)
plaintextMessage := fmt.Sprintf("Room Code: %s", plainRoomCode)
logger.Infof("📤 Sending plaintext: %s", plaintextMessage)
callback(plaintextMessage)
logger.Infof("✅ Successfully sent plaintext room code")
} else {
logger.Error("❌ Message callback is nil when trying to send delayed room code")
}
}()
c.log.Infof("✅ Step 4 complete: Goroutine launched, will fire in 19 seconds")
}
// handleSessionEnded processes session.ended events
func (c *WebSocketClient) handleSessionEnded(data json.RawMessage) {
c.log.Info("Session ended event received")
c.AnnounceSessionEnd()
}
// AnnounceSessionEnd announces the final votes and says goodnight
func (c *WebSocketClient) AnnounceSessionEnd() {
// Get and clear the last vote response for the final game
var message string
if c.apiClient != nil {
lastVote := c.apiClient.GetAndClearLastVoteResponse()
if lastVote != nil {
// Include final vote results
message = fmt.Sprintf("🗳️ Final votes for %s: %d👍 %d👎 (Score: %d)\n🌙 Game Night has ended! Thanks for playing!",
lastVote.Game.Title,
lastVote.Game.Upvotes, lastVote.Game.Downvotes, lastVote.Game.PopularityScore)
} else {
// No votes for final game
message = "🌙 Game Night has ended! Thanks for playing!"
}
// Clear the active session
c.apiClient.SetActiveSession(0)
} else {
message = "🌙 Game Night has ended! Thanks for playing!"
}
// Broadcast to chats via callback
if c.messageCallback != nil {
c.messageCallback(message)
}
}
// startHeartbeat sends ping messages periodically
func (c *WebSocketClient) startHeartbeat() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-c.stopChan:
return
case <-ticker.C:
c.mu.Lock()
if c.connected && c.conn != nil {
msg := WSMessage{Type: "ping"}
if err := c.sendMessage(msg); err != nil {
c.log.Errorf("Failed to send ping: %v", err)
}
}
c.mu.Unlock()
}
}
}
// sendMessage sends a message to the WebSocket server
func (c *WebSocketClient) sendMessage(msg WSMessage) error {
data, err := json.Marshal(msg)
if err != nil {
return err
}
if c.conn == nil {
return fmt.Errorf("not connected")
}
return c.conn.WriteMessage(websocket.TextMessage, data)
}
// handleDisconnect handles connection loss and attempts reconnection
func (c *WebSocketClient) handleDisconnect() {
c.mu.Lock()
c.connected = false
c.authenticated = false
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.mu.Unlock()
c.log.Warn("WebSocket disconnected, attempting to reconnect...")
// Exponential backoff reconnection
delay := c.reconnectDelay
for {
select {
case <-c.stopChan:
return
case <-time.After(delay):
c.log.Infof("Reconnecting... (delay: %v)", delay)
if err := c.Connect(); err != nil {
c.log.Errorf("Reconnection failed: %v", err)
// Increase delay with exponential backoff
delay *= 2
if delay > c.maxReconnect {
delay = c.maxReconnect
}
continue
}
// Reconnected successfully
c.log.Info("Reconnected successfully")
// Re-subscribe if we were subscribed before
if c.subscribedSession > 0 {
if err := c.Subscribe(c.subscribedSession); err != nil {
c.log.Errorf("Failed to re-subscribe: %v", err)
}
}
return
}
}
}
// Close closes the WebSocket connection
func (c *WebSocketClient) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
close(c.stopChan)
if c.conn != nil {
c.log.Info("Closing WebSocket connection")
err := c.conn.Close()
c.conn = nil
c.connected = false
c.authenticated = false
return err
}
return nil
}
// IsConnected returns whether the client is connected
func (c *WebSocketClient) IsConnected() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.connected && c.authenticated
}
// IsSubscribed returns whether the client is subscribed to a session
func (c *WebSocketClient) IsSubscribed() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.subscribedSession > 0
}