From 72c6dd69823b680e1201b990e60215ce9205fd33 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Tue, 22 Apr 2025 11:29:39 +0200 Subject: [PATCH 01/31] feat: remindme plugin --- docs/plugins.md | 4 + internal/admin/admin.go | 8 +- internal/app/app.go | 79 ++++++++++ internal/db/db.go | 127 ++++++++++++++- internal/migration/migration.go | 2 +- internal/migration/migrations.go | 32 +++- internal/model/message.go | 53 ++++--- internal/model/plugin.go | 10 +- internal/platform/telegram/telegram.go | 10 +- internal/plugin/reminder/reminder.go | 178 ++++++++++++++++++++++ internal/plugin/reminder/reminder_test.go | 164 ++++++++++++++++++++ internal/queue/queue.go | 76 ++++++++- 12 files changed, 695 insertions(+), 48 deletions(-) create mode 100644 internal/plugin/reminder/reminder.go create mode 100644 internal/plugin/reminder/reminder_test.go diff --git a/docs/plugins.md b/docs/plugins.md index 2988f80..84578e5 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -10,6 +10,10 @@ - Dice: Put `!dice` and wathever roll you want to perform. - Coin: Flip a coin and get heads or tails. +### Utility + +- Remind Me: Reply to a message with `!remindme ` to set a reminder. Supported duration units: y (years), mo (months), d (days), h (hours), m (minutes), s (seconds). Examples: `!remindme 1y` for 1 year, `!remindme 3mo` for 3 months, `!remindme 2d` for 2 days, `!remindme 3h` for 3 hours. The bot will mention you with a reminder after the specified time. + ### Social Media - Twitter Link Expander: Automatically converts twitter.com and x.com links to fxtwitter.com links and removes tracking parameters. This allows for better media embedding in chat platforms. diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 822495a..c2a78ca 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -106,19 +106,19 @@ func New(cfg *config.Config, database *db.Database, version string) *Admin { if err != nil { panic(err) } - + // Create a clone of the base template t, err := baseTemplate.Clone() if err != nil { panic(err) } - + // Parse the template content t, err = t.Parse(string(content)) if err != nil { panic(err) } - + templates[tf] = t } @@ -362,7 +362,7 @@ func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/login", http.StatusSeeOther) return } - + session.Values = make(map[interface{}]interface{}) session.Options.MaxAge = -1 // Delete session err = session.Save(r, w) diff --git a/internal/app/app.go b/internal/app/app.go index 5126672..1d878ab 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -17,10 +17,12 @@ import ( "git.nakama.town/fmartingr/butterrobot/internal/admin" "git.nakama.town/fmartingr/butterrobot/internal/config" "git.nakama.town/fmartingr/butterrobot/internal/db" + "git.nakama.town/fmartingr/butterrobot/internal/model" "git.nakama.town/fmartingr/butterrobot/internal/platform" "git.nakama.town/fmartingr/butterrobot/internal/plugin" "git.nakama.town/fmartingr/butterrobot/internal/plugin/fun" "git.nakama.town/fmartingr/butterrobot/internal/plugin/ping" + "git.nakama.town/fmartingr/butterrobot/internal/plugin/reminder" "git.nakama.town/fmartingr/butterrobot/internal/plugin/social" "git.nakama.town/fmartingr/butterrobot/internal/queue" ) @@ -86,12 +88,19 @@ func (a *App) Run() error { plugin.Register(social.NewTwitterExpander()) plugin.Register(social.NewInstagramExpander()) + // Register reminder plugin + reminderPlugin := reminder.New(a.db) + plugin.Register(reminderPlugin) + // Initialize routes a.initializeRoutes() // Start message queue worker a.queue.Start(a.handleMessage) + // Start reminder scheduler + a.queue.StartReminderScheduler(a.handleReminder) + // Create server addr := fmt.Sprintf(":%s", a.config.Port) srv := &http.Server{ @@ -304,3 +313,73 @@ func (a *App) handleMessage(item queue.Item) { } } } + +// handleReminder handles reminder processing +func (a *App) handleReminder(reminder *model.Reminder) { + // When called with nil, it means we should check for pending reminders + if reminder == nil { + // Get pending reminders + reminders, err := a.db.GetPendingReminders() + if err != nil { + a.logger.Error("Error getting pending reminders", "error", err) + return + } + + // Process each reminder + for _, r := range reminders { + a.processReminder(r) + } + return + } + + // Otherwise, process the specific reminder + a.processReminder(reminder) +} + +// processReminder processes an individual reminder +func (a *App) processReminder(reminder *model.Reminder) { + a.logger.Info("Processing reminder", + "id", reminder.ID, + "platform", reminder.Platform, + "channel", reminder.ChannelID, + "trigger_at", reminder.TriggerAt, + ) + + // Get the platform handler + p, err := platform.Get(reminder.Platform) + if err != nil { + a.logger.Error("Error getting platform for reminder", "error", err, "platform", reminder.Platform) + return + } + + // Get the channel + channel, err := a.db.GetChannelByPlatform(reminder.Platform, reminder.ChannelID) + if err != nil { + a.logger.Error("Error getting channel for reminder", "error", err) + return + } + + // Create the reminder message + reminderText := fmt.Sprintf("@%s reminding you of this", reminder.Username) + + message := &model.Message{ + Text: reminderText, + Chat: reminder.ChannelID, + Channel: channel, + Author: "bot", + FromBot: true, + Date: time.Now(), + ReplyTo: reminder.ReplyToID, // Reply to the original message + } + + // Send the reminder message + if err := p.SendMessage(message); err != nil { + a.logger.Error("Error sending reminder", "error", err) + return + } + + // Mark the reminder as processed + if err := a.db.MarkReminderAsProcessed(reminder.ID); err != nil { + a.logger.Error("Error marking reminder as processed", "error", err) + } +} diff --git a/internal/db/db.go b/internal/db/db.go index 8cdce4a..b71b543 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "time" "golang.org/x/crypto/bcrypt" _ "modernc.org/sqlite" @@ -591,6 +592,120 @@ func (d *Database) UpdateUserPassword(userID int64, newPassword string) error { return err } +// CreateReminder creates a new reminder +func (d *Database) CreateReminder(platform, channelID, messageID, replyToID, userID, username, content string, triggerAt time.Time) (*model.Reminder, error) { + query := ` + INSERT INTO reminders ( + platform, channel_id, message_id, reply_to_id, + user_id, username, created_at, trigger_at, + content, processed + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0) + ` + + createdAt := time.Now() + result, err := d.db.Exec( + query, + platform, channelID, messageID, replyToID, + userID, username, createdAt, triggerAt, + content, + ) + if err != nil { + return nil, err + } + + id, err := result.LastInsertId() + if err != nil { + return nil, err + } + + return &model.Reminder{ + ID: id, + Platform: platform, + ChannelID: channelID, + MessageID: messageID, + ReplyToID: replyToID, + UserID: userID, + Username: username, + CreatedAt: createdAt, + TriggerAt: triggerAt, + Content: content, + Processed: false, + }, nil +} + +// GetPendingReminders gets all pending reminders that need to be processed +func (d *Database) GetPendingReminders() ([]*model.Reminder, error) { + query := ` + SELECT id, platform, channel_id, message_id, reply_to_id, + user_id, username, created_at, trigger_at, content, processed + FROM reminders + WHERE processed = 0 AND trigger_at <= ? + ` + + rows, err := d.db.Query(query, time.Now()) + if err != nil { + return nil, err + } + defer rows.Close() + + var reminders []*model.Reminder + + for rows.Next() { + var ( + id int64 + platform, channelID, messageID, replyToID string + userID, username, content string + createdAt, triggerAt time.Time + processed bool + ) + + if err := rows.Scan( + &id, &platform, &channelID, &messageID, &replyToID, + &userID, &username, &createdAt, &triggerAt, &content, &processed, + ); err != nil { + return nil, err + } + + reminder := &model.Reminder{ + ID: id, + Platform: platform, + ChannelID: channelID, + MessageID: messageID, + ReplyToID: replyToID, + UserID: userID, + Username: username, + CreatedAt: createdAt, + TriggerAt: triggerAt, + Content: content, + Processed: processed, + } + + reminders = append(reminders, reminder) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + if len(reminders) == 0 { + return make([]*model.Reminder, 0), nil + } + + return reminders, nil +} + +// MarkReminderAsProcessed marks a reminder as processed +func (d *Database) MarkReminderAsProcessed(id int64) error { + query := ` + UPDATE reminders + SET processed = 1 + WHERE id = ? + ` + + _, err := d.db.Exec(query, id) + return err +} + // Helper function to hash password func hashPassword(password string) (string, error) { // Use bcrypt for secure password hashing @@ -609,25 +724,25 @@ func initDatabase(db *sql.DB) error { if err := migration.EnsureMigrationTable(db); err != nil { return fmt.Errorf("failed to create migration table: %w", err) } - + // Get applied migrations applied, err := migration.GetAppliedMigrations(db) if err != nil { return fmt.Errorf("failed to get applied migrations: %w", err) } - + // Get all migration versions allMigrations := make([]int, 0, len(migration.Migrations)) for version := range migration.Migrations { allMigrations = append(allMigrations, version) } - + // Create a map of applied migrations for quick lookup appliedMap := make(map[int]bool) for _, version := range applied { appliedMap[version] = true } - + // Count pending migrations pendingCount := 0 for _, version := range allMigrations { @@ -635,7 +750,7 @@ func initDatabase(db *sql.DB) error { pendingCount++ } } - + // Run migrations if needed if pendingCount > 0 { fmt.Printf("Running %d pending database migrations...\n", pendingCount) @@ -646,6 +761,6 @@ func initDatabase(db *sql.DB) error { } else { fmt.Println("Database schema is up to date.") } - + return nil } diff --git a/internal/migration/migration.go b/internal/migration/migration.go index 44096f3..dec4ff5 100644 --- a/internal/migration/migration.go +++ b/internal/migration/migration.go @@ -208,4 +208,4 @@ func MigrateDown(db *sql.DB, targetVersion int) error { } return nil -} \ No newline at end of file +} diff --git a/internal/migration/migrations.go b/internal/migration/migrations.go index 2852113..8db229b 100644 --- a/internal/migration/migrations.go +++ b/internal/migration/migrations.go @@ -8,6 +8,7 @@ import ( func init() { // Register migrations Register(1, "Initial schema with bcrypt passwords", migrateInitialSchemaUp, migrateInitialSchemaDown) + Register(2, "Add reminders table", migrateRemindersUp, migrateRemindersDown) } // Initial schema creation with bcrypt passwords - version 1 @@ -60,14 +61,14 @@ func migrateInitialSchemaUp(db *sql.DB) error { if err != nil { return err } - + // Check if users table is empty before inserting var count int err = db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) if err != nil { return err } - + if count == 0 { _, err = db.Exec( "INSERT INTO users (username, password) VALUES (?, ?)", @@ -99,4 +100,29 @@ func migrateInitialSchemaDown(db *sql.DB) error { } return nil -} \ No newline at end of file +} + +// Add reminders table - version 2 +func migrateRemindersUp(db *sql.DB) error { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS reminders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + channel_id TEXT NOT NULL, + message_id TEXT NOT NULL, + reply_to_id TEXT NOT NULL, + user_id TEXT NOT NULL, + username TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + trigger_at TIMESTAMP NOT NULL, + content TEXT NOT NULL, + processed BOOLEAN NOT NULL DEFAULT 0 + ) + `) + return err +} + +func migrateRemindersDown(db *sql.DB) error { + _, err := db.Exec(`DROP TABLE IF EXISTS reminders`) + return err +} diff --git a/internal/model/message.go b/internal/model/message.go index fe8c5e4..e6f86f6 100644 --- a/internal/model/message.go +++ b/internal/model/message.go @@ -6,25 +6,25 @@ import ( // Message represents a chat message type Message struct { - Text string - Chat string - Channel *Channel - Author string - FromBot bool - Date time.Time - ID string - ReplyTo string - Raw map[string]interface{} + Text string + Chat string + Channel *Channel + Author string + FromBot bool + Date time.Time + ID string + ReplyTo string + Raw map[string]interface{} } // Channel represents a chat channel type Channel struct { - ID int64 - Platform string + ID int64 + Platform string PlatformChannelID string - ChannelRaw map[string]interface{} - Enabled bool - Plugins map[string]*ChannelPlugin + ChannelRaw map[string]interface{} + Enabled bool + Plugins map[string]*ChannelPlugin } // HasEnabledPlugin checks if a plugin is enabled for this channel @@ -40,18 +40,18 @@ func (c *Channel) HasEnabledPlugin(pluginID string) bool { func (c *Channel) ChannelName() string { // In a real implementation, this would use the platform-specific // ParseChannelNameFromRaw function - + // For simplicity, we'll just use the PlatformChannelID if we can't extract a name // Check if ChannelRaw has a name field if c.ChannelRaw == nil { return c.PlatformChannelID } - + // Check common name fields in ChannelRaw if name, ok := c.ChannelRaw["name"].(string); ok && name != "" { return name } - + // Check for nested objects like "chat" (used by Telegram) if chat, ok := c.ChannelRaw["chat"].(map[string]interface{}); ok { // Try different fields in order of preference @@ -65,7 +65,7 @@ func (c *Channel) ChannelName() string { return firstName } } - + return c.PlatformChannelID } @@ -83,4 +83,19 @@ type User struct { ID int64 Username string Password string -} \ No newline at end of file +} + +// Reminder represents a scheduled reminder +type Reminder struct { + ID int64 + Platform string + ChannelID string + MessageID string + ReplyToID string + UserID string + Username string + CreatedAt time.Time + TriggerAt time.Time + Content string + Processed bool +} diff --git a/internal/model/plugin.go b/internal/model/plugin.go index ffc3c2f..9f2b34a 100644 --- a/internal/model/plugin.go +++ b/internal/model/plugin.go @@ -13,16 +13,16 @@ var ( type Plugin interface { // GetID returns the plugin ID GetID() string - + // GetName returns the plugin name GetName() string - + // GetHelp returns the plugin help text GetHelp() string - + // RequiresConfig indicates if the plugin requires configuration RequiresConfig() bool - + // OnMessage processes an incoming message and returns response messages OnMessage(msg *Message, config map[string]interface{}) []*Message -} \ No newline at end of file +} diff --git a/internal/platform/telegram/telegram.go b/internal/platform/telegram/telegram.go index a9ff2db..6c9a2b3 100644 --- a/internal/platform/telegram/telegram.go +++ b/internal/platform/telegram/telegram.go @@ -103,8 +103,11 @@ func (t *TelegramPlatform) ParseIncomingMessage(r *http.Request) (*model.Message Title string `json:"title,omitempty"` Username string `json:"username,omitempty"` } `json:"chat"` - Date int `json:"date"` - Text string `json:"text"` + Date int `json:"date"` + Text string `json:"text"` + ReplyToMessage struct { + MessageID int `json:"message_id"` + } `json:"reply_to_message"` } `json:"message"` } @@ -128,6 +131,7 @@ func (t *TelegramPlatform) ParseIncomingMessage(r *http.Request) (*model.Message FromBot: update.Message.From.IsBot, Date: time.Unix(int64(update.Message.Date), 0), ID: strconv.Itoa(update.Message.MessageID), + ReplyTo: strconv.Itoa(update.Message.ReplyToMessage.MessageID), Raw: raw, } @@ -259,4 +263,4 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error { t.log.Debug("Message sent successfully") return nil -} \ No newline at end of file +} diff --git a/internal/plugin/reminder/reminder.go b/internal/plugin/reminder/reminder.go new file mode 100644 index 0000000..6d7c1aa --- /dev/null +++ b/internal/plugin/reminder/reminder.go @@ -0,0 +1,178 @@ +package reminder + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// Duration regex patterns to match reminders +var ( + remindMePattern = regexp.MustCompile(`(?i)^!remindme\s(\d+)(y|mo|d|h|m|s)$`) +) + +// ReminderCreator is an interface for creating reminders +type ReminderCreator interface { + CreateReminder(platform, channelID, messageID, replyToID, userID, username, content string, triggerAt time.Time) (*model.Reminder, error) +} + +// Reminder is a plugin that sets reminders for messages +type Reminder struct { + plugin.BasePlugin + creator ReminderCreator +} + +// New creates a new Reminder plugin +func New(creator ReminderCreator) *Reminder { + return &Reminder{ + BasePlugin: plugin.BasePlugin{ + ID: "reminder.remindme", + Name: "Remind Me", + Help: "Reply to a message with `!remindme ` to set a reminder (e.g., `!remindme 2d` for 2 days, `!remindme 1y` for 1 year).", + ConfigRequired: false, + }, + creator: creator, + } +} + +// OnMessage processes incoming messages +func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { + // Only process replies to messages + if msg.ReplyTo == "" { + return []*model.Message{ + { + Text: "Please reply to a message with `!remindme ` to set a reminder.", + Chat: msg.Chat, + Channel: msg.Channel, + ReplyTo: msg.ID, + }, + } + } + + // Check if the message is a reminder command + match := remindMePattern.FindStringSubmatch(msg.Text) + if match == nil { + return nil + } + + // Parse the duration + amount, err := strconv.Atoi(match[1]) + if err != nil { + return []*model.Message{ + { + Text: "Invalid duration format. Please use a number followed by y (years), mo (months), d (days), h (hours), m (minutes), or s (seconds).", + Chat: msg.Chat, + Channel: msg.Channel, + Author: "bot", + FromBot: true, + Date: time.Now(), + ReplyTo: msg.ID, + }, + } + } + + // Calculate the trigger time + var duration time.Duration + unit := match[2] + switch strings.ToLower(unit) { + case "y": + duration = time.Duration(amount) * 365 * 24 * time.Hour + case "mo": + duration = time.Duration(amount) * 30 * 24 * time.Hour + case "d": + duration = time.Duration(amount) * 24 * time.Hour + case "h": + duration = time.Duration(amount) * time.Hour + case "m": + duration = time.Duration(amount) * time.Minute + case "s": + duration = time.Duration(amount) * time.Second + default: + return []*model.Message{ + { + Text: "Invalid duration unit. Please use y (years), mo (months), d (days), h (hours), m (minutes), or s (seconds).", + Chat: msg.Chat, + Channel: msg.Channel, + Author: "bot", + FromBot: true, + Date: time.Now(), + ReplyTo: msg.ID, + }, + } + } + + triggerAt := time.Now().Add(duration) + + // Determine the username for the reminder + username := msg.Author + if username == "" { + // Try to extract username from message raw data + if authorData, ok := msg.Raw["author"].(map[string]interface{}); ok { + if name, ok := authorData["username"].(string); ok { + username = name + } else if name, ok := authorData["name"].(string); ok { + username = name + } + } + } + + // Create the reminder + _, err = r.creator.CreateReminder( + msg.Channel.Platform, + msg.Chat, + msg.ID, + msg.ReplyTo, + msg.Author, + username, + "", // No additional content for now + triggerAt, + ) + + if err != nil { + return []*model.Message{ + { + Text: fmt.Sprintf("Failed to create reminder: %v", err), + Chat: msg.Chat, + Channel: msg.Channel, + Author: "bot", + FromBot: true, + Date: time.Now(), + ReplyTo: msg.ID, + }, + } + } + + // Format the acknowledgment message + var confirmText string + switch strings.ToLower(unit) { + case "y": + confirmText = fmt.Sprintf("I'll remind you about this message in %d year(s) on %s", amount, triggerAt.Format("Mon, Jan 2, 2006 at 15:04")) + case "mo": + confirmText = fmt.Sprintf("I'll remind you about this message in %d month(s) on %s", amount, triggerAt.Format("Mon, Jan 2 at 15:04")) + case "d": + confirmText = fmt.Sprintf("I'll remind you about this message in %d day(s) on %s", amount, triggerAt.Format("Mon, Jan 2 at 15:04")) + case "h": + confirmText = fmt.Sprintf("I'll remind you about this message in %d hour(s) at %s", amount, triggerAt.Format("15:04")) + case "m": + confirmText = fmt.Sprintf("I'll remind you about this message in %d minute(s) at %s", amount, triggerAt.Format("15:04")) + case "s": + confirmText = fmt.Sprintf("I'll remind you about this message in %d second(s)", amount) + } + + return []*model.Message{ + { + Text: confirmText, + Chat: msg.Chat, + Channel: msg.Channel, + Author: "bot", + FromBot: true, + Date: time.Now(), + ReplyTo: msg.ID, + }, + } +} diff --git a/internal/plugin/reminder/reminder_test.go b/internal/plugin/reminder/reminder_test.go new file mode 100644 index 0000000..b76fd2f --- /dev/null +++ b/internal/plugin/reminder/reminder_test.go @@ -0,0 +1,164 @@ +package reminder + +import ( + "testing" + "time" + + "git.nakama.town/fmartingr/butterrobot/internal/model" +) + +// MockCreator is a mock implementation of ReminderCreator for testing +type MockCreator struct { + reminders []*model.Reminder +} + +func (m *MockCreator) CreateReminder(platform, channelID, messageID, replyToID, userID, username, content string, triggerAt time.Time) (*model.Reminder, error) { + reminder := &model.Reminder{ + ID: int64(len(m.reminders) + 1), + Platform: platform, + ChannelID: channelID, + MessageID: messageID, + ReplyToID: replyToID, + UserID: userID, + Username: username, + Content: content, + TriggerAt: triggerAt, + } + m.reminders = append(m.reminders, reminder) + return reminder, nil +} + +func TestReminderOnMessage(t *testing.T) { + creator := &MockCreator{reminders: make([]*model.Reminder, 0)} + plugin := New(creator) + + tests := []struct { + name string + message *model.Message + expectResponse bool + expectReminder bool + }{ + { + name: "Valid reminder command - years", + message: &model.Message{ + Text: "!remindme 1y", + ReplyTo: "original-message-id", + Author: "testuser", + Channel: &model.Channel{Platform: "test"}, + }, + expectResponse: true, + expectReminder: true, + }, + { + name: "Valid reminder command - months", + message: &model.Message{ + Text: "!remindme 3mo", + ReplyTo: "original-message-id", + Author: "testuser", + Channel: &model.Channel{Platform: "test"}, + }, + expectResponse: true, + expectReminder: true, + }, + { + name: "Valid reminder command - days", + message: &model.Message{ + Text: "!remindme 2d", + ReplyTo: "original-message-id", + Author: "testuser", + Channel: &model.Channel{Platform: "test"}, + }, + expectResponse: true, + expectReminder: true, + }, + { + name: "Valid reminder command - hours", + message: &model.Message{ + Text: "!remindme 5h", + ReplyTo: "original-message-id", + Author: "testuser", + Channel: &model.Channel{Platform: "test"}, + }, + expectResponse: true, + expectReminder: true, + }, + { + name: "Valid reminder command - minutes", + message: &model.Message{ + Text: "!remindme 30m", + ReplyTo: "original-message-id", + Author: "testuser", + Channel: &model.Channel{Platform: "test"}, + }, + expectResponse: true, + expectReminder: true, + }, + { + name: "Valid reminder command - seconds", + message: &model.Message{ + Text: "!remindme 60s", + ReplyTo: "original-message-id", + Author: "testuser", + Channel: &model.Channel{Platform: "test"}, + }, + expectResponse: true, + expectReminder: true, + }, + { + name: "Not a reply", + message: &model.Message{ + Text: "!remindme 2d", + ReplyTo: "", + Author: "testuser", + Channel: &model.Channel{Platform: "test"}, + }, + expectResponse: false, + expectReminder: false, + }, + { + name: "Not a reminder command", + message: &model.Message{ + Text: "hello world", + ReplyTo: "original-message-id", + Author: "testuser", + Channel: &model.Channel{Platform: "test"}, + }, + expectResponse: false, + expectReminder: false, + }, + { + name: "Invalid duration format", + message: &model.Message{ + Text: "!remindme abc", + ReplyTo: "original-message-id", + Author: "testuser", + Channel: &model.Channel{Platform: "test"}, + }, + expectResponse: false, + expectReminder: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + initialCount := len(creator.reminders) + responses := plugin.OnMessage(tt.message, nil) + + if tt.expectResponse && len(responses) == 0 { + t.Errorf("Expected response, but got none") + } + + if !tt.expectResponse && len(responses) > 0 { + t.Errorf("Expected no response, but got %d", len(responses)) + } + + if tt.expectReminder && len(creator.reminders) != initialCount+1 { + t.Errorf("Expected reminder to be created, but it wasn't") + } + + if !tt.expectReminder && len(creator.reminders) != initialCount { + t.Errorf("Expected no reminder to be created, but got %d", len(creator.reminders)-initialCount) + } + }) + } +} \ No newline at end of file diff --git a/internal/queue/queue.go b/internal/queue/queue.go index 668bf60..692816e 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -3,6 +3,9 @@ package queue import ( "log/slog" "sync" + "time" + + "git.nakama.town/fmartingr/butterrobot/internal/model" ) // Item represents a queue item @@ -14,14 +17,19 @@ type Item struct { // HandlerFunc defines a function that processes queue items type HandlerFunc func(item Item) +// ReminderHandlerFunc defines a function that processes reminder items +type ReminderHandlerFunc func(reminder *model.Reminder) + // Queue represents a message queue type Queue struct { - items chan Item - wg sync.WaitGroup - quit chan struct{} - logger *slog.Logger - running bool - runMutex sync.Mutex + items chan Item + wg sync.WaitGroup + quit chan struct{} + logger *slog.Logger + running bool + runMutex sync.Mutex + reminderTicker *time.Ticker + reminderHandler ReminderHandlerFunc } // New creates a new Queue instance @@ -49,6 +57,24 @@ func (q *Queue) Start(handler HandlerFunc) { go q.worker(handler) } +// StartReminderScheduler starts the reminder scheduler +func (q *Queue) StartReminderScheduler(handler ReminderHandlerFunc) { + q.runMutex.Lock() + defer q.runMutex.Unlock() + + if q.reminderTicker != nil { + return + } + + q.reminderHandler = handler + + // Check for reminders every minute + q.reminderTicker = time.NewTicker(1 * time.Minute) + + q.wg.Add(1) + go q.reminderWorker() +} + // Stop stops processing queue items func (q *Queue) Stop() { q.runMutex.Lock() @@ -59,6 +85,12 @@ func (q *Queue) Stop() { } q.running = false + + // Stop reminder ticker if it exists + if q.reminderTicker != nil { + q.reminderTicker.Stop() + } + close(q.quit) q.wg.Wait() } @@ -96,4 +128,34 @@ func (q *Queue) worker(handler HandlerFunc) { return } } -} \ No newline at end of file +} + +// reminderWorker processes reminder items on a schedule +func (q *Queue) reminderWorker() { + defer q.wg.Done() + + for { + select { + case <-q.reminderTicker.C: + // This is triggered every minute to check for pending reminders + q.logger.Debug("Checking for pending reminders") + + if q.reminderHandler != nil { + // The handler is responsible for fetching and processing reminders + func() { + defer func() { + if r := recover(); r != nil { + q.logger.Error("Panic in reminder worker", "error", r) + } + }() + + // Call the handler with a nil reminder to indicate it should check the database + q.reminderHandler(nil) + }() + } + case <-q.quit: + // Quit worker + return + } + } +} From 323ea4e8cdfc7ac6c2226317629fbd1ff0782f7e Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Tue, 22 Apr 2025 11:40:10 +0200 Subject: [PATCH 02/31] fix(ci): updated woodpecker triggers --- .woodpecker/ci.yml | 2 +- .woodpecker/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index 4353088..5b32d48 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -3,7 +3,7 @@ when: - push - pull_request branch: - - main + - master steps: format: diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml index 3630566..39dbf65 100644 --- a/.woodpecker/release.yml +++ b/.woodpecker/release.yml @@ -1,6 +1,6 @@ when: - event: tag - branch: main + branch: master steps: - name: Release From abcd3c3c44b96951d1c3b297741bb7b57499f99b Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Tue, 22 Apr 2025 11:41:56 +0200 Subject: [PATCH 03/31] docs: updated README --- README.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 214afa6..920d087 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,6 @@ # Butter Robot -| Stable | Master | -| --- | --- | -| ![Build stable tag docker image](https://git.nakama.town/fmartingr/butterrobot/workflows/Build%20stable%20tag%20docker%20image/badge.svg?branch=stable) | ![Build latest tag docker image](https://git.nakama.town/fmartingr/butterrobot/workflows/Build%20latest%20tag%20docker%20image/badge.svg?branch=master) | -| ![Test](https://git.nakama.town/fmartingr/butterrobot/workflows/Test/badge.svg?branch=stable) | ![Test](https://git.nakama.town/fmartingr/butterrobot/workflows/Test/badge.svg?branch=master) | +![Status badge](https://woodpecker.local.fmartingr.dev/api/badges/5/status.svg) Go framework to create bots for several platforms. @@ -13,7 +10,7 @@ Go framework to create bots for several platforms. ## Features -- Support for multiple chat platforms (Slack, Telegram) +- Support for multiple chat platforms (Slack (untested!), Telegram) - Plugin system for easy extension - Admin interface for managing channels and plugins - Message queue for asynchronous processing From 763a451251c95c609887c64de4eda4fe01bbf39b Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Tue, 22 Apr 2025 11:56:33 +0200 Subject: [PATCH 04/31] fix: lint errors --- internal/admin/admin.go | 19 +++++----------- internal/app/app.go | 18 ++++++++++----- internal/db/db.go | 27 ++++++++++++++++------- internal/migration/migration.go | 24 +++++++++++++++----- internal/platform/slack/slack.go | 18 ++++++++++----- internal/platform/telegram/telegram.go | 20 +++++++++++++---- internal/plugin/fun/dice.go | 5 +++-- internal/plugin/reminder/reminder.go | 9 +------- internal/plugin/reminder/reminder_test.go | 2 +- internal/plugin/social/instagram.go | 4 +--- 10 files changed, 91 insertions(+), 55 deletions(-) diff --git a/internal/admin/admin.go b/internal/admin/admin.go index c2a78ca..e20c7c0 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -194,7 +194,7 @@ func (a *Admin) addFlash(w http.ResponseWriter, r *http.Request, message string, } // Map internal categories to Bootstrap alert classes - alertClass := category + var alertClass string switch category { case "success": alertClass = "success" @@ -249,16 +249,6 @@ func (a *Admin) getFlashes(w http.ResponseWriter, r *http.Request) []FlashMessag return messages } -// requireLogin middleware checks if the user is logged in -func (a *Admin) requireLogin(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if !a.isLoggedIn(r) { - http.Redirect(w, r, "/admin/login", http.StatusSeeOther) - return - } - next(w, r) - } -} // render renders a template with the given data func (a *Admin) render(w http.ResponseWriter, r *http.Request, templateName string, data TemplateData) { @@ -334,7 +324,10 @@ func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) { // Set session expiration session.Options.MaxAge = 3600 * 24 * 7 // 1 week - session.Save(r, w) + err = session.Save(r, w) + if err != nil { + fmt.Printf("Error saving session: %v\n", err) + } a.addFlash(w, r, "You were logged in", "success") @@ -715,4 +708,4 @@ func (a *Admin) handleChannelPluginDetailOrDelete(w http.ResponseWriter, r *http // Redirect to channel plugins list http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther) -} +} \ No newline at end of file diff --git a/internal/app/app.go b/internal/app/app.go index 1d878ab..ece6908 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -152,7 +152,9 @@ func (a *App) initializeRoutes() { a.router.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]interface{}{}) + if err := json.NewEncoder(w).Encode(map[string]interface{}{}); err != nil { + a.logger.Error("Error encoding response", "error", err) + } }) // Platform webhook endpoints @@ -175,7 +177,9 @@ func (a *App) handleIncomingWebhook(w http.ResponseWriter, r *http.Request) { if _, err := platform.Get(platformName); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": "Unknown platform"}) + if err := json.NewEncoder(w).Encode(map[string]string{"error": "Unknown platform"}); err != nil { + a.logger.Error("Error encoding response", "error", err) + } return } @@ -184,7 +188,9 @@ func (a *App) handleIncomingWebhook(w http.ResponseWriter, r *http.Request) { if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]string{"error": "Failed to read request body"}) + if err := json.NewEncoder(w).Encode(map[string]string{"error": "Failed to read request body"}); err != nil { + a.logger.Error("Error encoding response", "error", err) + } return } @@ -200,7 +206,9 @@ func (a *App) handleIncomingWebhook(w http.ResponseWriter, r *http.Request) { // Respond with success w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]any{}) + if err := json.NewEncoder(w).Encode(map[string]any{}); err != nil { + a.logger.Error("Error encoding response", "error", err) + } } // extractPlatformName extracts the platform name from the URL path @@ -382,4 +390,4 @@ func (a *App) processReminder(reminder *model.Reminder) { if err := a.db.MarkReminderAsProcessed(reminder.ID); err != nil { a.logger.Error("Error marking reminder as processed", "error", err) } -} +} \ No newline at end of file diff --git a/internal/db/db.go b/internal/db/db.go index b71b543..328d460 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -234,7 +234,11 @@ func (d *Database) GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, e if err != nil { return nil, err } - defer rows.Close() + defer func() { + if err := rows.Close(); err != nil { + fmt.Printf("Error closing rows: %v\n", err) + } + }() var plugins []*model.ChannelPlugin @@ -415,7 +419,11 @@ func (d *Database) GetAllChannels() ([]*model.Channel, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { + if err := rows.Close(); err != nil { + fmt.Printf("Error closing rows: %v\n", err) + } + }() var channels []*model.Channel @@ -454,10 +462,9 @@ func (d *Database) GetAllChannels() ([]*model.Channel, error) { continue // Skip this channel if plugins can't be retrieved } - if plugins != nil { - for _, plugin := range plugins { - channel.Plugins[plugin.PluginID] = plugin - } + // Add plugins to channel + for _, plugin := range plugins { + channel.Plugins[plugin.PluginID] = plugin } channels = append(channels, channel) @@ -646,7 +653,11 @@ func (d *Database) GetPendingReminders() ([]*model.Reminder, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { + if err := rows.Close(); err != nil { + fmt.Printf("Error closing rows: %v\n", err) + } + }() var reminders []*model.Reminder @@ -763,4 +774,4 @@ func initDatabase(db *sql.DB) error { } return nil -} +} \ No newline at end of file diff --git a/internal/migration/migration.go b/internal/migration/migration.go index dec4ff5..2067755 100644 --- a/internal/migration/migration.go +++ b/internal/migration/migration.go @@ -49,7 +49,11 @@ func GetAppliedMigrations(db *sql.DB) ([]int, error) { if err != nil { return nil, err } - defer rows.Close() + defer func() { + if err := rows.Close(); err != nil { + fmt.Printf("Error closing rows: %v\n", err) + } + }() var versions []int for rows.Next() { @@ -128,7 +132,9 @@ func Migrate(db *sql.DB) error { // Apply the migration if err := migration.Up(db); err != nil { - tx.Rollback() + if err := tx.Rollback(); err != nil { + fmt.Printf("Error rolling back transaction: %v\n", err) + } return fmt.Errorf("failed to apply migration %d: %w", version, err) } @@ -137,7 +143,9 @@ func Migrate(db *sql.DB) error { "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", version, time.Now(), ); err != nil { - tx.Rollback() + if err := tx.Rollback(); err != nil { + fmt.Printf("Error rolling back transaction: %v\n", err) + } return fmt.Errorf("failed to mark migration %d as applied: %w", version, err) } @@ -188,13 +196,17 @@ func MigrateDown(db *sql.DB, targetVersion int) error { // Apply the down migration if err := migration.Down(db); err != nil { - tx.Rollback() + if err := tx.Rollback(); err != nil { + fmt.Printf("Error rolling back transaction: %v\n", err) + } return fmt.Errorf("failed to roll back migration %d: %w", version, err) } // Remove from applied list if _, err := tx.Exec("DELETE FROM schema_migrations WHERE version = ?", version); err != nil { - tx.Rollback() + if err := tx.Rollback(); err != nil { + fmt.Printf("Error rolling back transaction: %v\n", err) + } return fmt.Errorf("failed to remove migration %d from applied list: %w", version, err) } @@ -208,4 +220,4 @@ func MigrateDown(db *sql.DB, targetVersion int) error { } return nil -} +} \ No newline at end of file diff --git a/internal/platform/slack/slack.go b/internal/platform/slack/slack.go index 3683ada..efa7c5d 100644 --- a/internal/platform/slack/slack.go +++ b/internal/platform/slack/slack.go @@ -4,7 +4,7 @@ import ( "encoding/json" "errors" "fmt" - "io/ioutil" + "io" "net/http" "strings" "time" @@ -37,11 +37,15 @@ func (s *SlackPlatform) Init(_ *config.Config) error { // ParseIncomingMessage parses an incoming Slack message func (s *SlackPlatform) ParseIncomingMessage(r *http.Request) (*model.Message, error) { // Read request body - body, err := ioutil.ReadAll(r.Body) + body, err := io.ReadAll(r.Body) if err != nil { return nil, err } - defer r.Body.Close() + defer func() { + if err := r.Body.Close(); err != nil { + fmt.Printf("Error closing request body: %v\n", err) + } + }() // Parse JSON var requestData map[string]interface{} @@ -194,7 +198,11 @@ func (s *SlackPlatform) SendMessage(msg *model.Message) error { if err != nil { return err } - defer resp.Body.Close() + defer func() { + if err := resp.Body.Close(); err != nil { + fmt.Printf("Error closing response body: %v\n", err) + } + }() // Check response if resp.StatusCode != http.StatusOK { @@ -209,4 +217,4 @@ func parseInt64(s string) (int64, error) { var n int64 _, err := fmt.Sscanf(s, "%d", &n) return n, err -} +} \ No newline at end of file diff --git a/internal/platform/telegram/telegram.go b/internal/platform/telegram/telegram.go index 6c9a2b3..baf26d0 100644 --- a/internal/platform/telegram/telegram.go +++ b/internal/platform/telegram/telegram.go @@ -62,7 +62,11 @@ func (t *TelegramPlatform) Init(cfg *config.Config) error { t.log.Error("Failed to set webhook", "error", err) return fmt.Errorf("failed to set webhook: %w", err) } - defer resp.Body.Close() + defer func() { + if err := resp.Body.Close(); err != nil { + t.log.Error("Error closing response body", "error", err) + } + }() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) @@ -85,7 +89,11 @@ func (t *TelegramPlatform) ParseIncomingMessage(r *http.Request) (*model.Message t.log.Error("Failed to read request body", "error", err) return nil, err } - defer r.Body.Close() + defer func() { + if err := r.Body.Close(); err != nil { + t.log.Error("Error closing request body", "error", err) + } + }() // Parse JSON var update struct { @@ -251,7 +259,11 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error { t.log.Error("Failed to send message", "error", err) return err } - defer resp.Body.Close() + defer func() { + if err := resp.Body.Close(); err != nil { + t.log.Error("Error closing response body", "error", err) + } + }() // Check response if resp.StatusCode != http.StatusOK { @@ -263,4 +275,4 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error { t.log.Debug("Message sent successfully") return nil -} +} \ No newline at end of file diff --git a/internal/plugin/fun/dice.go b/internal/plugin/fun/dice.go index 00fc7cc..2d5533b 100644 --- a/internal/plugin/fun/dice.go +++ b/internal/plugin/fun/dice.go @@ -107,9 +107,10 @@ func (p *DicePlugin) rollDice(formula string) (int, error) { return 0, fmt.Errorf("invalid modifier") } - if matches[3] == "+" { + switch matches[3] { + case "+": total += modifier - } else if matches[3] == "-" { + case "-": total -= modifier } } diff --git a/internal/plugin/reminder/reminder.go b/internal/plugin/reminder/reminder.go index 6d7c1aa..5eb47f9 100644 --- a/internal/plugin/reminder/reminder.go +++ b/internal/plugin/reminder/reminder.go @@ -44,14 +44,7 @@ func New(creator ReminderCreator) *Reminder { func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { // Only process replies to messages if msg.ReplyTo == "" { - return []*model.Message{ - { - Text: "Please reply to a message with `!remindme ` to set a reminder.", - Chat: msg.Chat, - Channel: msg.Channel, - ReplyTo: msg.ID, - }, - } + return nil } // Check if the message is a reminder command diff --git a/internal/plugin/reminder/reminder_test.go b/internal/plugin/reminder/reminder_test.go index b76fd2f..3070918 100644 --- a/internal/plugin/reminder/reminder_test.go +++ b/internal/plugin/reminder/reminder_test.go @@ -161,4 +161,4 @@ func TestReminderOnMessage(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/internal/plugin/social/instagram.go b/internal/plugin/social/instagram.go index a4f758a..7ff74a5 100644 --- a/internal/plugin/social/instagram.go +++ b/internal/plugin/social/instagram.go @@ -53,9 +53,7 @@ func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]inte } // Change the host - if strings.Contains(parsedURL.Host, "instagram.com") { - parsedURL.Host = strings.Replace(parsedURL.Host, "instagram.com", "ddinstagram.com", 1) - } + parsedURL.Host = strings.Replace(parsedURL.Host, "instagram.com", "ddinstagram.com", 1) // Remove query parameters parsedURL.RawQuery = "" From c9edb57505314aa573d3bdddd1aa2e96abdc76de Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Tue, 22 Apr 2025 11:56:57 +0200 Subject: [PATCH 05/31] fix: make format --- internal/admin/admin.go | 3 +-- internal/app/app.go | 2 +- internal/db/db.go | 2 +- internal/migration/migration.go | 2 +- internal/platform/slack/slack.go | 2 +- internal/platform/telegram/telegram.go | 2 +- 6 files changed, 6 insertions(+), 7 deletions(-) diff --git a/internal/admin/admin.go b/internal/admin/admin.go index e20c7c0..69c769b 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -249,7 +249,6 @@ func (a *Admin) getFlashes(w http.ResponseWriter, r *http.Request) []FlashMessag return messages } - // render renders a template with the given data func (a *Admin) render(w http.ResponseWriter, r *http.Request, templateName string, data TemplateData) { // Add current user data @@ -708,4 +707,4 @@ func (a *Admin) handleChannelPluginDetailOrDelete(w http.ResponseWriter, r *http // Redirect to channel plugins list http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther) -} \ No newline at end of file +} diff --git a/internal/app/app.go b/internal/app/app.go index ece6908..7403396 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -390,4 +390,4 @@ func (a *App) processReminder(reminder *model.Reminder) { if err := a.db.MarkReminderAsProcessed(reminder.ID); err != nil { a.logger.Error("Error marking reminder as processed", "error", err) } -} \ No newline at end of file +} diff --git a/internal/db/db.go b/internal/db/db.go index 328d460..bdf9eaf 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -774,4 +774,4 @@ func initDatabase(db *sql.DB) error { } return nil -} \ No newline at end of file +} diff --git a/internal/migration/migration.go b/internal/migration/migration.go index 2067755..63da5d8 100644 --- a/internal/migration/migration.go +++ b/internal/migration/migration.go @@ -220,4 +220,4 @@ func MigrateDown(db *sql.DB, targetVersion int) error { } return nil -} \ No newline at end of file +} diff --git a/internal/platform/slack/slack.go b/internal/platform/slack/slack.go index efa7c5d..9c12b1f 100644 --- a/internal/platform/slack/slack.go +++ b/internal/platform/slack/slack.go @@ -217,4 +217,4 @@ func parseInt64(s string) (int64, error) { var n int64 _, err := fmt.Sscanf(s, "%d", &n) return n, err -} \ No newline at end of file +} diff --git a/internal/platform/telegram/telegram.go b/internal/platform/telegram/telegram.go index baf26d0..0edb729 100644 --- a/internal/platform/telegram/telegram.go +++ b/internal/platform/telegram/telegram.go @@ -275,4 +275,4 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error { t.log.Debug("Message sent successfully") return nil -} \ No newline at end of file +} From 7dd02c0056b2fc3a6536e727bf2124361bef8bbc Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Tue, 22 Apr 2025 18:09:27 +0200 Subject: [PATCH 06/31] feat: domain blocker plugin --- cmd/butterrobot/main.go | 2 +- docs/creating-a-plugin.md | 124 ++++++++++++++++ docs/plugins.md | 4 + internal/admin/admin.go | 92 ++++++++++++ internal/admin/templates/channel_detail.html | 4 + .../templates/channel_plugin_config.html | 37 +++++ .../admin/templates/channel_plugins_list.html | 6 +- internal/app/app.go | 41 +++-- internal/db/db.go | 18 +++ internal/model/message.go | 22 ++- internal/model/platform.go | 3 + internal/model/plugin.go | 4 +- internal/platform/slack/slack.go | 63 ++++++++ internal/platform/telegram/telegram.go | 92 ++++++++++++ internal/plugin/domainblock/domainblock.go | 132 +++++++++++++++++ .../plugin/domainblock/domainblock_test.go | 140 ++++++++++++++++++ internal/plugin/fun/coin.go | 11 +- internal/plugin/fun/dice.go | 11 +- internal/plugin/fun/loquito.go | 11 +- internal/plugin/ping/ping.go | 13 +- internal/plugin/plugin.go | 7 +- internal/plugin/reminder/reminder.go | 81 ++++++---- internal/plugin/reminder/reminder_test.go | 21 ++- internal/plugin/social/instagram.go | 11 +- internal/plugin/social/twitter.go | 11 +- 25 files changed, 898 insertions(+), 63 deletions(-) create mode 100644 internal/admin/templates/channel_plugin_config.html create mode 100644 internal/plugin/domainblock/domainblock.go create mode 100644 internal/plugin/domainblock/domainblock_test.go diff --git a/cmd/butterrobot/main.go b/cmd/butterrobot/main.go index 3bc56cb..bf217de 100644 --- a/cmd/butterrobot/main.go +++ b/cmd/butterrobot/main.go @@ -45,4 +45,4 @@ func main() { logger.Error("Application error", "error", err) os.Exit(1) } -} +} \ No newline at end of file diff --git a/docs/creating-a-plugin.md b/docs/creating-a-plugin.md index 469491a..b8e4a78 100644 --- a/docs/creating-a-plugin.md +++ b/docs/creating-a-plugin.md @@ -7,6 +7,7 @@ ButterRobot organizes plugins into different categories: - **Development**: Utility plugins like `ping` - **Fun**: Entertainment plugins like dice rolling, coin flipping - **Social**: Social media related plugins like URL transformers/expanders +- **Security**: Moderation and protection features like domain blocking When creating a new plugin, consider which category it fits into and place it in the appropriate directory. @@ -59,6 +60,91 @@ func (p *MarcoPlugin) OnMessage(msg *model.Message, config map[string]interface{ } ``` +### Configuration-Enabled Plugin + +This plugin requires configuration to be set in the admin interface. It demonstrates how to create plugins that need channel-specific configuration: + +```go +package security + +import ( + "fmt" + "regexp" + "strings" + + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// DomainBlockPlugin is a plugin that blocks messages containing links from specific domains +type DomainBlockPlugin struct { + plugin.BasePlugin +} + +// New creates a new DomainBlockPlugin instance +func New() *DomainBlockPlugin { + return &DomainBlockPlugin{ + BasePlugin: plugin.BasePlugin{ + ID: "security.domainblock", + Name: "Domain Blocker", + Help: "Blocks messages containing links from configured domains", + ConfigRequired: true, // Mark this plugin as requiring configuration + }, + } +} + +// OnMessage processes incoming messages +func (p *DomainBlockPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { + // Get blocked domains from config + blockedDomainsStr, ok := config["blocked_domains"].(string) + if !ok || blockedDomainsStr == "" { + return nil // No blocked domains configured + } + + // Split and clean blocked domains + blockedDomains := strings.Split(blockedDomainsStr, ",") + for i, domain := range blockedDomains { + blockedDomains[i] = strings.ToLower(strings.TrimSpace(domain)) + } + + // Extract domains from message + urlRegex := regexp.MustCompile(`https?://([^\s/$.?#].[^\s]*)`) + matches := urlRegex.FindAllStringSubmatch(msg.Text, -1) + + // Check if any extracted domains are blocked + for _, match := range matches { + if len(match) < 2 { + continue + } + + domain := strings.ToLower(match[1]) + + for _, blockedDomain := range blockedDomains { + if blockedDomain == "" { + continue + } + + if strings.HasSuffix(domain, blockedDomain) || domain == blockedDomain { + // Domain is blocked, create warning message + response := &model.Message{ + Text: fmt.Sprintf("⚠️ Message contained a link to blocked domain: %s", blockedDomain), + Chat: msg.Chat, + ReplyTo: msg.ID, + Channel: msg.Channel, + } + return []*model.Message{response} + } + } + } + + return nil +} + +func init() { + plugin.Register(New()) +} +``` + ### Advanced Example: URL Transformer This more complex plugin transforms URLs, useful for improving media embedding in chat platforms: @@ -143,6 +229,36 @@ func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interf } ``` +## Enabling Configuration for Plugins + +To indicate that your plugin requires configuration: + +1. Set `ConfigRequired: true` in the BasePlugin struct: + ```go + BasePlugin: plugin.BasePlugin{ + ID: "myplugin.id", + Name: "Plugin Name", + Help: "Help text", + ConfigRequired: true, + }, + ``` + +2. Access the configuration in the OnMessage method: + ```go + func (p *MyPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { + // Extract configuration values + configValue, ok := config["some_config_key"].(string) + if !ok || configValue == "" { + // Handle missing or empty configuration + return nil + } + + // Use the configuration... + } + ``` + +3. The admin interface will show a "Configure" button for plugins that require configuration. + ## Registering Plugins To use the plugin, register it in your application: @@ -161,3 +277,11 @@ func (a *App) Run() error { // ... } ``` + +Alternatively, you can register your plugin in its init() function: + +```go +func init() { + plugin.Register(New()) +} +``` diff --git a/docs/plugins.md b/docs/plugins.md index 84578e5..25df16c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -14,6 +14,10 @@ - Remind Me: Reply to a message with `!remindme ` to set a reminder. Supported duration units: y (years), mo (months), d (days), h (hours), m (minutes), s (seconds). Examples: `!remindme 1y` for 1 year, `!remindme 3mo` for 3 months, `!remindme 2d` for 2 days, `!remindme 3h` for 3 hours. The bot will mention you with a reminder after the specified time. +### Security + +- Domain Blocker: Blocks messages containing links from specified domains. Configure it per channel with a comma-separated list of domains to block. When a message contains a link matching any of the blocked domains, the bot will notify that the message contained a blocked domain. This plugin requires configuration through the admin interface. + ### Social Media - Twitter Link Expander: Automatically converts twitter.com and x.com links to fxtwitter.com links and removes tracking parameters. This allows for better media embedding in chat platforms. diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 69c769b..dee4197 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -98,6 +98,7 @@ func New(cfg *config.Config, database *db.Database, version string) *Admin { "channel_detail.html", "plugin_list.html", "channel_plugins_list.html", + "channel_plugin_config.html", } for _, tf := range templateFiles { @@ -143,6 +144,7 @@ func (a *Admin) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/admin/channels", a.handleChannelList) mux.HandleFunc("/admin/channels/", a.handleChannelDetail) mux.HandleFunc("/admin/channelplugins", a.handleChannelPluginList) + mux.HandleFunc("/admin/channelplugins/config/", a.handleChannelPluginConfig) mux.HandleFunc("/admin/channelplugins/", a.handleChannelPluginDetailOrDelete) } @@ -628,6 +630,96 @@ func (a *Admin) handleChannelPluginList(w http.ResponseWriter, r *http.Request) }) } +// handleChannelPluginConfig handles the channel plugin configuration route +func (a *Admin) handleChannelPluginConfig(w http.ResponseWriter, r *http.Request) { + // Check if user is logged in + if !a.isLoggedIn(r) { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Extract channel plugin ID from path + path := r.URL.Path + channelPluginID := strings.TrimPrefix(path, "/admin/channelplugins/config/") + + // Convert channel plugin ID to int64 + id, err := strconv.ParseInt(channelPluginID, 10, 64) + if err != nil { + http.Error(w, "Invalid channel plugin ID", http.StatusBadRequest) + return + } + + // Get the channel plugin + channelPlugin, err := a.db.GetChannelPluginByID(id) + if err != nil { + http.Error(w, "Channel plugin not found", http.StatusNotFound) + return + } + + // Get the plugin + p, err := plugin.Get(channelPlugin.PluginID) + if err != nil { + http.Error(w, "Plugin not found", http.StatusNotFound) + return + } + + // Handle form submission + if r.Method == http.MethodPost { + // Parse form + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Create config map from form values + config := make(map[string]interface{}) + + // Process form values based on plugin type + if channelPlugin.PluginID == "security.domainblock" { + // Get blocked domains from form + blockedDomains := r.FormValue("blocked_domains") + config["blocked_domains"] = blockedDomains + } else { + // Generic handling for other plugins + for key, values := range r.Form { + if key == "form_submitted" { + continue + } + if len(values) == 1 { + config[key] = values[0] + } else { + config[key] = values + } + } + } + + // Update plugin configuration + if err := a.db.UpdateChannelPluginConfig(id, config); err != nil { + http.Error(w, "Failed to update plugin configuration", http.StatusInternalServerError) + return + } + + // Get the channel to redirect back to the channel detail page + channel, err := a.db.GetChannelByID(channelPlugin.ChannelID) + if err != nil { + a.addFlash(w, r, "Plugin configuration updated", "success") + http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther) + return + } + + a.addFlash(w, r, "Plugin configuration updated", "success") + http.Redirect(w, r, fmt.Sprintf("/admin/channels/%d", channel.ID), http.StatusSeeOther) + return + } + + // Render template + a.render(w, r, "channel_plugin_config.html", TemplateData{ + Title: "Configure Plugin: " + p.GetName(), + ChannelPlugin: channelPlugin, + Plugins: map[string]model.Plugin{channelPlugin.PluginID: p}, + }) +} + // handleChannelPluginDetailOrDelete handles the channel plugin detail or delete route func (a *Admin) handleChannelPluginDetailOrDelete(w http.ResponseWriter, r *http.Request) { // Check if user is logged in diff --git a/internal/admin/templates/channel_detail.html b/internal/admin/templates/channel_detail.html index 764d7b1..78909df 100644 --- a/internal/admin/templates/channel_detail.html +++ b/internal/admin/templates/channel_detail.html @@ -68,6 +68,10 @@ {{if $channelPlugin.Enabled}}Disable{{else}}Enable{{end}} + {{$plugin := index $.Plugins $pluginID}} + {{if $plugin.RequiresConfig}} + Configure + {{end}}
diff --git a/internal/admin/templates/channel_plugin_config.html b/internal/admin/templates/channel_plugin_config.html new file mode 100644 index 0000000..decf1a2 --- /dev/null +++ b/internal/admin/templates/channel_plugin_config.html @@ -0,0 +1,37 @@ +{{define "content"}} +
+
+
+
+

