Compare commits
2 commits
Author | SHA1 | Date | |
---|---|---|---|
c53942ac53 | |||
a9b4ad52cb |
5 changed files with 451 additions and 1 deletions
|
@ -24,6 +24,7 @@ import (
|
||||||
"git.nakama.town/fmartingr/butterrobot/internal/plugin/fun"
|
"git.nakama.town/fmartingr/butterrobot/internal/plugin/fun"
|
||||||
"git.nakama.town/fmartingr/butterrobot/internal/plugin/ping"
|
"git.nakama.town/fmartingr/butterrobot/internal/plugin/ping"
|
||||||
"git.nakama.town/fmartingr/butterrobot/internal/plugin/reminder"
|
"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/plugin/social"
|
||||||
"git.nakama.town/fmartingr/butterrobot/internal/queue"
|
"git.nakama.town/fmartingr/butterrobot/internal/queue"
|
||||||
)
|
)
|
||||||
|
@ -90,6 +91,7 @@ func (a *App) Run() error {
|
||||||
plugin.Register(social.NewInstagramExpander())
|
plugin.Register(social.NewInstagramExpander())
|
||||||
plugin.Register(reminder.New(a.db))
|
plugin.Register(reminder.New(a.db))
|
||||||
plugin.Register(domainblock.New())
|
plugin.Register(domainblock.New())
|
||||||
|
plugin.Register(searchreplace.New())
|
||||||
|
|
||||||
// Initialize routes
|
// Initialize routes
|
||||||
a.initializeRoutes()
|
a.initializeRoutes()
|
||||||
|
|
50
internal/plugin/searchreplace/README.md
Normal file
50
internal/plugin/searchreplace/README.md
Normal file
|
@ -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
|
182
internal/plugin/searchreplace/searchreplace.go
Normal file
182
internal/plugin/searchreplace/searchreplace.go
Normal file
|
@ -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
|
||||||
|
}
|
216
internal/plugin/searchreplace/searchreplace_test.go
Normal file
216
internal/plugin/searchreplace/searchreplace_test.go
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -57,7 +57,7 @@ func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]inte
|
||||||
}
|
}
|
||||||
|
|
||||||
// Change the host
|
// Change the host
|
||||||
parsedURL.Host = strings.Replace(parsedURL.Host, "instagram.com", "d.ddinstagram.com", 1)
|
parsedURL.Host = "d.ddinstagram.com"
|
||||||
|
|
||||||
// Remove query parameters
|
// Remove query parameters
|
||||||
parsedURL.RawQuery = ""
|
parsedURL.RawQuery = ""
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue