diff --git a/docs/plugins.md b/docs/plugins.md index 1596437..d84aec5 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -13,6 +13,7 @@ ### Utility +- Help: Shows available commands when you type `!help`. Lists all enabled plugins for the current channel organized by category with their descriptions and usage instructions. - Remind Me: Reply to a message with `!remindme ` to set a reminder. Supported duration units: y (years), mo (months), d (days), h (hours), m (minutes), s (seconds). Examples: `!remindme 1y` for 1 year, `!remindme 3mo` for 3 months, `!remindme 2d` for 2 days, `!remindme 3h` for 3 hours. The bot will mention you with a reminder after the specified time. - Search and Replace: Reply to any message with `s/search/replace/[flags]` to perform text substitution. Supports flags: `g` (global), `i` (case insensitive), `n` (regex pattern). Example: `s/hello/hi/gi` replaces all occurrences of "hello" with "hi" case-insensitively. diff --git a/go.mod b/go.mod index cd1bee5..5127bfd 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/gorilla/sessions v1.4.0 golang.org/x/crypto v0.37.0 golang.org/x/crypto/x509roots/fallback v0.0.0-20250418111936-9c1aa6af88df + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 modernc.org/sqlite v1.37.0 ) @@ -16,7 +17,6 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/sys v0.32.0 // indirect modernc.org/libc v1.63.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/internal/app/app.go b/internal/app/app.go index b54412a..9dc38c6 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -23,6 +23,7 @@ import ( "git.nakama.town/fmartingr/butterrobot/internal/plugin" "git.nakama.town/fmartingr/butterrobot/internal/plugin/domainblock" "git.nakama.town/fmartingr/butterrobot/internal/plugin/fun" + "git.nakama.town/fmartingr/butterrobot/internal/plugin/help" "git.nakama.town/fmartingr/butterrobot/internal/plugin/ping" "git.nakama.town/fmartingr/butterrobot/internal/plugin/reminder" "git.nakama.town/fmartingr/butterrobot/internal/plugin/searchreplace" @@ -94,6 +95,7 @@ func (a *App) Run() error { plugin.Register(reminder.New(a.db)) plugin.Register(domainblock.New()) plugin.Register(searchreplace.New()) + plugin.Register(help.New(a.db)) // Initialize routes a.initializeRoutes() diff --git a/internal/db/db.go b/internal/db/db.go index e40794d..2ca767c 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -261,7 +261,7 @@ func (d *Database) GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, e } // Parse config JSON - var config map[string]interface{} + var config map[string]any if err := json.Unmarshal([]byte(configJSON), &config); err != nil { return nil, err } @@ -288,6 +288,28 @@ func (d *Database) GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, e return plugins, nil } +// GetChannelPluginsFromPlatformID retrieves all plugins for a channel by platform and platform channel ID +func (d *Database) GetChannelPluginsFromPlatformID(platform, platformChannelID string) ([]*model.ChannelPlugin, error) { + // First, get the channel ID by platform and platform channel ID + query := ` + SELECT id + FROM channels + WHERE platform = ? AND platform_channel_id = ? + ` + + var channelID int64 + err := d.db.QueryRow(query, platform, platformChannelID).Scan(&channelID) + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + + // Now get the plugins for this channel + return d.GetChannelPlugins(channelID) +} + // GetChannelPluginByID retrieves a channel plugin by ID func (d *Database) GetChannelPluginByID(id int64) (*model.ChannelPlugin, error) { query := ` @@ -626,8 +648,8 @@ func (d *Database) UpdateUserPassword(userID int64, newPassword string) error { func (d *Database) CreateReminder(platform, channelID, messageID, replyToID, userID, username, content string, triggerAt time.Time) (*model.Reminder, error) { query := ` INSERT INTO reminders ( - platform, channel_id, message_id, reply_to_id, - user_id, username, created_at, trigger_at, + platform, channel_id, message_id, reply_to_id, + user_id, username, created_at, trigger_at, content, processed ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0) ` @@ -666,7 +688,7 @@ func (d *Database) CreateReminder(platform, channelID, messageID, replyToID, use // GetPendingReminders gets all pending reminders that need to be processed func (d *Database) GetPendingReminders() ([]*model.Reminder, error) { query := ` - SELECT id, platform, channel_id, message_id, reply_to_id, + SELECT id, platform, channel_id, message_id, reply_to_id, user_id, username, created_at, trigger_at, content, processed FROM reminders WHERE processed = 0 AND trigger_at <= ? diff --git a/internal/plugin/fun/hltb.go b/internal/plugin/fun/hltb.go index 99d3e91..227d637 100644 --- a/internal/plugin/fun/hltb.go +++ b/internal/plugin/fun/hltb.go @@ -70,7 +70,7 @@ func NewHLTB() *HLTBPlugin { BasePlugin: plugin.BasePlugin{ ID: "fun.hltb", Name: "How Long To Beat", - Help: "Get game completion times from HowLongToBeat.com using !hltb ", + Help: "Get game completion times from HowLongToBeat.com using `!hltb `", }, httpClient: &http.Client{ Timeout: 10 * time.Second, diff --git a/internal/plugin/fun/loquito.go b/internal/plugin/fun/loquito.go index 426ab92..4b102f7 100644 --- a/internal/plugin/fun/loquito.go +++ b/internal/plugin/fun/loquito.go @@ -23,6 +23,11 @@ func NewLoquito() *LoquitoPlugin { } } +// GetHelp returns the plugin help text +func (p *LoquitoPlugin) GetHelp() string { + return "" +} + // OnMessage handles incoming messages func (p *LoquitoPlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction { if !strings.Contains(strings.ToLower(msg.Text), "lo quito") { diff --git a/internal/plugin/help/help.go b/internal/plugin/help/help.go new file mode 100644 index 0000000..88b25dd --- /dev/null +++ b/internal/plugin/help/help.go @@ -0,0 +1,164 @@ +package help + +import ( + "fmt" + "sort" + "strings" + + "git.nakama.town/fmartingr/butterrobot/internal/db" + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" + "golang.org/x/exp/slog" +) + +// ChannelPluginGetter is an interface for getting channel plugins +type ChannelPluginGetter interface { + GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, error) + GetChannelPluginsFromPlatformID(platform, platformChannelID string) ([]*model.ChannelPlugin, error) +} + +// HelpPlugin provides help information about available commands +type HelpPlugin struct { + plugin.BasePlugin + db ChannelPluginGetter +} + +// New creates a new HelpPlugin instance +func New(db ChannelPluginGetter) *HelpPlugin { + return &HelpPlugin{ + BasePlugin: plugin.BasePlugin{ + ID: "utility.help", + Name: "Help", + Help: "Shows available commands when you type '!help'", + }, + db: db, + } +} + +// OnMessage handles incoming messages +func (p *HelpPlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction { + // Check if message is the help command + if !strings.EqualFold(strings.TrimSpace(msg.Text), "!help") { + return nil + } + + // Get channel plugins from database using platform and platform channel ID + channelPlugins, err := p.db.GetChannelPluginsFromPlatformID(msg.Channel.Platform, msg.Channel.PlatformChannelID) + if err != nil && err != db.ErrNotFound { + slog.Error("Failed to get channel plugins", slog.Any("err", err)) + return []*model.MessageAction{} + } + + // If no plugins found, initialize empty slice + if err == db.ErrNotFound { + channelPlugins = []*model.ChannelPlugin{} + } + + // Get all available plugins + availablePlugins := plugin.GetAvailablePlugins() + + // Filter to only enabled plugins for this channel + enabledPlugins := make(map[string]model.Plugin) + for _, channelPlugin := range channelPlugins { + if channelPlugin.Enabled { + if availablePlugin, exists := availablePlugins[channelPlugin.PluginID]; exists { + enabledPlugins[channelPlugin.PluginID] = availablePlugin + } + } + } + + // If no plugins are enabled, return a message + if len(enabledPlugins) == 0 { + response := &model.Message{ + Text: "No plugins are currently enabled for this channel.", + Chat: msg.Chat, + ReplyTo: msg.ID, + Channel: msg.Channel, + } + + return []*model.MessageAction{ + { + Type: model.ActionSendMessage, + Message: response, + Chat: msg.Chat, + Channel: msg.Channel, + }, + } + } + + // Group plugins by category + categories := map[string][]model.Plugin{ + "Development": {}, + "Fun and Entertainment": {}, + "Utility": {}, + "Security": {}, + "Social Media": {}, + "Other": {}, + } + + // Categorize plugins based on their ID prefix + for _, p := range enabledPlugins { + category := p.GetID() + switch { + case strings.HasPrefix(category, "dev."): + categories["Development"] = append(categories["Development"], p) + case strings.HasPrefix(category, "fun."): + categories["Fun and Entertainment"] = append(categories["Fun and Entertainment"], p) + case strings.HasPrefix(category, "util.") || strings.HasPrefix(category, "reminder.") || strings.HasPrefix(category, "utility."): + categories["Utility"] = append(categories["Utility"], p) + case strings.HasPrefix(category, "security."): + categories["Security"] = append(categories["Security"], p) + case strings.HasPrefix(category, "social."): + categories["Social Media"] = append(categories["Social Media"], p) + default: + categories["Other"] = append(categories["Other"], p) + } + } + + // Build the help message + var helpText strings.Builder + helpText.WriteString("🤖 **Available Commands**\n\n") + + // Sort category names for consistent output + categoryOrder := []string{"Development", "Fun and Entertainment", "Utility", "Security", "Social Media", "Other"} + + for _, categoryName := range categoryOrder { + pluginList := categories[categoryName] + if len(pluginList) == 0 { + continue + } + + // Sort plugins within category by name + sort.Slice(pluginList, func(i, j int) bool { + return pluginList[i].GetName() < pluginList[j].GetName() + }) + + helpText.WriteString(fmt.Sprintf("**%s:**\n", categoryName)) + for _, p := range pluginList { + if p.GetHelp() == "" { + continue + } + helpText.WriteString(fmt.Sprintf("• **%s** - %s\n", p.GetName(), p.GetHelp())) + } + helpText.WriteString("\n") + } + + // Add footer + helpText.WriteString("_Use the specific commands or triggers mentioned above to interact with the bot._") + + response := &model.Message{ + Text: helpText.String(), + Chat: msg.Chat, + ReplyTo: msg.ID, + Channel: msg.Channel, + } + + return []*model.MessageAction{ + { + Type: model.ActionSendMessage, + Message: response, + Chat: msg.Chat, + Channel: msg.Channel, + }, + } +} diff --git a/internal/plugin/help/help_test.go b/internal/plugin/help/help_test.go new file mode 100644 index 0000000..25d5376 --- /dev/null +++ b/internal/plugin/help/help_test.go @@ -0,0 +1,206 @@ +package help + +import ( + "strings" + "testing" + + "git.nakama.town/fmartingr/butterrobot/internal/db" + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// MockPlugin implements the Plugin interface for testing +type MockPlugin struct { + id string + name string + help string +} + +func (m *MockPlugin) GetID() string { return m.id } +func (m *MockPlugin) GetName() string { return m.name } +func (m *MockPlugin) GetHelp() string { return m.help } +func (m *MockPlugin) RequiresConfig() bool { + return false +} +func (m *MockPlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction { + return nil +} + +// MockDatabase implements the ChannelPluginGetter interface for testing +type MockDatabase struct { + channelPlugins map[int64][]*model.ChannelPlugin + platformChannelPlugins map[string][]*model.ChannelPlugin // key: "platform:platformChannelID" +} + +func (m *MockDatabase) GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, error) { + if plugins, exists := m.channelPlugins[channelID]; exists { + return plugins, nil + } + return nil, db.ErrNotFound +} + +func (m *MockDatabase) GetChannelPluginsFromPlatformID(platform, platformChannelID string) ([]*model.ChannelPlugin, error) { + key := platform + ":" + platformChannelID + if plugins, exists := m.platformChannelPlugins[key]; exists { + return plugins, nil + } + return nil, db.ErrNotFound +} + +func TestHelpPlugin_OnMessage(t *testing.T) { + tests := []struct { + name string + messageText string + enabledPlugins map[string]*MockPlugin + expectResponse bool + expectNoPlugins bool + expectCategories []string + }{ + { + name: "responds to !help command", + messageText: "!help", + enabledPlugins: map[string]*MockPlugin{ + "dev.ping": { + id: "dev.ping", + name: "Ping", + help: "Responds to 'ping' with 'pong'", + }, + "fun.dice": { + id: "fun.dice", + name: "Dice Roller", + help: "Rolls dice when you type '!dice [formula]'", + }, + }, + expectResponse: true, + expectCategories: []string{"Development", "Fun and Entertainment"}, + }, + { + name: "ignores non-help messages", + messageText: "hello world", + enabledPlugins: map[string]*MockPlugin{}, + expectResponse: false, + }, + { + name: "ignores case variation", + messageText: "!HELP", + enabledPlugins: map[string]*MockPlugin{}, + expectResponse: true, + expectNoPlugins: true, + }, + { + name: "handles no enabled plugins", + messageText: "!help", + enabledPlugins: map[string]*MockPlugin{}, + expectResponse: true, + expectNoPlugins: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock database + mockDB := &MockDatabase{ + channelPlugins: make(map[int64][]*model.ChannelPlugin), + platformChannelPlugins: make(map[string][]*model.ChannelPlugin), + } + + // Setup channel plugins in mock database + var channelPluginList []*model.ChannelPlugin + pluginCounter := int64(1) + for pluginID := range tt.enabledPlugins { + channelPluginList = append(channelPluginList, &model.ChannelPlugin{ + ID: pluginCounter, + ChannelID: 1, + PluginID: pluginID, + Enabled: true, + Config: make(map[string]interface{}), + }) + pluginCounter++ + } + + // Set up both mapping approaches for the test + mockDB.channelPlugins[1] = channelPluginList + mockDB.platformChannelPlugins["test:test-channel"] = channelPluginList + + // Create help plugin + p := New(mockDB) + + // Create mock channel + channel := &model.Channel{ + ID: 1, + Platform: "test", + PlatformChannelID: "test-channel", + } + + // Create test message + msg := &model.Message{ + ID: "test-msg", + Text: tt.messageText, + Chat: "test-chat", + Channel: channel, + } + + // Mock the plugin registry + originalRegistry := plugin.GetAvailablePlugins() + + // Override the registry for this test + plugin.ClearRegistry() + for _, mockPlugin := range tt.enabledPlugins { + plugin.Register(mockPlugin) + } + + // Call OnMessage + actions := p.OnMessage(msg, map[string]interface{}{}, nil) + + // Restore original registry + plugin.ClearRegistry() + for _, p := range originalRegistry { + plugin.Register(p) + } + + if !tt.expectResponse { + if len(actions) != 0 { + t.Errorf("Expected no response, but got %d actions", len(actions)) + } + return + } + + if len(actions) != 1 { + t.Errorf("Expected 1 action, got %d", len(actions)) + return + } + + action := actions[0] + if action.Type != model.ActionSendMessage { + t.Errorf("Expected ActionSendMessage, got %v", action.Type) + return + } + + responseText := action.Message.Text + + if tt.expectNoPlugins { + if !strings.Contains(responseText, "No plugins are currently enabled") { + t.Errorf("Expected 'no plugins' message, got: %s", responseText) + } + return + } + + // Check that expected categories appear in response + for _, category := range tt.expectCategories { + if !strings.Contains(responseText, "**"+category+":**") { + t.Errorf("Expected category '%s' in response, got: %s", category, responseText) + } + } + + // Check that plugin names and help text appear + for _, mockPlugin := range tt.enabledPlugins { + if !strings.Contains(responseText, mockPlugin.GetName()) { + t.Errorf("Expected plugin name '%s' in response", mockPlugin.GetName()) + } + if !strings.Contains(responseText, mockPlugin.GetHelp()) { + t.Errorf("Expected plugin help '%s' in response", mockPlugin.GetHelp()) + } + } + }) + } +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 3ff96ff..8d1f970 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -47,6 +47,13 @@ func GetAvailablePlugins() map[string]model.Plugin { return result } +// ClearRegistry clears all registered plugins (for testing) +func ClearRegistry() { + pluginsMu.Lock() + defer pluginsMu.Unlock() + plugins = make(map[string]model.Plugin) +} + // BasePlugin provides a common base for plugins type BasePlugin struct { ID string diff --git a/internal/plugin/searchreplace/searchreplace.go b/internal/plugin/searchreplace/searchreplace.go index b474b27..d9cd04c 100644 --- a/internal/plugin/searchreplace/searchreplace.go +++ b/internal/plugin/searchreplace/searchreplace.go @@ -23,7 +23,7 @@ func New() *SearchReplacePlugin { BasePlugin: plugin.BasePlugin{ ID: "util.searchreplace", Name: "Search and Replace", - Help: "Reply to a message with a search and replace pattern (s/search/replace/[flags]) to create a modified message. " + + Help: "Reply to a message with a search and replace pattern (`s/search/replace/[flags]`) to create a modified message. " + "Supported flags: g (global), i (case insensitive)", }, }