diff --git a/.gitignore b/.gitignore index 9dab4b7..d964ffb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,12 +5,9 @@ __pycache__ *.cert .env-local .coverage -coverage.out dist bin # Butterrobot *.sqlite* -butterrobot.db* -/butterrobot -*_test.db* +butterrobot.db diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index cb4e70a..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,29 +0,0 @@ -# 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 -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 - -**CRITICAL**: After making ANY changes to code files, you MUST run these commands in order: - -1. **Format code**: `make format` - Format all code according to project standards -2. **Lint code**: `make lint` - Check code style and quality (must show "0 issues") -3. **Run tests**: `make test` - Run all tests to ensure functionality works -4. Verify documentation accuracy -5. Ensure all examples work as described - -**These commands are MANDATORY after every code change, no exceptions.** diff --git a/docs/plugins.md b/docs/plugins.md index 3472a08..25df16c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -9,13 +9,10 @@ - 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 -- 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. ### Security @@ -23,5 +20,5 @@ ### Social Media -- 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). +- 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. diff --git a/go.mod b/go.mod index 5127bfd..cd1bee5 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ 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 ) @@ -17,6 +16,7 @@ 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/admin/admin.go b/internal/admin/admin.go index abefb72..2b41820 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -16,7 +16,7 @@ import ( "github.com/gorilla/sessions" ) -//go:embed templates/*.html templates/plugins/*.html +//go:embed templates/*.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 - mainTemplateFiles := []string{ + templateFiles := []string{ "index.html", "login.html", "change_password.html", @@ -101,13 +101,7 @@ func New(cfg *config.Config, database *db.Database, version string) *Admin { "channel_plugin_config.html", } - pluginTemplateFiles := []string{ - "plugins/security.domainblock.html", - "plugins/social.instagram.html", - "plugins/social.twitter.html", - } - - for _, tf := range mainTemplateFiles { + for _, tf := range templateFiles { // Read template content from embedded filesystem content, err := templateFS.ReadFile("templates/" + tf) if err != nil { @@ -126,20 +120,6 @@ 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 } @@ -564,13 +544,6 @@ 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 7e12d57..78909df 100644 --- a/internal/admin/templates/channel_detail.html +++ b/internal/admin/templates/channel_detail.html @@ -27,15 +27,6 @@ -
- -
- When enabled, all registered plugins will be automatically enabled for this channel. Individual plugin settings will be ignored. -
-
-{{end}} +{{end}} \ No newline at end of file diff --git a/internal/admin/templates/channel_plugin_config.html b/internal/admin/templates/channel_plugin_config.html index 0f229f9..decf1a2 100644 --- a/internal/admin/templates/channel_plugin_config.html +++ b/internal/admin/templates/channel_plugin_config.html @@ -9,11 +9,16 @@
{{if eq .ChannelPlugin.PluginID "security.domainblock"}} - {{template "plugins/security.domainblock.html" .}} - {{else if eq .ChannelPlugin.PluginID "social.instagram"}} - {{template "plugins/social.instagram.html" .}} - {{else if eq .ChannelPlugin.PluginID "social.twitter"}} - {{template "plugins/social.twitter.html" .}} +
+ + +
+ 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. diff --git a/internal/admin/templates/plugins/security.domainblock.html b/internal/admin/templates/plugins/security.domainblock.html deleted file mode 100644 index 7ffcc48..0000000 --- a/internal/admin/templates/plugins/security.domainblock.html +++ /dev/null @@ -1,12 +0,0 @@ -{{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 deleted file mode 100644 index a83485d..0000000 --- a/internal/admin/templates/plugins/social.instagram.html +++ /dev/null @@ -1,11 +0,0 @@ -{{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 deleted file mode 100644 index cb4885f..0000000 --- a/internal/admin/templates/plugins/social.twitter.html +++ /dev/null @@ -1,11 +0,0 @@ -{{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 diff --git a/internal/app/app.go b/internal/app/app.go index 037089f..b54412a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -23,7 +23,6 @@ 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" @@ -95,7 +94,6 @@ 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() @@ -314,21 +312,11 @@ func (a *App) handleMessage(item queue.Item) { } // Process message with plugins - var pluginsToProcess []string - - if channel.EnableAllPlugins { - // If EnableAllPlugins is true, process all registered plugins - pluginsToProcess = plugin.GetAvailablePluginIDs() - } else { - // Otherwise, process only explicitly enabled plugins - for pluginID := range channel.Plugins { - if channel.HasEnabledPlugin(pluginID) { - pluginsToProcess = append(pluginsToProcess, pluginID) - } + for pluginID, channelPlugin := range channel.Plugins { + if !channel.HasEnabledPlugin(pluginID) { + continue } - } - for _, pluginID := range pluginsToProcess { // Get plugin p, err := plugin.Get(pluginID) if err != nil { @@ -336,19 +324,11 @@ func (a *App) handleMessage(item queue.Item) { continue } - // Get plugin configuration (empty map if EnableAllPlugins and plugin not explicitly configured) - var config map[string]interface{} - if channelPlugin, exists := channel.Plugins[pluginID]; exists { - config = channelPlugin.Config - } else { - config = make(map[string]interface{}) - } - // Create cache instance for this plugin pluginCache := cache.New(a.db, pluginID) // Process message and get actions - actions := p.OnMessage(message, config, pluginCache) + actions := p.OnMessage(message, channelPlugin.Config, pluginCache) // Get platform for processing actions platform, err := platform.Get(item.Platform) diff --git a/internal/cache/test_cache.db b/internal/cache/test_cache.db new file mode 100644 index 0000000..d50b94d Binary files /dev/null and b/internal/cache/test_cache.db differ diff --git a/internal/db/db.go b/internal/db/db.go index 1c54ad4..e40794d 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -56,7 +56,7 @@ func (d *Database) Close() error { // GetChannelByID retrieves a channel by ID func (d *Database) GetChannelByID(id int64) (*model.Channel, error) { query := ` - SELECT id, platform, platform_channel_id, enabled, enable_all_plugins, channel_raw + SELECT id, platform, platform_channel_id, enabled, channel_raw FROM channels WHERE id = ? ` @@ -67,11 +67,10 @@ func (d *Database) GetChannelByID(id int64) (*model.Channel, error) { platform string platformChannelID string enabled bool - enableAllPlugins bool channelRawJSON string ) - err := row.Scan(&id, &platform, &platformChannelID, &enabled, &enableAllPlugins, &channelRawJSON) + err := row.Scan(&id, &platform, &platformChannelID, &enabled, &channelRawJSON) if err == sql.ErrNoRows { return nil, ErrNotFound } @@ -91,7 +90,6 @@ func (d *Database) GetChannelByID(id int64) (*model.Channel, error) { Platform: platform, PlatformChannelID: platformChannelID, Enabled: enabled, - EnableAllPlugins: enableAllPlugins, ChannelRaw: channelRaw, Plugins: make(map[string]*model.ChannelPlugin), } @@ -112,7 +110,7 @@ func (d *Database) GetChannelByID(id int64) (*model.Channel, error) { // GetChannelByPlatform retrieves a channel by platform and platform channel ID func (d *Database) GetChannelByPlatform(platform, platformChannelID string) (*model.Channel, error) { query := ` - SELECT id, platform, platform_channel_id, enabled, enable_all_plugins, channel_raw + SELECT id, platform, platform_channel_id, enabled, channel_raw FROM channels WHERE platform = ? AND platform_channel_id = ? ` @@ -120,13 +118,12 @@ func (d *Database) GetChannelByPlatform(platform, platformChannelID string) (*mo row := d.db.QueryRow(query, platform, platformChannelID) var ( - id int64 - enabled bool - enableAllPlugins bool - channelRawJSON string + id int64 + enabled bool + channelRawJSON string ) - err := row.Scan(&id, &platform, &platformChannelID, &enabled, &enableAllPlugins, &channelRawJSON) + err := row.Scan(&id, &platform, &platformChannelID, &enabled, &channelRawJSON) if err == sql.ErrNoRows { return nil, ErrNotFound } @@ -146,7 +143,6 @@ func (d *Database) GetChannelByPlatform(platform, platformChannelID string) (*mo Platform: platform, PlatformChannelID: platformChannelID, Enabled: enabled, - EnableAllPlugins: enableAllPlugins, ChannelRaw: channelRaw, Plugins: make(map[string]*model.ChannelPlugin), } @@ -174,11 +170,11 @@ func (d *Database) CreateChannel(platform, platformChannelID string, enabled boo // Insert channel query := ` - INSERT INTO channels (platform, platform_channel_id, enabled, enable_all_plugins, channel_raw) - VALUES (?, ?, ?, ?, ?) + INSERT INTO channels (platform, platform_channel_id, enabled, channel_raw) + VALUES (?, ?, ?, ?) ` - result, err := d.db.Exec(query, platform, platformChannelID, enabled, false, string(channelRawJSON)) + result, err := d.db.Exec(query, platform, platformChannelID, enabled, string(channelRawJSON)) if err != nil { return nil, err } @@ -195,7 +191,6 @@ func (d *Database) CreateChannel(platform, platformChannelID string, enabled boo Platform: platform, PlatformChannelID: platformChannelID, Enabled: enabled, - EnableAllPlugins: false, ChannelRaw: channelRaw, Plugins: make(map[string]*model.ChannelPlugin), } @@ -215,18 +210,6 @@ func (d *Database) UpdateChannel(id int64, enabled bool) error { return err } -// UpdateChannelEnableAllPlugins updates a channel's enable_all_plugins status -func (d *Database) UpdateChannelEnableAllPlugins(id int64, enableAllPlugins bool) error { - query := ` - UPDATE channels - SET enable_all_plugins = ? - WHERE id = ? - ` - - _, err := d.db.Exec(query, enableAllPlugins, id) - return err -} - // DeleteChannel deletes a channel func (d *Database) DeleteChannel(id int64) error { // First delete all channel plugins @@ -278,7 +261,7 @@ func (d *Database) GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, e } // Parse config JSON - var config map[string]any + var config map[string]interface{} if err := json.Unmarshal([]byte(configJSON), &config); err != nil { return nil, err } @@ -305,28 +288,6 @@ 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 := ` @@ -473,7 +434,7 @@ func (d *Database) DeleteChannelPluginsByChannel(channelID int64) error { // GetAllChannels retrieves all channels func (d *Database) GetAllChannels() ([]*model.Channel, error) { query := ` - SELECT id, platform, platform_channel_id, enabled, enable_all_plugins, channel_raw + SELECT id, platform, platform_channel_id, enabled, channel_raw FROM channels ` @@ -495,11 +456,10 @@ func (d *Database) GetAllChannels() ([]*model.Channel, error) { platform string platformChannelID string enabled bool - enableAllPlugins bool channelRawJSON string ) - if err := rows.Scan(&id, &platform, &platformChannelID, &enabled, &enableAllPlugins, &channelRawJSON); err != nil { + if err := rows.Scan(&id, &platform, &platformChannelID, &enabled, &channelRawJSON); err != nil { return nil, err } @@ -515,7 +475,6 @@ func (d *Database) GetAllChannels() ([]*model.Channel, error) { Platform: platform, PlatformChannelID: platformChannelID, Enabled: enabled, - EnableAllPlugins: enableAllPlugins, ChannelRaw: channelRaw, Plugins: make(map[string]*model.ChannelPlugin), } @@ -667,8 +626,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) ` @@ -707,7 +666,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/db/db_test.go b/internal/db/db_test.go deleted file mode 100644 index beb485d..0000000 --- a/internal/db/db_test.go +++ /dev/null @@ -1,203 +0,0 @@ -package db - -import ( - "fmt" - "os" - "testing" - "time" - - "git.nakama.town/fmartingr/butterrobot/internal/model" -) - -func TestEnableAllPlugins(t *testing.T) { - // Create temporary database for testing with unique name - dbFile := fmt.Sprintf("test_db_%d.db", time.Now().UnixNano()) - database, err := 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(dbFile) - }() - - t.Run("CreateChannel with EnableAllPlugins default false", func(t *testing.T) { - channelRaw := map[string]interface{}{ - "name": "test-channel", - } - - channel, err := database.CreateChannel("telegram", "123456", true, channelRaw) - if err != nil { - t.Fatalf("Failed to create channel: %v", err) - } - - if channel.EnableAllPlugins { - t.Errorf("Expected EnableAllPlugins to be false by default, got true") - } - - // Verify it's also false when retrieved from database - retrieved, err := database.GetChannelByID(channel.ID) - if err != nil { - t.Fatalf("Failed to retrieve channel: %v", err) - } - - if retrieved.EnableAllPlugins { - t.Errorf("Expected EnableAllPlugins to be false when retrieved from DB, got true") - } - }) - - t.Run("UpdateChannelEnableAllPlugins", func(t *testing.T) { - // Create a channel - channelRaw := map[string]interface{}{ - "name": "test-channel-2", - } - - channel, err := database.CreateChannel("telegram", "123457", true, channelRaw) - if err != nil { - t.Fatalf("Failed to create channel: %v", err) - } - - // Update EnableAllPlugins to true - err = database.UpdateChannelEnableAllPlugins(channel.ID, true) - if err != nil { - t.Fatalf("Failed to update EnableAllPlugins: %v", err) - } - - // Retrieve and verify - retrieved, err := database.GetChannelByID(channel.ID) - if err != nil { - t.Fatalf("Failed to retrieve channel: %v", err) - } - - if !retrieved.EnableAllPlugins { - t.Errorf("Expected EnableAllPlugins to be true after update, got false") - } - - // Update back to false - err = database.UpdateChannelEnableAllPlugins(channel.ID, false) - if err != nil { - t.Fatalf("Failed to update EnableAllPlugins back to false: %v", err) - } - - // Retrieve and verify again - retrieved, err = database.GetChannelByID(channel.ID) - if err != nil { - t.Fatalf("Failed to retrieve channel: %v", err) - } - - if retrieved.EnableAllPlugins { - t.Errorf("Expected EnableAllPlugins to be false after second update, got true") - } - }) - - t.Run("GetChannelByPlatform includes EnableAllPlugins", func(t *testing.T) { - // Create a channel - channelRaw := map[string]interface{}{ - "name": "test-channel-3", - } - - channel, err := database.CreateChannel("slack", "C123456", true, channelRaw) - if err != nil { - t.Fatalf("Failed to create channel: %v", err) - } - - // Enable all plugins - err = database.UpdateChannelEnableAllPlugins(channel.ID, true) - if err != nil { - t.Fatalf("Failed to update EnableAllPlugins: %v", err) - } - - // Retrieve by platform - retrieved, err := database.GetChannelByPlatform("slack", "C123456") - if err != nil { - t.Fatalf("Failed to retrieve channel by platform: %v", err) - } - - if !retrieved.EnableAllPlugins { - t.Errorf("Expected EnableAllPlugins to be true when retrieved by platform, got false") - } - }) - - t.Run("GetAllChannels includes EnableAllPlugins", func(t *testing.T) { - // Create multiple channels with different EnableAllPlugins settings - channelRaw1 := map[string]interface{}{"name": "channel-1"} - channelRaw2 := map[string]interface{}{"name": "channel-2"} - - channel1, err := database.CreateChannel("platform1", "ch1", true, channelRaw1) - if err != nil { - t.Fatalf("Failed to create channel1: %v", err) - } - - channel2, err := database.CreateChannel("platform2", "ch2", true, channelRaw2) - if err != nil { - t.Fatalf("Failed to create channel2: %v", err) - } - - // Enable all plugins for channel2 only - err = database.UpdateChannelEnableAllPlugins(channel2.ID, true) - if err != nil { - t.Fatalf("Failed to update EnableAllPlugins for channel2: %v", err) - } - - // Get all channels - channels, err := database.GetAllChannels() - if err != nil { - t.Fatalf("Failed to get all channels: %v", err) - } - - // Find our test channels - var foundChannel1, foundChannel2 *model.Channel - for _, ch := range channels { - if ch.ID == channel1.ID { - foundChannel1 = ch - } - if ch.ID == channel2.ID { - foundChannel2 = ch - } - } - - if foundChannel1 == nil { - t.Fatalf("Channel1 not found in GetAllChannels result") - } - if foundChannel2 == nil { - t.Fatalf("Channel2 not found in GetAllChannels result") - } - - if foundChannel1.EnableAllPlugins { - t.Errorf("Expected channel1 EnableAllPlugins to be false, got true") - } - if !foundChannel2.EnableAllPlugins { - t.Errorf("Expected channel2 EnableAllPlugins to be true, got false") - } - }) - - t.Run("Migration applied correctly", func(t *testing.T) { - // Test that we can create a channel and the enable_all_plugins column exists - // This implicitly tests that migration 4 was applied correctly - channelRaw := map[string]interface{}{ - "name": "migration-test-channel", - } - - channel, err := database.CreateChannel("test-platform", "migration-test", true, channelRaw) - if err != nil { - t.Fatalf("Failed to create channel after migration: %v", err) - } - - // Try to update EnableAllPlugins - this would fail if the column doesn't exist - err = database.UpdateChannelEnableAllPlugins(channel.ID, true) - if err != nil { - t.Fatalf("Failed to update EnableAllPlugins - migration may not have been applied: %v", err) - } - - // Verify the value was set correctly - retrieved, err := database.GetChannelByID(channel.ID) - if err != nil { - t.Fatalf("Failed to retrieve channel: %v", err) - } - - if !retrieved.EnableAllPlugins { - t.Errorf("EnableAllPlugins should be true after update") - } - }) -} diff --git a/internal/migration/migrations.go b/internal/migration/migrations.go index 11aa716..9004a9b 100644 --- a/internal/migration/migrations.go +++ b/internal/migration/migrations.go @@ -10,7 +10,6 @@ func init() { Register(1, "Initial schema with bcrypt passwords", migrateInitialSchemaUp, migrateInitialSchemaDown) Register(2, "Add reminders table", migrateRemindersUp, migrateRemindersDown) Register(3, "Add cache table", migrateCacheUp, migrateCacheDown) - Register(4, "Add enable_all_plugins column to channels", migrateEnableAllPluginsUp, migrateEnableAllPluginsDown) } // Initial schema creation with bcrypt passwords - version 1 @@ -155,60 +154,3 @@ func migrateCacheDown(db *sql.DB) error { _, err := db.Exec(`DROP TABLE IF EXISTS cache`) return err } - -// Add enable_all_plugins column to channels table - version 4 -func migrateEnableAllPluginsUp(db *sql.DB) error { - _, err := db.Exec(` - ALTER TABLE channels ADD COLUMN enable_all_plugins BOOLEAN NOT NULL DEFAULT 0 - `) - return err -} - -func migrateEnableAllPluginsDown(db *sql.DB) error { - // SQLite doesn't support DROP COLUMN, so we need to recreate the table - tx, err := db.Begin() - if err != nil { - return err - } - defer func() { - _ = tx.Rollback() // Ignore rollback errors - }() - - // Create backup table - _, err = tx.Exec(` - CREATE TABLE channels_backup ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - platform TEXT NOT NULL, - platform_channel_id TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT 0, - channel_raw TEXT NOT NULL, - UNIQUE(platform, platform_channel_id) - ) - `) - if err != nil { - return err - } - - // Copy data excluding enable_all_plugins column - _, err = tx.Exec(` - INSERT INTO channels_backup (id, platform, platform_channel_id, enabled, channel_raw) - SELECT id, platform, platform_channel_id, enabled, channel_raw FROM channels - `) - if err != nil { - return err - } - - // Drop original table - _, err = tx.Exec(`DROP TABLE channels`) - if err != nil { - return err - } - - // Rename backup table - _, err = tx.Exec(`ALTER TABLE channels_backup RENAME TO channels`) - if err != nil { - return err - } - - return tx.Commit() -} diff --git a/internal/model/message.go b/internal/model/message.go index 8b38830..26ec5da 100644 --- a/internal/model/message.go +++ b/internal/model/message.go @@ -44,17 +44,11 @@ type Channel struct { PlatformChannelID string ChannelRaw map[string]interface{} Enabled bool - EnableAllPlugins bool Plugins map[string]*ChannelPlugin } // HasEnabledPlugin checks if a plugin is enabled for this channel func (c *Channel) HasEnabledPlugin(pluginID string) bool { - // If EnableAllPlugins is true, all plugins are considered enabled - if c.EnableAllPlugins { - return true - } - plugin, exists := c.Plugins[pluginID] if !exists { return false diff --git a/internal/model/message_test.go b/internal/model/message_test.go deleted file mode 100644 index d2dfedc..0000000 --- a/internal/model/message_test.go +++ /dev/null @@ -1,234 +0,0 @@ -package model - -import ( - "testing" -) - -func TestChannel_HasEnabledPlugin(t *testing.T) { - t.Run("EnableAllPlugins false - plugin not in map", func(t *testing.T) { - channel := &Channel{ - ID: 1, - Platform: "telegram", - PlatformChannelID: "123456", - Enabled: true, - EnableAllPlugins: false, - Plugins: make(map[string]*ChannelPlugin), - } - - // Plugin not in map should return false - result := channel.HasEnabledPlugin("nonexistent.plugin") - if result { - t.Errorf("Expected HasEnabledPlugin to return false for nonexistent plugin, got true") - } - }) - - t.Run("EnableAllPlugins false - plugin disabled", func(t *testing.T) { - channel := &Channel{ - ID: 1, - Platform: "telegram", - PlatformChannelID: "123456", - Enabled: true, - EnableAllPlugins: false, - Plugins: map[string]*ChannelPlugin{ - "test.plugin": { - ID: 1, - ChannelID: 1, - PluginID: "test.plugin", - Enabled: false, - Config: make(map[string]any), - }, - }, - } - - // Disabled plugin should return false - result := channel.HasEnabledPlugin("test.plugin") - if result { - t.Errorf("Expected HasEnabledPlugin to return false for disabled plugin, got true") - } - }) - - t.Run("EnableAllPlugins false - plugin enabled", func(t *testing.T) { - channel := &Channel{ - ID: 1, - Platform: "telegram", - PlatformChannelID: "123456", - Enabled: true, - EnableAllPlugins: false, - Plugins: map[string]*ChannelPlugin{ - "test.plugin": { - ID: 1, - ChannelID: 1, - PluginID: "test.plugin", - Enabled: true, - Config: make(map[string]any), - }, - }, - } - - // Enabled plugin should return true - result := channel.HasEnabledPlugin("test.plugin") - if !result { - t.Errorf("Expected HasEnabledPlugin to return true for enabled plugin, got false") - } - }) - - t.Run("EnableAllPlugins true - plugin not in map", func(t *testing.T) { - channel := &Channel{ - ID: 1, - Platform: "telegram", - PlatformChannelID: "123456", - Enabled: true, - EnableAllPlugins: true, - Plugins: make(map[string]*ChannelPlugin), - } - - // When EnableAllPlugins is true, any plugin should be considered enabled - result := channel.HasEnabledPlugin("nonexistent.plugin") - if !result { - t.Errorf("Expected HasEnabledPlugin to return true when EnableAllPlugins is true, got false") - } - }) - - t.Run("EnableAllPlugins true - plugin disabled", func(t *testing.T) { - channel := &Channel{ - ID: 1, - Platform: "telegram", - PlatformChannelID: "123456", - Enabled: true, - EnableAllPlugins: true, - Plugins: map[string]*ChannelPlugin{ - "test.plugin": { - ID: 1, - ChannelID: 1, - PluginID: "test.plugin", - Enabled: false, - Config: make(map[string]any), - }, - }, - } - - // When EnableAllPlugins is true, even disabled plugins should be considered enabled - result := channel.HasEnabledPlugin("test.plugin") - if !result { - t.Errorf("Expected HasEnabledPlugin to return true when EnableAllPlugins is true (even for disabled plugin), got false") - } - }) - - t.Run("EnableAllPlugins true - plugin enabled", func(t *testing.T) { - channel := &Channel{ - ID: 1, - Platform: "telegram", - PlatformChannelID: "123456", - Enabled: true, - EnableAllPlugins: true, - Plugins: map[string]*ChannelPlugin{ - "test.plugin": { - ID: 1, - ChannelID: 1, - PluginID: "test.plugin", - Enabled: true, - Config: make(map[string]any), - }, - }, - } - - // When EnableAllPlugins is true, enabled plugins should also return true - result := channel.HasEnabledPlugin("test.plugin") - if !result { - t.Errorf("Expected HasEnabledPlugin to return true when EnableAllPlugins is true, got false") - } - }) - - t.Run("EnableAllPlugins true - multiple plugins", func(t *testing.T) { - channel := &Channel{ - ID: 1, - Platform: "telegram", - PlatformChannelID: "123456", - Enabled: true, - EnableAllPlugins: true, - Plugins: map[string]*ChannelPlugin{ - "plugin1": { - ID: 1, - ChannelID: 1, - PluginID: "plugin1", - Enabled: true, - Config: make(map[string]any), - }, - "plugin2": { - ID: 2, - ChannelID: 1, - PluginID: "plugin2", - Enabled: false, - Config: make(map[string]any), - }, - }, - } - - // All plugins should be enabled when EnableAllPlugins is true - testCases := []string{"plugin1", "plugin2", "plugin3", "any.plugin"} - for _, pluginID := range testCases { - result := channel.HasEnabledPlugin(pluginID) - if !result { - t.Errorf("Expected HasEnabledPlugin('%s') to return true when EnableAllPlugins is true, got false", pluginID) - } - } - }) -} - -func TestChannelName(t *testing.T) { - t.Run("Returns PlatformChannelID when ChannelRaw is nil", func(t *testing.T) { - channel := &Channel{ - PlatformChannelID: "test-id", - ChannelRaw: nil, - } - - result := channel.ChannelName() - if result != "test-id" { - t.Errorf("Expected channel name to be 'test-id', got '%s'", result) - } - }) - - t.Run("Returns name from ChannelRaw when available", func(t *testing.T) { - channel := &Channel{ - PlatformChannelID: "test-id", - ChannelRaw: map[string]interface{}{ - "name": "Test Channel", - }, - } - - result := channel.ChannelName() - if result != "Test Channel" { - t.Errorf("Expected channel name to be 'Test Channel', got '%s'", result) - } - }) - - t.Run("Returns title from nested chat object (Telegram style)", func(t *testing.T) { - channel := &Channel{ - PlatformChannelID: "test-id", - ChannelRaw: map[string]interface{}{ - "chat": map[string]interface{}{ - "title": "Telegram Group", - }, - }, - } - - result := channel.ChannelName() - if result != "Telegram Group" { - t.Errorf("Expected channel name to be 'Telegram Group', got '%s'", result) - } - }) - - t.Run("Falls back to PlatformChannelID when no valid name found", func(t *testing.T) { - channel := &Channel{ - PlatformChannelID: "fallback-id", - ChannelRaw: map[string]interface{}{ - "other_field": "value", - }, - } - - result := channel.ChannelName() - if result != "fallback-id" { - t.Errorf("Expected channel name to fallback to 'fallback-id', got '%s'", result) - } - }) -} diff --git a/internal/platform/telegram/telegram.go b/internal/platform/telegram/telegram.go index b015793..24714f2 100644 --- a/internal/platform/telegram/telegram.go +++ b/internal/platform/telegram/telegram.go @@ -233,17 +233,9 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error { // Prepare payload payload := map[string]interface{}{ - "chat_id": chatID, - "text": msg.Text, - } - - // 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 empty string (no formatting) - payload["parse_mode"] = "" + "chat_id": chatID, + "text": msg.Text, + "parse_mode": "Markdown", } // Add reply if needed diff --git a/internal/plugin/fun/hltb.go b/internal/plugin/fun/hltb.go index f94f2ba..99d3e91 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, @@ -131,15 +131,12 @@ func (p *HLTBPlugin) OnMessage(msg *model.Message, config map[string]interface{} Channel: msg.Channel, } - // Set parse mode for markdown formatting - if responseMsg.Raw == nil { - responseMsg.Raw = make(map[string]interface{}) - } - responseMsg.Raw["parse_mode"] = "Markdown" - // 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 } diff --git a/internal/plugin/fun/loquito.go b/internal/plugin/fun/loquito.go index 4b102f7..426ab92 100644 --- a/internal/plugin/fun/loquito.go +++ b/internal/plugin/fun/loquito.go @@ -23,11 +23,6 @@ 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 deleted file mode 100644 index 4e6215a..0000000 --- a/internal/plugin/help/help.go +++ /dev/null @@ -1,166 +0,0 @@ -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, - Raw: map[string]interface{}{"parse_mode": "Markdown"}, - } - - 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, - Raw: map[string]interface{}{"parse_mode": "Markdown"}, - } - - 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 deleted file mode 100644 index 25d5376..0000000 --- a/internal/plugin/help/help_test.go +++ /dev/null @@ -1,206 +0,0 @@ -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 8f8413a..3ff96ff 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -47,26 +47,6 @@ func GetAvailablePlugins() map[string]model.Plugin { return result } -// GetAvailablePluginIDs returns a slice of all registered plugin IDs -func GetAvailablePluginIDs() []string { - pluginsMu.RLock() - defer pluginsMu.RUnlock() - - result := make([]string, 0, len(plugins)) - for pluginID := range plugins { - result = append(result, pluginID) - } - - 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/plugin_test.go b/internal/plugin/plugin_test.go deleted file mode 100644 index 0bfd207..0000000 --- a/internal/plugin/plugin_test.go +++ /dev/null @@ -1,331 +0,0 @@ -package plugin - -import ( - "testing" - - "git.nakama.town/fmartingr/butterrobot/internal/model" -) - -// Mock plugin for testing -type testPlugin struct { - BasePlugin -} - -func (p *testPlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction { - return []*model.MessageAction{ - { - Type: model.ActionSendMessage, - Message: &model.Message{ - Text: "test response", - Chat: msg.Chat, - Channel: msg.Channel, - }, - }, - } -} - -func TestGetAvailablePluginIDs(t *testing.T) { - // Clear registry before test - ClearRegistry() - - // Register test plugins - testPlugin1 := &testPlugin{ - BasePlugin: BasePlugin{ - ID: "test.plugin1", - Name: "Test Plugin 1", - }, - } - testPlugin2 := &testPlugin{ - BasePlugin: BasePlugin{ - ID: "test.plugin2", - Name: "Test Plugin 2", - }, - } - - Register(testPlugin1) - Register(testPlugin2) - - // Test GetAvailablePluginIDs - pluginIDs := GetAvailablePluginIDs() - - if len(pluginIDs) != 2 { - t.Errorf("Expected 2 plugin IDs, got %d", len(pluginIDs)) - } - - // Check that both plugin IDs are present - found1, found2 := false, false - for _, id := range pluginIDs { - if id == "test.plugin1" { - found1 = true - } - if id == "test.plugin2" { - found2 = true - } - } - - if !found1 { - t.Errorf("Expected to find test.plugin1 in plugin IDs") - } - if !found2 { - t.Errorf("Expected to find test.plugin2 in plugin IDs") - } -} - -func TestEnableAllPluginsProcessingLogic(t *testing.T) { - // Clear registry before test - ClearRegistry() - - // Register test plugins - testPlugin1 := &testPlugin{ - BasePlugin: BasePlugin{ - ID: "ping", - Name: "Ping Plugin", - }, - } - testPlugin2 := &testPlugin{ - BasePlugin: BasePlugin{ - ID: "echo", - Name: "Echo Plugin", - }, - } - testPlugin3 := &testPlugin{ - BasePlugin: BasePlugin{ - ID: "help", - Name: "Help Plugin", - }, - } - - Register(testPlugin1) - Register(testPlugin2) - Register(testPlugin3) - - t.Run("EnableAllPlugins false - only explicitly enabled plugins", func(t *testing.T) { - // Create a channel with EnableAllPlugins = false and only some plugins enabled - channel := &model.Channel{ - ID: 1, - Platform: "telegram", - PlatformChannelID: "123456", - Enabled: true, - EnableAllPlugins: false, - Plugins: map[string]*model.ChannelPlugin{ - "ping": { - ID: 1, - ChannelID: 1, - PluginID: "ping", - Enabled: true, - Config: map[string]interface{}{"key": "value"}, - }, - "echo": { - ID: 2, - ChannelID: 1, - PluginID: "echo", - Enabled: false, // Disabled - Config: map[string]interface{}{}, - }, - // help plugin not configured - }, - } - - // Simulate the plugin processing logic from handleMessage - var pluginsToProcess []string - - if channel.EnableAllPlugins { - pluginsToProcess = GetAvailablePluginIDs() - } else { - for pluginID := range channel.Plugins { - if channel.HasEnabledPlugin(pluginID) { - pluginsToProcess = append(pluginsToProcess, pluginID) - } - } - } - - // Should only have "ping" since echo is disabled and help is not configured - if len(pluginsToProcess) != 1 { - t.Errorf("Expected 1 plugin to process, got %d: %v", len(pluginsToProcess), pluginsToProcess) - } - - if len(pluginsToProcess) > 0 && pluginsToProcess[0] != "ping" { - t.Errorf("Expected ping plugin to be processed, got %s", pluginsToProcess[0]) - } - }) - - t.Run("EnableAllPlugins true - all registered plugins", func(t *testing.T) { - // Create a channel with EnableAllPlugins = true - channel := &model.Channel{ - ID: 1, - Platform: "telegram", - PlatformChannelID: "123456", - Enabled: true, - EnableAllPlugins: true, - Plugins: map[string]*model.ChannelPlugin{ - "ping": { - ID: 1, - ChannelID: 1, - PluginID: "ping", - Enabled: true, - Config: map[string]interface{}{"key": "value"}, - }, - "echo": { - ID: 2, - ChannelID: 1, - PluginID: "echo", - Enabled: false, // Disabled, but should still be processed - Config: map[string]interface{}{}, - }, - // help plugin not configured, but should still be processed - }, - } - - // Simulate the plugin processing logic from handleMessage - var pluginsToProcess []string - - if channel.EnableAllPlugins { - pluginsToProcess = GetAvailablePluginIDs() - } else { - for pluginID := range channel.Plugins { - if channel.HasEnabledPlugin(pluginID) { - pluginsToProcess = append(pluginsToProcess, pluginID) - } - } - } - - // Should have all 3 registered plugins - if len(pluginsToProcess) != 3 { - t.Errorf("Expected 3 plugins to process, got %d: %v", len(pluginsToProcess), pluginsToProcess) - } - - // Check that all plugins are included - expectedPlugins := map[string]bool{"ping": false, "echo": false, "help": false} - for _, pluginID := range pluginsToProcess { - if _, exists := expectedPlugins[pluginID]; exists { - expectedPlugins[pluginID] = true - } else { - t.Errorf("Unexpected plugin in processing list: %s", pluginID) - } - } - - for pluginID, found := range expectedPlugins { - if !found { - t.Errorf("Expected plugin %s to be in processing list", pluginID) - } - } - }) - - t.Run("Plugin configuration handling", func(t *testing.T) { - // Test the configuration logic from handleMessage - channel := &model.Channel{ - ID: 1, - Platform: "telegram", - PlatformChannelID: "123456", - Enabled: true, - EnableAllPlugins: true, - Plugins: map[string]*model.ChannelPlugin{ - "ping": { - ID: 1, - ChannelID: 1, - PluginID: "ping", - Enabled: true, - Config: map[string]interface{}{"configured": "value"}, - }, - }, - } - - testCases := []struct { - pluginID string - expectedConfig map[string]interface{} - }{ - { - pluginID: "ping", - expectedConfig: map[string]interface{}{"configured": "value"}, - }, - { - pluginID: "echo", // Not explicitly configured - expectedConfig: map[string]interface{}{}, - }, - } - - for _, tc := range testCases { - // Simulate the config retrieval logic from handleMessage - var config map[string]interface{} - if channelPlugin, exists := channel.Plugins[tc.pluginID]; exists { - config = channelPlugin.Config - } else { - config = make(map[string]interface{}) - } - - if len(config) != len(tc.expectedConfig) { - t.Errorf("Plugin %s: expected config length %d, got %d", tc.pluginID, len(tc.expectedConfig), len(config)) - } - - for key, expectedValue := range tc.expectedConfig { - if actualValue, exists := config[key]; !exists || actualValue != expectedValue { - t.Errorf("Plugin %s: expected config[%s] = %v, got %v", tc.pluginID, key, expectedValue, actualValue) - } - } - } - }) -} - -func TestPluginRegistry(t *testing.T) { - // Clear registry before test - ClearRegistry() - - testPlugin := &testPlugin{ - BasePlugin: BasePlugin{ - ID: "test.registry", - Name: "Test Registry Plugin", - }, - } - - t.Run("Register and Get plugin", func(t *testing.T) { - Register(testPlugin) - - retrieved, err := Get("test.registry") - if err != nil { - t.Errorf("Failed to get registered plugin: %v", err) - } - - if retrieved.GetID() != "test.registry" { - t.Errorf("Expected plugin ID 'test.registry', got '%s'", retrieved.GetID()) - } - }) - - t.Run("Get nonexistent plugin", func(t *testing.T) { - _, err := Get("nonexistent.plugin") - if err == nil { - t.Errorf("Expected error when getting nonexistent plugin, got nil") - } - - if err != model.ErrPluginNotFound { - t.Errorf("Expected ErrPluginNotFound, got %v", err) - } - }) - - t.Run("GetAvailablePlugins", func(t *testing.T) { - plugins := GetAvailablePlugins() - - if len(plugins) != 1 { - t.Errorf("Expected 1 plugin in registry, got %d", len(plugins)) - } - - if plugin, exists := plugins["test.registry"]; !exists { - t.Errorf("Expected to find test.registry in available plugins") - } else if plugin.GetID() != "test.registry" { - t.Errorf("Expected plugin ID 'test.registry', got '%s'", plugin.GetID()) - } - }) - - t.Run("ClearRegistry", func(t *testing.T) { - ClearRegistry() - - plugins := GetAvailablePlugins() - if len(plugins) != 0 { - t.Errorf("Expected 0 plugins after clearing registry, got %d", len(plugins)) - } - - _, err := Get("test.registry") - if err == nil { - t.Errorf("Expected error when getting plugin after clearing registry, got nil") - } - }) -} diff --git a/internal/plugin/searchreplace/searchreplace.go b/internal/plugin/searchreplace/searchreplace.go index d9cd04c..b474b27 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)", }, } diff --git a/internal/plugin/social/instagram.go b/internal/plugin/social/instagram.go index b423b45..6b7aa4c 100644 --- a/internal/plugin/social/instagram.go +++ b/internal/plugin/social/instagram.go @@ -18,10 +18,9 @@ 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 alternative domain links and removes tracking parameters. Configure 'domain' option to set replacement domain (default: ddinstagram.com)", - ConfigRequired: true, + ID: "social.instagram", + Name: "Instagram Link Expander", + Help: "Automatically converts instagram.com links to ddinstagram.com links and removes tracking parameters", }, } } @@ -33,12 +32,6 @@ 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 @@ -49,7 +42,7 @@ func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]inte return nil } - // Replace instagram.com with configured domain in the message and clean query parameters + // Replace instagram.com with ddinstagram.com in the message and clean query parameters transformed := instagramRegex.ReplaceAllStringFunc(msg.Text, func(link string) string { // Parse the URL parsedURL, err := url.Parse(link) @@ -58,13 +51,13 @@ func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]inte return link } - // Ensure we don't change links that already come from the replacement domain + // 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 to the configured domain - parsedURL.Host = replacementDomain + // Change the host + parsedURL.Host = "d.ddinstagram.com" // Remove query parameters parsedURL.RawQuery = "" diff --git a/internal/plugin/social/twitter.go b/internal/plugin/social/twitter.go index f2c6cc9..553bd07 100644 --- a/internal/plugin/social/twitter.go +++ b/internal/plugin/social/twitter.go @@ -18,10 +18,9 @@ type TwitterExpander struct { func NewTwitterExpander() *TwitterExpander { return &TwitterExpander{ BasePlugin: plugin.BasePlugin{ - 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, + ID: "social.twitter", + Name: "Twitter Link Expander", + Help: "Automatically converts twitter.com links to fxtwitter.com links and removes tracking parameters", }, } } @@ -33,12 +32,6 @@ 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 @@ -49,17 +42,22 @@ func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interf return nil } - // Replace twitter.com/x.com with configured domain in the message and clean query parameters + // Replace twitter.com with fxtwitter.com 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) return link } - // Change the host to the configured domain - if strings.Contains(parsedURL.Host, "twitter.com") || strings.Contains(parsedURL.Host, "x.com") { - parsedURL.Host = replacementDomain + // Change the host + if strings.Contains(parsedURL.Host, "twitter.com") { + parsedURL.Host = strings.Replace(parsedURL.Host, "twitter.com", "fxtwitter.com", 1) + } else if strings.Contains(parsedURL.Host, "x.com") { + parsedURL.Host = strings.Replace(parsedURL.Host, "x.com", "fxtwitter.com", 1) } // Remove query parameters diff --git a/internal/plugin/social/twitter_test.go b/internal/plugin/social/twitter_test.go deleted file mode 100644 index c0e1681..0000000 --- a/internal/plugin/social/twitter_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package social - -import ( - "testing" - - "git.nakama.town/fmartingr/butterrobot/internal/model" -) - -func TestTwitterExpander_OnMessage(t *testing.T) { - plugin := NewTwitterExpander() - - tests := []struct { - name string - input string - config map[string]interface{} - expected string - hasReply bool - }{ - { - name: "Twitter URL with default domain", - input: "https://twitter.com/user/status/123456789", - config: map[string]interface{}{}, - expected: "https://fxtwitter.com/user/status/123456789", - hasReply: true, - }, - { - name: "X.com URL with custom domain", - input: "https://x.com/elonmusk/status/987654321", - config: map[string]interface{}{"domain": "vxtwitter.com"}, - expected: "https://vxtwitter.com/elonmusk/status/987654321", - hasReply: true, - }, - { - name: "Twitter URL with tracking parameters", - input: "https://twitter.com/openai/status/555?ref_src=twsrc%5Etfw&s=20", - config: map[string]interface{}{}, - expected: "https://fxtwitter.com/openai/status/555", - hasReply: true, - }, - { - name: "www.twitter.com URL", - input: "https://www.twitter.com/user/status/789", - config: map[string]interface{}{"domain": "nitter.net"}, - expected: "https://nitter.net/user/status/789", - hasReply: true, - }, - { - name: "Mixed text with Twitter URL", - input: "Check this out: https://twitter.com/user/status/123 amazing!", - config: map[string]interface{}{}, - expected: "Check this out: https://fxtwitter.com/user/status/123 amazing!", - hasReply: true, - }, - { - name: "No Twitter URLs", - input: "Just some regular text https://youtube.com/watch?v=abc", - config: map[string]interface{}{}, - expected: "", - hasReply: false, - }, - { - name: "Empty message", - input: "", - config: map[string]interface{}{}, - expected: "", - hasReply: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - msg := &model.Message{ - ID: "test_msg", - Text: tt.input, - Chat: "test_chat", - Channel: &model.Channel{ - ID: 1, - Platform: "telegram", - PlatformChannelID: "test_chat", - }, - } - - actions := plugin.OnMessage(msg, tt.config, nil) - - if !tt.hasReply { - if len(actions) != 0 { - t.Errorf("Expected no actions, got %d", 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 %s", action.Type) - } - - if action.Message == nil { - t.Error("Expected message in action, got nil") - return - } - - if action.Message.Text != tt.expected { - t.Errorf("Expected '%s', got '%s'", tt.expected, action.Message.Text) - } - - if action.Message.ReplyTo != msg.ID { - t.Errorf("Expected ReplyTo '%s', got '%s'", msg.ID, action.Message.ReplyTo) - } - - if action.Message.Raw == nil || action.Message.Raw["parse_mode"] != "" { - t.Error("Expected parse_mode to be empty string to disable markdown parsing") - } - }) - } -}