Configure Plugin: {{(index .Plugins .ChannelPlugin.PluginID).GetName}}

+
+
+ + + {{if eq .ChannelPlugin.PluginID "security.domainblock"}} +
+ + +
+ Enter comma-separated list of domains to block (e.g., example.com, evil.org). + Messages containing links to these domains will be blocked. +
+
+ {{else}} +
+ This plugin doesn't have specific configuration fields implemented yet. +
+ {{end}} + + + +
+
+
+
+{{end}} diff --git a/internal/admin/templates/channel_plugins_list.html b/internal/admin/templates/channel_plugins_list.html index b57c60e..485150b 100644 --- a/internal/admin/templates/channel_plugins_list.html +++ b/internal/admin/templates/channel_plugins_list.html @@ -38,6 +38,10 @@ {{if $channelPlugin.Enabled}}Disable{{else}}Enable{{end}} + {{$plugin := index $.Plugins $pluginID}} + {{if $plugin.ConfigRequired}} + Configure + {{end}}
@@ -90,4 +94,4 @@ -{{end}} \ No newline at end of file +{{end}} diff --git a/internal/app/app.go b/internal/app/app.go index 7403396..bd64b80 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -20,6 +20,7 @@ import ( "git.nakama.town/fmartingr/butterrobot/internal/model" "git.nakama.town/fmartingr/butterrobot/internal/platform" "git.nakama.town/fmartingr/butterrobot/internal/plugin" + "git.nakama.town/fmartingr/butterrobot/internal/plugin/domainblock" "git.nakama.town/fmartingr/butterrobot/internal/plugin/fun" "git.nakama.town/fmartingr/butterrobot/internal/plugin/ping" "git.nakama.town/fmartingr/butterrobot/internal/plugin/reminder" @@ -87,10 +88,8 @@ func (a *App) Run() error { plugin.Register(fun.NewLoquito()) plugin.Register(social.NewTwitterExpander()) plugin.Register(social.NewInstagramExpander()) - - // Register reminder plugin - reminderPlugin := reminder.New(a.db) - plugin.Register(reminderPlugin) + plugin.Register(reminder.New(a.db)) + plugin.Register(domainblock.New()) // Initialize routes a.initializeRoutes() @@ -304,19 +303,39 @@ func (a *App) handleMessage(item queue.Item) { continue } - // Process message - responses := p.OnMessage(message, channelPlugin.Config) + // Process message and get actions + actions := p.OnMessage(message, channelPlugin.Config) - // Send responses + // Get platform for processing actions platform, err := platform.Get(item.Platform) if err != nil { a.logger.Error("Error getting platform", "error", err) continue } - for _, response := range responses { - if err := platform.SendMessage(response); err != nil { - a.logger.Error("Error sending message", "error", err) + // Process each action + for _, action := range actions { + switch action.Type { + case model.ActionSendMessage: + // Send a message + if action.Message != nil { + if err := platform.SendMessage(action.Message); err != nil { + a.logger.Error("Error sending message", "error", err) + } + } else { + a.logger.Error("Send message action with nil message") + } + + case model.ActionDeleteMessage: + // Delete a message using direct DeleteMessage call + if err := platform.DeleteMessage(action.Chat, action.MessageID); err != nil { + a.logger.Error("Error deleting message", "error", err, "message_id", action.MessageID) + } else { + a.logger.Info("Message deleted", "message_id", action.MessageID) + } + + default: + a.logger.Error("Unknown action type", "type", action.Type) } } } @@ -390,4 +409,4 @@ func (a *App) processReminder(reminder *model.Reminder) { if err := a.db.MarkReminderAsProcessed(reminder.ID); err != nil { a.logger.Error("Error marking reminder as processed", "error", err) } -} +} \ No newline at end of file diff --git a/internal/db/db.go b/internal/db/db.go index bdf9eaf..0da285e 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -386,6 +386,24 @@ func (d *Database) UpdateChannelPlugin(id int64, enabled bool) error { return err } +// UpdateChannelPluginConfig updates a channel plugin's configuration +func (d *Database) UpdateChannelPluginConfig(id int64, config map[string]interface{}) error { + // Convert config to JSON + configJSON, err := json.Marshal(config) + if err != nil { + return err + } + + query := ` + UPDATE channel_plugin + SET config = ? + WHERE id = ? + ` + + _, err = d.db.Exec(query, string(configJSON), id) + return err +} + // DeleteChannelPlugin deletes a channel plugin func (d *Database) DeleteChannelPlugin(id int64) error { query := ` diff --git a/internal/model/message.go b/internal/model/message.go index e6f86f6..ea3b316 100644 --- a/internal/model/message.go +++ b/internal/model/message.go @@ -4,6 +4,26 @@ import ( "time" ) +// ActionType defines the type of action to perform +type ActionType string + +const ( + // ActionSendMessage is for sending a message to the chat + ActionSendMessage ActionType = "send_message" + // ActionDeleteMessage is for deleting a message from the chat + ActionDeleteMessage ActionType = "delete_message" +) + +// MessageAction represents an action to be performed on the platform +type MessageAction struct { + Type ActionType + Message *Message // For send_message + MessageID string // For delete_message + Chat string // Chat where the action happens + Channel *Channel // Channel reference + Raw map[string]interface{} // Additional data for the action +} + // Message represents a chat message type Message struct { Text string @@ -75,7 +95,7 @@ type ChannelPlugin struct { ChannelID int64 PluginID string Enabled bool - Config map[string]interface{} + Config map[string]any } // User represents an admin user diff --git a/internal/model/platform.go b/internal/model/platform.go index 01318eb..7d49ad3 100644 --- a/internal/model/platform.go +++ b/internal/model/platform.go @@ -43,4 +43,7 @@ type Platform interface { // SendMessage sends a message through the platform SendMessage(msg *Message) error + + // DeleteMessage deletes a message from the platform + DeleteMessage(channel string, messageID string) error } diff --git a/internal/model/plugin.go b/internal/model/plugin.go index 9f2b34a..03e4f96 100644 --- a/internal/model/plugin.go +++ b/internal/model/plugin.go @@ -23,6 +23,6 @@ type Plugin interface { // RequiresConfig indicates if the plugin requires configuration RequiresConfig() bool - // OnMessage processes an incoming message and returns response messages - OnMessage(msg *Message, config map[string]interface{}) []*Message + // OnMessage processes an incoming message and returns platform actions + OnMessage(msg *Message, config map[string]interface{}) []*MessageAction } diff --git a/internal/platform/slack/slack.go b/internal/platform/slack/slack.go index 9c12b1f..28e8363 100644 --- a/internal/platform/slack/slack.go +++ b/internal/platform/slack/slack.go @@ -167,6 +167,12 @@ func (s *SlackPlatform) SendMessage(msg *model.Message) error { return errors.New("bot token not configured") } + // Check for delete message action + if msg.Raw != nil && msg.Raw["action"] == "delete" { + // This is a request to delete a message + return s.deleteMessage(msg) + } + // Prepare payload payload := map[string]interface{}{ "channel": msg.Chat, @@ -212,6 +218,63 @@ func (s *SlackPlatform) SendMessage(msg *model.Message) error { return nil } +// DeleteMessage deletes a message on Slack +func (s *SlackPlatform) DeleteMessage(channel string, messageID string) error { + // Prepare payload for chat.delete API + payload := map[string]interface{}{ + "channel": channel, + "ts": messageID, // In Slack, the ts (timestamp) is the message ID + } + + // Convert payload to JSON + data, err := json.Marshal(payload) + if err != nil { + return err + } + + // Send HTTP request to chat.delete endpoint + req, err := http.NewRequest("POST", "https://slack.com/api/chat.delete", strings.NewReader(string(data))) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.config.BotOAuthAccessToken)) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer func() { + if err := resp.Body.Close(); err != nil { + fmt.Printf("Error closing response body: %v\n", err) + } + }() + + // Check response + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("slack API error: %d - %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +// deleteMessage is a legacy method that uses the Raw message approach +func (s *SlackPlatform) deleteMessage(msg *model.Message) error { + // Get message ID to delete + messageID, ok := msg.Raw["message_id"] + if !ok { + return fmt.Errorf("no message ID provided for deletion") + } + + // Convert to string if needed + messageIDStr := fmt.Sprintf("%v", messageID) + + return s.DeleteMessage(msg.Chat, messageIDStr) +} + // Helper function to parse int64 func parseInt64(s string) (int64, error) { var n int64 diff --git a/internal/platform/telegram/telegram.go b/internal/platform/telegram/telegram.go index 0edb729..8da4995 100644 --- a/internal/platform/telegram/telegram.go +++ b/internal/platform/telegram/telegram.go @@ -217,6 +217,13 @@ func (t *TelegramPlatform) ParseChannelFromMessage(body []byte) (map[string]any, // SendMessage sends a message to Telegram func (t *TelegramPlatform) SendMessage(msg *model.Message) error { + // Check for delete message action (legacy method) + if msg.Raw != nil && msg.Raw["action"] == "delete" { + // This is a request to delete a message using the legacy method + return t.deleteMessage(msg) + } + + // Regular message sending // Convert chat ID to int64 chatID, err := strconv.ParseInt(msg.Chat, 10, 64) if err != nil { @@ -276,3 +283,88 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error { t.log.Debug("Message sent successfully") return nil } + +// DeleteMessage deletes a message on Telegram +func (t *TelegramPlatform) DeleteMessage(channel string, messageID string) error { + // Convert chat ID to int64 + chatID, err := strconv.ParseInt(channel, 10, 64) + if err != nil { + t.log.Error("Invalid chat ID for message deletion", "chat_id", channel, "error", err) + return err + } + + // Convert message ID to integer + msgID, err := strconv.Atoi(messageID) + if err != nil { + t.log.Error("Invalid message ID for deletion", "message_id", messageID, "error", err) + return err + } + + // Prepare payload for deleteMessage API + payload := map[string]interface{}{ + "chat_id": chatID, + "message_id": msgID, + } + + t.log.Debug("Deleting message on Telegram", "chat_id", chatID, "message_id", msgID) + + // Convert payload to JSON + data, err := json.Marshal(payload) + if err != nil { + t.log.Error("Failed to marshal delete message payload", "error", err) + return err + } + + // Send HTTP request to deleteMessage endpoint + resp, err := http.Post( + t.apiURL+"/deleteMessage", + "application/json", + bytes.NewBuffer(data), + ) + if err != nil { + t.log.Error("Failed to delete message", "error", err) + return err + } + defer func() { + if err := resp.Body.Close(); err != nil { + t.log.Error("Error closing response body", "error", err) + } + }() + + // Check response + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + errMsg := string(bodyBytes) + t.log.Error("Telegram API error when deleting message", "status", resp.StatusCode, "response", errMsg) + return fmt.Errorf("telegram API error when deleting message: %d - %s", resp.StatusCode, errMsg) + } + + t.log.Debug("Message deleted successfully") + return nil +} + +// deleteMessage is a legacy method that uses the Raw message approach +func (t *TelegramPlatform) deleteMessage(msg *model.Message) error { + // Get message ID to delete + messageIDInterface, ok := msg.Raw["message_id"] + if !ok { + t.log.Error("No message ID provided for deletion") + return fmt.Errorf("no message ID provided for deletion") + } + + // Convert message ID to string + var messageIDStr string + switch v := messageIDInterface.(type) { + case string: + messageIDStr = v + case int: + messageIDStr = strconv.Itoa(v) + case float64: + messageIDStr = strconv.Itoa(int(v)) + default: + t.log.Error("Invalid message ID type for deletion", "type", fmt.Sprintf("%T", messageIDInterface)) + return fmt.Errorf("invalid message ID type for deletion") + } + + return t.DeleteMessage(msg.Chat, messageIDStr) +} diff --git a/internal/plugin/domainblock/domainblock.go b/internal/plugin/domainblock/domainblock.go new file mode 100644 index 0000000..5a44c49 --- /dev/null +++ b/internal/plugin/domainblock/domainblock.go @@ -0,0 +1,132 @@ +package domainblock + +import ( + "fmt" + "net/url" + "regexp" + "strings" + + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// DomainBlockPlugin is a plugin that blocks messages containing links from specific domains +type DomainBlockPlugin struct { + plugin.BasePlugin +} + +// Debug helper to check if RequiresConfig is working +func (p *DomainBlockPlugin) RequiresConfig() bool { + return true +} + +// New creates a new DomainBlockPlugin instance +func New() *DomainBlockPlugin { + return &DomainBlockPlugin{ + BasePlugin: plugin.BasePlugin{ + ID: "security.domainblock", + Name: "Domain Blocker", + Help: "Blocks messages containing links from configured domains", + ConfigRequired: true, + }, + } +} + +// extractDomains extracts domains from a message text +func extractDomains(text string) []string { + // URL regex pattern + urlPattern := regexp.MustCompile(`https?://([^\s/$.?#].[^\s]*)`) + matches := urlPattern.FindAllStringSubmatch(text, -1) + + domains := make([]string, 0, len(matches)) + for _, match := range matches { + if len(match) < 2 { + continue + } + + // Try to parse the URL to extract the domain + urlStr := match[0] + parsedURL, err := url.Parse(urlStr) + if err != nil { + continue + } + + // Extract the domain (host) from the URL + domain := parsedURL.Host + // Remove port if present + if i := strings.IndexByte(domain, ':'); i >= 0 { + domain = domain[:i] + } + + domains = append(domains, strings.ToLower(domain)) + } + + return domains +} + +// OnMessage processes incoming messages +func (p *DomainBlockPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { + // Skip messages from bots + if msg.FromBot { + return nil + } + + // Get blocked domains from config + blockedDomainsStr, ok := config["blocked_domains"].(string) + if !ok || blockedDomainsStr == "" { + return nil // No blocked domains configured + } + + // Split and clean blocked domains + blockedDomains := strings.Split(blockedDomainsStr, ",") + for i, domain := range blockedDomains { + blockedDomains[i] = strings.ToLower(strings.TrimSpace(domain)) + } + + // Extract domains from message + messageDomains := extractDomains(msg.Text) + if len(messageDomains) == 0 { + return nil // No domains in message + } + + // Check if any domains in the message are blocked + for _, msgDomain := range messageDomains { + for _, blockedDomain := range blockedDomains { + if blockedDomain == "" { + continue + } + + if strings.HasSuffix(msgDomain, blockedDomain) || msgDomain == blockedDomain { + // Domain is blocked, create actions + + // 1. Create a delete message action + deleteAction := &model.MessageAction{ + Type: model.ActionDeleteMessage, + MessageID: msg.ID, + Chat: msg.Chat, + Channel: msg.Channel, + } + + // 2. Create a notification message action + notificationMsg := &model.Message{ + Text: fmt.Sprintf("I don't like links from %s 🙈", blockedDomain), + Chat: msg.Chat, + Channel: msg.Channel, + } + + sendAction := &model.MessageAction{ + Type: model.ActionSendMessage, + Message: notificationMsg, + Chat: msg.Chat, + Channel: msg.Channel, + } + + return []*model.MessageAction{deleteAction, sendAction} + } + } + } + + return nil +} + +// Plugin is registered in app.go, not using init() diff --git a/internal/plugin/domainblock/domainblock_test.go b/internal/plugin/domainblock/domainblock_test.go new file mode 100644 index 0000000..3f75a18 --- /dev/null +++ b/internal/plugin/domainblock/domainblock_test.go @@ -0,0 +1,140 @@ +package domainblock + +import ( + "testing" + + "git.nakama.town/fmartingr/butterrobot/internal/model" +) + +func TestExtractDomains(t *testing.T) { + tests := []struct { + name string + text string + expected []string + }{ + { + name: "No URLs", + text: "Hello, world!", + expected: []string{}, + }, + { + name: "Single URL", + text: "Check out https://example.com for more info", + expected: []string{"example.com"}, + }, + { + name: "Multiple URLs", + text: "Check out https://example.com and http://test.example.org for more info", + expected: []string{"example.com", "test.example.org"}, + }, + { + name: "URL with path", + text: "Check out https://example.com/path/to/resource", + expected: []string{"example.com"}, + }, + { + name: "URL with port", + text: "Check out https://example.com:8080/path/to/resource", + expected: []string{"example.com"}, + }, + { + name: "URL with subdomain", + text: "Check out https://sub.example.com", + expected: []string{"sub.example.com"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + domains := extractDomains(test.text) + + if len(domains) != len(test.expected) { + t.Errorf("Expected %d domains, got %d", len(test.expected), len(domains)) + return + } + + for i, domain := range domains { + if domain != test.expected[i] { + t.Errorf("Expected domain %s, got %s", test.expected[i], domain) + } + } + }) + } +} + +func TestOnMessage(t *testing.T) { + plugin := New() + + tests := []struct { + name string + text string + blockedDomains string + expectBlocked bool + }{ + { + name: "No blocked domains", + text: "Check out https://example.com", + blockedDomains: "", + expectBlocked: false, + }, + { + name: "No matching domain", + text: "Check out https://example.com", + blockedDomains: "bad.com, evil.org", + expectBlocked: false, + }, + { + name: "Matching domain", + text: "Check out https://example.com", + blockedDomains: "example.com, evil.org", + expectBlocked: true, + }, + { + name: "Matching subdomain", + text: "Check out https://sub.example.com", + blockedDomains: "example.com", + expectBlocked: true, + }, + { + name: "Multiple domains, one matching", + text: "Check out https://example.com and https://good.org", + blockedDomains: "bad.com, example.com", + expectBlocked: true, + }, + { + name: "Spaces in blocked domains list", + text: "Check out https://example.com", + blockedDomains: "bad.com, example.com , evil.org", + expectBlocked: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + msg := &model.Message{ + Text: test.text, + Chat: "test-chat", + ID: "test-id", + Channel: &model.Channel{ + ID: 1, + }, + } + + config := map[string]interface{}{ + "blocked_domains": test.blockedDomains, + } + + responses := plugin.OnMessage(msg, config) + + if test.expectBlocked { + if responses == nil || len(responses) == 0 { + t.Errorf("Expected message to be blocked, but it wasn't") + } + } else { + if responses != nil && len(responses) > 0 { + t.Errorf("Expected message not to be blocked, but it was") + } + } + }) + } +} \ No newline at end of file diff --git a/internal/plugin/fun/coin.go b/internal/plugin/fun/coin.go index 8e12a8d..bd083d1 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.Message { +func (p *CoinPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { if !strings.Contains(strings.ToLower(msg.Text), "flip a coin") { return nil } @@ -46,5 +46,12 @@ func (p *CoinPlugin) OnMessage(msg *model.Message, config map[string]interface{} Channel: msg.Channel, } - return []*model.Message{response} + action := &model.MessageAction{ + Type: model.ActionSendMessage, + Message: response, + Chat: msg.Chat, + Channel: msg.Channel, + } + + return []*model.MessageAction{action} } diff --git a/internal/plugin/fun/dice.go b/internal/plugin/fun/dice.go index 2d5533b..8b13edb 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.Message { +func (p *DicePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { if !strings.HasPrefix(strings.TrimSpace(strings.ToLower(msg.Text)), "!dice") { return nil } @@ -62,7 +62,14 @@ func (p *DicePlugin) OnMessage(msg *model.Message, config map[string]interface{} Channel: msg.Channel, } - return []*model.Message{response} + action := &model.MessageAction{ + Type: model.ActionSendMessage, + Message: response, + Chat: msg.Chat, + Channel: msg.Channel, + } + + return []*model.MessageAction{action} } // rollDice parses a dice formula string and returns the result diff --git a/internal/plugin/fun/loquito.go b/internal/plugin/fun/loquito.go index 7b0ea43..fef78bd 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.Message { +func (p *LoquitoPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { if !strings.Contains(strings.ToLower(msg.Text), "lo quito") { return nil } @@ -36,5 +36,12 @@ func (p *LoquitoPlugin) OnMessage(msg *model.Message, config map[string]interfac Channel: msg.Channel, } - return []*model.Message{response} + action := &model.MessageAction{ + Type: model.ActionSendMessage, + Message: response, + Chat: msg.Chat, + Channel: msg.Channel, + } + + return []*model.MessageAction{action} } diff --git a/internal/plugin/ping/ping.go b/internal/plugin/ping/ping.go index b09caaf..e77cb78 100644 --- a/internal/plugin/ping/ping.go +++ b/internal/plugin/ping/ping.go @@ -24,17 +24,26 @@ func New() *PingPlugin { } // OnMessage handles incoming messages -func (p *PingPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { +func (p *PingPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { if !strings.EqualFold(strings.TrimSpace(msg.Text), "ping") { return nil } + // Create the response message response := &model.Message{ Text: "pong", Chat: msg.Chat, ReplyTo: msg.ID, Channel: msg.Channel, } + + // Create an action to send the message + action := &model.MessageAction{ + Type: model.ActionSendMessage, + Message: response, + Chat: msg.Chat, + Channel: msg.Channel, + } - return []*model.Message{response} + return []*model.MessageAction{action} } diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 69da2c2..eb3789f 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -1,6 +1,7 @@ package plugin import ( + "maps" "sync" "git.nakama.town/fmartingr/butterrobot/internal/model" @@ -41,9 +42,7 @@ func GetAvailablePlugins() map[string]model.Plugin { // Create a copy to avoid race conditions result := make(map[string]model.Plugin, len(plugins)) - for id, plugin := range plugins { - result[id] = plugin - } + maps.Copy(result, plugins) return result } @@ -77,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.Message { +func (p *BasePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { return nil } diff --git a/internal/plugin/reminder/reminder.go b/internal/plugin/reminder/reminder.go index 5eb47f9..c162e6e 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.Message { +func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { // Only process replies to messages if msg.ReplyTo == "" { return nil @@ -56,15 +56,22 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) // Parse the duration amount, err := strconv.Atoi(match[1]) if err != nil { - return []*model.Message{ + errorMsg := &model.Message{ + Text: "Invalid duration format. Please use a number followed by y (years), mo (months), d (days), h (hours), m (minutes), or s (seconds).", + Chat: msg.Chat, + Channel: msg.Channel, + Author: "bot", + FromBot: true, + Date: time.Now(), + ReplyTo: msg.ID, + } + + return []*model.MessageAction{ { - Text: "Invalid duration format. Please use a number followed by y (years), mo (months), d (days), h (hours), m (minutes), or s (seconds).", + Type: model.ActionSendMessage, + Message: errorMsg, Chat: msg.Chat, Channel: msg.Channel, - Author: "bot", - FromBot: true, - Date: time.Now(), - ReplyTo: msg.ID, }, } } @@ -86,15 +93,22 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) case "s": duration = time.Duration(amount) * time.Second default: - return []*model.Message{ + errorMsg := &model.Message{ + Text: "Invalid duration unit. Please use y (years), mo (months), d (days), h (hours), m (minutes), or s (seconds).", + Chat: msg.Chat, + Channel: msg.Channel, + Author: "bot", + FromBot: true, + Date: time.Now(), + ReplyTo: msg.ID, + } + + return []*model.MessageAction{ { - Text: "Invalid duration unit. Please use y (years), mo (months), d (days), h (hours), m (minutes), or s (seconds).", + Type: model.ActionSendMessage, + Message: errorMsg, Chat: msg.Chat, Channel: msg.Channel, - Author: "bot", - FromBot: true, - Date: time.Now(), - ReplyTo: msg.ID, }, } } @@ -127,15 +141,22 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) ) if err != nil { - return []*model.Message{ + errorMsg := &model.Message{ + Text: fmt.Sprintf("Failed to create reminder: %v", err), + Chat: msg.Chat, + Channel: msg.Channel, + Author: "bot", + FromBot: true, + Date: time.Now(), + ReplyTo: msg.ID, + } + + return []*model.MessageAction{ { - Text: fmt.Sprintf("Failed to create reminder: %v", err), + Type: model.ActionSendMessage, + Message: errorMsg, Chat: msg.Chat, Channel: msg.Channel, - Author: "bot", - FromBot: true, - Date: time.Now(), - ReplyTo: msg.ID, }, } } @@ -157,15 +178,23 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) confirmText = fmt.Sprintf("I'll remind you about this message in %d second(s)", amount) } - return []*model.Message{ + // Create confirmation message + confirmMsg := &model.Message{ + Text: confirmText, + Chat: msg.Chat, + Channel: msg.Channel, + Author: "bot", + FromBot: true, + Date: time.Now(), + ReplyTo: msg.ID, + } + + return []*model.MessageAction{ { - Text: confirmText, + Type: model.ActionSendMessage, + Message: confirmMsg, Chat: msg.Chat, Channel: msg.Channel, - Author: "bot", - FromBot: true, - Date: time.Now(), - ReplyTo: msg.ID, }, } -} +} \ No newline at end of file diff --git a/internal/plugin/reminder/reminder_test.go b/internal/plugin/reminder/reminder_test.go index 3070918..b54e281 100644 --- a/internal/plugin/reminder/reminder_test.go +++ b/internal/plugin/reminder/reminder_test.go @@ -142,14 +142,25 @@ func TestReminderOnMessage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { initialCount := len(creator.reminders) - responses := plugin.OnMessage(tt.message, nil) + actions := plugin.OnMessage(tt.message, nil) - if tt.expectResponse && len(responses) == 0 { - t.Errorf("Expected response, but got none") + if tt.expectResponse && len(actions) == 0 { + t.Errorf("Expected response action, but got none") } - if !tt.expectResponse && len(responses) > 0 { - t.Errorf("Expected no response, but got %d", len(responses)) + if !tt.expectResponse && len(actions) > 0 { + t.Errorf("Expected no actions, but got %d", len(actions)) + } + + // Verify action type is correct when actions are returned + if len(actions) > 0 { + if actions[0].Type != model.ActionSendMessage { + t.Errorf("Expected action type to be %s, but got %s", model.ActionSendMessage, actions[0].Type) + } + + if actions[0].Message == nil { + t.Errorf("Expected message in action to not be nil") + } } if tt.expectReminder && len(creator.reminders) != initialCount+1 { diff --git a/internal/plugin/social/instagram.go b/internal/plugin/social/instagram.go index 7ff74a5..d05bd30 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.Message { +func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { // Skip empty messages if strings.TrimSpace(msg.Text) == "" { return nil @@ -70,5 +70,12 @@ func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]inte Channel: msg.Channel, } - return []*model.Message{response} + action := &model.MessageAction{ + Type: model.ActionSendMessage, + Message: response, + Chat: msg.Chat, + Channel: msg.Channel, + } + + return []*model.MessageAction{action} } diff --git a/internal/plugin/social/twitter.go b/internal/plugin/social/twitter.go index 837b6c9..865f421 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.Message { +func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { // Skip empty messages if strings.TrimSpace(msg.Text) == "" { return nil @@ -75,5 +75,12 @@ func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interf Channel: msg.Channel, } - return []*model.Message{response} + action := &model.MessageAction{ + Type: model.ActionSendMessage, + Message: response, + Chat: msg.Chat, + Channel: msg.Channel, + } + + return []*model.MessageAction{action} } From fae6f35774e1dfa5c0b788a2636bdeaf7ae4102f Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Tue, 22 Apr 2025 18:10:24 +0200 Subject: [PATCH 07/31] chore: make format --- cmd/butterrobot/main.go | 2 +- internal/admin/admin.go | 2 +- internal/app/app.go | 6 +++--- internal/model/message.go | 8 ++++---- internal/platform/slack/slack.go | 4 ++-- internal/plugin/domainblock/domainblock_test.go | 2 +- internal/plugin/ping/ping.go | 2 +- internal/plugin/reminder/reminder.go | 10 +++++----- internal/plugin/reminder/reminder_test.go | 2 +- 9 files changed, 19 insertions(+), 19 deletions(-) diff --git a/cmd/butterrobot/main.go b/cmd/butterrobot/main.go index bf217de..3bc56cb 100644 --- a/cmd/butterrobot/main.go +++ b/cmd/butterrobot/main.go @@ -45,4 +45,4 @@ func main() { logger.Error("Application error", "error", err) os.Exit(1) } -} \ No newline at end of file +} diff --git a/internal/admin/admin.go b/internal/admin/admin.go index dee4197..2b41820 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -673,7 +673,7 @@ func (a *Admin) handleChannelPluginConfig(w http.ResponseWriter, r *http.Request // Create config map from form values config := make(map[string]interface{}) - + // Process form values based on plugin type if channelPlugin.PluginID == "security.domainblock" { // Get blocked domains from form diff --git a/internal/app/app.go b/internal/app/app.go index bd64b80..4614325 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -325,7 +325,7 @@ func (a *App) handleMessage(item queue.Item) { } else { a.logger.Error("Send message action with nil message") } - + case model.ActionDeleteMessage: // Delete a message using direct DeleteMessage call if err := platform.DeleteMessage(action.Chat, action.MessageID); err != nil { @@ -333,7 +333,7 @@ func (a *App) handleMessage(item queue.Item) { } else { a.logger.Info("Message deleted", "message_id", action.MessageID) } - + default: a.logger.Error("Unknown action type", "type", action.Type) } @@ -409,4 +409,4 @@ func (a *App) processReminder(reminder *model.Reminder) { if err := a.db.MarkReminderAsProcessed(reminder.ID); err != nil { a.logger.Error("Error marking reminder as processed", "error", err) } -} \ No newline at end of file +} diff --git a/internal/model/message.go b/internal/model/message.go index ea3b316..26ec5da 100644 --- a/internal/model/message.go +++ b/internal/model/message.go @@ -17,10 +17,10 @@ const ( // MessageAction represents an action to be performed on the platform type MessageAction struct { Type ActionType - Message *Message // For send_message - MessageID string // For delete_message - Chat string // Chat where the action happens - Channel *Channel // Channel reference + Message *Message // For send_message + MessageID string // For delete_message + Chat string // Chat where the action happens + Channel *Channel // Channel reference Raw map[string]interface{} // Additional data for the action } diff --git a/internal/platform/slack/slack.go b/internal/platform/slack/slack.go index 28e8363..2ca7bef 100644 --- a/internal/platform/slack/slack.go +++ b/internal/platform/slack/slack.go @@ -268,10 +268,10 @@ func (s *SlackPlatform) deleteMessage(msg *model.Message) error { if !ok { return fmt.Errorf("no message ID provided for deletion") } - + // Convert to string if needed messageIDStr := fmt.Sprintf("%v", messageID) - + return s.DeleteMessage(msg.Chat, messageIDStr) } diff --git a/internal/plugin/domainblock/domainblock_test.go b/internal/plugin/domainblock/domainblock_test.go index 3f75a18..69cd8b8 100644 --- a/internal/plugin/domainblock/domainblock_test.go +++ b/internal/plugin/domainblock/domainblock_test.go @@ -137,4 +137,4 @@ func TestOnMessage(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/internal/plugin/ping/ping.go b/internal/plugin/ping/ping.go index e77cb78..3dacf6f 100644 --- a/internal/plugin/ping/ping.go +++ b/internal/plugin/ping/ping.go @@ -36,7 +36,7 @@ func (p *PingPlugin) OnMessage(msg *model.Message, config map[string]interface{} ReplyTo: msg.ID, Channel: msg.Channel, } - + // Create an action to send the message action := &model.MessageAction{ Type: model.ActionSendMessage, diff --git a/internal/plugin/reminder/reminder.go b/internal/plugin/reminder/reminder.go index c162e6e..029c8d9 100644 --- a/internal/plugin/reminder/reminder.go +++ b/internal/plugin/reminder/reminder.go @@ -65,7 +65,7 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) Date: time.Now(), ReplyTo: msg.ID, } - + return []*model.MessageAction{ { Type: model.ActionSendMessage, @@ -102,7 +102,7 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) Date: time.Now(), ReplyTo: msg.ID, } - + return []*model.MessageAction{ { Type: model.ActionSendMessage, @@ -150,7 +150,7 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) Date: time.Now(), ReplyTo: msg.ID, } - + return []*model.MessageAction{ { Type: model.ActionSendMessage, @@ -188,7 +188,7 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) Date: time.Now(), ReplyTo: msg.ID, } - + return []*model.MessageAction{ { Type: model.ActionSendMessage, @@ -197,4 +197,4 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) Channel: msg.Channel, }, } -} \ No newline at end of file +} diff --git a/internal/plugin/reminder/reminder_test.go b/internal/plugin/reminder/reminder_test.go index b54e281..8e611ce 100644 --- a/internal/plugin/reminder/reminder_test.go +++ b/internal/plugin/reminder/reminder_test.go @@ -157,7 +157,7 @@ func TestReminderOnMessage(t *testing.T) { if actions[0].Type != model.ActionSendMessage { t.Errorf("Expected action type to be %s, but got %s", model.ActionSendMessage, actions[0].Type) } - + if actions[0].Message == nil { t.Errorf("Expected message in action to not be nil") } From 8d188217e9f17a72eff4fc581a880f64b8439ad1 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Sun, 27 Apr 2025 17:11:40 +0200 Subject: [PATCH 08/31] chore: lint fixes --- internal/plugin/domainblock/domainblock_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/plugin/domainblock/domainblock_test.go b/internal/plugin/domainblock/domainblock_test.go index 69cd8b8..1d65964 100644 --- a/internal/plugin/domainblock/domainblock_test.go +++ b/internal/plugin/domainblock/domainblock_test.go @@ -127,11 +127,11 @@ func TestOnMessage(t *testing.T) { responses := plugin.OnMessage(msg, config) if test.expectBlocked { - if responses == nil || len(responses) == 0 { + if len(responses) == 0 { t.Errorf("Expected message to be blocked, but it wasn't") } } else { - if responses != nil && len(responses) > 0 { + if len(responses) > 0 { t.Errorf("Expected message not to be blocked, but it was") } } From 4a154f16f9bdad7b5f5ebd1d90dc2669de8720b2 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Fri, 9 May 2025 09:33:11 +0200 Subject: [PATCH 09/31] fix: instagram expander replying to ddintagram links --- internal/plugin/social/instagram.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/plugin/social/instagram.go b/internal/plugin/social/instagram.go index d05bd30..c1dbe1f 100644 --- a/internal/plugin/social/instagram.go +++ b/internal/plugin/social/instagram.go @@ -48,12 +48,16 @@ func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]inte parsedURL, err := url.Parse(link) if err != nil { // If parsing fails, just do the simple replacement - link = strings.Replace(link, "instagram.com", "ddinstagram.com", 1) + return link + } + + // Ensure we don't change links that already come from ddinstagram.com + if parsedURL.Host != "instagram.com" && parsedURL.Host != "www.instagram.com" { return link } // Change the host - parsedURL.Host = strings.Replace(parsedURL.Host, "instagram.com", "ddinstagram.com", 1) + parsedURL.Host = strings.Replace(parsedURL.Host, "instagram.com", "d.ddinstagram.com", 1) // Remove query parameters parsedURL.RawQuery = "" From a9b4ad52cbebb6b604d9b353abfbd06e48a4b161 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Fri, 9 May 2025 14:00:08 +0200 Subject: [PATCH 10/31] plugin: search and replace --- internal/app/app.go | 2 + internal/plugin/searchreplace/README.md | 50 ++++ .../plugin/searchreplace/searchreplace.go | 182 +++++++++++++++ .../searchreplace/searchreplace_test.go | 216 ++++++++++++++++++ 4 files changed, 450 insertions(+) create mode 100644 internal/plugin/searchreplace/README.md create mode 100644 internal/plugin/searchreplace/searchreplace.go create mode 100644 internal/plugin/searchreplace/searchreplace_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 4614325..becd5ea 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -24,6 +24,7 @@ import ( "git.nakama.town/fmartingr/butterrobot/internal/plugin/fun" "git.nakama.town/fmartingr/butterrobot/internal/plugin/ping" "git.nakama.town/fmartingr/butterrobot/internal/plugin/reminder" + "git.nakama.town/fmartingr/butterrobot/internal/plugin/searchreplace" "git.nakama.town/fmartingr/butterrobot/internal/plugin/social" "git.nakama.town/fmartingr/butterrobot/internal/queue" ) @@ -90,6 +91,7 @@ func (a *App) Run() error { plugin.Register(social.NewInstagramExpander()) plugin.Register(reminder.New(a.db)) plugin.Register(domainblock.New()) + plugin.Register(searchreplace.New()) // Initialize routes a.initializeRoutes() diff --git a/internal/plugin/searchreplace/README.md b/internal/plugin/searchreplace/README.md new file mode 100644 index 0000000..c7b7786 --- /dev/null +++ b/internal/plugin/searchreplace/README.md @@ -0,0 +1,50 @@ +# Search and Replace Plugin + +This plugin allows users to perform search and replace operations on messages by replying to a message with a search/replace command. + +## Usage + +To use the plugin, reply to any message with a command in the following format: + +``` +s/search/replace/[flags] +``` + +Where: +- `search` is the text you want to find (case-sensitive by default) +- `replace` is the text you want to substitute in place of the search term +- `flags` (optional) control the behavior of the replacement + +### Supported Flags + +- `g` - Global: Replace all occurrences of the search term (without this flag, only the first occurrence is replaced) +- `i` - Case insensitive: Match regardless of case +- `n` - Treat search pattern as a regular expression (advanced users) + +### Examples + +1. Basic replacement (replaces first occurrence): + ``` + s/hello/hi/ + ``` + +2. Global replacement (replaces all occurrences): + ``` + s/hello/hi/g + ``` + +3. Case-insensitive replacement: + ``` + s/Hello/hi/i + ``` + +4. Combined flags (global and case-insensitive): + ``` + s/hello/hi/gi + ``` + +## Limitations + +- The plugin can only access the text content of the original message +- Regular expression support is available with the `n` flag, but should be used carefully as invalid regex patterns will cause errors +- The plugin does not modify the original message; it creates a new message with the replaced text \ No newline at end of file diff --git a/internal/plugin/searchreplace/searchreplace.go b/internal/plugin/searchreplace/searchreplace.go new file mode 100644 index 0000000..876e880 --- /dev/null +++ b/internal/plugin/searchreplace/searchreplace.go @@ -0,0 +1,182 @@ +package searchreplace + +import ( + "fmt" + "regexp" + "strings" + + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// Regex pattern for search and replace operations: s/search/replace/[flags] +var searchReplacePattern = regexp.MustCompile(`^s/([^/]*)/([^/]*)(?:/([gimnsuy]*))?$`) + +// SearchReplacePlugin is a plugin for performing search and replace operations on messages +type SearchReplacePlugin struct { + plugin.BasePlugin +} + +// New creates a new SearchReplacePlugin instance +func New() *SearchReplacePlugin { + return &SearchReplacePlugin{ + BasePlugin: plugin.BasePlugin{ + ID: "util.searchreplace", + Name: "Search and Replace", + Help: "Reply to a message with a search and replace pattern (s/search/replace/[flags]) to create a modified message. " + + "Supported flags: g (global), i (case insensitive)", + }, + } +} + +// OnMessage handles incoming messages +func (p *SearchReplacePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { + // Only process replies to messages + if msg.ReplyTo == "" { + return nil + } + + // Check if the message matches the search/replace pattern + match := searchReplacePattern.FindStringSubmatch(strings.TrimSpace(msg.Text)) + if match == nil { + return nil + } + + // Get the original message text from the reply_to_message structure in Telegram messages + var originalText string + + // For Telegram messages + if msgData, ok := msg.Raw["message"].(map[string]interface{}); ok { + if replyMsg, ok := msgData["reply_to_message"].(map[string]interface{}); ok { + if text, ok := replyMsg["text"].(string); ok { + originalText = text + } + } + } + + // Generic fallback for other platforms or if the above method fails + if originalText == "" && msg.Raw["original_message"] != nil { + if original, ok := msg.Raw["original_message"].(map[string]interface{}); ok { + if text, ok := original["text"].(string); ok { + originalText = text + } + } + } + + if originalText == "" { + // If we couldn't find the original message text, inform the user + return []*model.MessageAction{ + { + Type: model.ActionSendMessage, + Message: &model.Message{ + Text: "Sorry, I couldn't find the original message text to perform the replacement.", + Chat: msg.Chat, + Channel: msg.Channel, + ReplyTo: msg.ID, + }, + Chat: msg.Chat, + Channel: msg.Channel, + }, + } + } + + // Extract search pattern, replacement and flags + searchPattern := match[1] + replacement := match[2] + flags := "" + if len(match) > 3 { + flags = match[3] + } + + // Process the replacement + result, err := p.performReplacement(originalText, searchPattern, replacement, flags) + if err != nil { + return []*model.MessageAction{ + { + Type: model.ActionSendMessage, + Message: &model.Message{ + Text: fmt.Sprintf("Error performing replacement: %s", err.Error()), + Chat: msg.Chat, + Channel: msg.Channel, + ReplyTo: msg.ID, + }, + Chat: msg.Chat, + Channel: msg.Channel, + }, + } + } + + // Only send a response if the text actually changed + if result == originalText { + return []*model.MessageAction{ + { + Type: model.ActionSendMessage, + Message: &model.Message{ + Text: "No changes were made to the original message.", + Chat: msg.Chat, + Channel: msg.Channel, + ReplyTo: msg.ID, + }, + Chat: msg.Chat, + Channel: msg.Channel, + }, + } + } + + // Create a response with the modified text + return []*model.MessageAction{ + { + Type: model.ActionSendMessage, + Message: &model.Message{ + Text: result, + Chat: msg.Chat, + Channel: msg.Channel, + ReplyTo: msg.ReplyTo, // Reply to the original message + }, + Chat: msg.Chat, + Channel: msg.Channel, + }, + } +} + +// performReplacement performs the search and replace operation on the given text +func (p *SearchReplacePlugin) performReplacement(text, search, replace, flags string) (string, error) { + // Process flags + globalReplace := strings.Contains(flags, "g") + caseInsensitive := strings.Contains(flags, "i") + + // Create the regex pattern + pattern := search + regexFlags := "" + if caseInsensitive { + regexFlags += "(?i)" + } + + // Escape special characters if we're not in a regular expression + if !strings.Contains(flags, "n") { + pattern = regexp.QuoteMeta(pattern) + } + + // Compile the regex + reg, err := regexp.Compile(regexFlags + pattern) + if err != nil { + return "", fmt.Errorf("invalid search pattern: %v", err) + } + + // Perform the replacement + var result string + if globalReplace { + result = reg.ReplaceAllString(text, replace) + } else { + // For non-global replace, only replace the first occurrence + indices := reg.FindStringIndex(text) + if indices == nil { + // No match found + return text, nil + } + + result = text[:indices[0]] + replace + text[indices[1]:] + } + + return result, nil +} diff --git a/internal/plugin/searchreplace/searchreplace_test.go b/internal/plugin/searchreplace/searchreplace_test.go new file mode 100644 index 0000000..415610c --- /dev/null +++ b/internal/plugin/searchreplace/searchreplace_test.go @@ -0,0 +1,216 @@ +package searchreplace + +import ( + "testing" + "time" + + "git.nakama.town/fmartingr/butterrobot/internal/model" +) + +func TestSearchReplace(t *testing.T) { + // Create plugin instance + p := New() + + // Test cases + tests := []struct { + name string + command string + originalText string + expectedResult string + expectActions bool + }{ + { + name: "Simple replacement", + command: "s/hello/world/", + originalText: "hello everyone", + expectedResult: "world everyone", + expectActions: true, + }, + { + name: "Case-insensitive replacement", + command: "s/HELLO/world/i", + originalText: "Hello everyone", + expectedResult: "world everyone", + expectActions: true, + }, + { + name: "Global replacement", + command: "s/a/X/g", + originalText: "banana", + expectedResult: "bXnXnX", + expectActions: true, + }, + { + name: "No change", + command: "s/nothing/something/", + originalText: "test message", + expectedResult: "test message", + expectActions: true, // We send a "no changes" message + }, + { + name: "Not a search/replace command", + command: "hello", + originalText: "test message", + expectedResult: "", + expectActions: false, + }, + { + name: "Invalid pattern", + command: "s/(/)/", + originalText: "test message", + expectedResult: "error", + expectActions: true, // We send an error message + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Create message + msg := &model.Message{ + Text: tc.command, + Chat: "test-chat", + ReplyTo: "original-message-id", + Date: time.Now(), + Channel: &model.Channel{ + Platform: "test", + }, + Raw: map[string]interface{}{ + "message": map[string]interface{}{ + "reply_to_message": map[string]interface{}{ + "text": tc.originalText, + }, + }, + }, + } + + // Process message + actions := p.OnMessage(msg, nil) + + // Check results + if tc.expectActions { + if len(actions) == 0 { + t.Fatalf("Expected actions but got none") + } + + action := actions[0] + if action.Type != model.ActionSendMessage { + t.Fatalf("Expected send message action but got %v", action.Type) + } + + if tc.expectedResult == "error" { + // Just checking that we got an error message + if action.Message == nil || action.Message.Text == "" { + t.Fatalf("Expected error message but got empty message") + } + } else if tc.originalText == tc.expectedResult { + // Check if we got the "no changes" message + if action.Message == nil || action.Message.Text != "No changes were made to the original message." { + t.Fatalf("Expected 'no changes' message but got: %s", action.Message.Text) + } + } else { + // Check actual replacement result + if action.Message == nil || action.Message.Text != tc.expectedResult { + t.Fatalf("Expected result: %s, got: %s", tc.expectedResult, action.Message.Text) + } + } + } else if len(actions) > 0 { + t.Fatalf("Expected no actions but got %d", len(actions)) + } + }) + } +} + +func TestPerformReplacement(t *testing.T) { + p := New() + + // Test cases for the performReplacement function + tests := []struct { + name string + text string + search string + replace string + flags string + expected string + expectErr bool + }{ + { + name: "Simple replacement", + text: "Hello World", + search: "Hello", + replace: "Hi", + flags: "", + expected: "Hi World", + expectErr: false, + }, + { + name: "Case insensitive", + text: "Hello World", + search: "hello", + replace: "Hi", + flags: "i", + expected: "Hi World", + expectErr: false, + }, + { + name: "Global replacement", + text: "one two one two", + search: "one", + replace: "1", + flags: "g", + expected: "1 two 1 two", + expectErr: false, + }, + { + name: "No match", + text: "Hello World", + search: "Goodbye", + replace: "Hi", + flags: "", + expected: "Hello World", + expectErr: false, + }, + { + name: "Invalid regex", + text: "Hello World", + search: "(", + replace: "Hi", + flags: "n", // treat as regex + expected: "", + expectErr: true, + }, + { + name: "Escape special chars by default", + text: "Hello (World)", + search: "(World)", + replace: "[Earth]", + flags: "", + expected: "Hello [Earth]", + expectErr: false, + }, + { + name: "Regex mode with n flag", + text: "Hello (World)", + search: "\\(World\\)", + replace: "[Earth]", + flags: "n", + expected: "Hello [Earth]", + expectErr: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := p.performReplacement(tc.text, tc.search, tc.replace, tc.flags) + + if tc.expectErr { + if err == nil { + t.Fatalf("Expected error but got none") + } + } else if err != nil { + t.Fatalf("Unexpected error: %v", err) + } else if result != tc.expected { + t.Fatalf("Expected result: %s, got: %s", tc.expected, result) + } + }) + } +} From c53942ac5342da5393818f2bb05f8d7f7e7bd8e9 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Fri, 9 May 2025 20:24:18 +0200 Subject: [PATCH 11/31] fix: instagram leaving www. --- internal/plugin/social/instagram.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/plugin/social/instagram.go b/internal/plugin/social/instagram.go index c1dbe1f..0b4ff55 100644 --- a/internal/plugin/social/instagram.go +++ b/internal/plugin/social/instagram.go @@ -57,7 +57,7 @@ func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]inte } // Change the host - parsedURL.Host = strings.Replace(parsedURL.Host, "instagram.com", "d.ddinstagram.com", 1) + parsedURL.Host = "d.ddinstagram.com" // Remove query parameters parsedURL.RawQuery = "" From d09b763aa7c9b579fa7ffd672c7a27d4d484276c Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Thu, 12 Jun 2025 14:45:07 +0200 Subject: [PATCH 12/31] feat: hltb plugin --- internal/app/app.go | 24 +- internal/cache/cache.go | 83 ++++ internal/cache/cache_test.go | 170 ++++++++ internal/cache/test_cache.db | Bin 0 -> 53248 bytes internal/db/db.go | 53 +++ internal/migration/migrations.go | 28 ++ internal/model/plugin.go | 12 +- internal/platform/telegram/telegram.go | 5 +- internal/plugin/domainblock/domainblock.go | 2 +- .../plugin/domainblock/domainblock_test.go | 4 +- internal/plugin/fun/coin.go | 2 +- internal/plugin/fun/dice.go | 2 +- internal/plugin/fun/hltb.go | 391 ++++++++++++++++++ internal/plugin/fun/hltb_test.go | 131 ++++++ internal/plugin/fun/loquito.go | 2 +- internal/plugin/ping/ping.go | 2 +- internal/plugin/plugin.go | 2 +- internal/plugin/reminder/reminder.go | 2 +- internal/plugin/reminder/reminder_test.go | 4 +- .../plugin/searchreplace/searchreplace.go | 2 +- .../searchreplace/searchreplace_test.go | 4 +- internal/plugin/social/instagram.go | 2 +- internal/plugin/social/twitter.go | 2 +- internal/testutil/mock_cache.go | 29 ++ 24 files changed, 941 insertions(+), 17 deletions(-) create mode 100644 internal/cache/cache.go create mode 100644 internal/cache/cache_test.go create mode 100644 internal/cache/test_cache.db create mode 100644 internal/plugin/fun/hltb.go create mode 100644 internal/plugin/fun/hltb_test.go create mode 100644 internal/testutil/mock_cache.go diff --git a/internal/app/app.go b/internal/app/app.go index becd5ea..b54412a 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,20 @@ 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 range 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 +324,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..9419c08 --- /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) +} diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go new file mode 100644 index 0000000..b04d260 --- /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) + } + }) +} diff --git a/internal/cache/test_cache.db b/internal/cache/test_cache.db new file mode 100644 index 0000000000000000000000000000000000000000..d50b94d98903d86606891f5577b0f625ca0bb8e5 GIT binary patch literal 53248 zcmeI*-%i_B90zb4lZ5c6uBu9wHlZG&Y9#`K|0HojRc$F4mJt#flC4}!kw;F0)%iRpJ11Jfl9sTZgw+iyV#^Xz+PZauw85yJGKcVsbMfxnu@*_B=$Kz_W6B& zhZEwMcI#Hw>QHj0*{+)%qHz~Fp65OxgyXnN?0brR_m&V_8SQPbXTER!xYbKs^y|o! zXmXQd1x~asOpEf=&yycby_~2_{1yIn>{a+%R)GZq5P$##AOL~)T;R*epcI$*$5x|4 z_pHiZdB@z@rDeL;vf9)xn;mz1YPn#fiw4Q$R*c)kZ#R<95qH*@!*}UJQZ#NC$z~z5kuGeLbz^(pskm>}y40)6 z<%=X&%4Xg2qw|qsX2aMjrZ+a-ik&t!JG8=LII1AMrewufO_#DovRo<@j9jri*tXlM z{Fk<@pOMH!P`V-UoGWVItFd3$%4WCIbhi(971v<$*`;tux^jhoZo4{dT6cD8+lMRT z14@U*o|IK#`6wD|Mxoz|q)WwoCdV3Xu&`W+R?X}*zUvFKr-B-GbX}m^LFf{l*`;I z8D6SP+kVh&4`st0R%0g(E4Mdg#eE7trGbzXiSW+@-VfbDj$5^E)oS!I?rjAJVj8SF zV{pC3eZz9l-#xwmpr8$Bb~q_Bz2x4ps)M;bOy0FekFU9Vd7QIH{h23TuyOu$(B^1Rnw#Ud-4q>66E?lqr42Spy;-BT85l^?Q01&Mpyt`Gk*60&Lelg! z|IC|BdI9vFg@L2=O3#?&6Q+`0Rk;_a@0@blOdsI2%?E?&?$2=Vj81;N8Lsc#?8PS> zSrmfO^7OxC$Ue-C?YvaqP9jn=}?csuv0o}K% zJ?8stDq*etrfoMav(>6u$Gu`X@i=er1+m15KZ!5d6BYcv91oDKaOkvP{@Dp(3f@n3JPwBBtxQ9MklX zV;icmgyPdsi^YN45P$##AOHafKmY;|fB*y_ z009WRj{@UPr}6&(eM}wt3;_s000Izz00bZa0SG_<0uVU0fb;o3FaE)?FDwv%00bZa z0SG_<0uX=z1Rwwb2%IZ{FH+ZMIUU|WcAbZc9+__*j9m z))xzR*Gti8%Dz?lY;~ouqp#oNPq@{3)7s|ZPmI}tD%-{vrfx*>BE`(X01!r8C6!}3vyyX zQHi1~X^AB{-e077MbUUNCC3ypC#$kdmW{0O(K=h5^-7C=Y?P@!@}uMyA$f`>nP7GC#=?R}cBx4CpO=K@AceoENt4%9s>%Wb{#{~r)vaN;-O3-Oisi}?FH8ss<( z1Rwwb2tWV=5P$##AOHafKmY>60+)pae)w`w;HdEE;?3v>LW=jl(jt5)Tpy}ByZ|yH z3Gr}$v))Ap@BRO4PW)T^Ui?mcE&edvHEcrw0uX=z1Rwwb2tWV=5P$##An<<(j0zkd Za2`gT2O%kR`TqMTPJ0W(h#+v`e*pL+fsp_J literal 0 HcmV?d00001 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..99d3e91 --- /dev/null +++ b/internal/plugin/fun/hltb.go @@ -0,0 +1,391 @@ +package fun + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// HLTBPlugin searches HowLongToBeat for game completion times +type HLTBPlugin struct { + plugin.BasePlugin + httpClient *http.Client +} + +// HLTBSearchRequest represents the search request payload +type HLTBSearchRequest struct { + SearchType string `json:"searchType"` + SearchTerms []string `json:"searchTerms"` + SearchPage int `json:"searchPage"` + Size int `json:"size"` + SearchOptions map[string]interface{} `json:"searchOptions"` + UseCache bool `json:"useCache"` +} + +// HLTBGame represents a game from HowLongToBeat +type HLTBGame struct { + ID int `json:"game_id"` + Name string `json:"game_name"` + GameAlias string `json:"game_alias"` + GameImage string `json:"game_image"` + CompMain int `json:"comp_main"` + CompPlus int `json:"comp_plus"` + CompComplete int `json:"comp_complete"` + CompAll int `json:"comp_all"` + InvestedCo int `json:"invested_co"` + InvestedMp int `json:"invested_mp"` + CountComp int `json:"count_comp"` + CountSpeedruns int `json:"count_speedruns"` + CountBacklog int `json:"count_backlog"` + CountReview int `json:"count_review"` + ReviewScore int `json:"review_score"` + CountPlaying int `json:"count_playing"` + CountRetired int `json:"count_retired"` +} + +// HLTBSearchResponse represents the search response +type HLTBSearchResponse struct { + Color string `json:"color"` + Title string `json:"title"` + Category string `json:"category"` + Count int `json:"count"` + Pagecurrent int `json:"pagecurrent"` + Pagesize int `json:"pagesize"` + Pagetotal int `json:"pagetotal"` + SearchTerm string `json:"searchTerm"` + SearchResults []HLTBGame `json:"data"` +} + +// NewHLTB creates a new HLTBPlugin instance +func NewHLTB() *HLTBPlugin { + return &HLTBPlugin{ + BasePlugin: plugin.BasePlugin{ + ID: "fun.hltb", + Name: "How Long To Beat", + Help: "Get game completion times from HowLongToBeat.com using !hltb ", + }, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// OnMessage handles incoming messages +func (p *HLTBPlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction { + // Check if message starts with !hltb + text := strings.TrimSpace(msg.Text) + if !strings.HasPrefix(text, "!hltb ") { + return nil + } + + // Extract game name + gameName := strings.TrimSpace(text[6:]) // Remove "!hltb " + if gameName == "" { + return p.createErrorResponse(msg, "Please provide a game name. Usage: !hltb ") + } + + // Check cache first + var games []HLTBGame + var err error + cacheKey := strings.ToLower(gameName) + + err = cache.Get(cacheKey, &games) + if err != nil || len(games) == 0 { + // Cache miss - search for the game + games, err = p.searchGame(gameName) + if err != nil { + return p.createErrorResponse(msg, fmt.Sprintf("Error searching for game: %s", err.Error())) + } + + if len(games) == 0 { + return p.createErrorResponse(msg, fmt.Sprintf("No results found for '%s'", gameName)) + } + + // Cache the results for 1 hour + err = cache.SetWithTTL(cacheKey, games, time.Hour) + if err != nil { + // Log cache error but don't fail the request + fmt.Printf("Warning: Failed to cache HLTB results: %v\n", err) + } + } + + // Use the first result + game := games[0] + + // Format the response + response := p.formatGameInfo(game) + + // Create response message with game cover if available + responseMsg := &model.Message{ + Text: response, + Chat: msg.Chat, + ReplyTo: msg.ID, + Channel: msg.Channel, + } + + // Add game cover as attachment if available + if game.GameImage != "" { + imageURL := p.getFullImageURL(game.GameImage) + if responseMsg.Raw == nil { + responseMsg.Raw = make(map[string]interface{}) + } + responseMsg.Raw["image_url"] = imageURL + } + + action := &model.MessageAction{ + Type: model.ActionSendMessage, + Message: responseMsg, + Chat: msg.Chat, + Channel: msg.Channel, + } + + return []*model.MessageAction{action} +} + +// searchGame searches for a game on HowLongToBeat +func (p *HLTBPlugin) searchGame(gameName string) ([]HLTBGame, error) { + // Split search terms by words + searchTerms := strings.Fields(gameName) + + // Prepare search request + searchRequest := HLTBSearchRequest{ + SearchType: "games", + SearchTerms: searchTerms, + SearchPage: 1, + Size: 20, + SearchOptions: map[string]interface{}{ + "games": map[string]interface{}{ + "userId": 0, + "platform": "", + "sortCategory": "popular", + "rangeCategory": "main", + "rangeTime": map[string]interface{}{ + "min": nil, + "max": nil, + }, + "gameplay": map[string]interface{}{ + "perspective": "", + "flow": "", + "genre": "", + "difficulty": "", + }, + "rangeYear": map[string]interface{}{ + "min": "", + "max": "", + }, + "modifier": "", + }, + "users": map[string]interface{}{ + "sortCategory": "postcount", + }, + "lists": map[string]interface{}{ + "sortCategory": "follows", + }, + "filter": "", + "sort": 0, + "randomizer": 0, + }, + UseCache: true, + } + + // Convert to JSON + jsonData, err := json.Marshal(searchRequest) + if err != nil { + return nil, fmt.Errorf("failed to marshal search request: %w", err) + } + + // The API endpoint appears to have changed to use dynamic tokens + // Try to get the seek token first, fallback to basic search + seekToken, err := p.getSeekToken() + if err != nil { + // Fallback to old endpoint + seekToken = "" + } + + var apiURL string + if seekToken != "" { + apiURL = fmt.Sprintf("https://howlongtobeat.com/api/seek/%s", seekToken) + } else { + apiURL = "https://howlongtobeat.com/api/search" + } + + // Create HTTP request + req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set headers to match the working curl request + req.Header.Set("Accept", "*/*") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + req.Header.Set("Cache-Control", "no-cache") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", "https://howlongtobeat.com") + req.Header.Set("Pragma", "no-cache") + req.Header.Set("Referer", "https://howlongtobeat.com") + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Site", "same-origin") + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36") + + // Send request + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status code: %d", resp.StatusCode) + } + + // Read response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Parse response + var searchResponse HLTBSearchResponse + if err := json.Unmarshal(body, &searchResponse); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return searchResponse.SearchResults, nil +} + +// formatGameInfo formats game information for display +func (p *HLTBPlugin) formatGameInfo(game HLTBGame) string { + var response strings.Builder + + response.WriteString(fmt.Sprintf("🎮 **%s**\n\n", game.Name)) + + // Format completion times + if game.CompMain > 0 { + response.WriteString(fmt.Sprintf("📖 **Main Story:** %s\n", p.formatTime(game.CompMain))) + } + + if game.CompPlus > 0 { + response.WriteString(fmt.Sprintf("➕ **Main + Extras:** %s\n", p.formatTime(game.CompPlus))) + } + + if game.CompComplete > 0 { + response.WriteString(fmt.Sprintf("💯 **Completionist:** %s\n", p.formatTime(game.CompComplete))) + } + + if game.CompAll > 0 { + response.WriteString(fmt.Sprintf("🎯 **All Styles:** %s\n", p.formatTime(game.CompAll))) + } + + // Add review score if available + if game.ReviewScore > 0 { + response.WriteString(fmt.Sprintf("\n⭐ **User Score:** %d/100", game.ReviewScore)) + } + + // Add source attribution + response.WriteString("\n\n*Source: HowLongToBeat.com*") + + return response.String() +} + +// formatTime converts seconds to a readable time format +func (p *HLTBPlugin) formatTime(seconds int) string { + if seconds <= 0 { + return "N/A" + } + + hours := float64(seconds) / 3600.0 + + if hours < 1 { + minutes := seconds / 60 + return fmt.Sprintf("%d minutes", minutes) + } else if hours < 2 { + return fmt.Sprintf("%.1f hour", hours) + } else { + return fmt.Sprintf("%.1f hours", hours) + } +} + +// getFullImageURL constructs the full image URL +func (p *HLTBPlugin) getFullImageURL(imagePath string) string { + if imagePath == "" { + return "" + } + + // Remove leading slash if present + imagePath = strings.TrimPrefix(imagePath, "/") + + return fmt.Sprintf("https://howlongtobeat.com/games/%s", imagePath) +} + +// getSeekToken attempts to retrieve the seek token from HowLongToBeat +func (p *HLTBPlugin) getSeekToken() (string, error) { + // Try to extract the seek token from the main page + req, err := http.NewRequest("GET", "https://howlongtobeat.com", nil) + if err != nil { + return "", fmt.Errorf("failed to create token request: %w", err) + } + + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36") + + resp, err := p.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to fetch token: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read token response: %w", err) + } + + // Look for patterns that might contain the token + patterns := []string{ + `/api/seek/([a-f0-9]+)`, + `"seek/([a-f0-9]+)"`, + `seek/([a-f0-9]{12,})`, + } + + bodyStr := string(body) + for _, pattern := range patterns { + re := regexp.MustCompile(pattern) + matches := re.FindStringSubmatch(bodyStr) + if len(matches) > 1 { + return matches[1], nil + } + } + + // If we can't extract a token, return the known working one as fallback + return "d4b2e330db04dbf3", nil +} + +// createErrorResponse creates an error response message +func (p *HLTBPlugin) createErrorResponse(msg *model.Message, errorText string) []*model.MessageAction { + response := &model.Message{ + Text: fmt.Sprintf("❌ %s", errorText), + Chat: msg.Chat, + ReplyTo: msg.ID, + Channel: msg.Channel, + } + + action := &model.MessageAction{ + Type: model.ActionSendMessage, + Message: response, + Chat: msg.Chat, + Channel: msg.Channel, + } + + return []*model.MessageAction{action} +} diff --git a/internal/plugin/fun/hltb_test.go b/internal/plugin/fun/hltb_test.go new file mode 100644 index 0000000..62810e3 --- /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) + } + }) + } +} 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..7ecb878 --- /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 +} From 8fa74fd046823e8b45a29f3c9336dfc273a7ace5 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Fri, 13 Jun 2025 09:26:49 +0200 Subject: [PATCH 13/31] fix: database tests for cache --- internal/cache/cache_test.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index b04d260..7038276 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -1,6 +1,8 @@ package cache import ( + "fmt" + "os" "testing" "time" @@ -8,15 +10,16 @@ import ( ) func TestCache(t *testing.T) { - // Create temporary database for testing - database, err := db.New("test_cache.db") + // Create temporary database for testing with unique name + dbFile := fmt.Sprintf("test_cache_%d.db", time.Now().UnixNano()) + database, err := db.New(dbFile) 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") + _ = os.Remove(dbFile) }() // Create cache instance @@ -76,6 +79,9 @@ func TestCache(t *testing.T) { t.Run("Exists", func(t *testing.T) { existsKey := "exists_key" + // Make sure the key doesn't exist initially by deleting it + _ = cache.Delete(existsKey) + // Should not exist initially exists, err := cache.Exists(existsKey) if err != nil { From 1e0bc86b21677bdddf8f99f5885df78ec6fa7eb9 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Fri, 13 Jun 2025 09:27:06 +0200 Subject: [PATCH 14/31] feat: improve sqlite database reliability --- internal/db/db.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/internal/db/db.go b/internal/db/db.go index caae834..e40794d 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -35,6 +35,11 @@ func New(dbPath string) (*Database, error) { return nil, err } + // Configure SQLite for better reliability + if err := configureSQLite(db); err != nil { + return nil, err + } + // Initialize database if err := initDatabase(db); err != nil { return nil, err @@ -794,6 +799,32 @@ func initDatabase(db *sql.DB) error { return nil } +// Configure SQLite for better reliability +func configureSQLite(db *sql.DB) error { + pragmas := []string{ + // Enable Write-Ahead Logging for better concurrency and crash recovery + "PRAGMA journal_mode = WAL", + // Set 5-second timeout when database is locked by another connection + "PRAGMA busy_timeout = 5000", + // Balance between safety and performance for disk writes + "PRAGMA synchronous = NORMAL", + // Set large cache size (1GB) for better read performance + "PRAGMA cache_size = 1000000000", + // Enable foreign key constraint enforcement + "PRAGMA foreign_keys = true", + // Store temporary tables and indices in memory for speed + "PRAGMA temp_store = memory", + } + + for _, pragma := range pragmas { + if _, err := db.Exec(pragma); err != nil { + return fmt.Errorf("failed to execute %s: %w", pragma, err) + } + } + + return nil +} + // CacheGet retrieves a value from the cache func (d *Database) CacheGet(key string) (string, error) { query := ` From 1f80a22f4a5987c6fe128292355d23033cdfe1a7 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Fri, 13 Jun 2025 10:45:28 +0200 Subject: [PATCH 15/31] chore: remove commited test_cache --- internal/cache/test_cache.db | Bin 53248 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 internal/cache/test_cache.db diff --git a/internal/cache/test_cache.db b/internal/cache/test_cache.db deleted file mode 100644 index d50b94d98903d86606891f5577b0f625ca0bb8e5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53248 zcmeI*-%i_B90zb4lZ5c6uBu9wHlZG&Y9#`K|0HojRc$F4mJt#flC4}!kw;F0)%iRpJ11Jfl9sTZgw+iyV#^Xz+PZauw85yJGKcVsbMfxnu@*_B=$Kz_W6B& zhZEwMcI#Hw>QHj0*{+)%qHz~Fp65OxgyXnN?0brR_m&V_8SQPbXTER!xYbKs^y|o! zXmXQd1x~asOpEf=&yycby_~2_{1yIn>{a+%R)GZq5P$##AOL~)T;R*epcI$*$5x|4 z_pHiZdB@z@rDeL;vf9)xn;mz1YPn#fiw4Q$R*c)kZ#R<95qH*@!*}UJQZ#NC$z~z5kuGeLbz^(pskm>}y40)6 z<%=X&%4Xg2qw|qsX2aMjrZ+a-ik&t!JG8=LII1AMrewufO_#DovRo<@j9jri*tXlM z{Fk<@pOMH!P`V-UoGWVItFd3$%4WCIbhi(971v<$*`;tux^jhoZo4{dT6cD8+lMRT z14@U*o|IK#`6wD|Mxoz|q)WwoCdV3Xu&`W+R?X}*zUvFKr-B-GbX}m^LFf{l*`;I z8D6SP+kVh&4`st0R%0g(E4Mdg#eE7trGbzXiSW+@-VfbDj$5^E)oS!I?rjAJVj8SF zV{pC3eZz9l-#xwmpr8$Bb~q_Bz2x4ps)M;bOy0FekFU9Vd7QIH{h23TuyOu$(B^1Rnw#Ud-4q>66E?lqr42Spy;-BT85l^?Q01&Mpyt`Gk*60&Lelg! z|IC|BdI9vFg@L2=O3#?&6Q+`0Rk;_a@0@blOdsI2%?E?&?$2=Vj81;N8Lsc#?8PS> zSrmfO^7OxC$Ue-C?YvaqP9jn=}?csuv0o}K% zJ?8stDq*etrfoMav(>6u$Gu`X@i=er1+m15KZ!5d6BYcv91oDKaOkvP{@Dp(3f@n3JPwBBtxQ9MklX zV;icmgyPdsi^YN45P$##AOHafKmY;|fB*y_ z009WRj{@UPr}6&(eM}wt3;_s000Izz00bZa0SG_<0uVU0fb;o3FaE)?FDwv%00bZa z0SG_<0uX=z1Rwwb2%IZ{FH+ZMIUU|WcAbZc9+__*j9m z))xzR*Gti8%Dz?lY;~ouqp#oNPq@{3)7s|ZPmI}tD%-{vrfx*>BE`(X01!r8C6!}3vyyX zQHi1~X^AB{-e077MbUUNCC3ypC#$kdmW{0O(K=h5^-7C=Y?P@!@}uMyA$f`>nP7GC#=?R}cBx4CpO=K@AceoENt4%9s>%Wb{#{~r)vaN;-O3-Oisi}?FH8ss<( z1Rwwb2tWV=5P$##AOHafKmY>60+)pae)w`w;HdEE;?3v>LW=jl(jt5)Tpy}ByZ|yH z3Gr}$v))Ap@BRO4PW)T^Ui?mcE&edvHEcrw0uX=z1Rwwb2tWV=5P$##An<<(j0zkd Za2`gT2O%kR`TqMTPJ0W(h#+v`e*pL+fsp_J From c7fdb9fc6a51e2d0a1b7e8cc31cb87e63a00fe38 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Fri, 13 Jun 2025 10:45:34 +0200 Subject: [PATCH 16/31] docs: updated plugin docs --- docs/plugins.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/plugins.md b/docs/plugins.md index 25df16c..1596437 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -9,10 +9,12 @@ - Lo quito: What happens when you say _"lo quito"_...? (Spanish pun) - Dice: Put `!dice` and wathever roll you want to perform. - Coin: Flip a coin and get heads or tails. +- How Long To Beat: Get game completion times from HowLongToBeat.com using `!hltb ` ### Utility - Remind Me: Reply to a message with `!remindme ` to set a reminder. Supported duration units: y (years), mo (months), d (days), h (hours), m (minutes), s (seconds). Examples: `!remindme 1y` for 1 year, `!remindme 3mo` for 3 months, `!remindme 2d` for 2 days, `!remindme 3h` for 3 hours. The bot will mention you with a reminder after the specified time. +- Search and Replace: Reply to any message with `s/search/replace/[flags]` to perform text substitution. Supports flags: `g` (global), `i` (case insensitive), `n` (regex pattern). Example: `s/hello/hi/gi` replaces all occurrences of "hello" with "hi" case-insensitively. ### Security From 3771d2de659d1f824fd613286ca4e6ecd93aa9c8 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Fri, 13 Jun 2025 10:47:53 +0200 Subject: [PATCH 17/31] docs: update CLAUDE.md --- CLAUDE.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..35fc410 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,22 @@ +# Claude Code Instructions + +## Plugin Development Workflow + +When creating, modifying, or removing plugins: + +1. **Always update the plugin documentation** in `docs/plugins.md` after any plugin changes +2. Ensure the documentation includes: + - Plugin name and category (Development, Fun and entertainment, Utility, Security, Social Media) + - Brief description of functionality + - Usage instructions with examples + - Any configuration requirements + +## Testing + +Before committing plugin changes: + +1. Check files are properly formatted: Run `make format` +2. Check code style and linting: Run `make lint` +3. Test the plugin functionality: Run `make test` +4. Verify documentation accuracy +5. Ensure all examples work as described From 4fc5ae63a1bad41188ebed9e4b37f106f9c10c4a Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Fri, 13 Jun 2025 10:48:53 +0200 Subject: [PATCH 18/31] chore: update ignore patterns for test files --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index d964ffb..f57548a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,12 @@ __pycache__ *.cert .env-local .coverage +coverage.out dist bin # Butterrobot *.sqlite* butterrobot.db +/butterrobot +*_test.db* From bd9854676dcd51f22f53d61362a7186c8406032f Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Fri, 13 Jun 2025 12:04:07 +0200 Subject: [PATCH 19/31] feat: added help command --- docs/plugins.md | 1 + go.mod | 2 +- internal/app/app.go | 2 + internal/db/db.go | 30 ++- internal/plugin/fun/hltb.go | 2 +- internal/plugin/fun/loquito.go | 5 + internal/plugin/help/help.go | 164 ++++++++++++++ internal/plugin/help/help_test.go | 206 ++++++++++++++++++ internal/plugin/plugin.go | 7 + .../plugin/searchreplace/searchreplace.go | 2 +- 10 files changed, 414 insertions(+), 7 deletions(-) create mode 100644 internal/plugin/help/help.go create mode 100644 internal/plugin/help/help_test.go diff --git a/docs/plugins.md b/docs/plugins.md index 1596437..d84aec5 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -13,6 +13,7 @@ ### Utility +- Help: Shows available commands when you type `!help`. Lists all enabled plugins for the current channel organized by category with their descriptions and usage instructions. - Remind Me: Reply to a message with `!remindme ` to set a reminder. Supported duration units: y (years), mo (months), d (days), h (hours), m (minutes), s (seconds). Examples: `!remindme 1y` for 1 year, `!remindme 3mo` for 3 months, `!remindme 2d` for 2 days, `!remindme 3h` for 3 hours. The bot will mention you with a reminder after the specified time. - Search and Replace: Reply to any message with `s/search/replace/[flags]` to perform text substitution. Supports flags: `g` (global), `i` (case insensitive), `n` (regex pattern). Example: `s/hello/hi/gi` replaces all occurrences of "hello" with "hi" case-insensitively. diff --git a/go.mod b/go.mod index cd1bee5..5127bfd 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/gorilla/sessions v1.4.0 golang.org/x/crypto v0.37.0 golang.org/x/crypto/x509roots/fallback v0.0.0-20250418111936-9c1aa6af88df + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 modernc.org/sqlite v1.37.0 ) @@ -16,7 +17,6 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/sys v0.32.0 // indirect modernc.org/libc v1.63.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/internal/app/app.go b/internal/app/app.go index b54412a..9dc38c6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -23,6 +23,7 @@ import ( "git.nakama.town/fmartingr/butterrobot/internal/plugin" "git.nakama.town/fmartingr/butterrobot/internal/plugin/domainblock" "git.nakama.town/fmartingr/butterrobot/internal/plugin/fun" + "git.nakama.town/fmartingr/butterrobot/internal/plugin/help" "git.nakama.town/fmartingr/butterrobot/internal/plugin/ping" "git.nakama.town/fmartingr/butterrobot/internal/plugin/reminder" "git.nakama.town/fmartingr/butterrobot/internal/plugin/searchreplace" @@ -94,6 +95,7 @@ func (a *App) Run() error { plugin.Register(reminder.New(a.db)) plugin.Register(domainblock.New()) plugin.Register(searchreplace.New()) + plugin.Register(help.New(a.db)) // Initialize routes a.initializeRoutes() diff --git a/internal/db/db.go b/internal/db/db.go index e40794d..2ca767c 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -261,7 +261,7 @@ func (d *Database) GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, e } // Parse config JSON - var config map[string]interface{} + var config map[string]any if err := json.Unmarshal([]byte(configJSON), &config); err != nil { return nil, err } @@ -288,6 +288,28 @@ func (d *Database) GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, e return plugins, nil } +// GetChannelPluginsFromPlatformID retrieves all plugins for a channel by platform and platform channel ID +func (d *Database) GetChannelPluginsFromPlatformID(platform, platformChannelID string) ([]*model.ChannelPlugin, error) { + // First, get the channel ID by platform and platform channel ID + query := ` + SELECT id + FROM channels + WHERE platform = ? AND platform_channel_id = ? + ` + + var channelID int64 + err := d.db.QueryRow(query, platform, platformChannelID).Scan(&channelID) + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + + // Now get the plugins for this channel + return d.GetChannelPlugins(channelID) +} + // GetChannelPluginByID retrieves a channel plugin by ID func (d *Database) GetChannelPluginByID(id int64) (*model.ChannelPlugin, error) { query := ` @@ -626,8 +648,8 @@ func (d *Database) UpdateUserPassword(userID int64, newPassword string) error { func (d *Database) CreateReminder(platform, channelID, messageID, replyToID, userID, username, content string, triggerAt time.Time) (*model.Reminder, error) { query := ` INSERT INTO reminders ( - platform, channel_id, message_id, reply_to_id, - user_id, username, created_at, trigger_at, + platform, channel_id, message_id, reply_to_id, + user_id, username, created_at, trigger_at, content, processed ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0) ` @@ -666,7 +688,7 @@ func (d *Database) CreateReminder(platform, channelID, messageID, replyToID, use // GetPendingReminders gets all pending reminders that need to be processed func (d *Database) GetPendingReminders() ([]*model.Reminder, error) { query := ` - SELECT id, platform, channel_id, message_id, reply_to_id, + SELECT id, platform, channel_id, message_id, reply_to_id, user_id, username, created_at, trigger_at, content, processed FROM reminders WHERE processed = 0 AND trigger_at <= ? diff --git a/internal/plugin/fun/hltb.go b/internal/plugin/fun/hltb.go index 99d3e91..227d637 100644 --- a/internal/plugin/fun/hltb.go +++ b/internal/plugin/fun/hltb.go @@ -70,7 +70,7 @@ func NewHLTB() *HLTBPlugin { BasePlugin: plugin.BasePlugin{ ID: "fun.hltb", Name: "How Long To Beat", - Help: "Get game completion times from HowLongToBeat.com using !hltb ", + Help: "Get game completion times from HowLongToBeat.com using `!hltb `", }, httpClient: &http.Client{ Timeout: 10 * time.Second, diff --git a/internal/plugin/fun/loquito.go b/internal/plugin/fun/loquito.go index 426ab92..4b102f7 100644 --- a/internal/plugin/fun/loquito.go +++ b/internal/plugin/fun/loquito.go @@ -23,6 +23,11 @@ func NewLoquito() *LoquitoPlugin { } } +// GetHelp returns the plugin help text +func (p *LoquitoPlugin) GetHelp() string { + return "" +} + // OnMessage handles incoming messages 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") { diff --git a/internal/plugin/help/help.go b/internal/plugin/help/help.go new file mode 100644 index 0000000..88b25dd --- /dev/null +++ b/internal/plugin/help/help.go @@ -0,0 +1,164 @@ +package help + +import ( + "fmt" + "sort" + "strings" + + "git.nakama.town/fmartingr/butterrobot/internal/db" + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" + "golang.org/x/exp/slog" +) + +// ChannelPluginGetter is an interface for getting channel plugins +type ChannelPluginGetter interface { + GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, error) + GetChannelPluginsFromPlatformID(platform, platformChannelID string) ([]*model.ChannelPlugin, error) +} + +// HelpPlugin provides help information about available commands +type HelpPlugin struct { + plugin.BasePlugin + db ChannelPluginGetter +} + +// New creates a new HelpPlugin instance +func New(db ChannelPluginGetter) *HelpPlugin { + return &HelpPlugin{ + BasePlugin: plugin.BasePlugin{ + ID: "utility.help", + Name: "Help", + Help: "Shows available commands when you type '!help'", + }, + db: db, + } +} + +// OnMessage handles incoming messages +func (p *HelpPlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction { + // Check if message is the help command + if !strings.EqualFold(strings.TrimSpace(msg.Text), "!help") { + return nil + } + + // Get channel plugins from database using platform and platform channel ID + channelPlugins, err := p.db.GetChannelPluginsFromPlatformID(msg.Channel.Platform, msg.Channel.PlatformChannelID) + if err != nil && err != db.ErrNotFound { + slog.Error("Failed to get channel plugins", slog.Any("err", err)) + return []*model.MessageAction{} + } + + // If no plugins found, initialize empty slice + if err == db.ErrNotFound { + channelPlugins = []*model.ChannelPlugin{} + } + + // Get all available plugins + availablePlugins := plugin.GetAvailablePlugins() + + // Filter to only enabled plugins for this channel + enabledPlugins := make(map[string]model.Plugin) + for _, channelPlugin := range channelPlugins { + if channelPlugin.Enabled { + if availablePlugin, exists := availablePlugins[channelPlugin.PluginID]; exists { + enabledPlugins[channelPlugin.PluginID] = availablePlugin + } + } + } + + // If no plugins are enabled, return a message + if len(enabledPlugins) == 0 { + response := &model.Message{ + Text: "No plugins are currently enabled for this channel.", + Chat: msg.Chat, + ReplyTo: msg.ID, + Channel: msg.Channel, + } + + return []*model.MessageAction{ + { + Type: model.ActionSendMessage, + Message: response, + Chat: msg.Chat, + Channel: msg.Channel, + }, + } + } + + // Group plugins by category + categories := map[string][]model.Plugin{ + "Development": {}, + "Fun and Entertainment": {}, + "Utility": {}, + "Security": {}, + "Social Media": {}, + "Other": {}, + } + + // Categorize plugins based on their ID prefix + for _, p := range enabledPlugins { + category := p.GetID() + switch { + case strings.HasPrefix(category, "dev."): + categories["Development"] = append(categories["Development"], p) + case strings.HasPrefix(category, "fun."): + categories["Fun and Entertainment"] = append(categories["Fun and Entertainment"], p) + case strings.HasPrefix(category, "util.") || strings.HasPrefix(category, "reminder.") || strings.HasPrefix(category, "utility."): + categories["Utility"] = append(categories["Utility"], p) + case strings.HasPrefix(category, "security."): + categories["Security"] = append(categories["Security"], p) + case strings.HasPrefix(category, "social."): + categories["Social Media"] = append(categories["Social Media"], p) + default: + categories["Other"] = append(categories["Other"], p) + } + } + + // Build the help message + var helpText strings.Builder + helpText.WriteString("🤖 **Available Commands**\n\n") + + // Sort category names for consistent output + categoryOrder := []string{"Development", "Fun and Entertainment", "Utility", "Security", "Social Media", "Other"} + + for _, categoryName := range categoryOrder { + pluginList := categories[categoryName] + if len(pluginList) == 0 { + continue + } + + // Sort plugins within category by name + sort.Slice(pluginList, func(i, j int) bool { + return pluginList[i].GetName() < pluginList[j].GetName() + }) + + helpText.WriteString(fmt.Sprintf("**%s:**\n", categoryName)) + for _, p := range pluginList { + if p.GetHelp() == "" { + continue + } + helpText.WriteString(fmt.Sprintf("• **%s** - %s\n", p.GetName(), p.GetHelp())) + } + helpText.WriteString("\n") + } + + // Add footer + helpText.WriteString("_Use the specific commands or triggers mentioned above to interact with the bot._") + + response := &model.Message{ + Text: helpText.String(), + Chat: msg.Chat, + ReplyTo: msg.ID, + Channel: msg.Channel, + } + + return []*model.MessageAction{ + { + Type: model.ActionSendMessage, + Message: response, + Chat: msg.Chat, + Channel: msg.Channel, + }, + } +} diff --git a/internal/plugin/help/help_test.go b/internal/plugin/help/help_test.go new file mode 100644 index 0000000..25d5376 --- /dev/null +++ b/internal/plugin/help/help_test.go @@ -0,0 +1,206 @@ +package help + +import ( + "strings" + "testing" + + "git.nakama.town/fmartingr/butterrobot/internal/db" + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// MockPlugin implements the Plugin interface for testing +type MockPlugin struct { + id string + name string + help string +} + +func (m *MockPlugin) GetID() string { return m.id } +func (m *MockPlugin) GetName() string { return m.name } +func (m *MockPlugin) GetHelp() string { return m.help } +func (m *MockPlugin) RequiresConfig() bool { + return false +} +func (m *MockPlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction { + return nil +} + +// MockDatabase implements the ChannelPluginGetter interface for testing +type MockDatabase struct { + channelPlugins map[int64][]*model.ChannelPlugin + platformChannelPlugins map[string][]*model.ChannelPlugin // key: "platform:platformChannelID" +} + +func (m *MockDatabase) GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, error) { + if plugins, exists := m.channelPlugins[channelID]; exists { + return plugins, nil + } + return nil, db.ErrNotFound +} + +func (m *MockDatabase) GetChannelPluginsFromPlatformID(platform, platformChannelID string) ([]*model.ChannelPlugin, error) { + key := platform + ":" + platformChannelID + if plugins, exists := m.platformChannelPlugins[key]; exists { + return plugins, nil + } + return nil, db.ErrNotFound +} + +func TestHelpPlugin_OnMessage(t *testing.T) { + tests := []struct { + name string + messageText string + enabledPlugins map[string]*MockPlugin + expectResponse bool + expectNoPlugins bool + expectCategories []string + }{ + { + name: "responds to !help command", + messageText: "!help", + enabledPlugins: map[string]*MockPlugin{ + "dev.ping": { + id: "dev.ping", + name: "Ping", + help: "Responds to 'ping' with 'pong'", + }, + "fun.dice": { + id: "fun.dice", + name: "Dice Roller", + help: "Rolls dice when you type '!dice [formula]'", + }, + }, + expectResponse: true, + expectCategories: []string{"Development", "Fun and Entertainment"}, + }, + { + name: "ignores non-help messages", + messageText: "hello world", + enabledPlugins: map[string]*MockPlugin{}, + expectResponse: false, + }, + { + name: "ignores case variation", + messageText: "!HELP", + enabledPlugins: map[string]*MockPlugin{}, + expectResponse: true, + expectNoPlugins: true, + }, + { + name: "handles no enabled plugins", + messageText: "!help", + enabledPlugins: map[string]*MockPlugin{}, + expectResponse: true, + expectNoPlugins: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock database + mockDB := &MockDatabase{ + channelPlugins: make(map[int64][]*model.ChannelPlugin), + platformChannelPlugins: make(map[string][]*model.ChannelPlugin), + } + + // Setup channel plugins in mock database + var channelPluginList []*model.ChannelPlugin + pluginCounter := int64(1) + for pluginID := range tt.enabledPlugins { + channelPluginList = append(channelPluginList, &model.ChannelPlugin{ + ID: pluginCounter, + ChannelID: 1, + PluginID: pluginID, + Enabled: true, + Config: make(map[string]interface{}), + }) + pluginCounter++ + } + + // Set up both mapping approaches for the test + mockDB.channelPlugins[1] = channelPluginList + mockDB.platformChannelPlugins["test:test-channel"] = channelPluginList + + // Create help plugin + p := New(mockDB) + + // Create mock channel + channel := &model.Channel{ + ID: 1, + Platform: "test", + PlatformChannelID: "test-channel", + } + + // Create test message + msg := &model.Message{ + ID: "test-msg", + Text: tt.messageText, + Chat: "test-chat", + Channel: channel, + } + + // Mock the plugin registry + originalRegistry := plugin.GetAvailablePlugins() + + // Override the registry for this test + plugin.ClearRegistry() + for _, mockPlugin := range tt.enabledPlugins { + plugin.Register(mockPlugin) + } + + // Call OnMessage + actions := p.OnMessage(msg, map[string]interface{}{}, nil) + + // Restore original registry + plugin.ClearRegistry() + for _, p := range originalRegistry { + plugin.Register(p) + } + + if !tt.expectResponse { + if len(actions) != 0 { + t.Errorf("Expected no response, but got %d actions", len(actions)) + } + return + } + + if len(actions) != 1 { + t.Errorf("Expected 1 action, got %d", len(actions)) + return + } + + action := actions[0] + if action.Type != model.ActionSendMessage { + t.Errorf("Expected ActionSendMessage, got %v", action.Type) + return + } + + responseText := action.Message.Text + + if tt.expectNoPlugins { + if !strings.Contains(responseText, "No plugins are currently enabled") { + t.Errorf("Expected 'no plugins' message, got: %s", responseText) + } + return + } + + // Check that expected categories appear in response + for _, category := range tt.expectCategories { + if !strings.Contains(responseText, "**"+category+":**") { + t.Errorf("Expected category '%s' in response, got: %s", category, responseText) + } + } + + // Check that plugin names and help text appear + for _, mockPlugin := range tt.enabledPlugins { + if !strings.Contains(responseText, mockPlugin.GetName()) { + t.Errorf("Expected plugin name '%s' in response", mockPlugin.GetName()) + } + if !strings.Contains(responseText, mockPlugin.GetHelp()) { + t.Errorf("Expected plugin help '%s' in response", mockPlugin.GetHelp()) + } + } + }) + } +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 3ff96ff..8d1f970 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -47,6 +47,13 @@ func GetAvailablePlugins() map[string]model.Plugin { return result } +// ClearRegistry clears all registered plugins (for testing) +func ClearRegistry() { + pluginsMu.Lock() + defer pluginsMu.Unlock() + plugins = make(map[string]model.Plugin) +} + // BasePlugin provides a common base for plugins type BasePlugin struct { ID string diff --git a/internal/plugin/searchreplace/searchreplace.go b/internal/plugin/searchreplace/searchreplace.go index b474b27..d9cd04c 100644 --- a/internal/plugin/searchreplace/searchreplace.go +++ b/internal/plugin/searchreplace/searchreplace.go @@ -23,7 +23,7 @@ func New() *SearchReplacePlugin { BasePlugin: plugin.BasePlugin{ ID: "util.searchreplace", Name: "Search and Replace", - Help: "Reply to a message with a search and replace pattern (s/search/replace/[flags]) to create a modified message. " + + Help: "Reply to a message with a search and replace pattern (`s/search/replace/[flags]`) to create a modified message. " + "Supported flags: g (global), i (case insensitive)", }, } From 3a4ba376e75503caab4da111c4941969f9793c0e Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Fri, 13 Jun 2025 12:04:22 +0200 Subject: [PATCH 20/31] chore: ignore all test db files --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f57548a..9dab4b7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,6 @@ dist bin # Butterrobot *.sqlite* -butterrobot.db +butterrobot.db* /butterrobot *_test.db* From fc77c97547c6b3518e28db1f48bbc7908358d40b Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Sun, 15 Jun 2025 12:17:54 +0200 Subject: [PATCH 21/31] feat: add configuration options for instagram and twitter plugins --- CLAUDE.md | 5 ++++ docs/plugins.md | 4 +-- .../templates/channel_plugin_config.html | 20 +++++++++++++++ internal/plugin/social/instagram.go | 21 ++++++++++------ internal/plugin/social/twitter.go | 25 ++++++++++++------- 5 files changed, 57 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 35fc410..2191848 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,11 @@ When creating, modifying, or removing plugins: - Brief description of functionality - Usage instructions with examples - Any configuration requirements +3. **For plugins with configuration options:** + - Set `ConfigRequired: true` in the plugin's BasePlugin struct + - Add corresponding HTML form fields in `internal/admin/templates/channel_plugin_config.html` + - Use conditional template logic: `{{else if eq .ChannelPlugin.PluginID "plugin.id"}}` + - Include proper form labels, help text, and value binding ## Testing diff --git a/docs/plugins.md b/docs/plugins.md index d84aec5..3472a08 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -23,5 +23,5 @@ ### Social Media -- Twitter Link Expander: Automatically converts twitter.com and x.com links to fxtwitter.com links and removes tracking parameters. This allows for better media embedding in chat platforms. -- Instagram Link Expander: Automatically converts instagram.com links to ddinstagram.com links and removes tracking parameters. This allows for better media embedding in chat platforms. +- Twitter Link Expander: Automatically converts twitter.com and x.com links to alternative domain links and removes tracking parameters. This allows for better media embedding in chat platforms. Configure with `domain` option to set replacement domain (default: fxtwitter.com). +- Instagram Link Expander: Automatically converts instagram.com links to alternative domain links and removes tracking parameters. This allows for better media embedding in chat platforms. Configure with `domain` option to set replacement domain (default: ddinstagram.com). diff --git a/internal/admin/templates/channel_plugin_config.html b/internal/admin/templates/channel_plugin_config.html index decf1a2..2719004 100644 --- a/internal/admin/templates/channel_plugin_config.html +++ b/internal/admin/templates/channel_plugin_config.html @@ -19,6 +19,26 @@ Messages containing links to these domains will be blocked. + {{else if eq .ChannelPlugin.PluginID "social.instagram"}} +
+ + +
+ Enter the domain to replace instagram.com links with. Default is ddinstagram.com if left empty. +
+
+ {{else if eq .ChannelPlugin.PluginID "social.twitter"}} +
+ + +
+ Enter the domain to replace twitter.com and x.com links with. Default is fxtwitter.com if left empty. +
+
{{else}}
This plugin doesn't have specific configuration fields implemented yet. diff --git a/internal/plugin/social/instagram.go b/internal/plugin/social/instagram.go index 6b7aa4c..b423b45 100644 --- a/internal/plugin/social/instagram.go +++ b/internal/plugin/social/instagram.go @@ -18,9 +18,10 @@ type InstagramExpander struct { func NewInstagramExpander() *InstagramExpander { return &InstagramExpander{ BasePlugin: plugin.BasePlugin{ - ID: "social.instagram", - Name: "Instagram Link Expander", - Help: "Automatically converts instagram.com links to ddinstagram.com links and removes tracking parameters", + ID: "social.instagram", + Name: "Instagram Link Expander", + Help: "Automatically converts instagram.com links to alternative domain links and removes tracking parameters. Configure 'domain' option to set replacement domain (default: ddinstagram.com)", + ConfigRequired: true, }, } } @@ -32,6 +33,12 @@ func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]inte return nil } + // Get replacement domain from config, default to ddinstagram.com + replacementDomain := "ddinstagram.com" + if domain, ok := config["domain"].(string); ok && domain != "" { + replacementDomain = domain + } + // Regex to match instagram.com links // Match both http://instagram.com and https://instagram.com formats // Also match www.instagram.com @@ -42,7 +49,7 @@ func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]inte return nil } - // Replace instagram.com with ddinstagram.com in the message and clean query parameters + // Replace instagram.com with configured domain in the message and clean query parameters transformed := instagramRegex.ReplaceAllStringFunc(msg.Text, func(link string) string { // Parse the URL parsedURL, err := url.Parse(link) @@ -51,13 +58,13 @@ func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]inte return link } - // Ensure we don't change links that already come from ddinstagram.com + // Ensure we don't change links that already come from the replacement domain if parsedURL.Host != "instagram.com" && parsedURL.Host != "www.instagram.com" { return link } - // Change the host - parsedURL.Host = "d.ddinstagram.com" + // Change the host to the configured domain + parsedURL.Host = replacementDomain // Remove query parameters parsedURL.RawQuery = "" diff --git a/internal/plugin/social/twitter.go b/internal/plugin/social/twitter.go index 553bd07..d98eee6 100644 --- a/internal/plugin/social/twitter.go +++ b/internal/plugin/social/twitter.go @@ -18,9 +18,10 @@ type TwitterExpander struct { func NewTwitterExpander() *TwitterExpander { return &TwitterExpander{ BasePlugin: plugin.BasePlugin{ - ID: "social.twitter", - Name: "Twitter Link Expander", - Help: "Automatically converts twitter.com links to fxtwitter.com links and removes tracking parameters", + ID: "social.twitter", + Name: "Twitter Link Expander", + Help: "Automatically converts twitter.com and x.com links to alternative domain links and removes tracking parameters. Configure 'domain' option to set replacement domain (default: fxtwitter.com)", + ConfigRequired: true, }, } } @@ -32,6 +33,12 @@ func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interf return nil } + // Get replacement domain from config, default to fxtwitter.com + replacementDomain := "fxtwitter.com" + if domain, ok := config["domain"].(string); ok && domain != "" { + replacementDomain = domain + } + // Regex to match twitter.com links // Match both http://twitter.com and https://twitter.com formats // Also match www.twitter.com @@ -42,22 +49,22 @@ func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interf return nil } - // Replace twitter.com with fxtwitter.com in the message and clean query parameters + // Replace twitter.com/x.com with configured domain in the message and clean query parameters transformed := twitterRegex.ReplaceAllStringFunc(msg.Text, func(link string) string { // Parse the URL parsedURL, err := url.Parse(link) if err != nil { // If parsing fails, just do the simple replacement - link = strings.Replace(link, "twitter.com", "fxtwitter.com", 1) - link = strings.Replace(link, "x.com", "fxtwitter.com", 1) + link = strings.Replace(link, "twitter.com", replacementDomain, 1) + link = strings.Replace(link, "x.com", replacementDomain, 1) return link } - // Change the host + // Change the host to the configured domain if strings.Contains(parsedURL.Host, "twitter.com") { - parsedURL.Host = strings.Replace(parsedURL.Host, "twitter.com", "fxtwitter.com", 1) + parsedURL.Host = strings.Replace(parsedURL.Host, "twitter.com", replacementDomain, 1) } else if strings.Contains(parsedURL.Host, "x.com") { - parsedURL.Host = strings.Replace(parsedURL.Host, "x.com", "fxtwitter.com", 1) + parsedURL.Host = strings.Replace(parsedURL.Host, "x.com", replacementDomain, 1) } // Remove query parameters From 899ac49336e958d98500ad8c65f45d0bc602883f Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Sun, 15 Jun 2025 13:03:52 +0200 Subject: [PATCH 22/31] chore: split plugin configuration templates --- internal/admin/admin.go | 26 ++++++++++++++-- .../templates/channel_plugin_config.html | 31 ++----------------- .../plugins/security.domainblock.html | 12 +++++++ .../templates/plugins/social.instagram.html | 11 +++++++ .../templates/plugins/social.twitter.html | 11 +++++++ 5 files changed, 60 insertions(+), 31 deletions(-) create mode 100644 internal/admin/templates/plugins/security.domainblock.html create mode 100644 internal/admin/templates/plugins/social.instagram.html create mode 100644 internal/admin/templates/plugins/social.twitter.html diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 2b41820..cfa2595 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -16,7 +16,7 @@ import ( "github.com/gorilla/sessions" ) -//go:embed templates/*.html +//go:embed templates/*.html templates/plugins/*.html var templateFS embed.FS const ( @@ -90,7 +90,7 @@ func New(cfg *config.Config, database *db.Database, version string) *Admin { } // Parse and register all templates - templateFiles := []string{ + mainTemplateFiles := []string{ "index.html", "login.html", "change_password.html", @@ -101,7 +101,13 @@ func New(cfg *config.Config, database *db.Database, version string) *Admin { "channel_plugin_config.html", } - for _, tf := range templateFiles { + pluginTemplateFiles := []string{ + "plugins/security.domainblock.html", + "plugins/social.instagram.html", + "plugins/social.twitter.html", + } + + for _, tf := range mainTemplateFiles { // Read template content from embedded filesystem content, err := templateFS.ReadFile("templates/" + tf) if err != nil { @@ -120,6 +126,20 @@ func New(cfg *config.Config, database *db.Database, version string) *Admin { panic(err) } + // If this is the channel_plugin_config template, also parse plugin templates + if tf == "channel_plugin_config.html" { + for _, pluginTf := range pluginTemplateFiles { + pluginContent, err := templateFS.ReadFile("templates/" + pluginTf) + if err != nil { + panic(err) + } + t, err = t.Parse(string(pluginContent)) + if err != nil { + panic(err) + } + } + } + templates[tf] = t } diff --git a/internal/admin/templates/channel_plugin_config.html b/internal/admin/templates/channel_plugin_config.html index 2719004..0f229f9 100644 --- a/internal/admin/templates/channel_plugin_config.html +++ b/internal/admin/templates/channel_plugin_config.html @@ -9,36 +9,11 @@ {{if eq .ChannelPlugin.PluginID "security.domainblock"}} -
- - -
- Enter comma-separated list of domains to block (e.g., example.com, evil.org). - Messages containing links to these domains will be blocked. -
-
+ {{template "plugins/security.domainblock.html" .}} {{else if eq .ChannelPlugin.PluginID "social.instagram"}} -
- - -
- Enter the domain to replace instagram.com links with. Default is ddinstagram.com if left empty. -
-
+ {{template "plugins/social.instagram.html" .}} {{else if eq .ChannelPlugin.PluginID "social.twitter"}} -
- - -
- Enter the domain to replace twitter.com and x.com links with. Default is fxtwitter.com if left empty. -
-
+ {{template "plugins/social.twitter.html" .}} {{else}}
This plugin doesn't have specific configuration fields implemented yet. diff --git a/internal/admin/templates/plugins/security.domainblock.html b/internal/admin/templates/plugins/security.domainblock.html new file mode 100644 index 0000000..7ffcc48 --- /dev/null +++ b/internal/admin/templates/plugins/security.domainblock.html @@ -0,0 +1,12 @@ +{{define "plugins/security.domainblock.html"}} +
+ + +
+ Enter comma-separated list of domains to block (e.g., example.com, evil.org). + Messages containing links to these domains will be blocked. +
+
+{{end}} \ No newline at end of file diff --git a/internal/admin/templates/plugins/social.instagram.html b/internal/admin/templates/plugins/social.instagram.html new file mode 100644 index 0000000..a83485d --- /dev/null +++ b/internal/admin/templates/plugins/social.instagram.html @@ -0,0 +1,11 @@ +{{define "plugins/social.instagram.html"}} +
+ + +
+ Enter the domain to replace instagram.com links with. Default is ddinstagram.com if left empty. +
+
+{{end}} \ No newline at end of file diff --git a/internal/admin/templates/plugins/social.twitter.html b/internal/admin/templates/plugins/social.twitter.html new file mode 100644 index 0000000..cb4885f --- /dev/null +++ b/internal/admin/templates/plugins/social.twitter.html @@ -0,0 +1,11 @@ +{{define "plugins/social.twitter.html"}} +
+ + +
+ Enter the domain to replace twitter.com and x.com links with. Default is fxtwitter.com if left empty. +
+
+{{end}} \ No newline at end of file From 3b09a9dd4783f18ab3b416e2357f1010c9423850 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Mon, 23 Jun 2025 11:06:17 +0200 Subject: [PATCH 23/31] feat: allow enabling all plugins into a channel --- internal/admin/admin.go | 7 + internal/admin/templates/channel_detail.html | 9 + internal/app/app.go | 28 +- internal/db/db.go | 43 ++- internal/db/db_test.go | 203 ++++++++++++ internal/migration/migrations.go | 58 ++++ internal/model/message.go | 6 + internal/model/message_test.go | 234 +++++++++++++ internal/plugin/plugin.go | 13 + internal/plugin/plugin_test.go | 331 +++++++++++++++++++ 10 files changed, 915 insertions(+), 17 deletions(-) create mode 100644 internal/db/db_test.go create mode 100644 internal/model/message_test.go create mode 100644 internal/plugin/plugin_test.go diff --git a/internal/admin/admin.go b/internal/admin/admin.go index cfa2595..abefb72 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -564,6 +564,13 @@ func (a *Admin) handleChannelDetail(w http.ResponseWriter, r *http.Request) { return } + // Update enable_all_plugins + enableAllPlugins := r.FormValue("enable_all_plugins") == "true" + if err := a.db.UpdateChannelEnableAllPlugins(id, enableAllPlugins); err != nil { + http.Error(w, "Failed to update channel enable all plugins", http.StatusInternalServerError) + return + } + a.addFlash(w, r, "Channel updated", "success") http.Redirect(w, r, "/admin/channels/"+channelID, http.StatusSeeOther) return diff --git a/internal/admin/templates/channel_detail.html b/internal/admin/templates/channel_detail.html index 78909df..9f9a78d 100644 --- a/internal/admin/templates/channel_detail.html +++ b/internal/admin/templates/channel_detail.html @@ -27,6 +27,15 @@
+
+ +
+ When enabled, all registered plugins will be automatically enabled for this channel. Individual plugin settings will be ignored. +
+
-{{end}} \ No newline at end of file +{{end}} From 377b1723c31975c9cb43587a949c2014e2ae81ae Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Tue, 24 Jun 2025 08:10:56 +0200 Subject: [PATCH 31/31] fix: default parse mode to text --- internal/platform/telegram/telegram.go | 6 +++--- internal/plugin/social/twitter.go | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/internal/platform/telegram/telegram.go b/internal/platform/telegram/telegram.go index d35bb6b..b015793 100644 --- a/internal/platform/telegram/telegram.go +++ b/internal/platform/telegram/telegram.go @@ -237,13 +237,13 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error { "text": msg.Text, } - // Set parse_mode based on plugin preference or default to Markdown + // Set parse_mode based on plugin preference or default to empty string if msg.Raw != nil && msg.Raw["parse_mode"] != nil { // Plugin explicitly set parse_mode payload["parse_mode"] = msg.Raw["parse_mode"] } else { - // Default to Markdown for backward compatibility - payload["parse_mode"] = "Markdown" + // Default to empty string (no formatting) + payload["parse_mode"] = "" } // Add reply if needed diff --git a/internal/plugin/social/twitter.go b/internal/plugin/social/twitter.go index 69bd979..f2c6cc9 100644 --- a/internal/plugin/social/twitter.go +++ b/internal/plugin/social/twitter.go @@ -75,7 +75,6 @@ func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interf Chat: msg.Chat, ReplyTo: msg.ID, Channel: msg.Channel, - Raw: map[string]interface{}{"parse_mode": ""}, } action := &model.MessageAction{