Compare commits

...

5 commits

Author SHA1 Message Date
c53942ac53
fix: instagram leaving www.
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
2025-05-09 20:24:18 +02:00
a9b4ad52cb
plugin: search and replace
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
2025-05-09 14:01:00 +02:00
4a154f16f9
fix: instagram expander replying to ddintagram links
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
2025-05-09 09:33:11 +02:00
8d188217e9
chore: lint fixes
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-04-27 17:11:40 +02:00
fae6f35774
chore: make format
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-04-22 18:10:24 +02:00
13 changed files with 477 additions and 23 deletions

View file

@ -45,4 +45,4 @@ func main() {
logger.Error("Application error", "error", err)
os.Exit(1)
}
}
}

View file

@ -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

View file

@ -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()
@ -325,7 +327,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 +335,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 +411,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)
}
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -127,14 +127,14 @@ 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")
}
}
})
}
}
}

View file

@ -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,

View file

@ -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,
},
}
}
}

View file

@ -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")
}

View 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

View 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
}

View 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)
}
})
}
}

View file

@ -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 = "d.ddinstagram.com"
// Remove query parameters
parsedURL.RawQuery = ""