diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index 5b32d48..4353088 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -3,7 +3,7 @@ when: - push - pull_request branch: - - master + - main steps: format: diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml index 39dbf65..3630566 100644 --- a/.woodpecker/release.yml +++ b/.woodpecker/release.yml @@ -1,6 +1,6 @@ when: - event: tag - branch: master + branch: main steps: - name: Release diff --git a/README.md b/README.md index 920d087..214afa6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # Butter Robot -![Status badge](https://woodpecker.local.fmartingr.dev/api/badges/5/status.svg) +| 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) | Go framework to create bots for several platforms. @@ -10,7 +13,7 @@ Go framework to create bots for several platforms. ## Features -- Support for multiple chat platforms (Slack (untested!), Telegram) +- Support for multiple chat platforms (Slack, Telegram) - Plugin system for easy extension - Admin interface for managing channels and plugins - Message queue for asynchronous processing diff --git a/docs/plugins.md b/docs/plugins.md index 84578e5..2988f80 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -10,10 +10,6 @@ - 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 69c769b..822495a 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 } @@ -194,7 +194,7 @@ func (a *Admin) addFlash(w http.ResponseWriter, r *http.Request, message string, } // Map internal categories to Bootstrap alert classes - var alertClass string + alertClass := category switch category { case "success": alertClass = "success" @@ -249,6 +249,17 @@ 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) { // Add current user data @@ -323,10 +334,7 @@ func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) { // Set session expiration session.Options.MaxAge = 3600 * 24 * 7 // 1 week - err = session.Save(r, w) - if err != nil { - fmt.Printf("Error saving session: %v\n", err) - } + session.Save(r, w) a.addFlash(w, r, "You were logged in", "success") @@ -354,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 7403396..5126672 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -17,12 +17,10 @@ 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" ) @@ -88,19 +86,12 @@ 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{ @@ -152,9 +143,7 @@ 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) - if err := json.NewEncoder(w).Encode(map[string]interface{}{}); err != nil { - a.logger.Error("Error encoding response", "error", err) - } + json.NewEncoder(w).Encode(map[string]interface{}{}) }) // Platform webhook endpoints @@ -177,9 +166,7 @@ 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) - if err := json.NewEncoder(w).Encode(map[string]string{"error": "Unknown platform"}); err != nil { - a.logger.Error("Error encoding response", "error", err) - } + json.NewEncoder(w).Encode(map[string]string{"error": "Unknown platform"}) return } @@ -188,9 +175,7 @@ func (a *App) handleIncomingWebhook(w http.ResponseWriter, r *http.Request) { if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - 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) - } + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to read request body"}) return } @@ -206,9 +191,7 @@ func (a *App) handleIncomingWebhook(w http.ResponseWriter, r *http.Request) { // Respond with success w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(map[string]any{}); err != nil { - a.logger.Error("Error encoding response", "error", err) - } + json.NewEncoder(w).Encode(map[string]any{}) } // extractPlatformName extracts the platform name from the URL path @@ -321,73 +304,3 @@ 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 bdf9eaf..8cdce4a 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "time" "golang.org/x/crypto/bcrypt" _ "modernc.org/sqlite" @@ -234,11 +233,7 @@ func (d *Database) GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, e if err != nil { return nil, err } - defer func() { - if err := rows.Close(); err != nil { - fmt.Printf("Error closing rows: %v\n", err) - } - }() + defer rows.Close() var plugins []*model.ChannelPlugin @@ -419,11 +414,7 @@ func (d *Database) GetAllChannels() ([]*model.Channel, error) { if err != nil { return nil, err } - defer func() { - if err := rows.Close(); err != nil { - fmt.Printf("Error closing rows: %v\n", err) - } - }() + defer rows.Close() var channels []*model.Channel @@ -462,9 +453,10 @@ func (d *Database) GetAllChannels() ([]*model.Channel, error) { continue // Skip this channel if plugins can't be retrieved } - // Add plugins to channel - for _, plugin := range plugins { - channel.Plugins[plugin.PluginID] = plugin + if plugins != nil { + for _, plugin := range plugins { + channel.Plugins[plugin.PluginID] = plugin + } } channels = append(channels, channel) @@ -599,124 +591,6 @@ 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 func() { - if err := rows.Close(); err != nil { - fmt.Printf("Error closing rows: %v\n", err) - } - }() - - 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 @@ -735,25 +609,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 { @@ -761,7 +635,7 @@ func initDatabase(db *sql.DB) error { pendingCount++ } } - + // Run migrations if needed if pendingCount > 0 { fmt.Printf("Running %d pending database migrations...\n", pendingCount) @@ -772,6 +646,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 63da5d8..44096f3 100644 --- a/internal/migration/migration.go +++ b/internal/migration/migration.go @@ -49,11 +49,7 @@ func GetAppliedMigrations(db *sql.DB) ([]int, error) { if err != nil { return nil, err } - defer func() { - if err := rows.Close(); err != nil { - fmt.Printf("Error closing rows: %v\n", err) - } - }() + defer rows.Close() var versions []int for rows.Next() { @@ -132,9 +128,7 @@ func Migrate(db *sql.DB) error { // Apply the migration if err := migration.Up(db); err != nil { - if err := tx.Rollback(); err != nil { - fmt.Printf("Error rolling back transaction: %v\n", err) - } + tx.Rollback() return fmt.Errorf("failed to apply migration %d: %w", version, err) } @@ -143,9 +137,7 @@ func Migrate(db *sql.DB) error { "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", version, time.Now(), ); err != nil { - if err := tx.Rollback(); err != nil { - fmt.Printf("Error rolling back transaction: %v\n", err) - } + tx.Rollback() return fmt.Errorf("failed to mark migration %d as applied: %w", version, err) } @@ -196,17 +188,13 @@ func MigrateDown(db *sql.DB, targetVersion int) error { // Apply the down migration if err := migration.Down(db); err != nil { - if err := tx.Rollback(); err != nil { - fmt.Printf("Error rolling back transaction: %v\n", err) - } + tx.Rollback() 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 { - if err := tx.Rollback(); err != nil { - fmt.Printf("Error rolling back transaction: %v\n", err) - } + tx.Rollback() return fmt.Errorf("failed to remove migration %d from applied list: %w", version, err) } @@ -220,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 8db229b..2852113 100644 --- a/internal/migration/migrations.go +++ b/internal/migration/migrations.go @@ -8,7 +8,6 @@ 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 @@ -61,14 +60,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 (?, ?)", @@ -100,29 +99,4 @@ func migrateInitialSchemaDown(db *sql.DB) error { } return nil -} - -// 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 -} +} \ No newline at end of file diff --git a/internal/model/message.go b/internal/model/message.go index e6f86f6..fe8c5e4 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,19 +83,4 @@ type User struct { ID int64 Username string Password string -} - -// 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 -} +} \ No newline at end of file diff --git a/internal/model/plugin.go b/internal/model/plugin.go index 9f2b34a..ffc3c2f 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/slack/slack.go b/internal/platform/slack/slack.go index 9c12b1f..3683ada 100644 --- a/internal/platform/slack/slack.go +++ b/internal/platform/slack/slack.go @@ -4,7 +4,7 @@ import ( "encoding/json" "errors" "fmt" - "io" + "io/ioutil" "net/http" "strings" "time" @@ -37,15 +37,11 @@ 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 := io.ReadAll(r.Body) + body, err := ioutil.ReadAll(r.Body) if err != nil { return nil, err } - defer func() { - if err := r.Body.Close(); err != nil { - fmt.Printf("Error closing request body: %v\n", err) - } - }() + defer r.Body.Close() // Parse JSON var requestData map[string]interface{} @@ -198,11 +194,7 @@ func (s *SlackPlatform) SendMessage(msg *model.Message) error { if err != nil { return err } - defer func() { - if err := resp.Body.Close(); err != nil { - fmt.Printf("Error closing response body: %v\n", err) - } - }() + defer resp.Body.Close() // Check response if resp.StatusCode != http.StatusOK { diff --git a/internal/platform/telegram/telegram.go b/internal/platform/telegram/telegram.go index 0edb729..a9ff2db 100644 --- a/internal/platform/telegram/telegram.go +++ b/internal/platform/telegram/telegram.go @@ -62,11 +62,7 @@ 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 func() { - if err := resp.Body.Close(); err != nil { - t.log.Error("Error closing response body", "error", err) - } - }() + defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) @@ -89,11 +85,7 @@ func (t *TelegramPlatform) ParseIncomingMessage(r *http.Request) (*model.Message t.log.Error("Failed to read request body", "error", err) return nil, err } - defer func() { - if err := r.Body.Close(); err != nil { - t.log.Error("Error closing request body", "error", err) - } - }() + defer r.Body.Close() // Parse JSON var update struct { @@ -111,11 +103,8 @@ 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"` - ReplyToMessage struct { - MessageID int `json:"message_id"` - } `json:"reply_to_message"` + Date int `json:"date"` + Text string `json:"text"` } `json:"message"` } @@ -139,7 +128,6 @@ 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,11 +247,7 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error { t.log.Error("Failed to send message", "error", err) return err } - defer func() { - if err := resp.Body.Close(); err != nil { - t.log.Error("Error closing response body", "error", err) - } - }() + defer resp.Body.Close() // Check response if resp.StatusCode != http.StatusOK { @@ -275,4 +259,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 2d5533b..00fc7cc 100644 --- a/internal/plugin/fun/dice.go +++ b/internal/plugin/fun/dice.go @@ -107,10 +107,9 @@ func (p *DicePlugin) rollDice(formula string) (int, error) { return 0, fmt.Errorf("invalid modifier") } - switch matches[3] { - case "+": + if matches[3] == "+" { total += modifier - case "-": + } else if matches[3] == "-" { total -= modifier } } diff --git a/internal/plugin/reminder/reminder.go b/internal/plugin/reminder/reminder.go deleted file mode 100644 index 5eb47f9..0000000 --- a/internal/plugin/reminder/reminder.go +++ /dev/null @@ -1,171 +0,0 @@ -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 nil - } - - // 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 deleted file mode 100644 index 3070918..0000000 --- a/internal/plugin/reminder/reminder_test.go +++ /dev/null @@ -1,164 +0,0 @@ -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) - } - }) - } -} diff --git a/internal/plugin/social/instagram.go b/internal/plugin/social/instagram.go index 7ff74a5..a4f758a 100644 --- a/internal/plugin/social/instagram.go +++ b/internal/plugin/social/instagram.go @@ -53,7 +53,9 @@ func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]inte } // Change the host - parsedURL.Host = strings.Replace(parsedURL.Host, "instagram.com", "ddinstagram.com", 1) + if strings.Contains(parsedURL.Host, "instagram.com") { + parsedURL.Host = strings.Replace(parsedURL.Host, "instagram.com", "ddinstagram.com", 1) + } // Remove query parameters parsedURL.RawQuery = "" diff --git a/internal/queue/queue.go b/internal/queue/queue.go index 692816e..668bf60 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -3,9 +3,6 @@ package queue import ( "log/slog" "sync" - "time" - - "git.nakama.town/fmartingr/butterrobot/internal/model" ) // Item represents a queue item @@ -17,19 +14,14 @@ 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 - reminderTicker *time.Ticker - reminderHandler ReminderHandlerFunc + items chan Item + wg sync.WaitGroup + quit chan struct{} + logger *slog.Logger + running bool + runMutex sync.Mutex } // New creates a new Queue instance @@ -57,24 +49,6 @@ 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() @@ -85,12 +59,6 @@ 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() } @@ -128,34 +96,4 @@ func (q *Queue) worker(handler HandlerFunc) { return } } -} - -// 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 - } - } -} +} \ No newline at end of file