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 }