diff --git a/.gitignore b/.gitignore index 9dab4b7..f57548a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,6 @@ dist bin # Butterrobot *.sqlite* -butterrobot.db* +butterrobot.db /butterrobot *_test.db* diff --git a/docs/plugins.md b/docs/plugins.md index d84aec5..1596437 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -13,7 +13,6 @@ ### Utility -- Help: Shows available commands when you type `!help`. Lists all enabled plugins for the current channel organized by category with their descriptions and usage instructions. - Remind Me: Reply to a message with `!remindme ` to set a reminder. Supported duration units: y (years), mo (months), d (days), h (hours), m (minutes), s (seconds). Examples: `!remindme 1y` for 1 year, `!remindme 3mo` for 3 months, `!remindme 2d` for 2 days, `!remindme 3h` for 3 hours. The bot will mention you with a reminder after the specified time. - Search and Replace: Reply to any message with `s/search/replace/[flags]` to perform text substitution. Supports flags: `g` (global), `i` (case insensitive), `n` (regex pattern). Example: `s/hello/hi/gi` replaces all occurrences of "hello" with "hi" case-insensitively. diff --git a/go.mod b/go.mod index 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/app/app.go b/internal/app/app.go index 9dc38c6..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() diff --git a/internal/db/db.go b/internal/db/db.go index 2ca767c..e40794d 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -261,7 +261,7 @@ func (d *Database) GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, e } // Parse config JSON - var config map[string]any + var config map[string]interface{} if err := json.Unmarshal([]byte(configJSON), &config); err != nil { return nil, err } @@ -288,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 := ` @@ -648,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) ` @@ -688,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/plugin/fun/hltb.go b/internal/plugin/fun/hltb.go index 227d637..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, 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 88b25dd..0000000 --- a/internal/plugin/help/help.go +++ /dev/null @@ -1,164 +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, - } - - return []*model.MessageAction{ - { - Type: model.ActionSendMessage, - Message: response, - Chat: msg.Chat, - Channel: msg.Channel, - }, - } - } - - // Group plugins by category - categories := map[string][]model.Plugin{ - "Development": {}, - "Fun and Entertainment": {}, - "Utility": {}, - "Security": {}, - "Social Media": {}, - "Other": {}, - } - - // Categorize plugins based on their ID prefix - for _, p := range enabledPlugins { - category := p.GetID() - switch { - case strings.HasPrefix(category, "dev."): - categories["Development"] = append(categories["Development"], p) - case strings.HasPrefix(category, "fun."): - categories["Fun and Entertainment"] = append(categories["Fun and Entertainment"], p) - case strings.HasPrefix(category, "util.") || strings.HasPrefix(category, "reminder.") || strings.HasPrefix(category, "utility."): - categories["Utility"] = append(categories["Utility"], p) - case strings.HasPrefix(category, "security."): - categories["Security"] = append(categories["Security"], p) - case strings.HasPrefix(category, "social."): - categories["Social Media"] = append(categories["Social Media"], p) - default: - categories["Other"] = append(categories["Other"], p) - } - } - - // Build the help message - var helpText strings.Builder - helpText.WriteString("🤖 **Available Commands**\n\n") - - // Sort category names for consistent output - categoryOrder := []string{"Development", "Fun and Entertainment", "Utility", "Security", "Social Media", "Other"} - - for _, categoryName := range categoryOrder { - pluginList := categories[categoryName] - if len(pluginList) == 0 { - continue - } - - // Sort plugins within category by name - sort.Slice(pluginList, func(i, j int) bool { - return pluginList[i].GetName() < pluginList[j].GetName() - }) - - helpText.WriteString(fmt.Sprintf("**%s:**\n", categoryName)) - for _, p := range pluginList { - if p.GetHelp() == "" { - continue - } - helpText.WriteString(fmt.Sprintf("• **%s** - %s\n", p.GetName(), p.GetHelp())) - } - helpText.WriteString("\n") - } - - // Add footer - helpText.WriteString("_Use the specific commands or triggers mentioned above to interact with the bot._") - - response := &model.Message{ - Text: helpText.String(), - Chat: msg.Chat, - ReplyTo: msg.ID, - Channel: msg.Channel, - } - - return []*model.MessageAction{ - { - Type: model.ActionSendMessage, - Message: response, - Chat: msg.Chat, - Channel: msg.Channel, - }, - } -} diff --git a/internal/plugin/help/help_test.go b/internal/plugin/help/help_test.go 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 8d1f970..3ff96ff 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -47,13 +47,6 @@ func GetAvailablePlugins() map[string]model.Plugin { return result } -// ClearRegistry clears all registered plugins (for testing) -func ClearRegistry() { - pluginsMu.Lock() - defer pluginsMu.Unlock() - plugins = make(map[string]model.Plugin) -} - // BasePlugin provides a common base for plugins type BasePlugin struct { ID string diff --git a/internal/plugin/searchreplace/searchreplace.go b/internal/plugin/searchreplace/searchreplace.go index 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)", }, }