package fun import ( "bytes" "encoding/json" "fmt" "io" "net/http" "regexp" "strings" "time" "git.nakama.town/fmartingr/butterrobot/internal/model" "git.nakama.town/fmartingr/butterrobot/internal/plugin" ) // HLTBPlugin searches HowLongToBeat for game completion times type HLTBPlugin struct { plugin.BasePlugin httpClient *http.Client } // HLTBGame represents a game from HowLongToBeat type HLTBGame struct { ID int `json:"game_id"` Name string `json:"game_name"` GameAlias string `json:"game_alias"` GameImage string `json:"game_image"` CompMain int `json:"comp_main"` CompPlus int `json:"comp_plus"` CompComplete int `json:"comp_complete"` CompAll int `json:"comp_all"` InvestedCo int `json:"invested_co"` InvestedMp int `json:"invested_mp"` CountComp int `json:"count_comp"` CountSpeedruns int `json:"count_speedruns"` CountBacklog int `json:"count_backlog"` CountReview int `json:"count_review"` ReviewScore int `json:"review_score"` CountPlaying int `json:"count_playing"` CountRetired int `json:"count_retired"` } // NewHLTB creates a new HLTBPlugin instance func NewHLTB() *HLTBPlugin { return &HLTBPlugin{ BasePlugin: plugin.BasePlugin{ ID: "fun.hltb", Name: "How Long To Beat", Help: "Get game completion times from HowLongToBeat.com using `!hltb `", }, httpClient: &http.Client{ Timeout: 10 * time.Second, }, } } // OnMessage handles incoming messages func (p *HLTBPlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction { // Check if message starts with !hltb text := strings.TrimSpace(msg.Text) if !strings.HasPrefix(text, "!hltb ") { return nil } // Extract game name gameName := strings.TrimSpace(text[6:]) // Remove "!hltb " if gameName == "" { return p.createErrorResponse(msg, "Please provide a game name. Usage: !hltb ") } // Check cache first var games []HLTBGame var err error cacheKey := strings.ToLower(gameName) err = cache.Get(cacheKey, &games) if err != nil || len(games) == 0 { // Cache miss - search for the game games, err = p.searchGame(gameName) if err != nil { return p.createErrorResponse(msg, fmt.Sprintf("Error searching for game: %s", err.Error())) } if len(games) == 0 { return p.createErrorResponse(msg, fmt.Sprintf("No results found for '%s'", gameName)) } // Cache the results for 1 hour err = cache.SetWithTTL(cacheKey, games, time.Hour) if err != nil { // Log cache error but don't fail the request fmt.Printf("Warning: Failed to cache HLTB results: %v\n", err) } } // Use the first result game := games[0] // Format the response response := p.formatGameInfo(game) // Create response message with game cover if available responseMsg := &model.Message{ Text: response, Chat: msg.Chat, ReplyTo: msg.ID, Channel: msg.Channel, } // Set parse mode for markdown formatting if responseMsg.Raw == nil { responseMsg.Raw = make(map[string]interface{}) } responseMsg.Raw["parse_mode"] = "Markdown" // Add game cover as attachment if available if game.GameImage != "" { imageURL := p.getFullImageURL(game.GameImage) responseMsg.Raw["image_url"] = imageURL } action := &model.MessageAction{ Type: model.ActionSendMessage, Message: responseMsg, Chat: msg.Chat, Channel: msg.Channel, } return []*model.MessageAction{action} } // searchGame searches for a game on HowLongToBeat using the API func (p *HLTBPlugin) searchGame(gameName string) ([]HLTBGame, error) { // Only the seek token endpoint works now return p.searchWithSeekToken(gameName) } // searchWithSeekToken attempts to search using the seek token approach func (p *HLTBPlugin) searchWithSeekToken(gameName string) ([]HLTBGame, error) { // Get the seek token from the main page seekToken, err := p.getSeekToken() if err != nil { return nil, fmt.Errorf("failed to get seek token: %w", err) } // Split search terms by words searchTerms := strings.Fields(gameName) // Create search URL with seek token searchURL := fmt.Sprintf("https://howlongtobeat.com/api/seek/%s", seekToken) // Prepare search request searchRequest := map[string]interface{}{ "searchType": "games", "searchTerms": searchTerms, "searchPage": 1, "size": 20, "searchOptions": map[string]interface{}{ "games": map[string]interface{}{ "userId": 0, "platform": "", "sortCategory": "popular", "rangeCategory": "main", "rangeTime": map[string]interface{}{ "min": nil, "max": nil, }, "gameplay": map[string]interface{}{ "perspective": "", "flow": "", "genre": "", "difficulty": "", }, "rangeYear": map[string]interface{}{ "min": "", "max": "", }, "modifier": "", }, "users": map[string]interface{}{ "sortCategory": "postcount", }, "lists": map[string]interface{}{ "sortCategory": "follows", }, "filter": "", "sort": 0, "randomizer": 0, }, "useCache": true, } return p.performAPISearch(searchURL, searchRequest) } // performAPISearch performs the actual API search request func (p *HLTBPlugin) performAPISearch(searchURL string, searchRequest map[string]interface{}) ([]HLTBGame, error) { // Convert to JSON jsonData, err := json.Marshal(searchRequest) if err != nil { return nil, fmt.Errorf("failed to marshal search request: %w", err) } // Create HTTP request req, err := http.NewRequest("POST", searchURL, bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } // Set headers to match the working curl request req.Header.Set("Accept", "*/*") req.Header.Set("Accept-Language", "en-US,en;q=0.9") req.Header.Set("Cache-Control", "no-cache") req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", "https://howlongtobeat.com") req.Header.Set("Pragma", "no-cache") req.Header.Set("Referer", "https://howlongtobeat.com/") req.Header.Set("Sec-Fetch-Dest", "empty") req.Header.Set("Sec-Fetch-Mode", "cors") req.Header.Set("Sec-Fetch-Site", "same-origin") req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36") // Send request resp, err := p.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("API returned status code: %d", resp.StatusCode) } // Read response body body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } // Parse response var searchResponse struct { Color string `json:"color"` Title string `json:"title"` Category string `json:"category"` Count int `json:"count"` Pagecurrent int `json:"pagecurrent"` Pagesize int `json:"pagesize"` Pagetotal int `json:"pagetotal"` SearchTerm string `json:"searchTerm"` SearchResults []HLTBGame `json:"data"` } if err := json.Unmarshal(body, &searchResponse); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } return searchResponse.SearchResults, nil } // formatGameInfo formats game information for display func (p *HLTBPlugin) formatGameInfo(game HLTBGame) string { var response strings.Builder response.WriteString(fmt.Sprintf("šŸŽ® **%s**\n\n", game.Name)) // Format completion times if game.CompMain > 0 { response.WriteString(fmt.Sprintf("šŸ“– **Main Story:** %s\n", p.formatTime(game.CompMain))) } if game.CompPlus > 0 { response.WriteString(fmt.Sprintf("āž• **Main + Extras:** %s\n", p.formatTime(game.CompPlus))) } if game.CompComplete > 0 { response.WriteString(fmt.Sprintf("šŸ’Æ **Completionist:** %s\n", p.formatTime(game.CompComplete))) } if game.CompAll > 0 { response.WriteString(fmt.Sprintf("šŸŽÆ **All Styles:** %s\n", p.formatTime(game.CompAll))) } // Add review score if available if game.ReviewScore > 0 { response.WriteString(fmt.Sprintf("\n⭐ **User Score:** %d/100", game.ReviewScore)) } // Add source attribution response.WriteString("\n\n*Source: HowLongToBeat.com*") return response.String() } // formatTime converts seconds to a readable time format func (p *HLTBPlugin) formatTime(seconds int) string { if seconds <= 0 { return "N/A" } hours := float64(seconds) / 3600.0 if hours < 1 { minutes := seconds / 60 return fmt.Sprintf("%d minutes", minutes) } else if hours < 2 { return fmt.Sprintf("%.1f hour", hours) } else { return fmt.Sprintf("%.1f hours", hours) } } // getFullImageURL constructs the full image URL func (p *HLTBPlugin) getFullImageURL(imagePath string) string { if imagePath == "" { return "" } // Remove leading slash if present imagePath = strings.TrimPrefix(imagePath, "/") return fmt.Sprintf("https://howlongtobeat.com/games/%s", imagePath) } // getSeekToken retrieves the seek token from HowLongToBeat func (p *HLTBPlugin) getSeekToken() (string, error) { // Get the main page to extract buildId req, err := http.NewRequest("GET", "https://howlongtobeat.com", nil) if err != nil { return "", fmt.Errorf("failed to create token request: %w", err) } req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36") resp, err := p.httpClient.Do(req) if err != nil { return "", fmt.Errorf("failed to fetch token: %w", err) } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("failed to read token response: %w", err) } bodyStr := string(body) // First, try to find buildId in the __NEXT_DATA__ or page source buildIdPatterns := []string{ `"buildId":"([a-zA-Z0-9_-]+)"`, `buildId":"([a-zA-Z0-9_-]+)"`, `/_next/static/([a-zA-Z0-9_-]+)/_buildManifest`, } for _, pattern := range buildIdPatterns { re := regexp.MustCompile(pattern) matches := re.FindStringSubmatch(bodyStr) if len(matches) > 1 { buildId := matches[1] // Now try to get the seek token from the JavaScript files using buildId if token, err := p.getSeekTokenFromBuildId(buildId); err == nil { return token, nil } } } // If we can't find buildId, look for direct seek token patterns seekPatterns := []string{ `/api/seek/([a-f0-9]{16})`, `"seek/([a-f0-9]{16})"`, `api/seek/([a-f0-9]{16})`, `seek/([a-f0-9]{12,})`, } for _, pattern := range seekPatterns { re := regexp.MustCompile(pattern) matches := re.FindStringSubmatch(bodyStr) if len(matches) > 1 { return matches[1], nil } } // Last resort: try multiple known working tokens knownTokens := []string{ "6e17f7a193ef3188", // From your curl example "d4b2e330db04dbf3", // Common fallback } for _, token := range knownTokens { if p.testSeekToken(token) { return token, nil } } // Generate a token as last resort return p.generateSeekToken(), nil } // getSeekTokenFromBuildId attempts to extract seek token from build-specific files func (p *HLTBPlugin) getSeekTokenFromBuildId(buildId string) (string, error) { // Common build file patterns where seek tokens might be stored fileURLs := []string{ fmt.Sprintf("https://howlongtobeat.com/_next/static/%s/_buildManifest.js", buildId), fmt.Sprintf("https://howlongtobeat.com/_next/static/%s/_ssgManifest.js", buildId), fmt.Sprintf("https://howlongtobeat.com/_next/static/chunks/pages/index-%s.js", buildId[:12]), } for _, fileURL := range fileURLs { if token, err := p.extractSeekTokenFromFile(fileURL); err == nil && token != "" { return token, nil } } return "", fmt.Errorf("no seek token found in build files") } // extractSeekTokenFromFile downloads and searches a file for seek token func (p *HLTBPlugin) extractSeekTokenFromFile(fileURL string) (string, error) { req, err := http.NewRequest("GET", fileURL, nil) if err != nil { return "", err } req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36") resp, err := p.httpClient.Do(req) if err != nil { return "", err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("failed to fetch file: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return "", err } bodyStr := string(body) patterns := []string{ `seek/([a-f0-9]{16})`, `"([a-f0-9]{16})"`, `'([a-f0-9]{16})'`, } for _, pattern := range patterns { re := regexp.MustCompile(pattern) matches := re.FindStringSubmatch(bodyStr) if len(matches) > 1 { return matches[1], nil } } return "", fmt.Errorf("no seek token found in file") } // testSeekToken tests if a seek token works by making a simple API call func (p *HLTBPlugin) testSeekToken(token string) bool { searchURL := fmt.Sprintf("https://howlongtobeat.com/api/seek/%s", token) searchRequest := map[string]interface{}{ "searchType": "games", "searchTerms": []string{"test"}, "searchPage": 1, "size": 1, "searchOptions": map[string]interface{}{ "games": map[string]interface{}{ "userId": 0, "platform": "", "sortCategory": "popular", "rangeCategory": "main", "rangeTime": map[string]interface{}{ "min": nil, "max": nil, }, "gameplay": map[string]interface{}{ "perspective": "", "flow": "", "genre": "", "difficulty": "", }, "rangeYear": map[string]interface{}{ "min": "", "max": "", }, "modifier": "", }, "users": map[string]interface{}{ "sortCategory": "postcount", }, "lists": map[string]interface{}{ "sortCategory": "follows", }, "filter": "", "sort": 0, "randomizer": 0, }, "useCache": true, } // Test the token with a simple search if _, err := p.performAPISearch(searchURL, searchRequest); err == nil { return true } return false } // generateSeekToken generates a seek token based on current time func (p *HLTBPlugin) generateSeekToken() string { // Use a simple hash-like approach with current timestamp // This is a fallback approach since the real token generation is unknown now := time.Now().Unix() return fmt.Sprintf("%x", now%0xffffffff)[:16] } // createErrorResponse creates an error response message func (p *HLTBPlugin) createErrorResponse(msg *model.Message, errorText string) []*model.MessageAction { response := &model.Message{ Text: fmt.Sprintf("āŒ %s", errorText), Chat: msg.Chat, ReplyTo: msg.ID, Channel: msg.Channel, } action := &model.MessageAction{ Type: model.ActionSendMessage, Message: response, Chat: msg.Chat, Channel: msg.Channel, } return []*model.MessageAction{action} }