Compare commits
1 commit
Author | SHA1 | Date | |
---|---|---|---|
ae3c9f665d |
24 changed files with 940 additions and 17 deletions
|
@ -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
83
internal/cache/cache.go
vendored
Normal 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
170
internal/cache/cache_test.go
vendored
Normal 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
BIN
internal/cache/test_cache.db
vendored
Normal file
Binary file not shown.
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -233,8 +233,9 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error {
|
||||||
|
|
||||||
// Prepare payload
|
// Prepare payload
|
||||||
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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
387
internal/plugin/fun/hltb.go
Normal 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}
|
||||||
|
}
|
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
|
// 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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
29
internal/testutil/mock_cache.go
Normal file
29
internal/testutil/mock_cache.go
Normal 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
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue