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

287 lines
7.3 KiB
Markdown

# 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_:
```go
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:
```go
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:
```go
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:
```go
BasePlugin: plugin.BasePlugin{
ID: "myplugin.id",
Name: "Plugin Name",
Help: "Help text",
ConfigRequired: true,
},
```
2. Access the configuration in the OnMessage method:
```go
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:
```go
// 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:
```go
func init() {
plugin.Register(New())
}
```