This commit is contained in:
parent
c53942ac53
commit
d09b763aa7
24 changed files with 941 additions and 17 deletions
|
@ -29,7 +29,7 @@ func NewCoin() *CoinPlugin {
|
|||
}
|
||||
|
||||
// OnMessage handles incoming messages
|
||||
func (p *CoinPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
|
||||
func (p *CoinPlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction {
|
||||
if !strings.Contains(strings.ToLower(msg.Text), "flip a coin") {
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -32,7 +32,7 @@ func NewDice() *DicePlugin {
|
|||
}
|
||||
|
||||
// OnMessage handles incoming messages
|
||||
func (p *DicePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
|
||||
func (p *DicePlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction {
|
||||
if !strings.HasPrefix(strings.TrimSpace(strings.ToLower(msg.Text)), "!dice") {
|
||||
return nil
|
||||
}
|
||||
|
|
391
internal/plugin/fun/hltb.go
Normal file
391
internal/plugin/fun/hltb.go
Normal file
|
@ -0,0 +1,391 @@
|
|||
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 <game name>",
|
||||
},
|
||||
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 <game name>")
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
131
internal/plugin/fun/hltb_test.go
Normal file
131
internal/plugin/fun/hltb_test.go
Normal file
|
@ -0,0 +1,131 @@
|
|||
package fun
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
||||
"git.nakama.town/fmartingr/butterrobot/internal/testutil"
|
||||
)
|
||||
|
||||
func TestHLTBPlugin_OnMessage(t *testing.T) {
|
||||
plugin := NewHLTB()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
messageText string
|
||||
shouldRespond bool
|
||||
}{
|
||||
{
|
||||
name: "responds to !hltb command",
|
||||
messageText: "!hltb The Witcher 3",
|
||||
shouldRespond: true,
|
||||
},
|
||||
{
|
||||
name: "ignores non-hltb messages",
|
||||
messageText: "hello world",
|
||||
shouldRespond: false,
|
||||
},
|
||||
{
|
||||
name: "ignores !hltb without game name",
|
||||
messageText: "!hltb",
|
||||
shouldRespond: false,
|
||||
},
|
||||
{
|
||||
name: "ignores !hltb with only spaces",
|
||||
messageText: "!hltb ",
|
||||
shouldRespond: false,
|
||||
},
|
||||
{
|
||||
name: "ignores similar but incorrect commands",
|
||||
messageText: "hltb The Witcher 3",
|
||||
shouldRespond: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
msg := &model.Message{
|
||||
Text: tt.messageText,
|
||||
Chat: "test-chat",
|
||||
Channel: &model.Channel{ID: 1},
|
||||
Author: "test-user",
|
||||
}
|
||||
|
||||
mockCache := &testutil.MockCache{}
|
||||
actions := plugin.OnMessage(msg, make(map[string]interface{}), mockCache)
|
||||
|
||||
if tt.shouldRespond && len(actions) == 0 {
|
||||
t.Errorf("Expected plugin to respond to '%s', but it didn't", tt.messageText)
|
||||
}
|
||||
|
||||
if !tt.shouldRespond && len(actions) > 0 {
|
||||
t.Errorf("Expected plugin to not respond to '%s', but it did", tt.messageText)
|
||||
}
|
||||
|
||||
// For messages that should respond, verify the response structure
|
||||
if tt.shouldRespond && len(actions) > 0 {
|
||||
action := actions[0]
|
||||
if action.Type != model.ActionSendMessage {
|
||||
t.Errorf("Expected ActionSendMessage, got %s", action.Type)
|
||||
}
|
||||
|
||||
if action.Message == nil {
|
||||
t.Error("Expected action to have a message")
|
||||
}
|
||||
|
||||
if action.Message != nil && action.Message.ReplyTo != msg.ID {
|
||||
t.Error("Expected response to reply to original message")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHLTBPlugin_formatTime(t *testing.T) {
|
||||
plugin := NewHLTB()
|
||||
|
||||
tests := []struct {
|
||||
seconds int
|
||||
expected string
|
||||
}{
|
||||
{0, "N/A"},
|
||||
{-1, "N/A"},
|
||||
{1800, "30 minutes"}, // 30 minutes
|
||||
{3600, "1.0 hour"}, // 1 hour
|
||||
{7200, "2.0 hours"}, // 2 hours
|
||||
{10800, "3.0 hours"}, // 3 hours
|
||||
{36000, "10.0 hours"}, // 10 hours
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.expected, func(t *testing.T) {
|
||||
result := plugin.formatTime(tt.seconds)
|
||||
if result != tt.expected {
|
||||
t.Errorf("formatTime(%d) = %s, want %s", tt.seconds, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHLTBPlugin_getFullImageURL(t *testing.T) {
|
||||
plugin := NewHLTB()
|
||||
|
||||
tests := []struct {
|
||||
imagePath string
|
||||
expected string
|
||||
}{
|
||||
{"", ""},
|
||||
{"game.jpg", "https://howlongtobeat.com/games/game.jpg"},
|
||||
{"/game.jpg", "https://howlongtobeat.com/games/game.jpg"},
|
||||
{"folder/game.png", "https://howlongtobeat.com/games/folder/game.png"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.imagePath, func(t *testing.T) {
|
||||
result := plugin.getFullImageURL(tt.imagePath)
|
||||
if result != tt.expected {
|
||||
t.Errorf("getFullImageURL(%s) = %s, want %s", tt.imagePath, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -24,7 +24,7 @@ func NewLoquito() *LoquitoPlugin {
|
|||
}
|
||||
|
||||
// OnMessage handles incoming messages
|
||||
func (p *LoquitoPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
|
||||
func (p *LoquitoPlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction {
|
||||
if !strings.Contains(strings.ToLower(msg.Text), "lo quito") {
|
||||
return nil
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue