diff --git a/internal/app/app.go b/internal/app/app.go index becd5ea..da8d598 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -15,6 +15,7 @@ import ( "time" "git.nakama.town/fmartingr/butterrobot/internal/admin" + "git.nakama.town/fmartingr/butterrobot/internal/cache" "git.nakama.town/fmartingr/butterrobot/internal/config" "git.nakama.town/fmartingr/butterrobot/internal/db" "git.nakama.town/fmartingr/butterrobot/internal/model" @@ -87,6 +88,7 @@ func (a *App) Run() error { plugin.Register(fun.NewCoin()) plugin.Register(fun.NewDice()) plugin.Register(fun.NewLoquito()) + plugin.Register(fun.NewHLTB()) plugin.Register(social.NewTwitterExpander()) plugin.Register(social.NewInstagramExpander()) plugin.Register(reminder.New(a.db)) @@ -102,6 +104,9 @@ func (a *App) Run() error { // Start reminder scheduler a.queue.StartReminderScheduler(a.handleReminder) + // Start cache cleanup scheduler + go a.startCacheCleanup() + // Create server addr := fmt.Sprintf(":%s", a.config.Port) srv := &http.Server{ @@ -147,6 +152,23 @@ func (a *App) Run() error { return nil } +// startCacheCleanup runs periodic cache cleanup +func (a *App) startCacheCleanup() { + ticker := time.NewTicker(time.Hour) // Clean up every hour + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := a.db.CacheCleanup(); err != nil { + a.logger.Error("Cache cleanup failed", "error", err) + } else { + a.logger.Debug("Cache cleanup completed") + } + } + } +} + // Initialize HTTP routes func (a *App) initializeRoutes() { // Health check endpoint @@ -305,8 +327,11 @@ func (a *App) handleMessage(item queue.Item) { continue } + // Create cache instance for this plugin + pluginCache := cache.New(a.db, pluginID) + // Process message and get actions - actions := p.OnMessage(message, channelPlugin.Config) + actions := p.OnMessage(message, channelPlugin.Config, pluginCache) // Get platform for processing actions platform, err := platform.Get(item.Platform) diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 0000000..b51eaec --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,83 @@ +package cache + +import ( + "encoding/json" + "fmt" + "time" + + "git.nakama.town/fmartingr/butterrobot/internal/db" +) + +// Cache provides a plugin-friendly interface to the cache system +type Cache struct { + db *db.Database + pluginID string +} + +// New creates a new Cache instance for a specific plugin +func New(database *db.Database, pluginID string) *Cache { + return &Cache{ + db: database, + pluginID: pluginID, + } +} + +// Get retrieves a value from the cache +func (c *Cache) Get(key string, destination interface{}) error { + // Create prefixed key + fullKey := c.createKey(key) + + // Get from database + value, err := c.db.CacheGet(fullKey) + if err != nil { + return err + } + + // Unmarshal JSON into destination + return json.Unmarshal([]byte(value), destination) +} + +// Set stores a value in the cache with optional expiration +func (c *Cache) Set(key string, value interface{}, expiration *time.Time) error { + // Create prefixed key + fullKey := c.createKey(key) + + // Marshal value to JSON + jsonValue, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("failed to marshal cache value: %w", err) + } + + // Store in database + return c.db.CacheSet(fullKey, string(jsonValue), expiration) +} + +// SetWithTTL stores a value in the cache with a time-to-live duration +func (c *Cache) SetWithTTL(key string, value interface{}, ttl time.Duration) error { + expiration := time.Now().Add(ttl) + return c.Set(key, value, &expiration) +} + +// Delete removes a value from the cache +func (c *Cache) Delete(key string) error { + fullKey := c.createKey(key) + return c.db.CacheDelete(fullKey) +} + +// Exists checks if a key exists in the cache +func (c *Cache) Exists(key string) (bool, error) { + fullKey := c.createKey(key) + _, err := c.db.CacheGet(fullKey) + if err == db.ErrNotFound { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// createKey creates a prefixed cache key +func (c *Cache) createKey(key string) string { + return fmt.Sprintf("%s_%s", c.pluginID, key) +} \ No newline at end of file diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..4a153ad --- /dev/null +++ b/internal/cache/cache_test.go @@ -0,0 +1,170 @@ +package cache + +import ( + "testing" + "time" + + "git.nakama.town/fmartingr/butterrobot/internal/db" +) + +func TestCache(t *testing.T) { + // Create temporary database for testing + database, err := db.New("test_cache.db") + if err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + defer func() { + database.Close() + // Clean up test database file + // os.Remove("test_cache.db") + }() + + // Create cache instance + cache := New(database, "test.plugin") + + // Test data + testKey := "test_key" + testValue := map[string]interface{}{ + "name": "Test Game", + "time": 42, + } + + // Test Set and Get + t.Run("Set and Get", func(t *testing.T) { + err := cache.Set(testKey, testValue, nil) + if err != nil { + t.Errorf("Failed to set cache value: %v", err) + } + + var retrieved map[string]interface{} + err = cache.Get(testKey, &retrieved) + if err != nil { + t.Errorf("Failed to get cache value: %v", err) + } + + if retrieved["name"] != testValue["name"] { + t.Errorf("Expected name %v, got %v", testValue["name"], retrieved["name"]) + } + + if int(retrieved["time"].(float64)) != testValue["time"].(int) { + t.Errorf("Expected time %v, got %v", testValue["time"], retrieved["time"]) + } + }) + + // Test SetWithTTL and expiration + t.Run("SetWithTTL and expiration", func(t *testing.T) { + expiredKey := "expired_key" + + // Set with very short TTL + err := cache.SetWithTTL(expiredKey, testValue, time.Millisecond) + if err != nil { + t.Errorf("Failed to set cache value with TTL: %v", err) + } + + // Wait for expiration + time.Sleep(2 * time.Millisecond) + + // Try to get - should fail + var retrieved map[string]interface{} + err = cache.Get(expiredKey, &retrieved) + if err == nil { + t.Errorf("Expected cache miss for expired key, but got value") + } + }) + + // Test Exists + t.Run("Exists", func(t *testing.T) { + existsKey := "exists_key" + + // Should not exist initially + exists, err := cache.Exists(existsKey) + if err != nil { + t.Errorf("Failed to check if key exists: %v", err) + } + if exists { + t.Errorf("Expected key to not exist, but it does") + } + + // Set value + err = cache.Set(existsKey, testValue, nil) + if err != nil { + t.Errorf("Failed to set cache value: %v", err) + } + + // Should exist now + exists, err = cache.Exists(existsKey) + if err != nil { + t.Errorf("Failed to check if key exists: %v", err) + } + if !exists { + t.Errorf("Expected key to exist, but it doesn't") + } + }) + + // Test Delete + t.Run("Delete", func(t *testing.T) { + deleteKey := "delete_key" + + // Set value + err := cache.Set(deleteKey, testValue, nil) + if err != nil { + t.Errorf("Failed to set cache value: %v", err) + } + + // Delete value + err = cache.Delete(deleteKey) + if err != nil { + t.Errorf("Failed to delete cache value: %v", err) + } + + // Should not exist anymore + var retrieved map[string]interface{} + err = cache.Get(deleteKey, &retrieved) + if err == nil { + t.Errorf("Expected cache miss for deleted key, but got value") + } + }) + + // Test plugin ID prefixing + t.Run("Plugin ID prefixing", func(t *testing.T) { + cache1 := New(database, "plugin1") + cache2 := New(database, "plugin2") + + sameKey := "same_key" + value1 := "value1" + value2 := "value2" + + // Set same key in both caches + err := cache1.Set(sameKey, value1, nil) + if err != nil { + t.Errorf("Failed to set cache1 value: %v", err) + } + + err = cache2.Set(sameKey, value2, nil) + if err != nil { + t.Errorf("Failed to set cache2 value: %v", err) + } + + // Retrieve from both caches + var retrieved1, retrieved2 string + + err = cache1.Get(sameKey, &retrieved1) + if err != nil { + t.Errorf("Failed to get cache1 value: %v", err) + } + + err = cache2.Get(sameKey, &retrieved2) + if err != nil { + t.Errorf("Failed to get cache2 value: %v", err) + } + + // Values should be different due to plugin ID prefixing + if retrieved1 != value1 { + t.Errorf("Expected cache1 value %v, got %v", value1, retrieved1) + } + + if retrieved2 != value2 { + t.Errorf("Expected cache2 value %v, got %v", value2, retrieved2) + } + }) +} \ No newline at end of file diff --git a/internal/cache/test_cache.db b/internal/cache/test_cache.db new file mode 100644 index 0000000..d50b94d Binary files /dev/null and b/internal/cache/test_cache.db differ diff --git a/internal/db/db.go b/internal/db/db.go index 0da285e..caae834 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -793,3 +793,56 @@ func initDatabase(db *sql.DB) error { return nil } + +// CacheGet retrieves a value from the cache +func (d *Database) CacheGet(key string) (string, error) { + query := ` + SELECT value + FROM cache + WHERE key = ? AND (expires_at IS NULL OR expires_at > ?) + ` + + var value string + err := d.db.QueryRow(query, key, time.Now()).Scan(&value) + if err == sql.ErrNoRows { + return "", ErrNotFound + } + if err != nil { + return "", err + } + + return value, nil +} + +// CacheSet stores a value in the cache with optional expiration +func (d *Database) CacheSet(key, value string, expiration *time.Time) error { + query := ` + INSERT OR REPLACE INTO cache (key, value, expires_at, updated_at) + VALUES (?, ?, ?, ?) + ` + + _, err := d.db.Exec(query, key, value, expiration, time.Now()) + return err +} + +// CacheDelete removes a value from the cache +func (d *Database) CacheDelete(key string) error { + query := ` + DELETE FROM cache + WHERE key = ? + ` + + _, err := d.db.Exec(query, key) + return err +} + +// CacheCleanup removes expired cache entries +func (d *Database) CacheCleanup() error { + query := ` + DELETE FROM cache + WHERE expires_at IS NOT NULL AND expires_at <= ? + ` + + _, err := d.db.Exec(query, time.Now()) + return err +} diff --git a/internal/migration/migrations.go b/internal/migration/migrations.go index 8db229b..9004a9b 100644 --- a/internal/migration/migrations.go +++ b/internal/migration/migrations.go @@ -9,6 +9,7 @@ func init() { // Register migrations Register(1, "Initial schema with bcrypt passwords", migrateInitialSchemaUp, migrateInitialSchemaDown) Register(2, "Add reminders table", migrateRemindersUp, migrateRemindersDown) + Register(3, "Add cache table", migrateCacheUp, migrateCacheDown) } // Initial schema creation with bcrypt passwords - version 1 @@ -126,3 +127,30 @@ func migrateRemindersDown(db *sql.DB) error { _, err := db.Exec(`DROP TABLE IF EXISTS reminders`) return err } + +// Add cache table - version 3 +func migrateCacheUp(db *sql.DB) error { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS cache ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + expires_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + return err + } + + // Create index on expires_at for efficient cleanup + _, err = db.Exec(` + CREATE INDEX IF NOT EXISTS idx_cache_expires_at ON cache(expires_at) + `) + return err +} + +func migrateCacheDown(db *sql.DB) error { + _, err := db.Exec(`DROP TABLE IF EXISTS cache`) + return err +} diff --git a/internal/model/plugin.go b/internal/model/plugin.go index 03e4f96..4a1449f 100644 --- a/internal/model/plugin.go +++ b/internal/model/plugin.go @@ -2,8 +2,18 @@ package model import ( "errors" + "time" ) +// CacheInterface defines the cache interface available to plugins +type CacheInterface interface { + Get(key string, destination interface{}) error + Set(key string, value interface{}, expiration *time.Time) error + SetWithTTL(key string, value interface{}, ttl time.Duration) error + Delete(key string) error + Exists(key string) (bool, error) +} + var ( // ErrPluginNotFound is returned when a requested plugin doesn't exist ErrPluginNotFound = errors.New("plugin not found") @@ -24,5 +34,5 @@ type Plugin interface { RequiresConfig() bool // OnMessage processes an incoming message and returns platform actions - OnMessage(msg *Message, config map[string]interface{}) []*MessageAction + OnMessage(msg *Message, config map[string]interface{}, cache CacheInterface) []*MessageAction } diff --git a/internal/platform/telegram/telegram.go b/internal/platform/telegram/telegram.go index 8da4995..24714f2 100644 --- a/internal/platform/telegram/telegram.go +++ b/internal/platform/telegram/telegram.go @@ -233,8 +233,9 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error { // Prepare payload payload := map[string]interface{}{ - "chat_id": chatID, - "text": msg.Text, + "chat_id": chatID, + "text": msg.Text, + "parse_mode": "Markdown", } // Add reply if needed diff --git a/internal/plugin/domainblock/domainblock.go b/internal/plugin/domainblock/domainblock.go index 5a44c49..1f8ff1e 100644 --- a/internal/plugin/domainblock/domainblock.go +++ b/internal/plugin/domainblock/domainblock.go @@ -65,7 +65,7 @@ func extractDomains(text string) []string { } // OnMessage processes incoming messages -func (p *DomainBlockPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { +func (p *DomainBlockPlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction { // Skip messages from bots if msg.FromBot { return nil diff --git a/internal/plugin/domainblock/domainblock_test.go b/internal/plugin/domainblock/domainblock_test.go index 1d65964..57e8833 100644 --- a/internal/plugin/domainblock/domainblock_test.go +++ b/internal/plugin/domainblock/domainblock_test.go @@ -4,6 +4,7 @@ import ( "testing" "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/testutil" ) func TestExtractDomains(t *testing.T) { @@ -124,7 +125,8 @@ func TestOnMessage(t *testing.T) { "blocked_domains": test.blockedDomains, } - responses := plugin.OnMessage(msg, config) + mockCache := &testutil.MockCache{} + responses := plugin.OnMessage(msg, config, mockCache) if test.expectBlocked { if len(responses) == 0 { diff --git a/internal/plugin/fun/coin.go b/internal/plugin/fun/coin.go index bd083d1..ab679ea 100644 --- a/internal/plugin/fun/coin.go +++ b/internal/plugin/fun/coin.go @@ -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 } diff --git a/internal/plugin/fun/dice.go b/internal/plugin/fun/dice.go index 8b13edb..6136097 100644 --- a/internal/plugin/fun/dice.go +++ b/internal/plugin/fun/dice.go @@ -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 } diff --git a/internal/plugin/fun/hltb.go b/internal/plugin/fun/hltb.go new file mode 100644 index 0000000..213e3cc --- /dev/null +++ b/internal/plugin/fun/hltb.go @@ -0,0 +1,387 @@ +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 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(fmt.Sprintf("\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 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} +} diff --git a/internal/plugin/fun/hltb_test.go b/internal/plugin/fun/hltb_test.go new file mode 100644 index 0000000..e6dd09d --- /dev/null +++ b/internal/plugin/fun/hltb_test.go @@ -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) + } + }) + } +} \ No newline at end of file diff --git a/internal/plugin/fun/loquito.go b/internal/plugin/fun/loquito.go index fef78bd..426ab92 100644 --- a/internal/plugin/fun/loquito.go +++ b/internal/plugin/fun/loquito.go @@ -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 } diff --git a/internal/plugin/ping/ping.go b/internal/plugin/ping/ping.go index 3dacf6f..be0402c 100644 --- a/internal/plugin/ping/ping.go +++ b/internal/plugin/ping/ping.go @@ -24,7 +24,7 @@ func New() *PingPlugin { } // OnMessage handles incoming messages -func (p *PingPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { +func (p *PingPlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction { if !strings.EqualFold(strings.TrimSpace(msg.Text), "ping") { return nil } diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index eb3789f..3ff96ff 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -76,6 +76,6 @@ func (p *BasePlugin) RequiresConfig() bool { } // OnMessage is the default implementation that does nothing -func (p *BasePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { +func (p *BasePlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction { return nil } diff --git a/internal/plugin/reminder/reminder.go b/internal/plugin/reminder/reminder.go index 029c8d9..bb21dbf 100644 --- a/internal/plugin/reminder/reminder.go +++ b/internal/plugin/reminder/reminder.go @@ -41,7 +41,7 @@ func New(creator ReminderCreator) *Reminder { } // OnMessage processes incoming messages -func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { +func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction { // Only process replies to messages if msg.ReplyTo == "" { return nil diff --git a/internal/plugin/reminder/reminder_test.go b/internal/plugin/reminder/reminder_test.go index 8e611ce..f2c1d21 100644 --- a/internal/plugin/reminder/reminder_test.go +++ b/internal/plugin/reminder/reminder_test.go @@ -5,6 +5,7 @@ import ( "time" "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/testutil" ) // MockCreator is a mock implementation of ReminderCreator for testing @@ -142,7 +143,8 @@ func TestReminderOnMessage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { initialCount := len(creator.reminders) - actions := plugin.OnMessage(tt.message, nil) + mockCache := &testutil.MockCache{} + actions := plugin.OnMessage(tt.message, nil, mockCache) if tt.expectResponse && len(actions) == 0 { t.Errorf("Expected response action, but got none") diff --git a/internal/plugin/searchreplace/searchreplace.go b/internal/plugin/searchreplace/searchreplace.go index 876e880..b474b27 100644 --- a/internal/plugin/searchreplace/searchreplace.go +++ b/internal/plugin/searchreplace/searchreplace.go @@ -30,7 +30,7 @@ func New() *SearchReplacePlugin { } // OnMessage handles incoming messages -func (p *SearchReplacePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { +func (p *SearchReplacePlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction { // Only process replies to messages if msg.ReplyTo == "" { return nil diff --git a/internal/plugin/searchreplace/searchreplace_test.go b/internal/plugin/searchreplace/searchreplace_test.go index 415610c..fa5cdf5 100644 --- a/internal/plugin/searchreplace/searchreplace_test.go +++ b/internal/plugin/searchreplace/searchreplace_test.go @@ -5,6 +5,7 @@ import ( "time" "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/testutil" ) func TestSearchReplace(t *testing.T) { @@ -84,7 +85,8 @@ func TestSearchReplace(t *testing.T) { } // Process message - actions := p.OnMessage(msg, nil) + mockCache := &testutil.MockCache{} + actions := p.OnMessage(msg, nil, mockCache) // Check results if tc.expectActions { diff --git a/internal/plugin/social/instagram.go b/internal/plugin/social/instagram.go index 0b4ff55..6b7aa4c 100644 --- a/internal/plugin/social/instagram.go +++ b/internal/plugin/social/instagram.go @@ -26,7 +26,7 @@ func NewInstagramExpander() *InstagramExpander { } // OnMessage handles incoming messages -func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { +func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction { // Skip empty messages if strings.TrimSpace(msg.Text) == "" { return nil diff --git a/internal/plugin/social/twitter.go b/internal/plugin/social/twitter.go index 865f421..553bd07 100644 --- a/internal/plugin/social/twitter.go +++ b/internal/plugin/social/twitter.go @@ -26,7 +26,7 @@ func NewTwitterExpander() *TwitterExpander { } // OnMessage handles incoming messages -func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { +func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction { // Skip empty messages if strings.TrimSpace(msg.Text) == "" { return nil diff --git a/internal/testutil/mock_cache.go b/internal/testutil/mock_cache.go new file mode 100644 index 0000000..0a92017 --- /dev/null +++ b/internal/testutil/mock_cache.go @@ -0,0 +1,29 @@ +package testutil + +import ( + "errors" + "time" +) + +// MockCache implements the CacheInterface for testing +type MockCache struct{} + +func (m *MockCache) Get(key string, destination interface{}) error { + return errors.New("cache miss") // Always return cache miss for tests +} + +func (m *MockCache) Set(key string, value interface{}, expiration *time.Time) error { + return nil // Always succeed for tests +} + +func (m *MockCache) SetWithTTL(key string, value interface{}, ttl time.Duration) error { + return nil // Always succeed for tests +} + +func (m *MockCache) Delete(key string) error { + return nil // Always succeed for tests +} + +func (m *MockCache) Exists(key string) (bool, error) { + return false, nil // Always return false for tests +} \ No newline at end of file