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 } // HLTBSearchRequest represents the search request payload type HLTBSearchRequest struct { SearchType string `json:"searchType"` SearchTerms []string `json:"searchTerms"` SearchPage int `json:"searchPage"` Size int `json:"size"` SearchOptions map[string]interface{} `json:"searchOptions"` UseCache bool `json:"useCache"` } // 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"` } // HLTBSearchResponse represents the search response type HLTBSearchResponse 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"` } // 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, } // Add game cover as attachment if available if game.GameImage != "" { imageURL := p.getFullImageURL(game.GameImage) if responseMsg.Raw == nil { responseMsg.Raw = make(map[string]interface{}) } 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 func (p *HLTBPlugin) searchGame(gameName string) ([]HLTBGame, error) { // Split search terms by words searchTerms := strings.Fields(gameName) // Prepare search request searchRequest := HLTBSearchRequest{ 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, } // Convert to JSON jsonData, err := json.Marshal(searchRequest) if err != nil { return nil, fmt.Errorf("failed to marshal search request: %w", err) } // The API endpoint appears to have changed to use dynamic tokens // Try to get the seek token first, fallback to basic search seekToken, err := p.getSeekToken() if err != nil { // Fallback to old endpoint seekToken = "" } var apiURL string if seekToken != "" { apiURL = fmt.Sprintf("https://howlongtobeat.com/api/seek/%s", seekToken) } else { apiURL = "https://howlongtobeat.com/api/search" } // Create HTTP request req, err := http.NewRequest("POST", apiURL, 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 HLTBSearchResponse 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 attempts to retrieve the seek token from HowLongToBeat func (p *HLTBPlugin) getSeekToken() (string, error) { // Try to extract the seek token from the main page 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) } // Look for patterns that might contain the token patterns := []string{ `/api/seek/([a-f0-9]+)`, `"seek/([a-f0-9]+)"`, `seek/([a-f0-9]{12,})`, } bodyStr := string(body) for _, pattern := range patterns { re := regexp.MustCompile(pattern) matches := re.FindStringSubmatch(bodyStr) if len(matches) > 1 { return matches[1], nil } } // If we can't extract a token, return the known working one as fallback return "d4b2e330db04dbf3", nil } // 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} }