wow that took awhile
This commit is contained in:
547
bridge/jackbox/websocket_client.go
Normal file
547
bridge/jackbox/websocket_client.go
Normal 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user