Compare commits

...

1 commit

Author SHA1 Message Date
ae3c9f665d
feat: hltb plugin
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/tag/release Pipeline was successful
2025-06-12 14:49:15 +02:00
24 changed files with 940 additions and 17 deletions

View file

@ -15,6 +15,7 @@ import (
"time" "time"
"git.nakama.town/fmartingr/butterrobot/internal/admin" "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/config"
"git.nakama.town/fmartingr/butterrobot/internal/db" "git.nakama.town/fmartingr/butterrobot/internal/db"
"git.nakama.town/fmartingr/butterrobot/internal/model" "git.nakama.town/fmartingr/butterrobot/internal/model"
@ -87,6 +88,7 @@ func (a *App) Run() error {
plugin.Register(fun.NewCoin()) plugin.Register(fun.NewCoin())
plugin.Register(fun.NewDice()) plugin.Register(fun.NewDice())
plugin.Register(fun.NewLoquito()) plugin.Register(fun.NewLoquito())
plugin.Register(fun.NewHLTB())
plugin.Register(social.NewTwitterExpander()) plugin.Register(social.NewTwitterExpander())
plugin.Register(social.NewInstagramExpander()) plugin.Register(social.NewInstagramExpander())
plugin.Register(reminder.New(a.db)) plugin.Register(reminder.New(a.db))
@ -102,6 +104,9 @@ func (a *App) Run() error {
// Start reminder scheduler // Start reminder scheduler
a.queue.StartReminderScheduler(a.handleReminder) a.queue.StartReminderScheduler(a.handleReminder)
// Start cache cleanup scheduler
go a.startCacheCleanup()
// Create server // Create server
addr := fmt.Sprintf(":%s", a.config.Port) addr := fmt.Sprintf(":%s", a.config.Port)
srv := &http.Server{ srv := &http.Server{
@ -147,6 +152,23 @@ func (a *App) Run() error {
return nil 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 // Initialize HTTP routes
func (a *App) initializeRoutes() { func (a *App) initializeRoutes() {
// Health check endpoint // Health check endpoint
@ -305,8 +327,11 @@ func (a *App) handleMessage(item queue.Item) {
continue continue
} }
// Create cache instance for this plugin
pluginCache := cache.New(a.db, pluginID)
// Process message and get actions // Process message and get actions
actions := p.OnMessage(message, channelPlugin.Config) actions := p.OnMessage(message, channelPlugin.Config, pluginCache)
// Get platform for processing actions // Get platform for processing actions
platform, err := platform.Get(item.Platform) platform, err := platform.Get(item.Platform)

83
internal/cache/cache.go vendored Normal file
View file

@ -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)
}

170
internal/cache/cache_test.go vendored Normal file
View file

@ -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)
}
})
}

BIN
internal/cache/test_cache.db vendored Normal file

Binary file not shown.

View file

@ -793,3 +793,56 @@ func initDatabase(db *sql.DB) error {
return nil 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
}

View file

@ -9,6 +9,7 @@ func init() {
// Register migrations // Register migrations
Register(1, "Initial schema with bcrypt passwords", migrateInitialSchemaUp, migrateInitialSchemaDown) Register(1, "Initial schema with bcrypt passwords", migrateInitialSchemaUp, migrateInitialSchemaDown)
Register(2, "Add reminders table", migrateRemindersUp, migrateRemindersDown) Register(2, "Add reminders table", migrateRemindersUp, migrateRemindersDown)
Register(3, "Add cache table", migrateCacheUp, migrateCacheDown)
} }
// Initial schema creation with bcrypt passwords - version 1 // 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`) _, err := db.Exec(`DROP TABLE IF EXISTS reminders`)
return err 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
}

View file

