feat: domain blocker plugin
Some checks failed
ci/woodpecker/tag/release Pipeline was successful
ci/woodpecker/push/ci Pipeline failed

This commit is contained in:
Felipe M 2025-04-22 18:09:27 +02:00
parent c9edb57505
commit 7dd02c0056
Signed by: fmartingr
GPG key ID: CCFBC5637D4000A8
25 changed files with 898 additions and 63 deletions

View file

@ -0,0 +1,132 @@
package domainblock
import (
"fmt"
"net/url"
"regexp"
"strings"
"git.nakama.town/fmartingr/butterrobot/internal/model"
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
)
// DomainBlockPlugin is a plugin that blocks messages containing links from specific domains
type DomainBlockPlugin struct {
plugin.BasePlugin
}
// Debug helper to check if RequiresConfig is working
func (p *DomainBlockPlugin) RequiresConfig() bool {
return true
}
// New creates a new DomainBlockPlugin instance
func New() *DomainBlockPlugin {
return &DomainBlockPlugin{
BasePlugin: plugin.BasePlugin{
ID: "security.domainblock",
Name: "Domain Blocker",
Help: "Blocks messages containing links from configured domains",
ConfigRequired: true,
},
}
}
// extractDomains extracts domains from a message text
func extractDomains(text string) []string {
// URL regex pattern
urlPattern := regexp.MustCompile(`https?://([^\s/$.?#].[^\s]*)`)
matches := urlPattern.FindAllStringSubmatch(text, -1)
domains := make([]string, 0, len(matches))
for _, match := range matches {
if len(match) < 2 {
continue
}
// Try to parse the URL to extract the domain
urlStr := match[0]
parsedURL, err := url.Parse(urlStr)
if err != nil {
continue
}
// Extract the domain (host) from the URL
domain := parsedURL.Host
// Remove port if present
if i := strings.IndexByte(domain, ':'); i >= 0 {
domain = domain[:i]
}
domains = append(domains, strings.ToLower(domain))
}
return domains
}
// OnMessage processes incoming messages
func (p *DomainBlockPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
// Skip messages from bots
if msg.FromBot {
return nil
}
// Get blocked domains from config
blockedDomainsStr, ok := config["blocked_domains"].(string)
if !ok || blockedDomainsStr == "" {
return nil // No blocked domains configured
}
// Split and clean blocked domains
blockedDomains := strings.Split(blockedDomainsStr, ",")
for i, domain := range blockedDomains {
blockedDomains[i] = strings.ToLower(strings.TrimSpace(domain))
}
// Extract domains from message
messageDomains := extractDomains(msg.Text)
if len(messageDomains) == 0 {
return nil // No domains in message
}
// Check if any domains in the message are blocked
for _, msgDomain := range messageDomains {
for _, blockedDomain := range blockedDomains {
if blockedDomain == "" {
continue
}
if strings.HasSuffix(msgDomain, blockedDomain) || msgDomain == blockedDomain {
// Domain is blocked, create actions
// 1. Create a delete message action
deleteAction := &model.MessageAction{
Type: model.ActionDeleteMessage,
MessageID: msg.ID,
Chat: msg.Chat,
Channel: msg.Channel,
}
// 2. Create a notification message action
notificationMsg := &model.Message{
Text: fmt.Sprintf("I don't like links from %s 🙈", blockedDomain),
Chat: msg.Chat,
Channel: msg.Channel,
}
sendAction := &model.MessageAction{
Type: model.ActionSendMessage,
Message: notificationMsg,
Chat: msg.Chat,
Channel: msg.Channel,
}
return []*model.MessageAction{deleteAction, sendAction}
}
}
}
return nil
}
// Plugin is registered in app.go, not using init()

View file

@ -0,0 +1,140 @@
package domainblock
import (
"testing"
"git.nakama.town/fmartingr/butterrobot/internal/model"
)
func TestExtractDomains(t *testing.T) {
tests := []struct {
name string
text string
expected []string
}{
{
name: "No URLs",
text: "Hello, world!",
expected: []string{},
},
{
name: "Single URL",
text: "Check out https://example.com for more info",
expected: []string{"example.com"},
},
{
name: "Multiple URLs",
text: "Check out https://example.com and http://test.example.org for more info",
expected: []string{"example.com", "test.example.org"},
},
{
name: "URL with path",
text: "Check out https://example.com/path/to/resource",
expected: []string{"example.com"},
},
{
name: "URL with port",
text: "Check out https://example.com:8080/path/to/resource",
expected: []string{"example.com"},
},
{
name: "URL with subdomain",
text: "Check out https://sub.example.com",
expected: []string{"sub.example.com"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
domains := extractDomains(test.text)
if len(domains) != len(test.expected) {
t.Errorf("Expected %d domains, got %d", len(test.expected), len(domains))
return
}
for i, domain := range domains {
if domain != test.expected[i] {
t.Errorf("Expected domain %s, got %s", test.expected[i], domain)
}
}
})
}
}
func TestOnMessage(t *testing.T) {
plugin := New()
tests := []struct {
name string
text string
blockedDomains string
expectBlocked bool
}{
{
name: "No blocked domains",
text: "Check out https://example.com",
blockedDomains: "",
expectBlocked: false,
},
{
name: "No matching domain",
text: "Check out https://example.com",
blockedDomains: "bad.com, evil.org",
expectBlocked: false,
},
{
name: "Matching domain",
text: "Check out https://example.com",
blockedDomains: "example.com, evil.org",
expectBlocked: true,
},
{
name: "Matching subdomain",
text: "Check out https://sub.example.com",
blockedDomains: "example.com",
expectBlocked: true,
},
{
name: "Multiple domains, one matching",
text: "Check out https://example.com and https://good.org",
blockedDomains: "bad.com, example.com",
expectBlocked: true,
},
{
name: "Spaces in blocked domains list",
text: "Check out https://example.com",
blockedDomains: "bad.com, example.com , evil.org",
expectBlocked: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
msg := &model.Message{
Text: test.text,
Chat: "test-chat",
ID: "test-id",
Channel: &model.Channel{
ID: 1,
},
}
config := map[string]interface{}{
"blocked_domains": test.blockedDomains,
}
responses := plugin.OnMessage(msg, config)
if test.expectBlocked {
if responses == nil || len(responses) == 0 {
t.Errorf("Expected message to be blocked, but it wasn't")
}
} else {
if responses != nil && len(responses) > 0 {
t.Errorf("Expected message not to be blocked, but it was")
}
}
})
}
}

View file

