Compare commits

..

17 commits

Author SHA1 Message Date
377b1723c3
fix: default parse mode to text
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/tag/release Pipeline was successful
2025-06-24 08:10:56 +02:00
60ceaffd82
fix: enable all plugins help text
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-06-23 11:43:42 +02:00
3a5b5c216d
chore: try to ensure that code is checked after each session
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-06-23 11:35:30 +02:00
bdc797d5c1
chore: make format 2025-06-23 11:34:27 +02:00
0edf41c792
fix: markdown parse mode breaking some plugins
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/tag/release Pipeline was successful
2025-06-23 11:32:34 +02:00
35c14ce8a8
chore: update CLAUDE.md 2025-06-23 11:20:21 +02:00
e0ff369cff
chore: make format
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-06-23 11:19:22 +02:00
368c45cd13
fix: twitter plugin replacement logic
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/tag/release Pipeline was successful
2025-06-23 11:18:47 +02:00
3b09a9dd47
feat: allow enabling all plugins into a channel
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/tag/release Pipeline was successful
2025-06-23 11:10:43 +02:00
899ac49336
chore: split plugin configuration templates
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
2025-06-15 13:03:52 +02:00
fc77c97547
feat: add configuration options for instagram and twitter plugins 2025-06-15 12:17:54 +02:00
3a4ba376e7
chore: ignore all test db files
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
2025-06-13 12:04:22 +02:00
bd9854676d
feat: added help command 2025-06-13 12:04:07 +02:00
4fc5ae63a1
chore: update ignore patterns for test files
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-06-13 10:48:53 +02:00
3771d2de65
docs: update CLAUDE.md 2025-06-13 10:47:53 +02:00
c7fdb9fc6a
docs: updated plugin docs 2025-06-13 10:45:34 +02:00
1f80a22f4a
chore: remove commited test_cache 2025-06-13 10:45:28 +02:00
28 changed files with 1596 additions and 66 deletions

5
.gitignore vendored
View file

@ -5,9 +5,12 @@ __pycache__
*.cert *.cert
.env-local .env-local
.coverage .coverage
coverage.out
dist dist
bin bin
# Butterrobot # Butterrobot
*.sqlite* *.sqlite*
butterrobot.db butterrobot.db*
/butterrobot
*_test.db*

29
CLAUDE.md Normal file
View file

@ -0,0 +1,29 @@
# Claude Code Instructions
## Plugin Development Workflow
When creating, modifying, or removing plugins:
1. **Always update the plugin documentation** in `docs/plugins.md` after any plugin changes
2. Ensure the documentation includes:
- Plugin name and category (Development, Fun and entertainment, Utility, Security, Social Media)
- Brief description of functionality
- Usage instructions with examples
- Any configuration requirements
3. **For plugins with configuration options:**
- Set `ConfigRequired: true` in the plugin's BasePlugin struct
- Add corresponding HTML form fields in `internal/admin/templates/channel_plugin_config.html`
- Use conditional template logic: `{{else if eq .ChannelPlugin.PluginID "plugin.id"}}`
- Include proper form labels, help text, and value binding
## Testing
**CRITICAL**: After making ANY changes to code files, you MUST run these commands in order:
1. **Format code**: `make format` - Format all code according to project standards
2. **Lint code**: `make lint` - Check code style and quality (must show "0 issues")
3. **Run tests**: `make test` - Run all tests to ensure functionality works
4. Verify documentation accuracy
5. Ensure all examples work as described
**These commands are MANDATORY after every code change, no exceptions.**

View file

@ -9,10 +9,13 @@
- Lo quito: What happens when you say _"lo quito"_...? (Spanish pun) - Lo quito: What happens when you say _"lo quito"_...? (Spanish pun)
- Dice: Put `!dice` and wathever roll you want to perform. - Dice: Put `!dice` and wathever roll you want to perform.
- Coin: Flip a coin and get heads or tails. - Coin: Flip a coin and get heads or tails.
- How Long To Beat: Get game completion times from HowLongToBeat.com using `!hltb <game name>`
### Utility ### Utility
- Help: Shows available commands when you type `!help`. Lists all enabled plugins for the current channel organized by category with their descriptions and usage instructions.
- 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. - 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.
- Search and Replace: Reply to any message with `s/search/replace/[flags]` to perform text substitution. Supports flags: `g` (global), `i` (case insensitive), `n` (regex pattern). Example: `s/hello/hi/gi` replaces all occurrences of "hello" with "hi" case-insensitively.
### Security ### Security
@ -20,5 +23,5 @@
### Social Media ### 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. - Twitter Link Expander: Automatically converts twitter.com and x.com links to alternative domain links and removes tracking parameters. This allows for better media embedding in chat platforms. Configure with `domain` option to set replacement domain (default: fxtwitter.com).
- Instagram Link Expander: Automatically converts instagram.com links to ddinstagram.com links and removes tracking parameters. This allows for better media embedding in chat platforms. - Instagram Link Expander: Automatically converts instagram.com links to alternative domain links and removes tracking parameters. This allows for better media embedding in chat platforms. Configure with `domain` option to set replacement domain (default: ddinstagram.com).

2
go.mod
View file

@ -6,6 +6,7 @@ require (
github.com/gorilla/sessions v1.4.0 github.com/gorilla/sessions v1.4.0
golang.org/x/crypto v0.37.0 golang.org/x/crypto v0.37.0
golang.org/x/crypto/x509roots/fallback v0.0.0-20250418111936-9c1aa6af88df golang.org/x/crypto/x509roots/fallback v0.0.0-20250418111936-9c1aa6af88df
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
modernc.org/sqlite v1.37.0 modernc.org/sqlite v1.37.0
) )
@ -16,7 +17,6 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sys v0.32.0 // indirect golang.org/x/sys v0.32.0 // indirect
modernc.org/libc v1.63.0 // indirect modernc.org/libc v1.63.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect

View file

@ -16,7 +16,7 @@ import (
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
) )
//go:embed templates/*.html //go:embed templates/*.html templates/plugins/*.html
var templateFS embed.FS var templateFS embed.FS
const ( const (
@ -90,7 +90,7 @@ func New(cfg *config.Config, database *db.Database, version string) *Admin {
} }
// Parse and register all templates // Parse and register all templates
templateFiles := []string{ mainTemplateFiles := []string{
"index.html", "index.html",
"login.html", "login.html",
"change_password.html", "change_password.html",
@ -101,7 +101,13 @@ func New(cfg *config.Config, database *db.Database, version string) *Admin {
"channel_plugin_config.html", "channel_plugin_config.html",
} }
for _, tf := range templateFiles { pluginTemplateFiles := []string{
"plugins/security.domainblock.html",
"plugins/social.instagram.html",
"plugins/social.twitter.html",
}
for _, tf := range mainTemplateFiles {
// Read template content from embedded filesystem // Read template content from embedded filesystem
content, err := templateFS.ReadFile("templates/" + tf) content, err := templateFS.ReadFile("templates/" + tf)
if err != nil { if err != nil {
@ -120,6 +126,20 @@ func New(cfg *config.Config, database *db.Database, version string) *Admin {
panic(err) panic(err)
} }
// If this is the channel_plugin_config template, also parse plugin templates
if tf == "channel_plugin_config.html" {
for _, pluginTf := range pluginTemplateFiles {
pluginContent, err := templateFS.ReadFile("templates/" + pluginTf)
if err != nil {
panic(err)
}
t, err = t.Parse(string(pluginContent))
if err != nil {
panic(err)
}
}
}
templates[tf] = t templates[tf] = t
} }
@ -544,6 +564,13 @@ func (a *Admin) handleChannelDetail(w http.ResponseWriter, r *http.Request) {
return return
} }
// Update enable_all_plugins
enableAllPlugins := r.FormValue("enable_all_plugins") == "true"
if err := a.db.UpdateChannelEnableAllPlugins(id, enableAllPlugins); err != nil {
http.Error(w, "Failed to update channel enable all plugins", http.StatusInternalServerError)
return
}
a.addFlash(w, r, "Channel updated", "success") a.addFlash(w, r, "Channel updated", "success")
http.Redirect(w, r, "/admin/channels/"+channelID, http.StatusSeeOther) http.Redirect(w, r, "/admin/channels/"+channelID, http.StatusSeeOther)
return return

View file

@ -27,6 +27,15 @@
<!-- Add a hidden field to ensure a value is sent even when checkbox is unchecked --> <!-- Add a hidden field to ensure a value is sent even when checkbox is unchecked -->
<input type="hidden" name="form_submitted" value="true"> <input type="hidden" name="form_submitted" value="true">
</div> </div>
<div class="mb-3">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="enable_all_plugins" value="true" {{if .Channel.EnableAllPlugins}}checked{{end}}>
<span class="form-check-label">Enable All Plugins</span>
</label>
<div>
When enabled, all registered plugins will be automatically enabled for this channel. Individual plugin settings will be ignored.
</div>
</div>
<div class="form-footer"> <div class="form-footer">
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary">Save</button>
<a href="/admin/channels" class="btn btn-link">Back to Channels</a> <a href="/admin/channels" class="btn btn-link">Back to Channels</a>

View file

@ -9,16 +9,11 @@
<form method="post"> <form method="post">
<!-- Plugin configuration fields --> <!-- Plugin configuration fields -->
{{if eq .ChannelPlugin.PluginID "security.domainblock"}} {{if eq .ChannelPlugin.PluginID "security.domainblock"}}
<div class="mb-3"> {{template "plugins/security.domainblock.html" .}}
<label class="form-label">Blocked Domains</label> {{else if eq .ChannelPlugin.PluginID "social.instagram"}}
<input type="text" class="form-control" name="blocked_domains" {{template "plugins/social.instagram.html" .}}
value="{{with .ChannelPlugin.Config}}{{index . "blocked_domains"}}{{end}}" {{else if eq .ChannelPlugin.PluginID "social.twitter"}}
placeholder="example.com, evil.org, ads.com"> {{template "plugins/social.twitter.html" .}}
<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}} {{else}}
<div class="alert alert-warning"> <div class="alert alert-warning">
This plugin doesn't have specific configuration fields implemented yet. This plugin doesn't have specific configuration fields implemented yet.

View file

@ -0,0 +1,12 @@
{{define "plugins/security.domainblock.html"}}
<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>
{{end}}

View file

@ -0,0 +1,11 @@
{{define "plugins/social.instagram.html"}}
<div class="mb-3">
<label class="form-label">Replacement Domain</label>
<input type="text" class="form-control" name="domain"
value="{{with .ChannelPlugin.Config}}{{index . "domain"}}{{end}}"
placeholder="ddinstagram.com">
<div class="form-text text-muted">
Enter the domain to replace instagram.com links with. Default is ddinstagram.com if left empty.
</div>
</div>
{{end}}

View file

@ -0,0 +1,11 @@
{{define "plugins/social.twitter.html"}}
<div class="mb-3">
<label class="form-label">Replacement Domain</label>
<input type="text" class="form-control" name="domain"
value="{{with .ChannelPlugin.Config}}{{index . "domain"}}{{end}}"
placeholder="fxtwitter.com">
<div class="form-text text-muted">
Enter the domain to replace twitter.com and x.com links with. Default is fxtwitter.com if left empty.
</div>
</div>
{{end}}

View file

@ -23,6 +23,7 @@ import (
"git.nakama.town/fmartingr/butterrobot/internal/plugin" "git.nakama.town/fmartingr/butterrobot/internal/plugin"
"git.nakama.town/fmartingr/butterrobot/internal/plugin/domainblock" "git.nakama.town/fmartingr/butterrobot/internal/plugin/domainblock"
"git.nakama.town/fmartingr/butterrobot/internal/plugin/fun" "git.nakama.town/fmartingr/butterrobot/internal/plugin/fun"
"git.nakama.town/fmartingr/butterrobot/internal/plugin/help"
"git.nakama.town/fmartingr/butterrobot/internal/plugin/ping" "git.nakama.town/fmartingr/butterrobot/internal/plugin/ping"
"git.nakama.town/fmartingr/butterrobot/internal/plugin/reminder" "git.nakama.town/fmartingr/butterrobot/internal/plugin/reminder"
"git.nakama.town/fmartingr/butterrobot/internal/plugin/searchreplace" "git.nakama.town/fmartingr/butterrobot/internal/plugin/searchreplace"
@ -94,6 +95,7 @@ func (a *App) Run() error {
plugin.Register(reminder.New(a.db)) plugin.Register(reminder.New(a.db))
plugin.Register(domainblock.New()) plugin.Register(domainblock.New())
plugin.Register(searchreplace.New()) plugin.Register(searchreplace.New())
plugin.Register(help.New(a.db))
// Initialize routes // Initialize routes
a.initializeRoutes() a.initializeRoutes()
@ -312,11 +314,21 @@ func (a *App) handleMessage(item queue.Item) {
} }
// Process message with plugins // Process message with plugins
for pluginID, channelPlugin := range channel.Plugins { var pluginsToProcess []string
if !channel.HasEnabledPlugin(pluginID) {
continue if channel.EnableAllPlugins {
// If EnableAllPlugins is true, process all registered plugins
pluginsToProcess = plugin.GetAvailablePluginIDs()
} else {
// Otherwise, process only explicitly enabled plugins
for pluginID := range channel.Plugins {
if channel.HasEnabledPlugin(pluginID) {
pluginsToProcess = append(pluginsToProcess, pluginID)
}
}
} }
for _, pluginID := range pluginsToProcess {
// Get plugin // Get plugin
p, err := plugin.Get(pluginID) p, err := plugin.Get(pluginID)
if err != nil { if err != nil {
@ -324,11 +336,19 @@ func (a *App) handleMessage(item queue.Item) {
continue continue
} }
// Get plugin configuration (empty map if EnableAllPlugins and plugin not explicitly configured)
var config map[string]interface{}
if channelPlugin, exists := channel.Plugins[pluginID]; exists {
config = channelPlugin.Config
} else {
config = make(map[string]interface{})
}
// Create cache instance for this plugin // Create cache instance for this plugin
pluginCache := cache.New(a.db, pluginID) pluginCache := cache.New(a.db, pluginID)
// Process message and get actions // Process message and get actions
actions := p.OnMessage(message, channelPlugin.Config, pluginCache) actions := p.OnMessage(message, config, pluginCache)
// Get platform for processing actions // Get platform for processing actions
platform, err := platform.Get(item.Platform) platform, err := platform.Get(item.Platform)

Binary file not shown.

View file

@ -56,7 +56,7 @@ func (d *Database) Close() error {
// GetChannelByID retrieves a channel by ID // GetChannelByID retrieves a channel by ID
func (d *Database) GetChannelByID(id int64) (*model.Channel, error) { func (d *Database) GetChannelByID(id int64) (*model.Channel, error) {
query := ` query := `
SELECT id, platform, platform_channel_id, enabled, channel_raw SELECT id, platform, platform_channel_id, enabled, enable_all_plugins, channel_raw
FROM channels FROM channels
WHERE id = ? WHERE id = ?
` `
@ -67,10 +67,11 @@ func (d *Database) GetChannelByID(id int64) (*model.Channel, error) {
platform string platform string
platformChannelID string platformChannelID string
enabled bool enabled bool
enableAllPlugins bool
channelRawJSON string channelRawJSON string
) )
err := row.Scan(&id, &platform, &platformChannelID, &enabled, &channelRawJSON) err := row.Scan(&id, &platform, &platformChannelID, &enabled, &enableAllPlugins, &channelRawJSON)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, ErrNotFound return nil, ErrNotFound
} }
@ -90,6 +91,7 @@ func (d *Database) GetChannelByID(id int64) (*model.Channel, error) {
Platform: platform, Platform: platform,
PlatformChannelID: platformChannelID, PlatformChannelID: platformChannelID,
Enabled: enabled, Enabled: enabled,
EnableAllPlugins: enableAllPlugins,
ChannelRaw: channelRaw, ChannelRaw: channelRaw,
Plugins: make(map[string]*model.ChannelPlugin), Plugins: make(map[string]*model.ChannelPlugin),
} }
@ -110,7 +112,7 @@ func (d *Database) GetChannelByID(id int64) (*model.Channel, error) {
// GetChannelByPlatform retrieves a channel by platform and platform channel ID // GetChannelByPlatform retrieves a channel by platform and platform channel ID
func (d *Database) GetChannelByPlatform(platform, platformChannelID string) (*model.Channel, error) { func (d *Database) GetChannelByPlatform(platform, platformChannelID string) (*model.Channel, error) {
query := ` query := `
SELECT id, platform, platform_channel_id, enabled, channel_raw SELECT id, platform, platform_channel_id, enabled, enable_all_plugins, channel_raw
FROM channels FROM channels
WHERE platform = ? AND platform_channel_id = ? WHERE platform = ? AND platform_channel_id = ?
` `
@ -120,10 +122,11 @@ func (d *Database) GetChannelByPlatform(platform, platformChannelID string) (*mo
var ( var (
id int64 id int64
enabled bool enabled bool
enableAllPlugins bool
channelRawJSON string channelRawJSON string
) )
err := row.Scan(&id, &platform, &platformChannelID, &enabled, &channelRawJSON) err := row.Scan(&id, &platform, &platformChannelID, &enabled, &enableAllPlugins, &channelRawJSON)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, ErrNotFound return nil, ErrNotFound
} }
@ -143,6 +146,7 @@ func (d *Database) GetChannelByPlatform(platform, platformChannelID string) (*mo
Platform: platform, Platform: platform,
PlatformChannelID: platformChannelID, PlatformChannelID: platformChannelID,
Enabled: enabled, Enabled: enabled,
EnableAllPlugins: enableAllPlugins,
ChannelRaw: channelRaw, ChannelRaw: channelRaw,
Plugins: make(map[string]*model.ChannelPlugin), Plugins: make(map[string]*model.ChannelPlugin),
} }
@ -170,11 +174,11 @@ func (d *Database) CreateChannel(platform, platformChannelID string, enabled boo
// Insert channel // Insert channel
query := ` query := `
INSERT INTO channels (platform, platform_channel_id, enabled, channel_raw) INSERT INTO channels (platform, platform_channel_id, enabled, enable_all_plugins, channel_raw)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
` `
result, err := d.db.Exec(query, platform, platformChannelID, enabled, string(channelRawJSON)) result, err := d.db.Exec(query, platform, platformChannelID, enabled, false, string(channelRawJSON))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -191,6 +195,7 @@ func (d *Database) CreateChannel(platform, platformChannelID string, enabled boo
Platform: platform, Platform: platform,
PlatformChannelID: platformChannelID, PlatformChannelID: platformChannelID,
Enabled: enabled, Enabled: enabled,
EnableAllPlugins: false,
ChannelRaw: channelRaw, ChannelRaw: channelRaw,
Plugins: make(map[string]*model.ChannelPlugin), Plugins: make(map[string]*model.ChannelPlugin),
} }
@ -210,6 +215,18 @@ func (d *Database) UpdateChannel(id int64, enabled bool) error {
return err return err
} }
// UpdateChannelEnableAllPlugins updates a channel's enable_all_plugins status
func (d *Database) UpdateChannelEnableAllPlugins(id int64, enableAllPlugins bool) error {
query := `
UPDATE channels
SET enable_all_plugins = ?
WHERE id = ?
`
_, err := d.db.Exec(query, enableAllPlugins, id)
return err
}
// DeleteChannel deletes a channel // DeleteChannel deletes a channel
func (d *Database) DeleteChannel(id int64) error { func (d *Database) DeleteChannel(id int64) error {
// First delete all channel plugins // First delete all channel plugins
@ -261,7 +278,7 @@ func (d *Database) GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, e
} }
// Parse config JSON // Parse config JSON
var config map[string]interface{} var config map[string]any
if err := json.Unmarshal([]byte(configJSON), &config); err != nil { if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return nil, err return nil, err
} }
@ -288,6 +305,28 @@ func (d *Database) GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, e
return plugins, nil return plugins, nil
} }
// GetChannelPluginsFromPlatformID retrieves all plugins for a channel by platform and platform channel ID
func (d *Database) GetChannelPluginsFromPlatformID(platform, platformChannelID string) ([]*model.ChannelPlugin, error) {
// First, get the channel ID by platform and platform channel ID
query := `
SELECT id
FROM channels
WHERE platform = ? AND platform_channel_id = ?
`
var channelID int64
err := d.db.QueryRow(query, platform, platformChannelID).Scan(&channelID)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
// Now get the plugins for this channel
return d.GetChannelPlugins(channelID)
}
// GetChannelPluginByID retrieves a channel plugin by ID // GetChannelPluginByID retrieves a channel plugin by ID
func (d *Database) GetChannelPluginByID(id int64) (*model.ChannelPlugin, error) { func (d *Database) GetChannelPluginByID(id int64) (*model.ChannelPlugin, error) {
query := ` query := `
@ -434,7 +473,7 @@ func (d *Database) DeleteChannelPluginsByChannel(channelID int64) error {
// GetAllChannels retrieves all channels // GetAllChannels retrieves all channels
func (d *Database) GetAllChannels() ([]*model.Channel, error) { func (d *Database) GetAllChannels() ([]*model.Channel, error) {
query := ` query := `
SELECT id, platform, platform_channel_id, enabled, channel_raw SELECT id, platform, platform_channel_id, enabled, enable_all_plugins, channel_raw
FROM channels FROM channels
` `
@ -456,10 +495,11 @@ func (d *Database) GetAllChannels() ([]*model.Channel, error) {
platform string platform string
platformChannelID string platformChannelID string
enabled bool enabled bool
enableAllPlugins bool
channelRawJSON string channelRawJSON string
) )
if err := rows.Scan(&id, &platform, &platformChannelID, &enabled, &channelRawJSON); err != nil { if err := rows.Scan(&id, &platform, &platformChannelID, &enabled, &enableAllPlugins, &channelRawJSON); err != nil {
return nil, err return nil, err
} }
@ -475,6 +515,7 @@ func (d *Database) GetAllChannels() ([]*model.Channel, error) {
Platform: platform, Platform: platform,
PlatformChannelID: platformChannelID, PlatformChannelID: platformChannelID,
Enabled: enabled, Enabled: enabled,
EnableAllPlugins: enableAllPlugins,
ChannelRaw: channelRaw, ChannelRaw: channelRaw,
Plugins: make(map[string]*model.ChannelPlugin), Plugins: make(map[string]*model.ChannelPlugin),
} }

203
internal/db/db_test.go Normal file
View file

@ -0,0 +1,203 @@
package db
import (
"fmt"
"os"
"testing"
"time"
"git.nakama.town/fmartingr/butterrobot/internal/model"
)
func TestEnableAllPlugins(t *testing.T) {
// Create temporary database for testing with unique name
dbFile := fmt.Sprintf("test_db_%d.db", time.Now().UnixNano())
database, err := New(dbFile)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
defer func() {
_ = database.Close()
// Clean up test database file
_ = os.Remove(dbFile)
}()
t.Run("CreateChannel with EnableAllPlugins default false", func(t *testing.T) {
channelRaw := map[string]interface{}{
"name": "test-channel",
}
channel, err := database.CreateChannel("telegram", "123456", true, channelRaw)
if err != nil {
t.Fatalf("Failed to create channel: %v", err)
}
if channel.EnableAllPlugins {
t.Errorf("Expected EnableAllPlugins to be false by default, got true")
}
// Verify it's also false when retrieved from database
retrieved, err := database.GetChannelByID(channel.ID)
if err != nil {
t.Fatalf("Failed to retrieve channel: %v", err)
}
if retrieved.EnableAllPlugins {
t.Errorf("Expected EnableAllPlugins to be false when retrieved from DB, got true")
}
})
t.Run("UpdateChannelEnableAllPlugins", func(t *testing.T) {
// Create a channel
channelRaw := map[string]interface{}{
"name": "test-channel-2",
}
channel, err := database.CreateChannel("telegram", "123457", true, channelRaw)
if err != nil {
t.Fatalf("Failed to create channel: %v", err)
}
// Update EnableAllPlugins to true
err = database.UpdateChannelEnableAllPlugins(channel.ID, true)
if err != nil {
t.Fatalf("Failed to update EnableAllPlugins: %v", err)
}
// Retrieve and verify
retrieved, err := database.GetChannelByID(channel.ID)
if err != nil {
t.Fatalf("Failed to retrieve channel: %v", err)
}
if !retrieved.EnableAllPlugins {
t.Errorf("Expected EnableAllPlugins to be true after update, got false")
}
// Update back to false
err = database.UpdateChannelEnableAllPlugins(channel.ID, false)
if err != nil {
t.Fatalf("Failed to update EnableAllPlugins back to false: %v", err)
}
// Retrieve and verify again
retrieved, err = database.GetChannelByID(channel.ID)
if err != nil {
t.Fatalf("Failed to retrieve channel: %v", err)
}
if retrieved.EnableAllPlugins {
t.Errorf("Expected EnableAllPlugins to be false after second update, got true")
}
})
t.Run("GetChannelByPlatform includes EnableAllPlugins", func(t *testing.T) {
// Create a channel
channelRaw := map[string]interface{}{
"name": "test-channel-3",
}
channel, err := database.CreateChannel("slack", "C123456", true, channelRaw)
if err != nil {
t.Fatalf("Failed to create channel: %v", err)
}
// Enable all plugins
err = database.UpdateChannelEnableAllPlugins(channel.ID, true)
if err != nil {
t.Fatalf("Failed to update EnableAllPlugins: %v", err)
}
// Retrieve by platform
retrieved, err := database.GetChannelByPlatform("slack", "C123456")
if err != nil {
t.Fatalf("Failed to retrieve channel by platform: %v", err)
}
if !retrieved.EnableAllPlugins {
t.Errorf("Expected EnableAllPlugins to be true when retrieved by platform, got false")
}
})
t.Run("GetAllChannels includes EnableAllPlugins", func(t *testing.T) {
// Create multiple channels with different EnableAllPlugins settings
channelRaw1 := map[string]interface{}{"name": "channel-1"}
channelRaw2 := map[string]interface{}{"name": "channel-2"}
channel1, err := database.CreateChannel("platform1", "ch1", true, channelRaw1)
if err != nil {
t.Fatalf("Failed to create channel1: %v", err)
}
channel2, err := database.CreateChannel("platform2", "ch2", true, channelRaw2)
if err != nil {
t.Fatalf("Failed to create channel2: %v", err)
}
// Enable all plugins for channel2 only
err = database.UpdateChannelEnableAllPlugins(channel2.ID, true)
if err != nil {
t.Fatalf("Failed to update EnableAllPlugins for channel2: %v", err)
}
// Get all channels
channels, err := database.GetAllChannels()
if err != nil {
t.Fatalf("Failed to get all channels: %v", err)
}
// Find our test channels
var foundChannel1, foundChannel2 *model.Channel
for _, ch := range channels {
if ch.ID == channel1.ID {
foundChannel1 = ch
}
if ch.ID == channel2.ID {
foundChannel2 = ch
}
}
if foundChannel1 == nil {
t.Fatalf("Channel1 not found in GetAllChannels result")
}
if foundChannel2 == nil {
t.Fatalf("Channel2 not found in GetAllChannels result")
}
if foundChannel1.EnableAllPlugins {
t.Errorf("Expected channel1 EnableAllPlugins to be false, got true")
}
if !foundChannel2.EnableAllPlugins {
t.Errorf("Expected channel2 EnableAllPlugins to be true, got false")
}
})
t.Run("Migration applied correctly", func(t *testing.T) {
// Test that we can create a channel and the enable_all_plugins column exists
// This implicitly tests that migration 4 was applied correctly
channelRaw := map[string]interface{}{
"name": "migration-test-channel",
}
channel, err := database.CreateChannel("test-platform", "migration-test", true, channelRaw)
if err != nil {
t.Fatalf("Failed to create channel after migration: %v", err)
}
// Try to update EnableAllPlugins - this would fail if the column doesn't exist
err = database.UpdateChannelEnableAllPlugins(channel.ID, true)
if err != nil {
t.Fatalf("Failed to update EnableAllPlugins - migration may not have been applied: %v", err)
}
// Verify the value was set correctly
retrieved, err := database.GetChannelByID(channel.ID)
if err != nil {
t.Fatalf("Failed to retrieve channel: %v", err)
}
if !retrieved.EnableAllPlugins {
t.Errorf("EnableAllPlugins should be true after update")
}
})
}

View file

@ -10,6 +10,7 @@ func init() {
Register(1, "Initial schema with bcrypt passwords", migrateInitialSchemaUp, migrateInitialSchemaDown) Register(1, "Initial schema with bcrypt passwords", migrateInitialSchemaUp, migrateInitialSchemaDown)
Register(2, "Add reminders table", migrateRemindersUp, migrateRemindersDown) Register(2, "Add reminders table", migrateRemindersUp, migrateRemindersDown)
Register(3, "Add cache table", migrateCacheUp, migrateCacheDown) Register(3, "Add cache table", migrateCacheUp, migrateCacheDown)
Register(4, "Add enable_all_plugins column to channels", migrateEnableAllPluginsUp, migrateEnableAllPluginsDown)
} }
// Initial schema creation with bcrypt passwords - version 1 // Initial schema creation with bcrypt passwords - version 1
@ -154,3 +155,60 @@ func migrateCacheDown(db *sql.DB) error {
_, err := db.Exec(`DROP TABLE IF EXISTS cache`) _, err := db.Exec(`DROP TABLE IF EXISTS cache`)
return err return err
} }
// Add enable_all_plugins column to channels table - version 4
func migrateEnableAllPluginsUp(db *sql.DB) error {
_, err := db.Exec(`
ALTER TABLE channels ADD COLUMN enable_all_plugins BOOLEAN NOT NULL DEFAULT 0
`)
return err
}
func migrateEnableAllPluginsDown(db *sql.DB) error {
// SQLite doesn't support DROP COLUMN, so we need to recreate the table
tx, err := db.Begin()
if err != nil {
return err
}
defer func() {
_ = tx.Rollback() // Ignore rollback errors
}()
// Create backup table
_, err = tx.Exec(`
CREATE TABLE channels_backup (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
platform_channel_id TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT 0,
channel_raw TEXT NOT NULL,
UNIQUE(platform, platform_channel_id)
)
`)
if err != nil {
return err
}
// Copy data excluding enable_all_plugins column
_, err = tx.Exec(`
INSERT INTO channels_backup (id, platform, platform_channel_id, enabled, channel_raw)
SELECT id, platform, platform_channel_id, enabled, channel_raw FROM channels
`)
if err != nil {
return err
}
// Drop original table
_, err = tx.Exec(`DROP TABLE channels`)
if err != nil {
return err
}
// Rename backup table
_, err = tx.Exec(`ALTER TABLE channels_backup RENAME TO channels`)
if err != nil {
return err
}
return tx.Commit()
}

View file

@ -44,11 +44,17 @@ type Channel struct {
PlatformChannelID string PlatformChannelID string
ChannelRaw map[string]interface{} ChannelRaw map[string]interface{}
Enabled bool Enabled bool
EnableAllPlugins bool
Plugins map[string]*ChannelPlugin Plugins map[string]*ChannelPlugin
} }
// HasEnabledPlugin checks if a plugin is enabled for this channel // HasEnabledPlugin checks if a plugin is enabled for this channel
func (c *Channel) HasEnabledPlugin(pluginID string) bool { func (c *Channel) HasEnabledPlugin(pluginID string) bool {
// If EnableAllPlugins is true, all plugins are considered enabled
if c.EnableAllPlugins {
return true
}
plugin, exists := c.Plugins[pluginID] plugin, exists := c.Plugins[pluginID]
if !exists { if !exists {
return false return false

View file

@ -0,0 +1,234 @@
package model
import (
"testing"
)
func TestChannel_HasEnabledPlugin(t *testing.T) {
t.Run("EnableAllPlugins false - plugin not in map", func(t *testing.T) {
channel := &Channel{
ID: 1,
Platform: "telegram",
PlatformChannelID: "123456",
Enabled: true,
EnableAllPlugins: false,
Plugins: make(map[string]*ChannelPlugin),
}
// Plugin not in map should return false
result := channel.HasEnabledPlugin("nonexistent.plugin")
if result {
t.Errorf("Expected HasEnabledPlugin to return false for nonexistent plugin, got true")
}
})
t.Run("EnableAllPlugins false - plugin disabled", func(t *testing.T) {
channel := &Channel{
ID: 1,
Platform: "telegram",
PlatformChannelID: "123456",
Enabled: true,
EnableAllPlugins: false,
Plugins: map[string]*ChannelPlugin{
"test.plugin": {
ID: 1,
ChannelID: 1,
PluginID: "test.plugin",
Enabled: false,
Config: make(map[string]any),
},
},
}
// Disabled plugin should return false
result := channel.HasEnabledPlugin("test.plugin")
if result {
t.Errorf("Expected HasEnabledPlugin to return false for disabled plugin, got true")
}
})
t.Run("EnableAllPlugins false - plugin enabled", func(t *testing.T) {
channel := &Channel{
ID: 1,
Platform: "telegram",
PlatformChannelID: "123456",
Enabled: true,
EnableAllPlugins: false,
Plugins: map[string]*ChannelPlugin{
"test.plugin": {
ID: 1,
ChannelID: 1,
PluginID: "test.plugin",
Enabled: true,
Config: make(map[string]any),
},
},
}
// Enabled plugin should return true
result := channel.HasEnabledPlugin("test.plugin")
if !result {
t.Errorf("Expected HasEnabledPlugin to return true for enabled plugin, got false")
}
})
t.Run("EnableAllPlugins true - plugin not in map", func(t *testing.T) {
channel := &Channel{
ID: 1,
Platform: "telegram",
PlatformChannelID: "123456",
Enabled: true,
EnableAllPlugins: true,
Plugins: make(map[string]*ChannelPlugin),
}
// When EnableAllPlugins is true, any plugin should be considered enabled
result := channel.HasEnabledPlugin("nonexistent.plugin")
if !result {
t.Errorf("Expected HasEnabledPlugin to return true when EnableAllPlugins is true, got false")
}
})
t.Run("EnableAllPlugins true - plugin disabled", func(t *testing.T) {
channel := &Channel{
ID: 1,
Platform: "telegram",
PlatformChannelID: "123456",
Enabled: true,
EnableAllPlugins: true,
Plugins: map[string]*ChannelPlugin{
"test.plugin": {
ID: 1,
ChannelID: 1,
PluginID: "test.plugin",
Enabled: false,
Config: make(map[string]any),
},
},
}
// When EnableAllPlugins is true, even disabled plugins should be considered enabled
result := channel.HasEnabledPlugin("test.plugin")
if !result {
t.Errorf("Expected HasEnabledPlugin to return true when EnableAllPlugins is true (even for disabled plugin), got false")
}
})
t.Run("EnableAllPlugins true - plugin enabled", func(t *testing.T) {
channel := &Channel{
ID: 1,
Platform: "telegram",
PlatformChannelID: "123456",
Enabled: true,
EnableAllPlugins: true,
Plugins: map[string]*ChannelPlugin{
"test.plugin": {
ID: 1,
ChannelID: 1,
PluginID: "test.plugin",
Enabled: true,
Config: make(map[string]any),
},
},
}
// When EnableAllPlugins is true, enabled plugins should also return true
result := channel.HasEnabledPlugin("test.plugin")
if !result {
t.Errorf("Expected HasEnabledPlugin to return true when EnableAllPlugins is true, got false")
}
})
t.Run("EnableAllPlugins true - multiple plugins", func(t *testing.T) {
channel := &Channel{
ID: 1,
Platform: "telegram",
PlatformChannelID: "123456",
Enabled: true,
EnableAllPlugins: true,
Plugins: map[string]*ChannelPlugin{
"plugin1": {
ID: 1,
ChannelID: 1,
PluginID: "plugin1",
Enabled: true,
Config: make(map[string]any),
},
"plugin2": {
ID: 2,
ChannelID: 1,
PluginID: "plugin2",
Enabled: false,
Config: make(map[string]any),
},
},
}
// All plugins should be enabled when EnableAllPlugins is true
testCases := []string{"plugin1", "plugin2", "plugin3", "any.plugin"}
for _, pluginID := range testCases {
result := channel.HasEnabledPlugin(pluginID)
if !result {
t.Errorf("Expected HasEnabledPlugin('%s') to return true when EnableAllPlugins is true, got false", pluginID)
}
}
})
}
func TestChannelName(t *testing.T) {
t.Run("Returns PlatformChannelID when ChannelRaw is nil", func(t *testing.T) {
channel := &Channel{
PlatformChannelID: "test-id",
ChannelRaw: nil,
}
result := channel.ChannelName()
if result != "test-id" {
t.Errorf("Expected channel name to be 'test-id', got '%s'", result)
}
})
t.Run("Returns name from ChannelRaw when available", func(t *testing.T) {
channel := &Channel{
PlatformChannelID: "test-id",
ChannelRaw: map[string]interface{}{
"name": "Test Channel",
},
}
result := channel.ChannelName()
if result != "Test Channel" {
t.Errorf("Expected channel name to be 'Test Channel', got '%s'", result)
}
})
t.Run("Returns title from nested chat object (Telegram style)", func(t *testing.T) {
channel := &Channel{
PlatformChannelID: "test-id",
ChannelRaw: map[string]interface{}{
"chat": map[string]interface{}{
"title": "Telegram Group",
},
},
}
result := channel.ChannelName()
if result != "Telegram Group" {
t.Errorf("Expected channel name to be 'Telegram Group', got '%s'", result)
}
})
t.Run("Falls back to PlatformChannelID when no valid name found", func(t *testing.T) {
channel := &Channel{
PlatformChannelID: "fallback-id",
ChannelRaw: map[string]interface{}{
"other_field": "value",
},
}
result := channel.ChannelName()
if result != "fallback-id" {
t.Errorf("Expected channel name to fallback to 'fallback-id', got '%s'", result)
}
})
}

View file

@ -235,7 +235,15 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error {
payload := map[string]interface{}{ payload := map[string]interface{}{
"chat_id": chatID, "chat_id": chatID,
"text": msg.Text, "text": msg.Text,
"parse_mode": "Markdown", }
// Set parse_mode based on plugin preference or default to empty string
if msg.Raw != nil && msg.Raw["parse_mode"] != nil {
// Plugin explicitly set parse_mode
payload["parse_mode"] = msg.Raw["parse_mode"]
} else {
// Default to empty string (no formatting)
payload["parse_mode"] = ""
} }
// Add reply if needed // Add reply if needed

View file

@ -70,7 +70,7 @@ func NewHLTB() *HLTBPlugin {
BasePlugin: plugin.BasePlugin{ BasePlugin: plugin.BasePlugin{
ID: "fun.hltb", ID: "fun.hltb",
Name: "How Long To Beat", Name: "How Long To Beat",
Help: "Get game completion times from HowLongToBeat.com using !hltb <game name>", Help: "Get game completion times from HowLongToBeat.com using `!hltb <game name>`",
}, },
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 10 * time.Second, Timeout: 10 * time.Second,
@ -131,12 +131,15 @@ func (p *HLTBPlugin) OnMessage(msg *model.Message, config map[string]interface{}
Channel: msg.Channel, Channel: msg.Channel,
} }
// Add game cover as attachment if available // Set parse mode for markdown formatting
if game.GameImage != "" {
imageURL := p.getFullImageURL(game.GameImage)
if responseMsg.Raw == nil { if responseMsg.Raw == nil {
responseMsg.Raw = make(map[string]interface{}) responseMsg.Raw = make(map[string]interface{})
} }
responseMsg.Raw["parse_mode"] = "Markdown"
// Add game cover as attachment if available
if game.GameImage != "" {
imageURL := p.getFullImageURL(game.GameImage)
responseMsg.Raw["image_url"] = imageURL responseMsg.Raw["image_url"] = imageURL
} }

View file

@ -23,6 +23,11 @@ func NewLoquito() *LoquitoPlugin {
} }
} }
// GetHelp returns the plugin help text
func (p *LoquitoPlugin) GetHelp() string {
return ""
}
// OnMessage handles incoming messages // OnMessage handles incoming messages
func (p *LoquitoPlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction { func (p *LoquitoPlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction {
if !strings.Contains(strings.ToLower(msg.Text), "lo quito") { if !strings.Contains(strings.ToLower(msg.Text), "lo quito") {

View file

@ -0,0 +1,166 @@
package help
import (
"fmt"
"sort"
"strings"
"git.nakama.town/fmartingr/butterrobot/internal/db"
"git.nakama.town/fmartingr/butterrobot/internal/model"
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
"golang.org/x/exp/slog"
)
// ChannelPluginGetter is an interface for getting channel plugins
type ChannelPluginGetter interface {
GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, error)
GetChannelPluginsFromPlatformID(platform, platformChannelID string) ([]*model.ChannelPlugin, error)
}
// HelpPlugin provides help information about available commands
type HelpPlugin struct {
plugin.BasePlugin
db ChannelPluginGetter
}
// New creates a new HelpPlugin instance
func New(db ChannelPluginGetter) *HelpPlugin {
return &HelpPlugin{
BasePlugin: plugin.BasePlugin{
ID: "utility.help",
Name: "Help",
Help: "Shows available commands when you type '!help'",
},
db: db,
}
}
// OnMessage handles incoming messages
func (p *HelpPlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction {
// Check if message is the help command
if !strings.EqualFold(strings.TrimSpace(msg.Text), "!help") {
return nil
}
// Get channel plugins from database using platform and platform channel ID
channelPlugins, err := p.db.GetChannelPluginsFromPlatformID(msg.Channel.Platform, msg.Channel.PlatformChannelID)
if err != nil && err != db.ErrNotFound {
slog.Error("Failed to get channel plugins", slog.Any("err", err))
return []*model.MessageAction{}
}
// If no plugins found, initialize empty slice
if err == db.ErrNotFound {
channelPlugins = []*model.ChannelPlugin{}
}
// Get all available plugins
availablePlugins := plugin.GetAvailablePlugins()
// Filter to only enabled plugins for this channel
enabledPlugins := make(map[string]model.Plugin)
for _, channelPlugin := range channelPlugins {
if channelPlugin.Enabled {
if availablePlugin, exists := availablePlugins[channelPlugin.PluginID]; exists {
enabledPlugins[channelPlugin.PluginID] = availablePlugin
}
}
}
// If no plugins are enabled, return a message
if len(enabledPlugins) == 0 {
response := &model.Message{
Text: "No plugins are currently enabled for this channel.",
Chat: msg.Chat,
ReplyTo: msg.ID,
Channel: msg.Channel,
Raw: map[string]interface{}{"parse_mode": "Markdown"},
}
return []*model.MessageAction{
{
Type: model.ActionSendMessage,
Message: response,
Chat: msg.Chat,
Channel: msg.Channel,
},
}
}
// Group plugins by category
categories := map[string][]model.Plugin{
"Development": {},
"Fun and Entertainment": {},
"Utility": {},
"Security": {},
"Social Media": {},
"Other": {},
}
// Categorize plugins based on their ID prefix
for _, p := range enabledPlugins {
category := p.GetID()
switch {
case strings.HasPrefix(category, "dev."):
categories["Development"] = append(categories["Development"], p)
case strings.HasPrefix(category, "fun."):
categories["Fun and Entertainment"] = append(categories["Fun and Entertainment"], p)
case strings.HasPrefix(category, "util.") || strings.HasPrefix(category, "reminder.") || strings.HasPrefix(category, "utility."):
categories["Utility"] = append(categories["Utility"], p)
case strings.HasPrefix(category, "security."):
categories["Security"] = append(categories["Security"], p)
case strings.HasPrefix(category, "social."):
categories["Social Media"] = append(categories["Social Media"], p)
default:
categories["Other"] = append(categories["Other"], p)
}
}
// Build the help message
var helpText strings.Builder
helpText.WriteString("🤖 **Available Commands**\n\n")
// Sort category names for consistent output
categoryOrder := []string{"Development", "Fun and Entertainment", "Utility", "Security", "Social Media", "Other"}
for _, categoryName := range categoryOrder {
pluginList := categories[categoryName]
if len(pluginList) == 0 {
continue
}
// Sort plugins within category by name
sort.Slice(pluginList, func(i, j int) bool {
return pluginList[i].GetName() < pluginList[j].GetName()
})
helpText.WriteString(fmt.Sprintf("**%s:**\n", categoryName))
for _, p := range pluginList {
if p.GetHelp() == "" {
continue
}
helpText.WriteString(fmt.Sprintf("• **%s** - %s\n", p.GetName(), p.GetHelp()))
}
helpText.WriteString("\n")
}
// Add footer
helpText.WriteString("_Use the specific commands or triggers mentioned above to interact with the bot._")
response := &model.Message{
Text: helpText.String(),
Chat: msg.Chat,
ReplyTo: msg.ID,
Channel: msg.Channel,
Raw: map[string]interface{}{"parse_mode": "Markdown"},
}
return []*model.MessageAction{
{
Type: model.ActionSendMessage,
Message: response,
Chat: msg.Chat,
Channel: msg.Channel,
},
}
}

View file

@ -0,0 +1,206 @@
package help
import (
"strings"
"testing"
"git.nakama.town/fmartingr/butterrobot/internal/db"
"git.nakama.town/fmartingr/butterrobot/internal/model"
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
)
// MockPlugin implements the Plugin interface for testing
type MockPlugin struct {
id string
name string
help string
}
func (m *MockPlugin) GetID() string { return m.id }
func (m *MockPlugin) GetName() string { return m.name }
func (m *MockPlugin) GetHelp() string { return m.help }
func (m *MockPlugin) RequiresConfig() bool {
return false
}
func (m *MockPlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction {
return nil
}
// MockDatabase implements the ChannelPluginGetter interface for testing
type MockDatabase struct {
channelPlugins map[int64][]*model.ChannelPlugin
platformChannelPlugins map[string][]*model.ChannelPlugin // key: "platform:platformChannelID"
}
func (m *MockDatabase) GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, error) {
if plugins, exists := m.channelPlugins[channelID]; exists {
return plugins, nil
}
return nil, db.ErrNotFound
}
func (m *MockDatabase) GetChannelPluginsFromPlatformID(platform, platformChannelID string) ([]*model.ChannelPlugin, error) {
key := platform + ":" + platformChannelID
if plugins, exists := m.platformChannelPlugins[key]; exists {
return plugins, nil
}
return nil, db.ErrNotFound
}
func TestHelpPlugin_OnMessage(t *testing.T) {
tests := []struct {
name string
messageText string
enabledPlugins map[string]*MockPlugin
expectResponse bool
expectNoPlugins bool
expectCategories []string
}{
{
name: "responds to !help command",
messageText: "!help",
enabledPlugins: map[string]*MockPlugin{
"dev.ping": {
id: "dev.ping",
name: "Ping",
help: "Responds to 'ping' with 'pong'",
},
"fun.dice": {
id: "fun.dice",
name: "Dice Roller",
help: "Rolls dice when you type '!dice [formula]'",
},
},
expectResponse: true,
expectCategories: []string{"Development", "Fun and Entertainment"},
},
{
name: "ignores non-help messages",
messageText: "hello world",
enabledPlugins: map[string]*MockPlugin{},
expectResponse: false,
},
{
name: "ignores case variation",
messageText: "!HELP",
enabledPlugins: map[string]*MockPlugin{},
expectResponse: true,
expectNoPlugins: true,
},
{
name: "handles no enabled plugins",
messageText: "!help",
enabledPlugins: map[string]*MockPlugin{},
expectResponse: true,
expectNoPlugins: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock database
mockDB := &MockDatabase{
channelPlugins: make(map[int64][]*model.ChannelPlugin),
platformChannelPlugins: make(map[string][]*model.ChannelPlugin),
}
// Setup channel plugins in mock database
var channelPluginList []*model.ChannelPlugin
pluginCounter := int64(1)
for pluginID := range tt.enabledPlugins {
channelPluginList = append(channelPluginList, &model.ChannelPlugin{
ID: pluginCounter,
ChannelID: 1,
PluginID: pluginID,
Enabled: true,
Config: make(map[string]interface{}),
})
pluginCounter++
}
// Set up both mapping approaches for the test
mockDB.channelPlugins[1] = channelPluginList
mockDB.platformChannelPlugins["test:test-channel"] = channelPluginList
// Create help plugin
p := New(mockDB)
// Create mock channel
channel := &model.Channel{
ID: 1,
Platform: "test",
PlatformChannelID: "test-channel",
}
// Create test message
msg := &model.Message{
ID: "test-msg",
Text: tt.messageText,
Chat: "test-chat",
Channel: channel,
}
// Mock the plugin registry
originalRegistry := plugin.GetAvailablePlugins()
// Override the registry for this test
plugin.ClearRegistry()
for _, mockPlugin := range tt.enabledPlugins {
plugin.Register(mockPlugin)
}
// Call OnMessage
actions := p.OnMessage(msg, map[string]interface{}{}, nil)
// Restore original registry
plugin.ClearRegistry()
for _, p := range originalRegistry {
plugin.Register(p)
}
if !tt.expectResponse {
if len(actions) != 0 {
t.Errorf("Expected no response, but got %d actions", len(actions))
}
return
}
if len(actions) != 1 {
t.Errorf("Expected 1 action, got %d", len(actions))
return
}
action := actions[0]
if action.Type != model.ActionSendMessage {
t.Errorf("Expected ActionSendMessage, got %v", action.Type)
return
}
responseText := action.Message.Text
if tt.expectNoPlugins {
if !strings.Contains(responseText, "No plugins are currently enabled") {
t.Errorf("Expected 'no plugins' message, got: %s", responseText)
}
return
}
// Check that expected categories appear in response
for _, category := range tt.expectCategories {
if !strings.Contains(responseText, "**"+category+":**") {
t.Errorf("Expected category '%s' in response, got: %s", category, responseText)
}
}
// Check that plugin names and help text appear
for _, mockPlugin := range tt.enabledPlugins {
if !strings.Contains(responseText, mockPlugin.GetName()) {
t.Errorf("Expected plugin name '%s' in response", mockPlugin.GetName())
}
if !strings.Contains(responseText, mockPlugin.GetHelp()) {
t.Errorf("Expected plugin help '%s' in response", mockPlugin.GetHelp())
}
}
})
}
}

View file

@ -47,6 +47,26 @@ func GetAvailablePlugins() map[string]model.Plugin {
return result return result
} }
// GetAvailablePluginIDs returns a slice of all registered plugin IDs
func GetAvailablePluginIDs() []string {
pluginsMu.RLock()
defer pluginsMu.RUnlock()
result := make([]string, 0, len(plugins))
for pluginID := range plugins {
result = append(result, pluginID)
}
return result
}
// ClearRegistry clears all registered plugins (for testing)
func ClearRegistry() {
pluginsMu.Lock()
defer pluginsMu.Unlock()
plugins = make(map[string]model.Plugin)
}
// BasePlugin provides a common base for plugins // BasePlugin provides a common base for plugins
type BasePlugin struct { type BasePlugin struct {
ID string ID string

View file

@ -0,0 +1,331 @@
package plugin
import (
"testing"
"git.nakama.town/fmartingr/butterrobot/internal/model"
)
// Mock plugin for testing
type testPlugin struct {
BasePlugin
}
func (p *testPlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction {
return []*model.MessageAction{
{
Type: model.ActionSendMessage,
Message: &model.Message{
Text: "test response",
Chat: msg.Chat,
Channel: msg.Channel,
},
},
}
}
func TestGetAvailablePluginIDs(t *testing.T) {
// Clear registry before test
ClearRegistry()
// Register test plugins
testPlugin1 := &testPlugin{
BasePlugin: BasePlugin{
ID: "test.plugin1",
Name: "Test Plugin 1",
},
}
testPlugin2 := &testPlugin{
BasePlugin: BasePlugin{
ID: "test.plugin2",
Name: "Test Plugin 2",
},
}
Register(testPlugin1)
Register(testPlugin2)
// Test GetAvailablePluginIDs
pluginIDs := GetAvailablePluginIDs()
if len(pluginIDs) != 2 {
t.Errorf("Expected 2 plugin IDs, got %d", len(pluginIDs))
}
// Check that both plugin IDs are present
found1, found2 := false, false
for _, id := range pluginIDs {
if id == "test.plugin1" {
found1 = true
}
if id == "test.plugin2" {
found2 = true
}
}
if !found1 {
t.Errorf("Expected to find test.plugin1 in plugin IDs")
}
if !found2 {
t.Errorf("Expected to find test.plugin2 in plugin IDs")
}
}
func TestEnableAllPluginsProcessingLogic(t *testing.T) {
// Clear registry before test
ClearRegistry()
// Register test plugins
testPlugin1 := &testPlugin{
BasePlugin: BasePlugin{
ID: "ping",
Name: "Ping Plugin",
},
}
testPlugin2 := &testPlugin{
BasePlugin: BasePlugin{
ID: "echo",
Name: "Echo Plugin",
},
}
testPlugin3 := &testPlugin{
BasePlugin: BasePlugin{
ID: "help",
Name: "Help Plugin",
},
}
Register(testPlugin1)
Register(testPlugin2)
Register(testPlugin3)
t.Run("EnableAllPlugins false - only explicitly enabled plugins", func(t *testing.T) {
// Create a channel with EnableAllPlugins = false and only some plugins enabled
channel := &model.Channel{
ID: 1,
Platform: "telegram",
PlatformChannelID: "123456",
Enabled: true,
EnableAllPlugins: false,
Plugins: map[string]*model.ChannelPlugin{
"ping": {
ID: 1,
ChannelID: 1,
PluginID: "ping",
Enabled: true,
Config: map[string]interface{}{"key": "value"},
},
"echo": {
ID: 2,
ChannelID: 1,
PluginID: "echo",
Enabled: false, // Disabled
Config: map[string]interface{}{},
},
// help plugin not configured
},
}
// Simulate the plugin processing logic from handleMessage
var pluginsToProcess []string
if channel.EnableAllPlugins {
pluginsToProcess = GetAvailablePluginIDs()
} else {
for pluginID := range channel.Plugins {
if channel.HasEnabledPlugin(pluginID) {
pluginsToProcess = append(pluginsToProcess, pluginID)
}
}
}
// Should only have "ping" since echo is disabled and help is not configured
if len(pluginsToProcess) != 1 {
t.Errorf("Expected 1 plugin to process, got %d: %v", len(pluginsToProcess), pluginsToProcess)
}
if len(pluginsToProcess) > 0 && pluginsToProcess[0] != "ping" {
t.Errorf("Expected ping plugin to be processed, got %s", pluginsToProcess[0])
}
})
t.Run("EnableAllPlugins true - all registered plugins", func(t *testing.T) {
// Create a channel with EnableAllPlugins = true
channel := &model.Channel{
ID: 1,
Platform: "telegram",
PlatformChannelID: "123456",
Enabled: true,
EnableAllPlugins: true,
Plugins: map[string]*model.ChannelPlugin{
"ping": {
ID: 1,
ChannelID: 1,
PluginID: "ping",
Enabled: true,
Config: map[string]interface{}{"key": "value"},
},
"echo": {
ID: 2,
ChannelID: 1,
PluginID: "echo",
Enabled: false, // Disabled, but should still be processed
Config: map[string]interface{}{},
},
// help plugin not configured, but should still be processed
},
}
// Simulate the plugin processing logic from handleMessage
var pluginsToProcess []string
if channel.EnableAllPlugins {
pluginsToProcess = GetAvailablePluginIDs()
} else {
for pluginID := range channel.Plugins {
if channel.HasEnabledPlugin(pluginID) {
pluginsToProcess = append(pluginsToProcess, pluginID)
}
}
}
// Should have all 3 registered plugins
if len(pluginsToProcess) != 3 {
t.Errorf("Expected 3 plugins to process, got %d: %v", len(pluginsToProcess), pluginsToProcess)
}
// Check that all plugins are included
expectedPlugins := map[string]bool{"ping": false, "echo": false, "help": false}
for _, pluginID := range pluginsToProcess {
if _, exists := expectedPlugins[pluginID]; exists {
expectedPlugins[pluginID] = true
} else {
t.Errorf("Unexpected plugin in processing list: %s", pluginID)
}
}
for pluginID, found := range expectedPlugins {
if !found {
t.Errorf("Expected plugin %s to be in processing list", pluginID)
}
}
})
t.Run("Plugin configuration handling", func(t *testing.T) {
// Test the configuration logic from handleMessage
channel := &model.Channel{
ID: 1,
Platform: "telegram",
PlatformChannelID: "123456",
Enabled: true,
EnableAllPlugins: true,
Plugins: map[string]*model.ChannelPlugin{
"ping": {
ID: 1,
ChannelID: 1,
PluginID: "ping",
Enabled: true,
Config: map[string]interface{}{"configured": "value"},
},
},
}
testCases := []struct {
pluginID string
expectedConfig map[string]interface{}
}{
{
pluginID: "ping",
expectedConfig: map[string]interface{}{"configured": "value"},
},
{
pluginID: "echo", // Not explicitly configured
expectedConfig: map[string]interface{}{},
},
}
for _, tc := range testCases {
// Simulate the config retrieval logic from handleMessage
var config map[string]interface{}
if channelPlugin, exists := channel.Plugins[tc.pluginID]; exists {
config = channelPlugin.Config
} else {
config = make(map[string]interface{})
}
if len(config) != len(tc.expectedConfig) {
t.Errorf("Plugin %s: expected config length %d, got %d", tc.pluginID, len(tc.expectedConfig), len(config))
}
for key, expectedValue := range tc.expectedConfig {
if actualValue, exists := config[key]; !exists || actualValue != expectedValue {
t.Errorf("Plugin %s: expected config[%s] = %v, got %v", tc.pluginID, key, expectedValue, actualValue)
}
}
}
})
}
func TestPluginRegistry(t *testing.T) {
// Clear registry before test
ClearRegistry()
testPlugin := &testPlugin{
BasePlugin: BasePlugin{
ID: "test.registry",
Name: "Test Registry Plugin",
},
}
t.Run("Register and Get plugin", func(t *testing.T) {
Register(testPlugin)
retrieved, err := Get("test.registry")
if err != nil {
t.Errorf("Failed to get registered plugin: %v", err)
}
if retrieved.GetID() != "test.registry" {
t.Errorf("Expected plugin ID 'test.registry', got '%s'", retrieved.GetID())
}
})
t.Run("Get nonexistent plugin", func(t *testing.T) {
_, err := Get("nonexistent.plugin")
if err == nil {
t.Errorf("Expected error when getting nonexistent plugin, got nil")
}
if err != model.ErrPluginNotFound {
t.Errorf("Expected ErrPluginNotFound, got %v", err)
}
})
t.Run("GetAvailablePlugins", func(t *testing.T) {
plugins := GetAvailablePlugins()
if len(plugins) != 1 {
t.Errorf("Expected 1 plugin in registry, got %d", len(plugins))
}
if plugin, exists := plugins["test.registry"]; !exists {
t.Errorf("Expected to find test.registry in available plugins")
} else if plugin.GetID() != "test.registry" {
t.Errorf("Expected plugin ID 'test.registry', got '%s'", plugin.GetID())
}
})
t.Run("ClearRegistry", func(t *testing.T) {
ClearRegistry()
plugins := GetAvailablePlugins()
if len(plugins) != 0 {
t.Errorf("Expected 0 plugins after clearing registry, got %d", len(plugins))
}
_, err := Get("test.registry")
if err == nil {
t.Errorf("Expected error when getting plugin after clearing registry, got nil")
}
})
}

View file

@ -23,7 +23,7 @@ func New() *SearchReplacePlugin {
BasePlugin: plugin.BasePlugin{ BasePlugin: plugin.BasePlugin{
ID: "util.searchreplace", ID: "util.searchreplace",
Name: "Search and Replace", Name: "Search and Replace",
Help: "Reply to a message with a search and replace pattern (s/search/replace/[flags]) to create a modified message. " + Help: "Reply to a message with a search and replace pattern (`s/search/replace/[flags]`) to create a modified message. " +
"Supported flags: g (global), i (case insensitive)", "Supported flags: g (global), i (case insensitive)",
}, },
} }

View file

@ -20,7 +20,8 @@ func NewInstagramExpander() *InstagramExpander {
BasePlugin: plugin.BasePlugin{ BasePlugin: plugin.BasePlugin{
ID: "social.instagram", ID: "social.instagram",
Name: "Instagram Link Expander", Name: "Instagram Link Expander",
Help: "Automatically converts instagram.com links to ddinstagram.com links and removes tracking parameters", Help: "Automatically converts instagram.com links to alternative domain links and removes tracking parameters. Configure 'domain' option to set replacement domain (default: ddinstagram.com)",
ConfigRequired: true,
}, },
} }
} }
@ -32,6 +33,12 @@ func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]inte
return nil return nil
} }
// Get replacement domain from config, default to ddinstagram.com
replacementDomain := "ddinstagram.com"
if domain, ok := config["domain"].(string); ok && domain != "" {
replacementDomain = domain
}
// Regex to match instagram.com links // Regex to match instagram.com links
// Match both http://instagram.com and https://instagram.com formats // Match both http://instagram.com and https://instagram.com formats
// Also match www.instagram.com // Also match www.instagram.com
@ -42,7 +49,7 @@ func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]inte
return nil return nil
} }
// Replace instagram.com with ddinstagram.com in the message and clean query parameters // Replace instagram.com with configured domain in the message and clean query parameters
transformed := instagramRegex.ReplaceAllStringFunc(msg.Text, func(link string) string { transformed := instagramRegex.ReplaceAllStringFunc(msg.Text, func(link string) string {
// Parse the URL // Parse the URL
parsedURL, err := url.Parse(link) parsedURL, err := url.Parse(link)
@ -51,13 +58,13 @@ func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]inte
return link return link
} }
// Ensure we don't change links that already come from ddinstagram.com // Ensure we don't change links that already come from the replacement domain
if parsedURL.Host != "instagram.com" && parsedURL.Host != "www.instagram.com" { if parsedURL.Host != "instagram.com" && parsedURL.Host != "www.instagram.com" {
return link return link
} }
// Change the host // Change the host to the configured domain
parsedURL.Host = "d.ddinstagram.com" parsedURL.Host = replacementDomain
// Remove query parameters // Remove query parameters
parsedURL.RawQuery = "" parsedURL.RawQuery = ""

View file

@ -20,7 +20,8 @@ func NewTwitterExpander() *TwitterExpander {
BasePlugin: plugin.BasePlugin{ BasePlugin: plugin.BasePlugin{
ID: "social.twitter", ID: "social.twitter",
Name: "Twitter Link Expander", Name: "Twitter Link Expander",
Help: "Automatically converts twitter.com links to fxtwitter.com links and removes tracking parameters", Help: "Automatically converts twitter.com and x.com links to alternative domain links and removes tracking parameters. Configure 'domain' option to set replacement domain (default: fxtwitter.com)",
ConfigRequired: true,
}, },
} }
} }
@ -32,6 +33,12 @@ func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interf
return nil return nil
} }
// Get replacement domain from config, default to fxtwitter.com
replacementDomain := "fxtwitter.com"
if domain, ok := config["domain"].(string); ok && domain != "" {
replacementDomain = domain
}
// Regex to match twitter.com links // Regex to match twitter.com links
// Match both http://twitter.com and https://twitter.com formats // Match both http://twitter.com and https://twitter.com formats
// Also match www.twitter.com // Also match www.twitter.com
@ -42,22 +49,17 @@ func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interf
return nil return nil
} }
// Replace twitter.com with fxtwitter.com in the message and clean query parameters // Replace twitter.com/x.com with configured domain in the message and clean query parameters
transformed := twitterRegex.ReplaceAllStringFunc(msg.Text, func(link string) string { transformed := twitterRegex.ReplaceAllStringFunc(msg.Text, func(link string) string {
// Parse the URL // Parse the URL
parsedURL, err := url.Parse(link) parsedURL, err := url.Parse(link)
if err != nil { 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 return link
} }
// Change the host // Change the host to the configured domain
if strings.Contains(parsedURL.Host, "twitter.com") { if strings.Contains(parsedURL.Host, "twitter.com") || strings.Contains(parsedURL.Host, "x.com") {
parsedURL.Host = strings.Replace(parsedURL.Host, "twitter.com", "fxtwitter.com", 1) parsedURL.Host = replacementDomain
} else if strings.Contains(parsedURL.Host, "x.com") {
parsedURL.Host = strings.Replace(parsedURL.Host, "x.com", "fxtwitter.com", 1)
} }
// Remove query parameters // Remove query parameters

View file

@ -0,0 +1,120 @@
package social
import (
"testing"
"git.nakama.town/fmartingr/butterrobot/internal/model"
)
func TestTwitterExpander_OnMessage(t *testing.T) {
plugin := NewTwitterExpander()
tests := []struct {
name string
input string
config map[string]interface{}
expected string
hasReply bool
}{
{
name: "Twitter URL with default domain",
input: "https://twitter.com/user/status/123456789",
config: map[string]interface{}{},
expected: "https://fxtwitter.com/user/status/123456789",
hasReply: true,
},
{
name: "X.com URL with custom domain",
input: "https://x.com/elonmusk/status/987654321",
config: map[string]interface{}{"domain": "vxtwitter.com"},
expected: "https://vxtwitter.com/elonmusk/status/987654321",
hasReply: true,
},
{
name: "Twitter URL with tracking parameters",
input: "https://twitter.com/openai/status/555?ref_src=twsrc%5Etfw&s=20",
config: map[string]interface{}{},
expected: "https://fxtwitter.com/openai/status/555",
hasReply: true,
},
{
name: "www.twitter.com URL",
input: "https://www.twitter.com/user/status/789",
config: map[string]interface{}{"domain": "nitter.net"},
expected: "https://nitter.net/user/status/789",
hasReply: true,
},
{
name: "Mixed text with Twitter URL",
input: "Check this out: https://twitter.com/user/status/123 amazing!",
config: map[string]interface{}{},
expected: "Check this out: https://fxtwitter.com/user/status/123 amazing!",
hasReply: true,
},
{
name: "No Twitter URLs",
input: "Just some regular text https://youtube.com/watch?v=abc",
config: map[string]interface{}{},
expected: "",
hasReply: false,
},
{
name: "Empty message",
input: "",
config: map[string]interface{}{},
expected: "",
hasReply: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg := &model.Message{
ID: "test_msg",
Text: tt.input,
Chat: "test_chat",
Channel: &model.Channel{
ID: 1,
Platform: "telegram",
PlatformChannelID: "test_chat",
},
}
actions := plugin.OnMessage(msg, tt.config, nil)
if !tt.hasReply {
if len(actions) != 0 {
t.Errorf("Expected no actions, got %d", len(actions))
}
return
}
if len(actions) != 1 {
t.Errorf("Expected 1 action, got %d", len(actions))
return
}
action := actions[0]
if action.Type != model.ActionSendMessage {
t.Errorf("Expected ActionSendMessage, got %s", action.Type)
}
if action.Message == nil {
t.Error("Expected message in action, got nil")
return
}
if action.Message.Text != tt.expected {
t.Errorf("Expected '%s', got '%s'", tt.expected, action.Message.Text)
}
if action.Message.ReplyTo != msg.ID {
t.Errorf("Expected ReplyTo '%s', got '%s'", msg.ID, action.Message.ReplyTo)
}
if action.Message.Raw == nil || action.Message.Raw["parse_mode"] != "" {
t.Error("Expected parse_mode to be empty string to disable markdown parsing")
}
})
}
}