@ -2,8 +2,18 @@ package model
import ( import (
"errors" "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 ( var (
// ErrPluginNotFound is returned when a requested plugin doesn't exist // ErrPluginNotFound is returned when a requested plugin doesn't exist
ErrPluginNotFound = errors.New("plugin not found") ErrPluginNotFound = errors.New("plugin not found")
@ -24,5 +34,5 @@ type Plugin interface {
RequiresConfig() bool RequiresConfig() bool
// OnMessage processes an incoming message and returns platform actions // 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
} }

View file

@ -235,6 +235,7 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error {
payload := map[string]interface{}{ payload := map[string]interface{}{
"chat_id": chatID, "chat_id": chatID,
"text": msg.Text, "text": msg.Text,
"parse_mode": "Markdown",
} }
// Add reply if needed // Add reply if needed

View file

@ -65,7 +65,7 @@ func extractDomains(text string) []string {
} }
// OnMessage processes incoming messages // 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 // Skip messages from bots
if msg.FromBot { if msg.FromBot {
return nil return nil

View file

@ -4,6 +4,7 @@ import (
"testing" "testing"
"git.nakama.town/fmartingr/butterrobot/internal/model" "git.nakama.town/fmartingr/butterrobot/internal/model"
"git.nakama.town/fmartingr/butterrobot/internal/testutil"
) )
func TestExtractDomains(t *testing.T) { func TestExtractDomains(t *testing.T) {
@ -124,7 +125,8 @@ func TestOnMessage(t *testing.T) {
"blocked_domains": test.blockedDomains, "blocked_domains": test.blockedDomains,
} }
responses := plugin.OnMessage(msg, config) mockCache := &testutil.MockCache{}
responses := plugin.OnMessage(msg, config, mockCache)
if test.expectBlocked { if test.expectBlocked {
if len(responses) == 0 { if len(responses) == 0 {

View file

@ -29,7 +29,7 @@ func NewCoin() *CoinPlugin {
} }
// OnMessage handles incoming messages // 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") { if !strings.Contains(strings.ToLower(msg.Text), "flip a coin") {
return nil return nil
} }

View file

@ -32,7 +32,7 @@ func NewDice() *DicePlugin {
} }
// OnMessage handles incoming messages // 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") { if !strings.HasPrefix(strings.TrimSpace(strings.ToLower(msg.Text)), "!dice") {
return nil return nil
} }

387
internal/plugin/fun/hltb.go Normal file
View file

@ -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 <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 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}
}

View 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)
}
})
}
}

View file

@ -24,7 +24,7 @@ func NewLoquito() *LoquitoPlugin {
} }
// OnMessage handles incoming messages // 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") { if !strings.Contains(strings.ToLower(msg.Text), "lo quito") {
return nil return nil
} }

View file

@ -24,7 +24,7 @@ func New() *PingPlugin {
} }
// OnMessage handles incoming messages // 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") { if !strings.EqualFold(strings.TrimSpace(msg.Text), "ping") {
return nil return nil
} }

View file

@ -76,6 +76,6 @@ func (p *BasePlugin) RequiresConfig() bool {
} }
// OnMessage is the default implementation that does nothing // 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 return nil
} }

View file

@ -41,7 +41,7 @@ func New(creator ReminderCreator) *Reminder {
} }
// OnMessage processes incoming messages // 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 // Only process replies to messages
if msg.ReplyTo == "" { if msg.ReplyTo == "" {
return nil return nil

View file

@ -5,6 +5,7 @@ import (
"time" "time"
"git.nakama.town/fmartingr/butterrobot/internal/model" "git.nakama.town/fmartingr/butterrobot/internal/model"
"git.nakama.town/fmartingr/butterrobot/internal/testutil"
) )
// MockCreator is a mock implementation of ReminderCreator for testing // MockCreator is a mock implementation of ReminderCreator for testing
@ -142,7 +143,8 @@ func TestReminderOnMessage(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
initialCount := len(creator.reminders) 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 { if tt.expectResponse && len(actions) == 0 {
t.Errorf("Expected response action, but got none") t.Errorf("Expected response action, but got none")

View file

@ -30,7 +30,7 @@ func New() *SearchReplacePlugin {
} }
// OnMessage handles incoming messages // 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 // Only process replies to messages
if msg.ReplyTo == "" { if msg.ReplyTo == "" {
return nil return nil

View file

@ -5,6 +5,7 @@ import (
"time" "time"
"git.nakama.town/fmartingr/butterrobot/internal/model" "git.nakama.town/fmartingr/butterrobot/internal/model"
"git.nakama.town/fmartingr/butterrobot/internal/testutil"
) )
func TestSearchReplace(t *testing.T) { func TestSearchReplace(t *testing.T) {
@ -84,7 +85,8 @@ func TestSearchReplace(t *testing.T) {
} }
// Process message // Process message
actions := p.OnMessage(msg, nil) mockCache := &testutil.MockCache{}
actions := p.OnMessage(msg, nil, mockCache)
// Check results // Check results
if tc.expectActions { if tc.expectActions {

View file

@ -26,7 +26,7 @@ func NewInstagramExpander() *InstagramExpander {
} }
// OnMessage handles incoming messages // 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 // Skip empty messages
if strings.TrimSpace(msg.Text) == "" { if strings.TrimSpace(msg.Text) == "" {
return nil return nil

View file

@ -26,7 +26,7 @@ func NewTwitterExpander() *TwitterExpander {
} }
// OnMessage handles incoming messages // 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 // Skip empty messages
if strings.TrimSpace(msg.Text) == "" { if strings.TrimSpace(msg.Text) == "" {
return nil return nil

View file

@ -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
}