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

548 lines
15 KiB
Go

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
}