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/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()
|
||||
|
|
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
|
||||
parsedURL.Host = strings.Replace(parsedURL.Host, "instagram.com", "d.ddinstagram.com", 1)
|
||||
parsedURL.Host = "d.ddinstagram.com"
|
||||
|
||||
// Remove query parameters
|
||||
parsedURL.RawQuery = ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue