549 lines
15 KiB
Go
549 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(message, 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.)
|
|
// The message parameter should contain the full game announcement including any vote results
|
|
func (c *WebSocketClient) broadcastWithRoomCodeImage(message, 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("%s - Room Code %s", message, 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("%s - Room Code %s", message, 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 the message and URL
|
|
c.log.Infof("📢 Step 3: Broadcasting game announcement with URL...")
|
|
fullMessage := fmt.Sprintf("%s %s", message, 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
|
|
}
|
|
|