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") // Get configurable delays for room code broadcast imageDelay := time.Duration(m.config.Viper().GetInt("jackbox.RoomCodeImageDelay")) * time.Second plaintextDelay := time.Duration(m.config.Viper().GetInt("jackbox.RoomCodePlaintextDelay")) * time.Second if plaintextDelay == 0 { plaintextDelay = 29 * time.Second // default } m.log.Infof("Room code delays: imageDelay=%v, plaintextDelay=%v (raw config: image=%d, plaintext=%d)", imageDelay, plaintextDelay, m.config.Viper().GetInt("jackbox.RoomCodeImageDelay"), m.config.Viper().GetInt("jackbox.RoomCodePlaintextDelay")) // Create WebSocket client (pass the API client for vote tracking) m.wsClient = NewWebSocketClient(apiURL, token, wrappedCallback, m.client, enableRoomCodeImage, imageDelay, plaintextDelay, 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 }