feat: domain blocker plugin
This commit is contained in:
parent
c9edb57505
commit
7dd02c0056
25 changed files with 898 additions and 63 deletions
|
@ -7,6 +7,7 @@ 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.
|
||||
|
||||
|
@ -59,6 +60,91 @@ func (p *MarcoPlugin) OnMessage(msg *model.Message, config map[string]interface{
|
|||
}
|
||||
```
|
||||
|
||||
### 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:
|
||||
|
@ -143,6 +229,36 @@ func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interf
|
|||
}
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
@ -161,3 +277,11 @@ func (a *App) Run() error {
|
|||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, you can register your plugin in its init() function:
|
||||
|
||||
```go
|
||||
func init() {
|
||||
plugin.Register(New())
|
||||
}
|
||||
```
|
||||
|
|
|
@ -14,6 +14,10 @@
|
|||
|
||||
- Remind Me: Reply to a message with `!remindme <duration>` to set a reminder. Supported duration units: y (years), mo (months), d (days), h (hours), m (minutes), s (seconds). Examples: `!remindme 1y` for 1 year, `!remindme 3mo` for 3 months, `!remindme 2d` for 2 days, `!remindme 3h` for 3 hours. The bot will mention you with a reminder after the specified time.
|
||||
|
||||
### Security
|
||||
|
||||
- Domain Blocker: Blocks messages containing links from specified domains. Configure it per channel with a comma-separated list of domains to block. When a message contains a link matching any of the blocked domains, the bot will notify that the message contained a blocked domain. This plugin requires configuration through the admin interface.
|
||||
|
||||
### Social Media
|
||||
|
||||
- Twitter Link Expander: Automatically converts twitter.com and x.com links to fxtwitter.com links and removes tracking parameters. This allows for better media embedding in chat platforms.
|
||||
|
|
|
@ -98,6 +98,7 @@ func New(cfg *config.Config, database *db.Database, version string) *Admin {
|
|||
"channel_detail.html",
|
||||
"plugin_list.html",
|
||||
"channel_plugins_list.html",
|
||||
"channel_plugin_config.html",
|
||||
}
|
||||
|
||||
for _, tf := range templateFiles {
|
||||
|
@ -143,6 +144,7 @@ func (a *Admin) RegisterRoutes(mux *http.ServeMux) {
|
|||
mux.HandleFunc("/admin/channels", a.handleChannelList)
|
||||
mux.HandleFunc("/admin/channels/", a.handleChannelDetail)
|
||||
mux.HandleFunc("/admin/channelplugins", a.handleChannelPluginList)
|
||||
mux.HandleFunc("/admin/channelplugins/config/", a.handleChannelPluginConfig)
|
||||
mux.HandleFunc("/admin/channelplugins/", a.handleChannelPluginDetailOrDelete)
|
||||
}
|
||||
|
||||
|
@ -628,6 +630,96 @@ func (a *Admin) handleChannelPluginList(w http.ResponseWriter, r *http.Request)
|
|||
})
|
||||
}
|
||||
|
||||
// handleChannelPluginConfig handles the channel plugin configuration route
|
||||
func (a *Admin) handleChannelPluginConfig(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if user is logged in
|
||||
if !a.isLoggedIn(r) {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract channel plugin ID from path
|
||||
path := r.URL.Path
|
||||
channelPluginID := strings.TrimPrefix(path, "/admin/channelplugins/config/")
|
||||
|
||||
// Convert channel plugin ID to int64
|
||||
id, err := strconv.ParseInt(channelPluginID, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid channel plugin ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the channel plugin
|
||||
channelPlugin, err := a.db.GetChannelPluginByID(id)
|
||||
if err != nil {
|
||||
http.Error(w, "Channel plugin not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the plugin
|
||||
p, err := plugin.Get(channelPlugin.PluginID)
|
||||
if err != nil {
|
||||
http.Error(w, "Plugin not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
if r.Method == http.MethodPost {
|
||||
// Parse form
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
blockedDomains := r.FormValue("blocked_domains")
|
||||
config["blocked_domains"] = blockedDomains
|
||||
} else {
|
||||
// Generic handling for other plugins
|
||||
for key, values := range r.Form {
|
||||
if key == "form_submitted" {
|
||||
continue
|
||||
}
|
||||
if len(values) == 1 {
|
||||
config[key] = values[0]
|
||||
} else {
|
||||
config[key] = values
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update plugin configuration
|
||||
if err := a.db.UpdateChannelPluginConfig(id, config); err != nil {
|
||||
http.Error(w, "Failed to update plugin configuration", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the channel to redirect back to the channel detail page
|
||||
channel, err := a.db.GetChannelByID(channelPlugin.ChannelID)
|
||||
if err != nil {
|
||||
a.addFlash(w, r, "Plugin configuration updated", "success")
|
||||
http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
a.addFlash(w, r, "Plugin configuration updated", "success")
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/channels/%d", channel.ID), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Render template
|
||||
a.render(w, r, "channel_plugin_config.html", TemplateData{
|
||||
Title: "Configure Plugin: " + p.GetName(),
|
||||
ChannelPlugin: channelPlugin,
|
||||
Plugins: map[string]model.Plugin{channelPlugin.PluginID: p},
|
||||
})
|
||||
}
|
||||
|
||||
// handleChannelPluginDetailOrDelete handles the channel plugin detail or delete route
|
||||
func (a *Admin) handleChannelPluginDetailOrDelete(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if user is logged in
|
||||
|
|
|
@ -68,6 +68,10 @@
|
|||
{{if $channelPlugin.Enabled}}Disable{{else}}Enable{{end}}
|
||||
</button>
|
||||
</form>
|
||||
{{$plugin := index $.Plugins $pluginID}}
|
||||
{{if $plugin.RequiresConfig}}
|
||||
<a href="/admin/channelplugins/config/{{$channelPlugin.ID}}" class="btn btn-info btn-sm">Configure</a>
|
||||
{{end}}
|
||||
<form method="post" action="/admin/channelplugins/{{$channelPlugin.ID}}/delete" class="d-inline">
|
||||
<button type="submit" class="btn btn-danger btn-sm"
|
||||
onclick="return confirm('Are you sure you want to remove this plugin?')">Remove</button>
|
||||
|
|
37
internal/admin/templates/channel_plugin_config.html
Normal file
37
internal/admin/templates/channel_plugin_config.html
Normal file
|
@ -0,0 +1,37 @@
|
|||
{{define "content"}}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Configure Plugin: {{(index .Plugins .ChannelPlugin.PluginID).GetName}}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<!-- Plugin configuration fields -->
|
||||
{{if eq .ChannelPlugin.PluginID "security.domainblock"}}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Blocked Domains</label>
|
||||
<input type="text" class="form-control" name="blocked_domains"
|
||||
value="{{with .ChannelPlugin.Config}}{{index . "blocked_domains"}}{{end}}"
|
||||
placeholder="example.com, evil.org, ads.com">
|
||||
<div class="form-text text-muted">
|
||||
Enter comma-separated list of domains to block (e.g., example.com, evil.org).
|
||||
Messages containing links to these domains will be blocked.
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="alert alert-warning">
|
||||
This plugin doesn't have specific configuration fields implemented yet.
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary">Save Configuration</button>
|
||||
<a href="/admin/channels/{{.ChannelPlugin.ChannelID}}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
|
@ -38,6 +38,10 @@
|
|||
{{if $channelPlugin.Enabled}}Disable{{else}}Enable{{end}}
|
||||
</button>
|
||||
</form>
|
||||
{{$plugin := index $.Plugins $pluginID}}
|
||||
{{if $plugin.ConfigRequired}}
|
||||
<a href="/admin/channelplugins/config/{{$channelPlugin.ID}}" class="btn btn-info btn-sm">Configure</a>
|
||||
{{end}}
|
||||
<form method="post" action="/admin/channelplugins/{{$channelPlugin.ID}}/delete" class="d-inline">
|
||||
<button type="submit" class="btn btn-danger btn-sm"
|
||||
onclick="return confirm('Are you sure you want to remove this plugin?')">Remove</button>
|
||||
|
|
|
@ -20,6 +20,7 @@ import (
|
|||
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
||||
"git.nakama.town/fmartingr/butterrobot/internal/platform"
|
||||
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
|
||||
"git.nakama.town/fmartingr/butterrobot/internal/plugin/domainblock"
|
||||
"git.nakama.town/fmartingr/butterrobot/internal/plugin/fun"
|
||||
"git.nakama.town/fmartingr/butterrobot/internal/plugin/ping"
|
||||
"git.nakama.town/fmartingr/butterrobot/internal/plugin/reminder"
|
||||
|
@ -87,10 +88,8 @@ func (a *App) Run() error {
|
|||
plugin.Register(fun.NewLoquito())
|
||||
plugin.Register(social.NewTwitterExpander())
|
||||
plugin.Register(social.NewInstagramExpander())
|
||||
|
||||
// Register reminder plugin
|
||||
reminderPlugin := reminder.New(a.db)
|
||||
plugin.Register(reminderPlugin)
|
||||
plugin.Register(reminder.New(a.db))
|
||||
plugin.Register(domainblock.New())
|
||||
|
||||
// Initialize routes
|
||||
a.initializeRoutes()
|
||||
|
@ -304,20 +303,40 @@ func (a *App) handleMessage(item queue.Item) {
|
|||
continue
|
||||
}
|
||||
|
||||
// Process message
|
||||
responses := p.OnMessage(message, channelPlugin.Config)
|
||||
// Process message and get actions
|
||||
actions := p.OnMessage(message, channelPlugin.Config)
|
||||
|
||||
// Send responses
|
||||
// Get platform for processing actions
|
||||
platform, err := platform.Get(item.Platform)
|
||||
if err != nil {
|
||||
a.logger.Error("Error getting platform", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, response := range responses {
|
||||
if err := platform.SendMessage(response); err != nil {
|
||||
// Process each action
|
||||
for _, action := range actions {
|
||||
switch action.Type {
|
||||
case model.ActionSendMessage:
|
||||
// Send a message
|
||||
if action.Message != nil {
|
||||
if err := platform.SendMessage(action.Message); err != nil {
|
||||
a.logger.Error("Error sending message", "error", err)
|
||||
}
|
||||
} 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 {
|
||||
a.logger.Error("Error deleting message", "error", err, "message_id", action.MessageID)
|
||||
} else {
|
||||
a.logger.Info("Message deleted", "message_id", action.MessageID)
|
||||
}
|
||||
|
||||
default:
|
||||
a.logger.Error("Unknown action type", "type", action.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -386,6 +386,24 @@ func (d *Database) UpdateChannelPlugin(id int64, enabled bool) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// UpdateChannelPluginConfig updates a channel plugin's configuration
|
||||
func (d *Database) UpdateChannelPluginConfig(id int64, config map[string]interface{}) error {
|
||||
// Convert config to JSON
|
||||
configJSON, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
query := `
|
||||
UPDATE channel_plugin
|
||||
SET config = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
_, err = d.db.Exec(query, string(configJSON), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteChannelPlugin deletes a channel plugin
|
||||
func (d *Database) DeleteChannelPlugin(id int64) error {
|
||||
query := `
|
||||
|
|
|
@ -4,6 +4,26 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
// ActionType defines the type of action to perform
|
||||
type ActionType string
|
||||
|
||||
const (
|
||||
// ActionSendMessage is for sending a message to the chat
|
||||
ActionSendMessage ActionType = "send_message"
|
||||
// ActionDeleteMessage is for deleting a message from the chat
|
||||
ActionDeleteMessage ActionType = "delete_message"
|
||||
)
|
||||
|
||||
// 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
|
||||
Raw map[string]interface{} // Additional data for the action
|
||||
}
|
||||
|
||||
// Message represents a chat message
|
||||
type Message struct {
|
||||
Text string
|
||||
|
@ -75,7 +95,7 @@ type ChannelPlugin struct {
|
|||
ChannelID int64
|
||||
PluginID string
|
||||
Enabled bool
|
||||
Config map[string]interface{}
|
||||
Config map[string]any
|
||||
}
|
||||
|
||||
// User represents an admin user
|
||||
|
|
|
@ -43,4 +43,7 @@ type Platform interface {
|
|||
|
||||
// SendMessage sends a message through the platform
|
||||
SendMessage(msg *Message) error
|
||||
|
||||
// DeleteMessage deletes a message from the platform
|
||||
DeleteMessage(channel string, messageID string) error
|
||||
}
|
||||
|
|
|
@ -23,6 +23,6 @@ type Plugin interface {
|
|||
// RequiresConfig indicates if the plugin requires configuration
|
||||
RequiresConfig() bool
|
||||
|
||||
// OnMessage processes an incoming message and returns response messages
|
||||
OnMessage(msg *Message, config map[string]interface{}) []*Message
|
||||
// OnMessage processes an incoming message and returns platform actions
|
||||
OnMessage(msg *Message, config map[string]interface{}) []*MessageAction
|
||||
}
|
||||
|
|
|
@ -167,6 +167,12 @@ func (s *SlackPlatform) SendMessage(msg *model.Message) error {
|
|||
return errors.New("bot token not configured")
|
||||
}
|
||||
|
||||
// Check for delete message action
|
||||
if msg.Raw != nil && msg.Raw["action"] == "delete" {
|
||||
// This is a request to delete a message
|
||||
return s.deleteMessage(msg)
|
||||
}
|
||||
|
||||
// Prepare payload
|
||||
payload := map[string]interface{}{
|
||||
"channel": msg.Chat,
|
||||
|
@ -212,6 +218,63 @@ func (s *SlackPlatform) SendMessage(msg *model.Message) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// DeleteMessage deletes a message on Slack
|
||||
func (s *SlackPlatform) DeleteMessage(channel string, messageID string) error {
|
||||
// Prepare payload for chat.delete API
|
||||
payload := map[string]interface{}{
|
||||
"channel": channel,
|
||||
"ts": messageID, // In Slack, the ts (timestamp) is the message ID
|
||||
}
|
||||
|
||||
// Convert payload to JSON
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send HTTP request to chat.delete endpoint
|
||||
req, err := http.NewRequest("POST", "https://slack.com/api/chat.delete", strings.NewReader(string(data)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.config.BotOAuthAccessToken))
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
fmt.Printf("Error closing response body: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Check response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("slack API error: %d - %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteMessage is a legacy method that uses the Raw message approach
|
||||
func (s *SlackPlatform) deleteMessage(msg *model.Message) error {
|
||||
// Get message ID to delete
|
||||
messageID, ok := msg.Raw["message_id"]
|
||||
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)
|
||||
}
|
||||
|
||||
// Helper function to parse int64
|
||||
func parseInt64(s string) (int64, error) {
|
||||
var n int64
|
||||
|
|
|
@ -217,6 +217,13 @@ func (t *TelegramPlatform) ParseChannelFromMessage(body []byte) (map[string]any,
|
|||
|
||||
// SendMessage sends a message to Telegram
|
||||
func (t *TelegramPlatform) SendMessage(msg *model.Message) error {
|
||||
// Check for delete message action (legacy method)
|
||||
if msg.Raw != nil && msg.Raw["action"] == "delete" {
|
||||
// This is a request to delete a message using the legacy method
|
||||
return t.deleteMessage(msg)
|
||||
}
|
||||
|
||||
// Regular message sending
|
||||
// Convert chat ID to int64
|
||||
chatID, err := strconv.ParseInt(msg.Chat, 10, 64)
|
||||
if err != nil {
|
||||
|
@ -276,3 +283,88 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error {
|
|||
t.log.Debug("Message sent successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteMessage deletes a message on Telegram
|
||||
func (t *TelegramPlatform) DeleteMessage(channel string, messageID string) error {
|
||||
// Convert chat ID to int64
|
||||
chatID, err := strconv.ParseInt(channel, 10, 64)
|
||||
if err != nil {
|
||||
t.log.Error("Invalid chat ID for message deletion", "chat_id", channel, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Convert message ID to integer
|
||||
msgID, err := strconv.Atoi(messageID)
|
||||
if err != nil {
|
||||
t.log.Error("Invalid message ID for deletion", "message_id", messageID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Prepare payload for deleteMessage API
|
||||
payload := map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"message_id": msgID,
|
||||
}
|
||||
|
||||
t.log.Debug("Deleting message on Telegram", "chat_id", chatID, "message_id", msgID)
|
||||
|
||||
// Convert payload to JSON
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.log.Error("Failed to marshal delete message payload", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Send HTTP request to deleteMessage endpoint
|
||||
resp, err := http.Post(
|
||||
t.apiURL+"/deleteMessage",
|
||||
"application/json",
|
||||
bytes.NewBuffer(data),
|
||||
)
|
||||
if err != nil {
|
||||
t.log.Error("Failed to delete message", "error", err)
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
t.log.Error("Error closing response body", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Check response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
errMsg := string(bodyBytes)
|
||||
t.log.Error("Telegram API error when deleting message", "status", resp.StatusCode, "response", errMsg)
|
||||
return fmt.Errorf("telegram API error when deleting message: %d - %s", resp.StatusCode, errMsg)
|
||||
}
|
||||
|
||||
t.log.Debug("Message deleted successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteMessage is a legacy method that uses the Raw message approach
|
||||
func (t *TelegramPlatform) deleteMessage(msg *model.Message) error {
|
||||
// Get message ID to delete
|
||||
messageIDInterface, ok := msg.Raw["message_id"]
|
||||
if !ok {
|
||||
t.log.Error("No message ID provided for deletion")
|
||||
return fmt.Errorf("no message ID provided for deletion")
|
||||
}
|
||||
|
||||
// Convert message ID to string
|
||||
var messageIDStr string
|
||||
switch v := messageIDInterface.(type) {
|
||||
case string:
|
||||
messageIDStr = v
|
||||
case int:
|
||||
messageIDStr = strconv.Itoa(v)
|
||||
case float64:
|
||||
messageIDStr = strconv.Itoa(int(v))
|
||||
default:
|
||||
t.log.Error("Invalid message ID type for deletion", "type", fmt.Sprintf("%T", messageIDInterface))
|
||||
return fmt.Errorf("invalid message ID type for deletion")
|
||||
}
|
||||
|
||||
return t.DeleteMessage(msg.Chat, messageIDStr)
|
||||
}
|
||||
|
|
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,11 +24,12 @@ 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,
|
||||
|
@ -36,5 +37,13 @@ func (p *PingPlugin) OnMessage(msg *model.Message, config map[string]interface{}
|
|||
Channel: msg.Channel,
|
||||
}
|
||||
|
||||
return []*model.Message{response}
|
||||
// Create an action to send the message
|
||||
action := &model.MessageAction{
|
||||
Type: model.ActionSendMessage,
|
||||
Message: response,
|
||||
Chat: msg.Chat,
|
||||
Channel: msg.Channel,
|
||||
}
|
||||
|
||||
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,8 +56,7 @@ 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,
|
||||
|
@ -65,6 +64,14 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{})
|
|||
FromBot: true,
|
||||
Date: time.Now(),
|
||||
ReplyTo: msg.ID,
|
||||
}
|
||||
|
||||
return []*model.MessageAction{
|
||||
{
|
||||
Type: model.ActionSendMessage,
|
||||
Message: errorMsg,
|
||||
Chat: msg.Chat,
|
||||
Channel: msg.Channel,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -86,8 +93,7 @@ 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,
|
||||
|
@ -95,6 +101,14 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{})
|
|||
FromBot: true,
|
||||
Date: time.Now(),
|
||||
ReplyTo: msg.ID,
|
||||
}
|
||||
|
||||
return []*model.MessageAction{
|
||||
{
|
||||
Type: model.ActionSendMessage,
|
||||
Message: errorMsg,
|
||||
Chat: msg.Chat,
|
||||
Channel: msg.Channel,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -127,8 +141,7 @@ 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,
|
||||
|
@ -136,6 +149,14 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{})
|
|||
FromBot: true,
|
||||
Date: time.Now(),
|
||||
ReplyTo: msg.ID,
|
||||
}
|
||||
|
||||
return []*model.MessageAction{
|
||||
{
|
||||
Type: model.ActionSendMessage,
|
||||
Message: errorMsg,
|
||||
Chat: msg.Chat,
|
||||
Channel: msg.Channel,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -157,8 +178,8 @@ 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,
|
||||
|
@ -166,6 +187,14 @@ func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{})
|
|||
FromBot: true,
|
||||
Date: time.Now(),
|
||||
ReplyTo: msg.ID,
|
||||
}
|
||||
|
||||
return []*model.MessageAction{
|
||||
{
|
||||
Type: model.ActionSendMessage,
|
||||
Message: confirmMsg,
|
||||
Chat: msg.Chat,
|
||||
Channel: msg.Channel,
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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