From fae6f35774e1dfa5c0b788a2636bdeaf7ae4102f Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Tue, 22 Apr 2025 18:10:24 +0200 Subject: [PATCH 1/5] chore: make format --- cmd/butterrobot/main.go | 2 +- internal/admin/admin.go | 2 +- internal/app/app.go | 6 +++--- internal/model/message.go | 8 ++++---- internal/platform/slack/slack.go | 4 ++-- internal/plugin/domainblock/domainblock_test.go | 2 +- internal/plugin/ping/ping.go | 2 +- internal/plugin/reminder/reminder.go | 10 +++++----- internal/plugin/reminder/reminder_test.go | 2 +- 9 files changed, 19 insertions(+), 19 deletions(-) diff --git a/cmd/butterrobot/main.go b/cmd/butterrobot/main.go index bf217de..3bc56cb 100644 --- a/cmd/butterrobot/main.go +++ b/cmd/butterrobot/main.go @@ -45,4 +45,4 @@ func main() { logger.Error("Application error", "error", err) os.Exit(1) } -} \ No newline at end of file +} diff --git a/internal/admin/admin.go b/internal/admin/admin.go index dee4197..2b41820 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -673,7 +673,7 @@ func (a *Admin) handleChannelPluginConfig(w http.ResponseWriter, r *http.Request // Create config map from form values config := make(map[string]interface{}) - + // Process form values based on plugin type if channelPlugin.PluginID == "security.domainblock" { // Get blocked domains from form diff --git a/internal/app/app.go b/internal/app/app.go index bd64b80..4614325 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -325,7 +325,7 @@ func (a *App) handleMessage(item queue.Item) { } else { a.logger.Error("Send message action with nil message") } - + case model.ActionDeleteMessage: // Delete a message using direct DeleteMessage call if err := platform.DeleteMessage(action.Chat, action.MessageID); err != nil { @@ -333,7 +333,7 @@ func (a *App) handleMessage(item queue.Item) { } else { a.logger.Info("Message deleted", "message_id", action.MessageID) } - + default: a.logger.Error("Unknown action type", "type", action.Type) } @@ -409,4 +409,4 @@ func (a *App) processReminder(reminder *model.Reminder) { if err := a.db.MarkReminderAsProcessed(reminder.ID); err != nil { a.logger.Error("Error marking reminder as processed", "error", err) } -} \ No newline at end of file +} diff --git a/internal/model/message.go b/internal/model/message.go index ea3b316..26ec5da 100644 --- a/internal/model/message.go +++ b/internal/model/message.go @@ -17,10 +17,10 @@ const ( // MessageAction represents an action to be performed on the platform type MessageAction struct { Type ActionType - Message *Message // For send_message - MessageID string // For delete_message - Chat string // Chat where the action happens - Channel *Channel // Channel reference + Message *Message // For send_message + MessageID string // For delete_message + Chat string // Chat where the action happens + Channel *Channel // Channel reference Raw map[string]interface{} // Additional data for the action } diff --git a/internal/platform/slack/slack.go b/internal/platform/slack/slack.go index 28e8363..2ca7bef 100644 --- a/internal/platform/slack/slack.go +++ b/internal/platform/slack/slack.go @@ -268,10 +268,10 @@ func (s *SlackPlatform) deleteMessage(msg *model.Message) error { if !ok { return fmt.Errorf("no message ID provided for deletion") } - + // Convert to string if needed messageIDStr := fmt.Sprintf("%v", messageID) - + return s.DeleteMessage(msg.Chat, messageIDStr) } diff --git a/internal/plugin/domainblock/domainblock_test.go b/internal/plugin/domainblock/domainblock_test.go index 3f75a18..69cd8b8 100644 --- a/internal/plugin/domainblock/domainblock_test.go +++ b/internal/plugin/domainblock/domainblock_test.go @@ -137,4 +137,4 @@ func TestOnMessage(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/internal/plugin/ping/ping.go b/internal/plugin/ping/ping.go index e77cb78..3dacf6f 100644 --- a/internal/plugin/ping/ping.go +++ b/internal/plugin/ping/ping.go @@ -36,7 +36,7 @@ func (p *PingPlugin) OnMessage(msg *model.Message, config map[string]interface{} ReplyTo: msg.ID, Channel: msg.Channel, } - + // Create an action to send the message action := &model.MessageAction{ Type: model.ActionSendMessage, diff --git a/internal/plugin/reminder/reminder.go b/internal/plugin/reminder/reminder.go index c162e6e..029c8d9 100644 --- a/internal/plugin/reminder/reminder.go +++ b/internal/plugin/reminder/reminder.go @@ -65,7 +65,7 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) Date: time.Now(), ReplyTo: msg.ID, } - + return []*model.MessageAction{ { Type: model.ActionSendMessage, @@ -102,7 +102,7 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) Date: time.Now(), ReplyTo: msg.ID, } - + return []*model.MessageAction{ { Type: model.ActionSendMessage, @@ -150,7 +150,7 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) Date: time.Now(), ReplyTo: msg.ID, } - + return []*model.MessageAction{ { Type: model.ActionSendMessage, @@ -188,7 +188,7 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) Date: time.Now(), ReplyTo: msg.ID, } - + return []*model.MessageAction{ { Type: model.ActionSendMessage, @@ -197,4 +197,4 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) Channel: msg.Channel, }, } -} \ No newline at end of file +} diff --git a/internal/plugin/reminder/reminder_test.go b/internal/plugin/reminder/reminder_test.go index b54e281..8e611ce 100644 --- a/internal/plugin/reminder/reminder_test.go +++ b/internal/plugin/reminder/reminder_test.go @@ -157,7 +157,7 @@ func TestReminderOnMessage(t *testing.T) { if actions[0].Type != model.ActionSendMessage { t.Errorf("Expected action type to be %s, but got %s", model.ActionSendMessage, actions[0].Type) } - + if actions[0].Message == nil { t.Errorf("Expected message in action to not be nil") } From 8d188217e9f17a72eff4fc581a880f64b8439ad1 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Sun, 27 Apr 2025 17:11:40 +0200 Subject: [PATCH 2/5] chore: lint fixes --- internal/plugin/domainblock/domainblock_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/plugin/domainblock/domainblock_test.go b/internal/plugin/domainblock/domainblock_test.go index 69cd8b8..1d65964 100644 --- a/internal/plugin/domainblock/domainblock_test.go +++ b/internal/plugin/domainblock/domainblock_test.go @@ -127,11 +127,11 @@ func TestOnMessage(t *testing.T) { responses := plugin.OnMessage(msg, config) if test.expectBlocked { - if responses == nil || len(responses) == 0 { + if len(responses) == 0 { t.Errorf("Expected message to be blocked, but it wasn't") } } else { - if responses != nil && len(responses) > 0 { + if len(responses) > 0 { t.Errorf("Expected message not to be blocked, but it was") } } From 4a154f16f9bdad7b5f5ebd1d90dc2669de8720b2 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Fri, 9 May 2025 09:33:11 +0200 Subject: [PATCH 3/5] fix: instagram expander replying to ddintagram links --- internal/plugin/social/instagram.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/plugin/social/instagram.go b/internal/plugin/social/instagram.go index d05bd30..c1dbe1f 100644 --- a/internal/plugin/social/instagram.go +++ b/internal/plugin/social/instagram.go @@ -48,12 +48,16 @@ func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]inte parsedURL, err := url.Parse(link) if err != nil { // If parsing fails, just do the simple replacement - link = strings.Replace(link, "instagram.com", "ddinstagram.com", 1) + return link + } + + // 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 - parsedURL.Host = strings.Replace(parsedURL.Host, "instagram.com", "ddinstagram.com", 1) + parsedURL.Host = strings.Replace(parsedURL.Host, "instagram.com", "d.ddinstagram.com", 1) // Remove query parameters parsedURL.RawQuery = "" From a9b4ad52cbebb6b604d9b353abfbd06e48a4b161 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Fri, 9 May 2025 14:00:08 +0200 Subject: [PATCH 4/5] plugin: search and replace --- internal/app/app.go | 2 + internal/plugin/searchreplace/README.md | 50 ++++ .../plugin/searchreplace/searchreplace.go | 182 +++++++++++++++ .../searchreplace/searchreplace_test.go | 216 ++++++++++++++++++ 4 files changed, 450 insertions(+) create mode 100644 internal/plugin/searchreplace/README.md create mode 100644 internal/plugin/searchreplace/searchreplace.go create mode 100644 internal/plugin/searchreplace/searchreplace_test.go 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) + } + }) + } +} From c53942ac5342da5393818f2bb05f8d7f7e7bd8e9 Mon Sep 17 00:00:00 2001 From: "Felipe M." Date: Fri, 9 May 2025 20:24:18 +0200 Subject: [PATCH 5/5] fix: instagram leaving www. --- internal/plugin/social/instagram.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 = ""