diff --git a/internal/app/app.go b/internal/app/app.go index 4614325..becd5ea 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -24,6 +24,7 @@ import ( "git.nakama.town/fmartingr/butterrobot/internal/plugin/fun" "git.nakama.town/fmartingr/butterrobot/internal/plugin/ping" "git.nakama.town/fmartingr/butterrobot/internal/plugin/reminder" + "git.nakama.town/fmartingr/butterrobot/internal/plugin/searchreplace" "git.nakama.town/fmartingr/butterrobot/internal/plugin/social" "git.nakama.town/fmartingr/butterrobot/internal/queue" ) @@ -90,6 +91,7 @@ func (a *App) Run() error { plugin.Register(social.NewInstagramExpander()) plugin.Register(reminder.New(a.db)) plugin.Register(domainblock.New()) + plugin.Register(searchreplace.New()) // Initialize routes a.initializeRoutes() diff --git a/internal/plugin/searchreplace/README.md b/internal/plugin/searchreplace/README.md new file mode 100644 index 0000000..c7b7786 --- /dev/null +++ b/internal/plugin/searchreplace/README.md @@ -0,0 +1,50 @@ +# Search and Replace Plugin + +This plugin allows users to perform search and replace operations on messages by replying to a message with a search/replace command. + +## Usage + +To use the plugin, reply to any message with a command in the following format: + +``` +s/search/replace/[flags] +``` + +Where: +- `search` is the text you want to find (case-sensitive by default) +- `replace` is the text you want to substitute in place of the search term +- `flags` (optional) control the behavior of the replacement + +### Supported Flags + +- `g` - Global: Replace all occurrences of the search term (without this flag, only the first occurrence is replaced) +- `i` - Case insensitive: Match regardless of case +- `n` - Treat search pattern as a regular expression (advanced users) + +### Examples + +1. Basic replacement (replaces first occurrence): + ``` + s/hello/hi/ + ``` + +2. Global replacement (replaces all occurrences): + ``` + s/hello/hi/g + ``` + +3. Case-insensitive replacement: + ``` + s/Hello/hi/i + ``` + +4. Combined flags (global and case-insensitive): + ``` + s/hello/hi/gi + ``` + +## Limitations + +- The plugin can only access the text content of the original message +- Regular expression support is available with the `n` flag, but should be used carefully as invalid regex patterns will cause errors +- The plugin does not modify the original message; it creates a new message with the replaced text \ No newline at end of file diff --git a/internal/plugin/searchreplace/searchreplace.go b/internal/plugin/searchreplace/searchreplace.go new file mode 100644 index 0000000..876e880 --- /dev/null +++ b/internal/plugin/searchreplace/searchreplace.go @@ -0,0 +1,182 @@ +package searchreplace + +import ( + "fmt" + "regexp" + "strings" + + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// Regex pattern for search and replace operations: s/search/replace/[flags] +var searchReplacePattern = regexp.MustCompile(`^s/([^/]*)/([^/]*)(?:/([gimnsuy]*))?$`) + +// SearchReplacePlugin is a plugin for performing search and replace operations on messages +type SearchReplacePlugin struct { + plugin.BasePlugin +} + +// New creates a new SearchReplacePlugin instance +func New() *SearchReplacePlugin { + return &SearchReplacePlugin{ + BasePlugin: plugin.BasePlugin{ + ID: "util.searchreplace", + Name: "Search and Replace", + Help: "Reply to a message with a search and replace pattern (s/search/replace/[flags]) to create a modified message. " + + "Supported flags: g (global), i (case insensitive)", + }, + } +} + +// OnMessage handles incoming messages +func (p *SearchReplacePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { + // Only process replies to messages + if msg.ReplyTo == "" { + return nil + } + + // Check if the message matches the search/replace pattern + match := searchReplacePattern.FindStringSubmatch(strings.TrimSpace(msg.Text)) + if match == nil { + return nil + } + + // Get the original message text from the reply_to_message structure in Telegram messages + var originalText string + + // For Telegram messages + if msgData, ok := msg.Raw["message"].(map[string]interface{}); ok { + if replyMsg, ok := msgData["reply_to_message"].(map[string]interface{}); ok { + if text, ok := replyMsg["text"].(string); ok { + originalText = text + } + } + } + + // Generic fallback for other platforms or if the above method fails + if originalText == "" && msg.Raw["original_message"] != nil { + if original, ok := msg.Raw["original_message"].(map[string]interface{}); ok { + if text, ok := original["text"].(string); ok { + originalText = text + } + } + } + + if originalText == "" { + // If we couldn't find the original message text, inform the user + return []*model.MessageAction{ + { + Type: model.ActionSendMessage, + Message: &model.Message{ + Text: "Sorry, I couldn't find the original message text to perform the replacement.", + Chat: msg.Chat, + Channel: msg.Channel, + ReplyTo: msg.ID, + }, + Chat: msg.Chat, + Channel: msg.Channel, + }, + } + } + + // Extract search pattern, replacement and flags + searchPattern := match[1] + replacement := match[2] + flags := "" + if len(match) > 3 { + flags = match[3] + } + + // Process the replacement + result, err := p.performReplacement(originalText, searchPattern, replacement, flags) + if err != nil { + return []*model.MessageAction{ + { + Type: model.ActionSendMessage, + Message: &model.Message{ + Text: fmt.Sprintf("Error performing replacement: %s", err.Error()), + Chat: msg.Chat, + Channel: msg.Channel, + ReplyTo: msg.ID, + }, + Chat: msg.Chat, + Channel: msg.Channel, + }, + } + } + + // Only send a response if the text actually changed + if result == originalText { + return []*model.MessageAction{ + { + Type: model.ActionSendMessage, + Message: &model.Message{ + Text: "No changes were made to the original message.", + Chat: msg.Chat, + Channel: msg.Channel, + ReplyTo: msg.ID, + }, + Chat: msg.Chat, + Channel: msg.Channel, + }, + } + } + + // Create a response with the modified text + return []*model.MessageAction{ + { + Type: model.ActionSendMessage, + Message: &model.Message{ + Text: result, + Chat: msg.Chat, + Channel: msg.Channel, + ReplyTo: msg.ReplyTo, // Reply to the original message + }, + Chat: msg.Chat, + Channel: msg.Channel, + }, + } +} + +// performReplacement performs the search and replace operation on the given text +func (p *SearchReplacePlugin) performReplacement(text, search, replace, flags string) (string, error) { + // Process flags + globalReplace := strings.Contains(flags, "g") + caseInsensitive := strings.Contains(flags, "i") + + // Create the regex pattern + pattern := search + regexFlags := "" + if caseInsensitive { + regexFlags += "(?i)" + } + + // Escape special characters if we're not in a regular expression + if !strings.Contains(flags, "n") { + pattern = regexp.QuoteMeta(pattern) + } + + // Compile the regex + reg, err := regexp.Compile(regexFlags + pattern) + if err != nil { + return "", fmt.Errorf("invalid search pattern: %v", err) + } + + // Perform the replacement + var result string + if globalReplace { + result = reg.ReplaceAllString(text, replace) + } else { + // For non-global replace, only replace the first occurrence + indices := reg.FindStringIndex(text) + if indices == nil { + // No match found + return text, nil + } + + result = text[:indices[0]] + replace + text[indices[1]:] + } + + return result, nil +} diff --git a/internal/plugin/searchreplace/searchreplace_test.go b/internal/plugin/searchreplace/searchreplace_test.go new file mode 100644 index 0000000..415610c --- /dev/null +++ b/internal/plugin/searchreplace/searchreplace_test.go @@ -0,0 +1,216 @@ +package searchreplace + +import ( + "testing" + "time" + + "git.nakama.town/fmartingr/butterrobot/internal/model" +) + +func TestSearchReplace(t *testing.T) { + // Create plugin instance + p := New() + + // Test cases + tests := []struct { + name string + command string + originalText string + expectedResult string + expectActions bool + }{ + { + name: "Simple replacement", + command: "s/hello/world/", + originalText: "hello everyone", + expectedResult: "world everyone", + expectActions: true, + }, + { + name: "Case-insensitive replacement", + command: "s/HELLO/world/i", + originalText: "Hello everyone", + expectedResult: "world everyone", + expectActions: true, + }, + { + name: "Global replacement", + command: "s/a/X/g", + originalText: "banana", + expectedResult: "bXnXnX", + expectActions: true, + }, + { + name: "No change", + command: "s/nothing/something/", + originalText: "test message", + expectedResult: "test message", + expectActions: true, // We send a "no changes" message + }, + { + name: "Not a search/replace command", + command: "hello", + originalText: "test message", + expectedResult: "", + expectActions: false, + }, + { + name: "Invalid pattern", + command: "s/(/)/", + originalText: "test message", + expectedResult: "error", + expectActions: true, // We send an error message + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Create message + msg := &model.Message{ + Text: tc.command, + Chat: "test-chat", + ReplyTo: "original-message-id", + Date: time.Now(), + Channel: &model.Channel{ + Platform: "test", + }, + Raw: map[string]interface{}{ + "message": map[string]interface{}{ + "reply_to_message": map[string]interface{}{ + "text": tc.originalText, + }, + }, + }, + } + + // Process message + actions := p.OnMessage(msg, nil) + + // Check results + if tc.expectActions { + if len(actions) == 0 { + t.Fatalf("Expected actions but got none") + } + + action := actions[0] + if action.Type != model.ActionSendMessage { + t.Fatalf("Expected send message action but got %v", action.Type) + } + + if tc.expectedResult == "error" { + // Just checking that we got an error message + if action.Message == nil || action.Message.Text == "" { + t.Fatalf("Expected error message but got empty message") + } + } else if tc.originalText == tc.expectedResult { + // Check if we got the "no changes" message + if action.Message == nil || action.Message.Text != "No changes were made to the original message." { + t.Fatalf("Expected 'no changes' message but got: %s", action.Message.Text) + } + } else { + // Check actual replacement result + if action.Message == nil || action.Message.Text != tc.expectedResult { + t.Fatalf("Expected result: %s, got: %s", tc.expectedResult, action.Message.Text) + } + } + } else if len(actions) > 0 { + t.Fatalf("Expected no actions but got %d", len(actions)) + } + }) + } +} + +func TestPerformReplacement(t *testing.T) { + p := New() + + // Test cases for the performReplacement function + tests := []struct { + name string + text string + search string + replace string + flags string + expected string + expectErr bool + }{ + { + name: "Simple replacement", + text: "Hello World", + search: "Hello", + replace: "Hi", + flags: "", + expected: "Hi World", + expectErr: false, + }, + { + name: "Case insensitive", + text: "Hello World", + search: "hello", + replace: "Hi", + flags: "i", + expected: "Hi World", + expectErr: false, + }, + { + name: "Global replacement", + text: "one two one two", + search: "one", + replace: "1", + flags: "g", + expected: "1 two 1 two", + expectErr: false, + }, + { + name: "No match", + text: "Hello World", + search: "Goodbye", + replace: "Hi", + flags: "", + expected: "Hello World", + expectErr: false, + }, + { + name: "Invalid regex", + text: "Hello World", + search: "(", + replace: "Hi", + flags: "n", // treat as regex + expected: "", + expectErr: true, + }, + { + name: "Escape special chars by default", + text: "Hello (World)", + search: "(World)", + replace: "[Earth]", + flags: "", + expected: "Hello [Earth]", + expectErr: false, + }, + { + name: "Regex mode with n flag", + text: "Hello (World)", + search: "\\(World\\)", + replace: "[Earth]", + flags: "n", + expected: "Hello [Earth]", + expectErr: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := p.performReplacement(tc.text, tc.search, tc.replace, tc.flags) + + if tc.expectErr { + if err == nil { + t.Fatalf("Expected error but got none") + } + } else if err != nil { + t.Fatalf("Unexpected error: %v", err) + } else if result != tc.expected { + t.Fatalf("Expected result: %s, got: %s", tc.expected, result) + } + }) + } +} diff --git a/internal/plugin/social/instagram.go b/internal/plugin/social/instagram.go index c1dbe1f..0b4ff55 100644 --- a/internal/plugin/social/instagram.go +++ b/internal/plugin/social/instagram.go @@ -57,7 +57,7 @@ func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]inte } // Change the host - parsedURL.Host = strings.Replace(parsedURL.Host, "instagram.com", "d.ddinstagram.com", 1) + parsedURL.Host = "d.ddinstagram.com" // Remove query parameters parsedURL.RawQuery = ""