@ -29,7 +29,7 @@ func NewCoin() *CoinPlugin {
}
// OnMessage handles incoming messages
func (p *CoinPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
func (p *CoinPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
if !strings.Contains(strings.ToLower(msg.Text), "flip a coin") {
return nil
}
@ -46,5 +46,12 @@ func (p *CoinPlugin) OnMessage(msg *model.Message, config map[string]interface{}
Channel: msg.Channel,
}
return []*model.Message{response}
action := &model.MessageAction{
Type: model.ActionSendMessage,
Message: response,
Chat: msg.Chat,
Channel: msg.Channel,
}
return []*model.MessageAction{action}
}

View file

@ -32,7 +32,7 @@ func NewDice() *DicePlugin {
}
// OnMessage handles incoming messages
func (p *DicePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
func (p *DicePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
if !strings.HasPrefix(strings.TrimSpace(strings.ToLower(msg.Text)), "!dice") {
return nil
}
@ -62,7 +62,14 @@ func (p *DicePlugin) OnMessage(msg *model.Message, config map[string]interface{}
Channel: msg.Channel,
}
return []*model.Message{response}
action := &model.MessageAction{
Type: model.ActionSendMessage,
Message: response,
Chat: msg.Chat,
Channel: msg.Channel,
}
return []*model.MessageAction{action}
}
// rollDice parses a dice formula string and returns the result

View file

@ -24,7 +24,7 @@ func NewLoquito() *LoquitoPlugin {
}
// OnMessage handles incoming messages
func (p *LoquitoPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
func (p *LoquitoPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
if !strings.Contains(strings.ToLower(msg.Text), "lo quito") {
return nil
}
@ -36,5 +36,12 @@ func (p *LoquitoPlugin) OnMessage(msg *model.Message, config map[string]interfac
Channel: msg.Channel,
}
return []*model.Message{response}
action := &model.MessageAction{
Type: model.ActionSendMessage,
Message: response,
Chat: msg.Chat,
Channel: msg.Channel,
}
return []*model.MessageAction{action}
}

View file

@ -24,17 +24,26 @@ func New() *PingPlugin {
}
// OnMessage handles incoming messages
func (p *PingPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
func (p *PingPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
if !strings.EqualFold(strings.TrimSpace(msg.Text), "ping") {
return nil
}
// Create the response message
response := &model.Message{
Text: "pong",
Chat: msg.Chat,
ReplyTo: msg.ID,
Channel: msg.Channel,
}
// Create an action to send the message
action := &model.MessageAction{
Type: model.ActionSendMessage,
Message: response,
Chat: msg.Chat,
Channel: msg.Channel,
}
return []*model.Message{response}
return []*model.MessageAction{action}
}

View file

@ -1,6 +1,7 @@
package plugin
import (
"maps"
"sync"
"git.nakama.town/fmartingr/butterrobot/internal/model"
@ -41,9 +42,7 @@ func GetAvailablePlugins() map[string]model.Plugin {
// Create a copy to avoid race conditions
result := make(map[string]model.Plugin, len(plugins))
for id, plugin := range plugins {
result[id] = plugin
}
maps.Copy(result, plugins)
return result
}
@ -77,6 +76,6 @@ func (p *BasePlugin) RequiresConfig() bool {
}
// OnMessage is the default implementation that does nothing
func (p *BasePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
func (p *BasePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
return nil
}

View file

@ -41,7 +41,7 @@ func New(creator ReminderCreator) *Reminder {
}
// OnMessage processes incoming messages
func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
// Only process replies to messages
if msg.ReplyTo == "" {
return nil
@ -56,15 +56,22 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{})
// Parse the duration
amount, err := strconv.Atoi(match[1])
if err != nil {
return []*model.Message{
errorMsg := &model.Message{
Text: "Invalid duration format. Please use a number followed by y (years), mo (months), d (days), h (hours), m (minutes), or s (seconds).",
Chat: msg.Chat,
Channel: msg.Channel,
Author: "bot",
FromBot: true,
Date: time.Now(),
ReplyTo: msg.ID,
}
return []*model.MessageAction{
{
Text: "Invalid duration format. Please use a number followed by y (years), mo (months), d (days), h (hours), m (minutes), or s (seconds).",
Type: model.ActionSendMessage,
Message: errorMsg,
Chat: msg.Chat,
Channel: msg.Channel,
Author: "bot",
FromBot: true,
Date: time.Now(),
ReplyTo: msg.ID,
},
}
}
@ -86,15 +93,22 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{})
case "s":
duration = time.Duration(amount) * time.Second
default:
return []*model.Message{
errorMsg := &model.Message{
Text: "Invalid duration unit. Please use y (years), mo (months), d (days), h (hours), m (minutes), or s (seconds).",
Chat: msg.Chat,
Channel: msg.Channel,
Author: "bot",
FromBot: true,
Date: time.Now(),
ReplyTo: msg.ID,
}
return []*model.MessageAction{
{
Text: "Invalid duration unit. Please use y (years), mo (months), d (days), h (hours), m (minutes), or s (seconds).",
Type: model.ActionSendMessage,
Message: errorMsg,
Chat: msg.Chat,
Channel: msg.Channel,
Author: "bot",
FromBot: true,
Date: time.Now(),
ReplyTo: msg.ID,
},
}
}
@ -127,15 +141,22 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{})
)
if err != nil {
return []*model.Message{
errorMsg := &model.Message{
Text: fmt.Sprintf("Failed to create reminder: %v", err),
Chat: msg.Chat,
Channel: msg.Channel,
Author: "bot",
FromBot: true,
Date: time.Now(),
ReplyTo: msg.ID,
}
return []*model.MessageAction{
{
Text: fmt.Sprintf("Failed to create reminder: %v", err),
Type: model.ActionSendMessage,
Message: errorMsg,
Chat: msg.Chat,
Channel: msg.Channel,
Author: "bot",
FromBot: true,
Date: time.Now(),
ReplyTo: msg.ID,
},
}
}
@ -157,15 +178,23 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{})
confirmText = fmt.Sprintf("I'll remind you about this message in %d second(s)", amount)
}
return []*model.Message{
// Create confirmation message
confirmMsg := &model.Message{
Text: confirmText,
Chat: msg.Chat,
Channel: msg.Channel,
Author: "bot",
FromBot: true,
Date: time.Now(),
ReplyTo: msg.ID,
}
return []*model.MessageAction{
{
Text: confirmText,
Type: model.ActionSendMessage,
Message: confirmMsg,
Chat: msg.Chat,
Channel: msg.Channel,
Author: "bot",
FromBot: true,
Date: time.Now(),
ReplyTo: msg.ID,
},
}
}
}

View file

@ -142,14 +142,25 @@ func TestReminderOnMessage(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
initialCount := len(creator.reminders)
responses := plugin.OnMessage(tt.message, nil)
actions := plugin.OnMessage(tt.message, nil)
if tt.expectResponse && len(responses) == 0 {
t.Errorf("Expected response, but got none")
if tt.expectResponse && len(actions) == 0 {
t.Errorf("Expected response action, but got none")
}
if !tt.expectResponse && len(responses) > 0 {
t.Errorf("Expected no response, but got %d", len(responses))
if !tt.expectResponse && len(actions) > 0 {
t.Errorf("Expected no actions, but got %d", len(actions))
}
// Verify action type is correct when actions are returned
if len(actions) > 0 {
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")
}
}
if tt.expectReminder && len(creator.reminders) != initialCount+1 {

View file

@ -26,7 +26,7 @@ func NewInstagramExpander() *InstagramExpander {
}
// OnMessage handles incoming messages
func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
// Skip empty messages
if strings.TrimSpace(msg.Text) == "" {
return nil
@ -70,5 +70,12 @@ func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]inte
Channel: msg.Channel,
}
return []*model.Message{response}
action := &model.MessageAction{
Type: model.ActionSendMessage,
Message: response,
Chat: msg.Chat,
Channel: msg.Channel,
}
return []*model.MessageAction{action}
}

View file

@ -26,7 +26,7 @@ func NewTwitterExpander() *TwitterExpander {
}
// OnMessage handles incoming messages
func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
// Skip empty messages
if strings.TrimSpace(msg.Text) == "" {
return nil
@ -75,5 +75,12 @@ func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interf
Channel: msg.Channel,
}
return []*model.Message{response}
action := &model.MessageAction{
Type: model.ActionSendMessage,
Message: response,
Chat: msg.Chat,
Channel: msg.Channel,
}
return []*model.MessageAction{action}
}