package main import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "os" "strings" "time" "github.com/chromedp/cdproto/network" "github.com/chromedp/chromedp" ) // PlayerInfo represents a player in the lobby type PlayerInfo struct { ID string `json:"id"` Role string `json:"role"` Name string `json:"name"` } // LobbyStatus contains all information about the current lobby type LobbyStatus struct { RoomCode string `json:"roomCode"` AppTag string `json:"appTag"` PlayerCount int `json:"playerCount"` AudienceCount int `json:"audienceCount"` MaxPlayers int `json:"maxPlayers"` GameState string `json:"gameState"` LobbyState string `json:"lobbyState"` Locked bool `json:"locked"` Full bool `json:"full"` Players []PlayerInfo `json:"players"` } // WebSocketMessage represents a parsed WebSocket message type WebSocketMessage struct { PC int `json:"pc"` Opcode string `json:"opcode"` Result map[string]interface{} `json:"result"` } func min(a, b int) int { if a < b { return a } return b } func main() { if len(os.Args) < 2 { fmt.Println("Usage: go run get-player-count.go ") fmt.Println("Example: go run get-player-count.go JYET") fmt.Println("\nSet DEBUG=true for verbose output:") fmt.Println("DEBUG=true go run get-player-count.go JYET") os.Exit(1) } roomCode := strings.ToUpper(strings.TrimSpace(os.Args[1])) if len(roomCode) != 4 { fmt.Println("Error: Room code must be exactly 4 characters") os.Exit(1) } fmt.Printf("šŸŽ® Jackbox Player Count Fetcher\n") fmt.Printf("Room Code: %s\n\n", roomCode) status, err := getPlayerCount(roomCode) if err != nil { log.Fatalf("Error: %v\n", err) } printStatus(status) } func getPlayerCount(roomCode string) (*LobbyStatus, error) { // Create chrome context with less verbose logging opts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.Flag("headless", true), chromedp.Flag("disable-gpu", true), chromedp.Flag("no-sandbox", true), chromedp.Flag("disable-web-security", true), ) allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) defer cancel() // Create context without default logging to reduce cookie errors ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(func(s string, i ...interface{}) { // Only log non-cookie errors msg := fmt.Sprintf(s, i...) if !strings.Contains(msg, "cookiePart") && !strings.Contains(msg, "could not unmarshal") { log.Printf(msg) } })) defer cancel() // Set timeout ctx, cancel = context.WithTimeout(ctx, 30*time.Second) defer cancel() var lobbyStatus *LobbyStatus welcomeMessageFound := false wsMessages := make([]string, 0) // Listen for WebSocket frames - this is the most reliable method debugMode := os.Getenv("DEBUG") == "true" chromedp.ListenTarget(ctx, func(ev interface{}) { if debugMode { fmt.Printf("[DEBUG] Event type: %T\n", ev) } switch ev := ev.(type) { case *network.EventWebSocketCreated: if debugMode { fmt.Printf("[DEBUG] WebSocket Created: %s\n", ev.URL) } case *network.EventWebSocketFrameReceived: // Capture all WebSocket frames wsMessages = append(wsMessages, ev.Response.PayloadData) if debugMode { fmt.Printf("[DEBUG] WS Frame received (%d bytes)\n", len(ev.Response.PayloadData)) if len(ev.Response.PayloadData) < 200 { fmt.Printf("[DEBUG] Data: %s\n", ev.Response.PayloadData) } else { fmt.Printf("[DEBUG] Data (truncated): %s...\n", ev.Response.PayloadData[:200]) } } // Try to parse immediately var wsMsg WebSocketMessage if err := json.Unmarshal([]byte(ev.Response.PayloadData), &wsMsg); err == nil { if debugMode { fmt.Printf("[DEBUG] Parsed opcode: %s\n", wsMsg.Opcode) } if wsMsg.Opcode == "client/welcome" && wsMsg.Result != nil { lobbyStatus = parseWelcomeMessage(&wsMsg) welcomeMessageFound = true fmt.Println("āœ“ Captured lobby data from WebSocket") } } else if debugMode { fmt.Printf("[DEBUG] Failed to parse JSON: %v\n", err) } case *network.EventWebSocketFrameSent: if debugMode { fmt.Printf("[DEBUG] WS Frame sent: %s\n", ev.Response.PayloadData) } } }) fmt.Println("ā³ Navigating to jackbox.tv...") // Enable network tracking BEFORE navigation if err := chromedp.Run(ctx, network.Enable()); err != nil { return nil, fmt.Errorf("failed to enable network tracking: %w", err) } err := chromedp.Run(ctx, chromedp.Navigate("https://jackbox.tv/"), chromedp.WaitVisible(`input[placeholder*="ENTER 4-LETTER CODE"]`, chromedp.ByQuery), ) if err != nil { return nil, fmt.Errorf("failed to load jackbox.tv: %w", err) } fmt.Printf("āœ“ Loaded jackbox.tv\n") fmt.Printf("ā³ Joining room %s...\n", roomCode) // Type room code and press Enter to join err = chromedp.Run(ctx, chromedp.Focus(`input[placeholder*="ENTER 4-LETTER CODE"]`, chromedp.ByQuery), chromedp.SendKeys(`input[placeholder*="ENTER 4-LETTER CODE"]`, roomCode+"\n", chromedp.ByQuery), ) if err != nil { return nil, fmt.Errorf("failed to enter room code: %w", err) } if debugMode { fmt.Println("[DEBUG] Entered room code and pressed Enter") } // Wait for room code validation and page transition time.Sleep(2 * time.Second) fmt.Println("āœ“ Clicked Play button, waiting for WebSocket data...") // Check if we successfully joined (look for typical lobby UI elements) time.Sleep(2 * time.Second) var pageText string err = chromedp.Run(ctx, chromedp.Text("body", &pageText, chromedp.ByQuery), ) if err == nil && debugMode { if strings.Contains(pageText, "Sit back") || strings.Contains(pageText, "waiting") { fmt.Println("[DEBUG] Successfully joined lobby (found lobby text)") } else { fmt.Printf("[DEBUG] Page text: %s\n", pageText[:min(300, len(pageText))]) } } // Wait longer for WebSocket to connect and receive welcome message for i := 0; i < 15 && !welcomeMessageFound; i++ { time.Sleep(500 * time.Millisecond) if i%4 == 0 { fmt.Printf("ā³ Waiting for lobby data... (%ds)\n", i/2) } } // If we still didn't get it from WebSocket frames, try parsing all captured messages if !welcomeMessageFound && len(wsMessages) > 0 { fmt.Printf("ā³ Parsing %d captured WebSocket messages...\n", len(wsMessages)) for _, msg := range wsMessages { var wsMsg WebSocketMessage if err := json.Unmarshal([]byte(msg), &wsMsg); err == nil { if wsMsg.Opcode == "client/welcome" && wsMsg.Result != nil { lobbyStatus = parseWelcomeMessage(&wsMsg) welcomeMessageFound = true fmt.Println("āœ“ Found lobby data in captured messages") break } } } } if lobbyStatus == nil { if len(wsMessages) == 0 { return nil, fmt.Errorf("no WebSocket messages captured - connection may have failed\nTry running with DEBUG=true for more details") } // Show what we captured fmt.Printf("\nāš ļø Captured %d WebSocket messages but couldn't find 'client/welcome'\n", len(wsMessages)) fmt.Println("\nMessage types found:") opcodes := make(map[string]int) for _, msg := range wsMessages { var wsMsg WebSocketMessage if err := json.Unmarshal([]byte(msg), &wsMsg); err == nil { opcodes[wsMsg.Opcode]++ } } for opcode, count := range opcodes { fmt.Printf(" - %s: %d\n", opcode, count) } return nil, fmt.Errorf("could not find player count data in WebSocket messages\nRoom may be invalid, closed, or not in lobby state") } lobbyStatus.RoomCode = roomCode // Fetch additional room info from REST API if err := enrichWithRestAPI(lobbyStatus); err != nil { fmt.Printf("Warning: Could not fetch additional room info: %v\n", err) } return lobbyStatus, nil } func parseWelcomeMessage(msg *WebSocketMessage) *LobbyStatus { status := &LobbyStatus{ Players: []PlayerInfo{}, } // Parse "here" object for players if here, ok := msg.Result["here"].(map[string]interface{}); ok { status.PlayerCount = len(here) for id, playerData := range here { if pd, ok := playerData.(map[string]interface{}); ok { player := PlayerInfo{ID: id} if roles, ok := pd["roles"].(map[string]interface{}); ok { if _, hasHost := roles["host"]; hasHost { player.Role = "host" player.Name = "Host" } else if playerRole, ok := roles["player"].(map[string]interface{}); ok { player.Role = "player" if name, ok := playerRole["name"].(string); ok { player.Name = name } else { player.Name = "Unknown" } } } status.Players = append(status.Players, player) } } } // Parse entities for additional info if entities, ok := msg.Result["entities"].(map[string]interface{}); ok { // Audience count if audience, ok := entities["audience"].([]interface{}); ok && len(audience) > 1 { if audienceData, ok := audience[1].(map[string]interface{}); ok { if count, ok := audienceData["count"].(float64); ok { status.AudienceCount = int(count) } } } // Room state if bcRoom, ok := entities["bc:room"].([]interface{}); ok && len(bcRoom) > 1 { if roomData, ok := bcRoom[1].(map[string]interface{}); ok { if val, ok := roomData["val"].(map[string]interface{}); ok { if gameState, ok := val["state"].(string); ok { status.GameState = gameState } if lobbyState, ok := val["lobbyState"].(string); ok { status.LobbyState = lobbyState } } } } } return status } func enrichWithRestAPI(status *LobbyStatus) error { // Fetch additional room info from REST API url := fmt.Sprintf("https://ecast.jackboxgames.com/api/v2/rooms/%s", status.RoomCode) resp, err := http.Get(url) if err != nil { return err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return err } var result struct { OK bool `json:"ok"` Body struct { AppTag string `json:"appTag"` MaxPlayers int `json:"maxPlayers"` Locked bool `json:"locked"` Full bool `json:"full"` } `json:"body"` } if err := json.Unmarshal(body, &result); err != nil { return err } if result.OK { status.AppTag = result.Body.AppTag status.MaxPlayers = result.Body.MaxPlayers status.Locked = result.Body.Locked status.Full = result.Body.Full } return nil } func printStatus(status *LobbyStatus) { fmt.Println() fmt.Println("═══════════════════════════════════════════") fmt.Println(" Jackbox Room Status") fmt.Println("═══════════════════════════════════════════") fmt.Println() fmt.Printf("Room Code: %s\n", status.RoomCode) fmt.Printf("Game: %s\n", status.AppTag) fmt.Printf("Game State: %s\n", status.GameState) fmt.Printf("Lobby State: %s\n", status.LobbyState) fmt.Printf("Locked: %t\n", status.Locked) fmt.Printf("Full: %t\n", status.Full) fmt.Println() fmt.Printf("Players: %d / %d\n", status.PlayerCount, status.MaxPlayers) fmt.Printf("Audience: %d\n", status.AudienceCount) fmt.Println() if len(status.Players) > 0 { fmt.Println("Current Players:") for i, player := range status.Players { fmt.Printf(" %d. %s (%s)\n", i+1, player.Name, player.Role) } fmt.Println() } fmt.Println("═══════════════════════════════════════════") fmt.Println() }