butterrobot/docs/creating-a-plugin.md
Felipe M. 7dd02c0056
Some checks failed
ci/woodpecker/tag/release Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
feat: domain blocker plugin
2025-04-22 18:09:27 +02:00

7.3 KiB

Creating a Plugin

Plugin Categories

ButterRobot organizes plugins into different categories:

  • Development: Utility plugins like ping
  • Fun: Entertainment plugins like dice rolling, coin flipping
  • Social: Social media related plugins like URL transformers/expanders
  • Security: Moderation and protection features like domain blocking

When creating a new plugin, consider which category it fits into and place it in the appropriate directory.

Plugin Examples

Basic Example: Marco Polo

This simple "Marco Polo" plugin will answer Polo to the user that says Marco:

package myplugin

import (
	"strings"

	"git.nakama.town/fmartingr/butterrobot/internal/model"
	"git.nakama.town/fmartingr/butterrobot/internal/plugin"
)

// MarcoPlugin is a simple Marco/Polo plugin
type MarcoPlugin struct {
	plugin.BasePlugin
}

// New creates a new MarcoPlugin instance
func New() *MarcoPlugin {
	return &MarcoPlugin{
		BasePlugin: plugin.BasePlugin{
			ID:   "test.marco",
			Name: "Marco/Polo",
			Help: "Responds to 'Marco' with 'Polo'",
		},
	}
}

// OnMessage handles incoming messages
func (p *MarcoPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
	if !strings.EqualFold(strings.TrimSpace(msg.Text), "Marco") {
		return nil
	}

	response := &model.Message{
		Text:     "Polo",
		Chat:     msg.Chat,
		ReplyTo:  msg.ID,
		Channel:  msg.Channel,
	}

	return []*model.Message{response}
}

Configuration-Enabled Plugin

This plugin requires configuration to be set in the admin interface. It demonstrates how to create plugins that need channel-specific configuration:

package security

import (
	"fmt"
	"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
}

// 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, // Mark this plugin as requiring configuration
		},
	}
}

// OnMessage processes incoming messages
func (p *DomainBlockPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
	// 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
	urlRegex := regexp.MustCompile(`https?://([^\s/$.?#].[^\s]*)`)
	matches := urlRegex.FindAllStringSubmatch(msg.Text, -1)
	
	// Check if any extracted domains are blocked
	for _, match := range matches {
		if len(match) < 2 {
			continue
		}
		
		domain := strings.ToLower(match[1])
		
		for _, blockedDomain := range blockedDomains {
			if blockedDomain == "" {
				continue
			}
			
			if strings.HasSuffix(domain, blockedDomain) || domain == blockedDomain {
				// Domain is blocked, create warning message
				response := &model.Message{
					Text:    fmt.Sprintf("⚠️ Message contained a link to blocked domain: %s", blockedDomain),
					Chat:    msg.Chat,
					ReplyTo: msg.ID,
					Channel: msg.Channel,
				}
				return []*model.Message{response}
			}
		}
	}

	return nil
}

func init() {
	plugin.Register(New())
}

Advanced Example: URL Transformer

This more complex plugin transforms URLs, useful for improving media embedding in chat platforms:

package social

import (
	"net/url"
	"regexp"
	"strings"

	"git.nakama.town/fmartingr/butterrobot/internal/model"
	"git.nakama.town/fmartingr/butterrobot/internal/plugin"
)

// TwitterExpander transforms twitter.com links to fxtwitter.com links
type TwitterExpander struct {
	plugin.BasePlugin
}

// New creates a new TwitterExpander instance
func NewTwitter() *TwitterExpander {
	return &TwitterExpander{
		BasePlugin: plugin.BasePlugin{
			ID:   "social.twitter",
			Name: "Twitter Link Expander",
			Help: "Automatically converts twitter.com links to fxtwitter.com links and removes tracking parameters",
		},
	}
}

// OnMessage handles incoming messages
func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
	// Skip empty messages
	if strings.TrimSpace(msg.Text) == "" {
		return nil
	}

	// Regex to match twitter.com links
	twitterRegex := regexp.MustCompile(`https?://(www\.)?(twitter\.com|x\.com)/[^\s]+`)

	// Check if the message contains a Twitter link
	if !twitterRegex.MatchString(msg.Text) {
		return nil
	}

	// Transform the URL
	transformed := twitterRegex.ReplaceAllStringFunc(msg.Text, func(link string) string {
		// Parse the URL
		parsedURL, err := url.Parse(link)
		if err != nil {
			// If parsing fails, just do the simple replacement
			link = strings.Replace(link, "twitter.com", "fxtwitter.com", 1)
			link = strings.Replace(link, "x.com", "fxtwitter.com", 1)
			return link
		}
		
		// Change the host
		if strings.Contains(parsedURL.Host, "twitter.com") {
			parsedURL.Host = strings.Replace(parsedURL.Host, "twitter.com", "fxtwitter.com", 1)
		} else if strings.Contains(parsedURL.Host, "x.com") {
			parsedURL.Host = strings.Replace(parsedURL.Host, "x.com", "fxtwitter.com", 1)
		}
		
		// Remove query parameters
		parsedURL.RawQuery = ""
		
		// Return the cleaned URL
		return parsedURL.String()
	})

	// Create response message
	response := &model.Message{
		Text:    transformed,
		Chat:    msg.Chat,
		ReplyTo: msg.ID,
		Channel: msg.Channel,
	}

	return []*model.Message{response}
}

Enabling Configuration for Plugins

To indicate that your plugin requires configuration:

  1. Set ConfigRequired: true in the BasePlugin struct:

    BasePlugin: plugin.BasePlugin{
        ID:             "myplugin.id",
        Name:           "Plugin Name",
        Help:           "Help text",
        ConfigRequired: true,
    },
    
  2. Access the configuration in the OnMessage method:

    func (p *MyPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
        // Extract configuration values
        configValue, ok := config["some_config_key"].(string)
        if !ok || configValue == "" {
            // Handle missing or empty configuration
            return nil
        }
    
        // Use the configuration...
    }
    
  3. The admin interface will show a "Configure" button for plugins that require configuration.

Registering Plugins

To use the plugin, register it in your application:

// In app.go or similar initialization file
func (a *App) Run() error {
    // ...

    // Register plugins
    plugin.Register(ping.New())                 // Development plugin
    plugin.Register(fun.NewCoin())              // Fun plugin  
    plugin.Register(social.NewTwitter())        // Social media plugin
    plugin.Register(myplugin.New())             // Your custom plugin

    // ...
}

Alternatively, you can register your plugin in its init() function:

func init() {
    plugin.Register(New())
}