feat: domain blocker plugin
This commit is contained in:
parent
c9edb57505
commit
7dd02c0056
25 changed files with 898 additions and 63 deletions
132
internal/plugin/domainblock/domainblock.go
Normal file
132
internal/plugin/domainblock/domainblock.go
Normal 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()
|
140
internal/plugin/domainblock/domainblock_test.go
Normal file
140
internal/plugin/domainblock/domainblock_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue