Compare commits

..

No commits in common. "master" and "v0.2.2" have entirely different histories.

31 changed files with 130 additions and 1935 deletions

View file

@ -3,7 +3,7 @@ when:
- push - push
- pull_request - pull_request
branch: branch:
- master - main
steps: steps:
format: format:

View file

@ -1,6 +1,6 @@
when: when:
- event: tag - event: tag
branch: master branch: main
steps: steps:
- name: Release - name: Release

View file

@ -1,6 +1,9 @@
# Butter Robot # Butter Robot
![Status badge](https://woodpecker.local.fmartingr.dev/api/badges/5/status.svg) | Stable | Master |
| --- | --- |
| ![Build stable tag docker image](https://git.nakama.town/fmartingr/butterrobot/workflows/Build%20stable%20tag%20docker%20image/badge.svg?branch=stable) | ![Build latest tag docker image](https://git.nakama.town/fmartingr/butterrobot/workflows/Build%20latest%20tag%20docker%20image/badge.svg?branch=master) |
| ![Test](https://git.nakama.town/fmartingr/butterrobot/workflows/Test/badge.svg?branch=stable) | ![Test](https://git.nakama.town/fmartingr/butterrobot/workflows/Test/badge.svg?branch=master) |
Go framework to create bots for several platforms. Go framework to create bots for several platforms.
@ -10,7 +13,7 @@ Go framework to create bots for several platforms.
## Features ## Features
- Support for multiple chat platforms (Slack (untested!), Telegram) - Support for multiple chat platforms (Slack, Telegram)
- Plugin system for easy extension - Plugin system for easy extension
- Admin interface for managing channels and plugins - Admin interface for managing channels and plugins
- Message queue for asynchronous processing - Message queue for asynchronous processing

View file

@ -1,19 +1,6 @@
# Creating a Plugin # Creating a Plugin
## Plugin Categories ## Example
ButterRobot organizes plugins into different categories:
- **Development**: Utility plugins like `ping`
- **Fun**: Entertainment plugins like dice rolling, coin flipping
- **Social**: Social media related plugins like URL transformers/expanders
- **Security**: Moderation and protection features like domain blocking
When creating a new plugin, consider which category it fits into and place it in the appropriate directory.
## Plugin Examples
### Basic Example: Marco Polo
This simple "Marco Polo" plugin will answer _Polo_ to the user that says _Marco_: This simple "Marco Polo" plugin will answer _Polo_ to the user that says _Marco_:
@ -60,207 +47,6 @@ 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:
```go
package social
import (
"net/url"
"regexp"
"strings"
"git.nakama.town/fmartingr/butterrobot/internal/model"
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
)
// TwitterExpander transforms twitter.com links to fxtwitter.com links
type TwitterExpander struct {
plugin.BasePlugin
}
// New creates a new TwitterExpander instance
func NewTwitter() *TwitterExpander {
return &TwitterExpander{
BasePlugin: plugin.BasePlugin{
ID: "social.twitter",
Name: "Twitter Link Expander",
Help: "Automatically converts twitter.com links to fxtwitter.com links and removes tracking parameters",
},
}
}
// OnMessage handles incoming messages
func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
// Skip empty messages
if strings.TrimSpace(msg.Text) == "" {
return nil
}
// Regex to match twitter.com links
twitterRegex := regexp.MustCompile(`https?://(www\.)?(twitter\.com|x\.com)/[^\s]+`)
// Check if the message contains a Twitter link
if !twitterRegex.MatchString(msg.Text) {
return nil
}
// Transform the URL
transformed := twitterRegex.ReplaceAllStringFunc(msg.Text, func(link string) string {
// Parse the URL
parsedURL, err := url.Parse(link)
if err != nil {
// If parsing fails, just do the simple replacement
link = strings.Replace(link, "twitter.com", "fxtwitter.com", 1)
link = strings.Replace(link, "x.com", "fxtwitter.com", 1)
return link
}
// Change the host
if strings.Contains(parsedURL.Host, "twitter.com") {
parsedURL.Host = strings.Replace(parsedURL.Host, "twitter.com", "fxtwitter.com", 1)
} else if strings.Contains(parsedURL.Host, "x.com") {
parsedURL.Host = strings.Replace(parsedURL.Host, "x.com", "fxtwitter.com", 1)
}
// Remove query parameters
parsedURL.RawQuery = ""
// Return the cleaned URL
return parsedURL.String()
})
// Create response message
response := &model.Message{
Text: transformed,
Chat: msg.Chat,
ReplyTo: msg.ID,
Channel: msg.Channel,
}
return []*model.Message{response}
}
```
## Enabling Configuration for Plugins
To indicate that your plugin requires configuration:
1. Set `ConfigRequired: true` in the BasePlugin struct:
```go
BasePlugin: plugin.BasePlugin{
ID: "myplugin.id",
Name: "Plugin Name",
Help: "Help text",
ConfigRequired: true,
},
```
2. Access the configuration in the OnMessage method:
```go
func (p *MyPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
// Extract configuration values
configValue, ok := config["some_config_key"].(string)
if !ok || configValue == "" {
// Handle missing or empty configuration
return nil
}
// Use the configuration...
}
```
3. The admin interface will show a "Configure" button for plugins that require configuration.
## Registering Plugins
To use the plugin, register it in your application: To use the plugin, register it in your application:
```go ```go
@ -269,19 +55,8 @@ func (a *App) Run() error {
// ... // ...
// Register plugins // Register plugins
plugin.Register(ping.New()) // Development plugin plugin.Register(myplugin.New())
plugin.Register(fun.NewCoin()) // Fun plugin
plugin.Register(social.NewTwitter()) // Social media plugin
plugin.Register(myplugin.New()) // Your custom plugin
// ... // ...
} }
``` ```
Alternatively, you can register your plugin in its init() function:
```go
func init() {
plugin.Register(New())
}
```

View file

@ -9,16 +9,3 @@
- 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.
### Utility
- 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.
- 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.

View file

@ -46,7 +46,6 @@ type TemplateData struct {
Channels []*model.Channel Channels []*model.Channel
Channel *model.Channel Channel *model.Channel
ChannelPlugin *model.ChannelPlugin ChannelPlugin *model.ChannelPlugin
Version string
} }
// Admin represents the admin interface // Admin represents the admin interface
@ -56,11 +55,10 @@ type Admin struct {
store *sessions.CookieStore store *sessions.CookieStore
templates map[string]*template.Template templates map[string]*template.Template
baseTemplate *template.Template baseTemplate *template.Template
version string
} }
// New creates a new Admin instance // New creates a new Admin instance
func New(cfg *config.Config, database *db.Database, version string) *Admin { func New(cfg *config.Config, database *db.Database) *Admin {
// Create session store with appropriate options // Create session store with appropriate options
store := sessions.NewCookieStore([]byte(cfg.SecretKey)) store := sessions.NewCookieStore([]byte(cfg.SecretKey))
store.Options = &sessions.Options{ store.Options = &sessions.Options{
@ -98,7 +96,6 @@ func New(cfg *config.Config, database *db.Database, version string) *Admin {
"channel_detail.html", "channel_detail.html",
"plugin_list.html", "plugin_list.html",
"channel_plugins_list.html", "channel_plugins_list.html",
"channel_plugin_config.html",
} }
for _, tf := range templateFiles { for _, tf := range templateFiles {
@ -107,19 +104,19 @@ func New(cfg *config.Config, database *db.Database, version string) *Admin {
if err != nil { if err != nil {
panic(err) panic(err)
} }
// Create a clone of the base template // Create a clone of the base template
t, err := baseTemplate.Clone() t, err := baseTemplate.Clone()
if err != nil { if err != nil {
panic(err) panic(err)
} }
// Parse the template content // Parse the template content
t, err = t.Parse(string(content)) t, err = t.Parse(string(content))
if err != nil { if err != nil {
panic(err) panic(err)
} }
templates[tf] = t templates[tf] = t
} }
@ -129,7 +126,6 @@ func New(cfg *config.Config, database *db.Database, version string) *Admin {
store: store, store: store,
templates: templates, templates: templates,
baseTemplate: baseTemplate, baseTemplate: baseTemplate,
version: version,
} }
} }
@ -144,7 +140,6 @@ func (a *Admin) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/admin/channels", a.handleChannelList) mux.HandleFunc("/admin/channels", a.handleChannelList)
mux.HandleFunc("/admin/channels/", a.handleChannelDetail) mux.HandleFunc("/admin/channels/", a.handleChannelDetail)
mux.HandleFunc("/admin/channelplugins", a.handleChannelPluginList) mux.HandleFunc("/admin/channelplugins", a.handleChannelPluginList)
mux.HandleFunc("/admin/channelplugins/config/", a.handleChannelPluginConfig)
mux.HandleFunc("/admin/channelplugins/", a.handleChannelPluginDetailOrDelete) mux.HandleFunc("/admin/channelplugins/", a.handleChannelPluginDetailOrDelete)
} }
@ -196,7 +191,7 @@ func (a *Admin) addFlash(w http.ResponseWriter, r *http.Request, message string,
} }
// Map internal categories to Bootstrap alert classes // Map internal categories to Bootstrap alert classes
var alertClass string alertClass := category
switch category { switch category {
case "success": case "success":
alertClass = "success" alertClass = "success"
@ -251,6 +246,17 @@ func (a *Admin) getFlashes(w http.ResponseWriter, r *http.Request) []FlashMessag
return messages return messages
} }
// requireLogin middleware checks if the user is logged in
func (a *Admin) requireLogin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !a.isLoggedIn(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
next(w, r)
}
}
// render renders a template with the given data // render renders a template with the given data
func (a *Admin) render(w http.ResponseWriter, r *http.Request, templateName string, data TemplateData) { func (a *Admin) render(w http.ResponseWriter, r *http.Request, templateName string, data TemplateData) {
// Add current user data // Add current user data
@ -258,7 +264,6 @@ func (a *Admin) render(w http.ResponseWriter, r *http.Request, templateName stri
data.LoggedIn = a.isLoggedIn(r) data.LoggedIn = a.isLoggedIn(r)
data.Path = r.URL.Path data.Path = r.URL.Path
data.Flash = a.getFlashes(w, r) data.Flash = a.getFlashes(w, r)
data.Version = a.version
// Get template // Get template
tmpl, ok := a.templates[templateName] tmpl, ok := a.templates[templateName]
@ -325,10 +330,7 @@ func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
// Set session expiration // Set session expiration
session.Options.MaxAge = 3600 * 24 * 7 // 1 week session.Options.MaxAge = 3600 * 24 * 7 // 1 week
err = session.Save(r, w) session.Save(r, w)
if err != nil {
fmt.Printf("Error saving session: %v\n", err)
}
a.addFlash(w, r, "You were logged in", "success") a.addFlash(w, r, "You were logged in", "success")
@ -356,7 +358,7 @@ func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther) http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return return
} }
session.Values = make(map[interface{}]interface{}) session.Values = make(map[interface{}]interface{})
session.Options.MaxAge = -1 // Delete session session.Options.MaxAge = -1 // Delete session
err = session.Save(r, w) err = session.Save(r, w)
@ -630,96 +632,6 @@ 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 // handleChannelPluginDetailOrDelete handles the channel plugin detail or delete route
func (a *Admin) handleChannelPluginDetailOrDelete(w http.ResponseWriter, r *http.Request) { func (a *Admin) handleChannelPluginDetailOrDelete(w http.ResponseWriter, r *http.Request) {
// Check if user is logged in // Check if user is logged in

View file

@ -117,19 +117,6 @@
</div> </div>
</div> </div>
<footer class="footer footer-transparent d-print-none">
<div class="container-xl">
<div class="row text-center align-items-center flex-row-reverse">
<div class="col-12 col-lg-auto mt-3 mt-lg-0">
<ul class="list-inline list-inline-dots mb-0">
<li class="list-inline-item">
ButterRobot {{if .Version}}v{{.Version}}{{else}}(development){{end}}
</li>
</ul>
</div>
</div>
</div>
</footer>
</div> </div>
<script src="https://unpkg.com/@tabler/core@latest/dist/js/tabler.min.js"></script> <script src="https://unpkg.com/@tabler/core@latest/dist/js/tabler.min.js"></script>

View file

@ -68,10 +68,6 @@
{{if $channelPlugin.Enabled}}Disable{{else}}Enable{{end}} {{if $channelPlugin.Enabled}}Disable{{else}}Enable{{end}}
</button> </button>
</form> </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"> <form method="post" action="/admin/channelplugins/{{$channelPlugin.ID}}/delete" class="d-inline">
<button type="submit" class="btn btn-danger btn-sm" <button type="submit" class="btn btn-danger btn-sm"
onclick="return confirm('Are you sure you want to remove this plugin?')">Remove</button> onclick="return confirm('Are you sure you want to remove this plugin?')">Remove</button>

View file

@ -1,37 +0,0 @@
{{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}}

View file

@ -38,10 +38,6 @@
{{if $channelPlugin.Enabled}}Disable{{else}}Enable{{end}} {{if $channelPlugin.Enabled}}Disable{{else}}Enable{{end}}
</button> </button>
</form> </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"> <form method="post" action="/admin/channelplugins/{{$channelPlugin.ID}}/delete" class="d-inline">
<button type="submit" class="btn btn-danger btn-sm" <button type="submit" class="btn btn-danger btn-sm"
onclick="return confirm('Are you sure you want to remove this plugin?')">Remove</button> onclick="return confirm('Are you sure you want to remove this plugin?')">Remove</button>
@ -94,4 +90,4 @@
</div> </div>
</div> </div>
</div> </div>
{{end}} {{end}}

View file

@ -9,7 +9,6 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"runtime/debug"
"strings" "strings"
"syscall" "syscall"
"time" "time"
@ -17,26 +16,21 @@ import (
"git.nakama.town/fmartingr/butterrobot/internal/admin" "git.nakama.town/fmartingr/butterrobot/internal/admin"
"git.nakama.town/fmartingr/butterrobot/internal/config" "git.nakama.town/fmartingr/butterrobot/internal/config"
"git.nakama.town/fmartingr/butterrobot/internal/db" "git.nakama.town/fmartingr/butterrobot/internal/db"
"git.nakama.town/fmartingr/butterrobot/internal/model"
"git.nakama.town/fmartingr/butterrobot/internal/platform" "git.nakama.town/fmartingr/butterrobot/internal/platform"
"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/fun" "git.nakama.town/fmartingr/butterrobot/internal/plugin/fun"
"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/social"
"git.nakama.town/fmartingr/butterrobot/internal/queue" "git.nakama.town/fmartingr/butterrobot/internal/queue"
) )
// App represents the application // App represents the application
type App struct { type App struct {
config *config.Config config *config.Config
logger *slog.Logger logger *slog.Logger
db *db.Database db *db.Database
router *http.ServeMux router *http.ServeMux
queue *queue.Queue queue *queue.Queue
admin *admin.Admin admin *admin.Admin
version string
} }
// New creates a new App instance // New creates a new App instance
@ -53,24 +47,16 @@ func New(cfg *config.Config, logger *slog.Logger) (*App, error) {
// Initialize message queue // Initialize message queue
messageQueue := queue.New(logger) messageQueue := queue.New(logger)
// Get version information
version := ""
info, ok := debug.ReadBuildInfo()
if ok {
version = info.Main.Version
}
// Initialize admin interface // Initialize admin interface
adminInterface := admin.New(cfg, database, version) adminInterface := admin.New(cfg, database)
return &App{ return &App{
config: cfg, config: cfg,
logger: logger, logger: logger,
db: database, db: database,
router: router, router: router,
queue: messageQueue, queue: messageQueue,
admin: adminInterface, admin: adminInterface,
version: version,
}, nil }, nil
} }
@ -86,10 +72,6 @@ func (a *App) Run() error {
plugin.Register(fun.NewCoin()) plugin.Register(fun.NewCoin())
plugin.Register(fun.NewDice()) plugin.Register(fun.NewDice())
plugin.Register(fun.NewLoquito()) plugin.Register(fun.NewLoquito())
plugin.Register(social.NewTwitterExpander())
plugin.Register(social.NewInstagramExpander())
plugin.Register(reminder.New(a.db))
plugin.Register(domainblock.New())
// Initialize routes // Initialize routes
a.initializeRoutes() a.initializeRoutes()
@ -97,9 +79,6 @@ func (a *App) Run() error {
// Start message queue worker // Start message queue worker
a.queue.Start(a.handleMessage) a.queue.Start(a.handleMessage)
// Start reminder scheduler
a.queue.StartReminderScheduler(a.handleReminder)
// Create server // Create server
addr := fmt.Sprintf(":%s", a.config.Port) addr := fmt.Sprintf(":%s", a.config.Port)
srv := &http.Server{ srv := &http.Server{
@ -151,9 +130,7 @@ func (a *App) initializeRoutes() {
a.router.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { a.router.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(map[string]interface{}{}); err != nil { json.NewEncoder(w).Encode(map[string]interface{}{})
a.logger.Error("Error encoding response", "error", err)
}
}) })
// Platform webhook endpoints // Platform webhook endpoints
@ -176,9 +153,7 @@ func (a *App) handleIncomingWebhook(w http.ResponseWriter, r *http.Request) {
if _, err := platform.Get(platformName); err != nil { if _, err := platform.Get(platformName); err != nil {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(map[string]string{"error": "Unknown platform"}); err != nil { json.NewEncoder(w).Encode(map[string]string{"error": "Unknown platform"})
a.logger.Error("Error encoding response", "error", err)
}
return return
} }
@ -187,9 +162,7 @@ func (a *App) handleIncomingWebhook(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(map[string]string{"error": "Failed to read request body"}); err != nil { json.NewEncoder(w).Encode(map[string]string{"error": "Failed to read request body"})
a.logger.Error("Error encoding response", "error", err)
}
return return
} }
@ -205,9 +178,7 @@ func (a *App) handleIncomingWebhook(w http.ResponseWriter, r *http.Request) {
// Respond with success // Respond with success
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(map[string]any{}); err != nil { json.NewEncoder(w).Encode(map[string]any{})
a.logger.Error("Error encoding response", "error", err)
}
} }
// extractPlatformName extracts the platform name from the URL path // extractPlatformName extracts the platform name from the URL path
@ -303,110 +274,20 @@ func (a *App) handleMessage(item queue.Item) {
continue continue
} }
// Process message and get actions // Process message
actions := p.OnMessage(message, channelPlugin.Config) responses := p.OnMessage(message, channelPlugin.Config)
// Get platform for processing actions // Send responses
platform, err := platform.Get(item.Platform) platform, err := platform.Get(item.Platform)
if err != nil { if err != nil {
a.logger.Error("Error getting platform", "error", err) a.logger.Error("Error getting platform", "error", err)
continue continue
} }
// Process each action for _, response := range responses {
for _, action := range actions { if err := platform.SendMessage(response); err != nil {
switch action.Type { a.logger.Error("Error sending message", "error", err)
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)
} }
} }
} }
} }
// handleReminder handles reminder processing
func (a *App) handleReminder(reminder *model.Reminder) {
// When called with nil, it means we should check for pending reminders
if reminder == nil {
// Get pending reminders
reminders, err := a.db.GetPendingReminders()
if err != nil {
a.logger.Error("Error getting pending reminders", "error", err)
return
}
// Process each reminder
for _, r := range reminders {
a.processReminder(r)
}
return
}
// Otherwise, process the specific reminder
a.processReminder(reminder)
}
// processReminder processes an individual reminder
func (a *App) processReminder(reminder *model.Reminder) {
a.logger.Info("Processing reminder",
"id", reminder.ID,
"platform", reminder.Platform,
"channel", reminder.ChannelID,
"trigger_at", reminder.TriggerAt,
)
// Get the platform handler
p, err := platform.Get(reminder.Platform)
if err != nil {
a.logger.Error("Error getting platform for reminder", "error", err, "platform", reminder.Platform)
return
}
// Get the channel
channel, err := a.db.GetChannelByPlatform(reminder.Platform, reminder.ChannelID)
if err != nil {
a.logger.Error("Error getting channel for reminder", "error", err)
return
}
// Create the reminder message
reminderText := fmt.Sprintf("@%s reminding you of this", reminder.Username)
message := &model.Message{
Text: reminderText,
Chat: reminder.ChannelID,
Channel: channel,
Author: "bot",
FromBot: true,
Date: time.Now(),
ReplyTo: reminder.ReplyToID, // Reply to the original message
}
// Send the reminder message
if err := p.SendMessage(message); err != nil {
a.logger.Error("Error sending reminder", "error", err)
return
}
// Mark the reminder as processed
if err := a.db.MarkReminderAsProcessed(reminder.ID); err != nil {
a.logger.Error("Error marking reminder as processed", "error", err)
}
}

View file

@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"time"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
@ -234,11 +233,7 @@ func (d *Database) GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, e
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer func() { defer rows.Close()
if err := rows.Close(); err != nil {
fmt.Printf("Error closing rows: %v\n", err)
}
}()
var plugins []*model.ChannelPlugin var plugins []*model.ChannelPlugin
@ -386,24 +381,6 @@ func (d *Database) UpdateChannelPlugin(id int64, enabled bool) error {
return err 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 // DeleteChannelPlugin deletes a channel plugin
func (d *Database) DeleteChannelPlugin(id int64) error { func (d *Database) DeleteChannelPlugin(id int64) error {
query := ` query := `
@ -437,11 +414,7 @@ func (d *Database) GetAllChannels() ([]*model.Channel, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer func() { defer rows.Close()
if err := rows.Close(); err != nil {
fmt.Printf("Error closing rows: %v\n", err)
}
}()
var channels []*model.Channel var channels []*model.Channel
@ -480,9 +453,10 @@ func (d *Database) GetAllChannels() ([]*model.Channel, error) {
continue // Skip this channel if plugins can't be retrieved continue // Skip this channel if plugins can't be retrieved
} }
// Add plugins to channel if plugins != nil {
for _, plugin := range plugins { for _, plugin := range plugins {
channel.Plugins[plugin.PluginID] = plugin channel.Plugins[plugin.PluginID] = plugin
}
} }
channels = append(channels, channel) channels = append(channels, channel)
@ -617,124 +591,6 @@ func (d *Database) UpdateUserPassword(userID int64, newPassword string) error {
return err return err
} }
// CreateReminder creates a new reminder
func (d *Database) CreateReminder(platform, channelID, messageID, replyToID, userID, username, content string, triggerAt time.Time) (*model.Reminder, error) {
query := `
INSERT INTO reminders (
platform, channel_id, message_id, reply_to_id,
user_id, username, created_at, trigger_at,
content, processed
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
`
createdAt := time.Now()
result, err := d.db.Exec(
query,
platform, channelID, messageID, replyToID,
userID, username, createdAt, triggerAt,
content,
)
if err != nil {
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
return &model.Reminder{
ID: id,
Platform: platform,
ChannelID: channelID,
MessageID: messageID,
ReplyToID: replyToID,
UserID: userID,
Username: username,
CreatedAt: createdAt,
TriggerAt: triggerAt,
Content: content,
Processed: false,
}, nil
}
// GetPendingReminders gets all pending reminders that need to be processed
func (d *Database) GetPendingReminders() ([]*model.Reminder, error) {
query := `
SELECT id, platform, channel_id, message_id, reply_to_id,
user_id, username, created_at, trigger_at, content, processed
FROM reminders
WHERE processed = 0 AND trigger_at <= ?
`
rows, err := d.db.Query(query, time.Now())
if err != nil {
return nil, err
}
defer func() {
if err := rows.Close(); err != nil {
fmt.Printf("Error closing rows: %v\n", err)
}
}()
var reminders []*model.Reminder
for rows.Next() {
var (
id int64
platform, channelID, messageID, replyToID string
userID, username, content string
createdAt, triggerAt time.Time
processed bool
)
if err := rows.Scan(
&id, &platform, &channelID, &messageID, &replyToID,
&userID, &username, &createdAt, &triggerAt, &content, &processed,
); err != nil {
return nil, err
}
reminder := &model.Reminder{
ID: id,
Platform: platform,
ChannelID: channelID,
MessageID: messageID,
ReplyToID: replyToID,
UserID: userID,
Username: username,
CreatedAt: createdAt,
TriggerAt: triggerAt,
Content: content,
Processed: processed,
}
reminders = append(reminders, reminder)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(reminders) == 0 {
return make([]*model.Reminder, 0), nil
}
return reminders, nil
}
// MarkReminderAsProcessed marks a reminder as processed
func (d *Database) MarkReminderAsProcessed(id int64) error {
query := `
UPDATE reminders
SET processed = 1
WHERE id = ?
`
_, err := d.db.Exec(query, id)
return err
}
// Helper function to hash password // Helper function to hash password
func hashPassword(password string) (string, error) { func hashPassword(password string) (string, error) {
// Use bcrypt for secure password hashing // Use bcrypt for secure password hashing
@ -753,25 +609,25 @@ func initDatabase(db *sql.DB) error {
if err := migration.EnsureMigrationTable(db); err != nil { if err := migration.EnsureMigrationTable(db); err != nil {
return fmt.Errorf("failed to create migration table: %w", err) return fmt.Errorf("failed to create migration table: %w", err)
} }
// Get applied migrations // Get applied migrations
applied, err := migration.GetAppliedMigrations(db) applied, err := migration.GetAppliedMigrations(db)
if err != nil { if err != nil {
return fmt.Errorf("failed to get applied migrations: %w", err) return fmt.Errorf("failed to get applied migrations: %w", err)
} }
// Get all migration versions // Get all migration versions
allMigrations := make([]int, 0, len(migration.Migrations)) allMigrations := make([]int, 0, len(migration.Migrations))
for version := range migration.Migrations { for version := range migration.Migrations {
allMigrations = append(allMigrations, version) allMigrations = append(allMigrations, version)
} }
// Create a map of applied migrations for quick lookup // Create a map of applied migrations for quick lookup
appliedMap := make(map[int]bool) appliedMap := make(map[int]bool)
for _, version := range applied { for _, version := range applied {
appliedMap[version] = true appliedMap[version] = true
} }
// Count pending migrations // Count pending migrations
pendingCount := 0 pendingCount := 0
for _, version := range allMigrations { for _, version := range allMigrations {
@ -779,7 +635,7 @@ func initDatabase(db *sql.DB) error {
pendingCount++ pendingCount++
} }
} }
// Run migrations if needed // Run migrations if needed
if pendingCount > 0 { if pendingCount > 0 {
fmt.Printf("Running %d pending database migrations...\n", pendingCount) fmt.Printf("Running %d pending database migrations...\n", pendingCount)
@ -790,6 +646,6 @@ func initDatabase(db *sql.DB) error {
} else { } else {
fmt.Println("Database schema is up to date.") fmt.Println("Database schema is up to date.")
} }
return nil return nil
} }

View file

@ -49,11 +49,7 @@ func GetAppliedMigrations(db *sql.DB) ([]int, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer func() { defer rows.Close()
if err := rows.Close(); err != nil {
fmt.Printf("Error closing rows: %v\n", err)
}
}()
var versions []int var versions []int
for rows.Next() { for rows.Next() {
@ -132,9 +128,7 @@ func Migrate(db *sql.DB) error {
// Apply the migration // Apply the migration
if err := migration.Up(db); err != nil { if err := migration.Up(db); err != nil {
if err := tx.Rollback(); err != nil { tx.Rollback()
fmt.Printf("Error rolling back transaction: %v\n", err)
}
return fmt.Errorf("failed to apply migration %d: %w", version, err) return fmt.Errorf("failed to apply migration %d: %w", version, err)
} }
@ -143,9 +137,7 @@ func Migrate(db *sql.DB) error {
"INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)",
version, time.Now(), version, time.Now(),
); err != nil { ); err != nil {
if err := tx.Rollback(); err != nil { tx.Rollback()
fmt.Printf("Error rolling back transaction: %v\n", err)
}
return fmt.Errorf("failed to mark migration %d as applied: %w", version, err) return fmt.Errorf("failed to mark migration %d as applied: %w", version, err)
} }
@ -196,17 +188,13 @@ func MigrateDown(db *sql.DB, targetVersion int) error {
// Apply the down migration // Apply the down migration
if err := migration.Down(db); err != nil { if err := migration.Down(db); err != nil {
if err := tx.Rollback(); err != nil { tx.Rollback()
fmt.Printf("Error rolling back transaction: %v\n", err)
}
return fmt.Errorf("failed to roll back migration %d: %w", version, err) return fmt.Errorf("failed to roll back migration %d: %w", version, err)
} }
// Remove from applied list // Remove from applied list
if _, err := tx.Exec("DELETE FROM schema_migrations WHERE version = ?", version); err != nil { if _, err := tx.Exec("DELETE FROM schema_migrations WHERE version = ?", version); err != nil {
if err := tx.Rollback(); err != nil { tx.Rollback()
fmt.Printf("Error rolling back transaction: %v\n", err)
}
return fmt.Errorf("failed to remove migration %d from applied list: %w", version, err) return fmt.Errorf("failed to remove migration %d from applied list: %w", version, err)
} }
@ -220,4 +208,4 @@ func MigrateDown(db *sql.DB, targetVersion int) error {
} }
return nil return nil
} }

View file

@ -8,7 +8,6 @@ import (
func init() { func init() {
// Register migrations // Register migrations
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)
} }
// Initial schema creation with bcrypt passwords - version 1 // Initial schema creation with bcrypt passwords - version 1
@ -61,14 +60,14 @@ func migrateInitialSchemaUp(db *sql.DB) error {
if err != nil { if err != nil {
return err return err
} }
// Check if users table is empty before inserting // Check if users table is empty before inserting
var count int var count int
err = db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) err = db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
if err != nil { if err != nil {
return err return err
} }
if count == 0 { if count == 0 {
_, err = db.Exec( _, err = db.Exec(
"INSERT INTO users (username, password) VALUES (?, ?)", "INSERT INTO users (username, password) VALUES (?, ?)",
@ -100,29 +99,4 @@ func migrateInitialSchemaDown(db *sql.DB) error {
} }
return nil return nil
} }
// Add reminders table - version 2
func migrateRemindersUp(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
channel_id TEXT NOT NULL,
message_id TEXT NOT NULL,
reply_to_id TEXT NOT NULL,
user_id TEXT NOT NULL,
username TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
trigger_at TIMESTAMP NOT NULL,
content TEXT NOT NULL,
processed BOOLEAN NOT NULL DEFAULT 0
)
`)
return err
}
func migrateRemindersDown(db *sql.DB) error {
_, err := db.Exec(`DROP TABLE IF EXISTS reminders`)
return err
}

View file

@ -4,47 +4,27 @@ import (
"time" "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 // Message represents a chat message
type Message struct { type Message struct {
Text string Text string
Chat string Chat string
Channel *Channel Channel *Channel
Author string Author string
FromBot bool FromBot bool
Date time.Time Date time.Time
ID string ID string
ReplyTo string ReplyTo string
Raw map[string]interface{} Raw map[string]interface{}
} }
// Channel represents a chat channel // Channel represents a chat channel
type Channel struct { type Channel struct {
ID int64 ID int64
Platform string Platform string
PlatformChannelID string PlatformChannelID string
ChannelRaw map[string]interface{} ChannelRaw map[string]interface{}
Enabled bool Enabled 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
@ -60,18 +40,18 @@ func (c *Channel) HasEnabledPlugin(pluginID string) bool {
func (c *Channel) ChannelName() string { func (c *Channel) ChannelName() string {
// In a real implementation, this would use the platform-specific // In a real implementation, this would use the platform-specific
// ParseChannelNameFromRaw function // ParseChannelNameFromRaw function
// For simplicity, we'll just use the PlatformChannelID if we can't extract a name // For simplicity, we'll just use the PlatformChannelID if we can't extract a name
// Check if ChannelRaw has a name field // Check if ChannelRaw has a name field
if c.ChannelRaw == nil { if c.ChannelRaw == nil {
return c.PlatformChannelID return c.PlatformChannelID
} }
// Check common name fields in ChannelRaw // Check common name fields in ChannelRaw
if name, ok := c.ChannelRaw["name"].(string); ok && name != "" { if name, ok := c.ChannelRaw["name"].(string); ok && name != "" {
return name return name
} }
// Check for nested objects like "chat" (used by Telegram) // Check for nested objects like "chat" (used by Telegram)
if chat, ok := c.ChannelRaw["chat"].(map[string]interface{}); ok { if chat, ok := c.ChannelRaw["chat"].(map[string]interface{}); ok {
// Try different fields in order of preference // Try different fields in order of preference
@ -85,7 +65,7 @@ func (c *Channel) ChannelName() string {
return firstName return firstName
} }
} }
return c.PlatformChannelID return c.PlatformChannelID
} }
@ -95,7 +75,7 @@ type ChannelPlugin struct {
ChannelID int64 ChannelID int64
PluginID string PluginID string
Enabled bool Enabled bool
Config map[string]any Config map[string]interface{}
} }
// User represents an admin user // User represents an admin user
@ -103,19 +83,4 @@ type User struct {
ID int64 ID int64
Username string Username string
Password string Password string
} }
// Reminder represents a scheduled reminder
type Reminder struct {
ID int64
Platform string
ChannelID string
MessageID string
ReplyToID string
UserID string
Username string
CreatedAt time.Time
TriggerAt time.Time
Content string
Processed bool
}

View file

@ -43,7 +43,4 @@ type Platform interface {
// SendMessage sends a message through the platform // SendMessage sends a message through the platform
SendMessage(msg *Message) error SendMessage(msg *Message) error
// DeleteMessage deletes a message from the platform
DeleteMessage(channel string, messageID string) error
} }

View file

@ -13,16 +13,16 @@ var (
type Plugin interface { type Plugin interface {
// GetID returns the plugin ID // GetID returns the plugin ID
GetID() string GetID() string
// GetName returns the plugin name // GetName returns the plugin name
GetName() string GetName() string
// GetHelp returns the plugin help text // GetHelp returns the plugin help text
GetHelp() string GetHelp() string
// RequiresConfig indicates if the plugin requires configuration // RequiresConfig indicates if the plugin requires configuration
RequiresConfig() bool RequiresConfig() bool
// OnMessage processes an incoming message and returns platform actions // OnMessage processes an incoming message and returns response messages
OnMessage(msg *Message, config map[string]interface{}) []*MessageAction OnMessage(msg *Message, config map[string]interface{}) []*Message
} }

View file

@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io/ioutil"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@ -37,15 +37,11 @@ func (s *SlackPlatform) Init(_ *config.Config) error {
// ParseIncomingMessage parses an incoming Slack message // ParseIncomingMessage parses an incoming Slack message
func (s *SlackPlatform) ParseIncomingMessage(r *http.Request) (*model.Message, error) { func (s *SlackPlatform) ParseIncomingMessage(r *http.Request) (*model.Message, error) {
// Read request body // Read request body
body, err := io.ReadAll(r.Body) body, err := ioutil.ReadAll(r.Body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer func() { defer r.Body.Close()
if err := r.Body.Close(); err != nil {
fmt.Printf("Error closing request body: %v\n", err)
}
}()
// Parse JSON // Parse JSON
var requestData map[string]interface{} var requestData map[string]interface{}
@ -167,12 +163,6 @@ func (s *SlackPlatform) SendMessage(msg *model.Message) error {
return errors.New("bot token not configured") 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 // Prepare payload
payload := map[string]interface{}{ payload := map[string]interface{}{
"channel": msg.Chat, "channel": msg.Chat,
@ -204,11 +194,7 @@ func (s *SlackPlatform) SendMessage(msg *model.Message) error {
if err != nil { if err != nil {
return err return err
} }
defer func() { defer resp.Body.Close()
if err := resp.Body.Close(); err != nil {
fmt.Printf("Error closing response body: %v\n", err)
}
}()
// Check response // Check response
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
@ -218,63 +204,6 @@ func (s *SlackPlatform) SendMessage(msg *model.Message) error {
return nil 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 // Helper function to parse int64
func parseInt64(s string) (int64, error) { func parseInt64(s string) (int64, error) {
var n int64 var n int64

View file

@ -62,11 +62,7 @@ func (t *TelegramPlatform) Init(cfg *config.Config) error {
t.log.Error("Failed to set webhook", "error", err) t.log.Error("Failed to set webhook", "error", err)
return fmt.Errorf("failed to set webhook: %w", err) return fmt.Errorf("failed to set webhook: %w", err)
} }
defer func() { defer resp.Body.Close()
if err := resp.Body.Close(); err != nil {
t.log.Error("Error closing response body", "error", err)
}
}()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body) bodyBytes, _ := io.ReadAll(resp.Body)
@ -89,11 +85,7 @@ func (t *TelegramPlatform) ParseIncomingMessage(r *http.Request) (*model.Message
t.log.Error("Failed to read request body", "error", err) t.log.Error("Failed to read request body", "error", err)
return nil, err return nil, err
} }
defer func() { defer r.Body.Close()
if err := r.Body.Close(); err != nil {
t.log.Error("Error closing request body", "error", err)
}
}()
// Parse JSON // Parse JSON
var update struct { var update struct {
@ -111,11 +103,8 @@ func (t *TelegramPlatform) ParseIncomingMessage(r *http.Request) (*model.Message
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Username string `json:"username,omitempty"` Username string `json:"username,omitempty"`
} `json:"chat"` } `json:"chat"`
Date int `json:"date"` Date int `json:"date"`
Text string `json:"text"` Text string `json:"text"`
ReplyToMessage struct {
MessageID int `json:"message_id"`
} `json:"reply_to_message"`
} `json:"message"` } `json:"message"`
} }
@ -139,7 +128,6 @@ func (t *TelegramPlatform) ParseIncomingMessage(r *http.Request) (*model.Message
FromBot: update.Message.From.IsBot, FromBot: update.Message.From.IsBot,
Date: time.Unix(int64(update.Message.Date), 0), Date: time.Unix(int64(update.Message.Date), 0),
ID: strconv.Itoa(update.Message.MessageID), ID: strconv.Itoa(update.Message.MessageID),
ReplyTo: strconv.Itoa(update.Message.ReplyToMessage.MessageID),
Raw: raw, Raw: raw,
} }
@ -217,13 +205,6 @@ func (t *TelegramPlatform) ParseChannelFromMessage(body []byte) (map[string]any,
// SendMessage sends a message to Telegram // SendMessage sends a message to Telegram
func (t *TelegramPlatform) SendMessage(msg *model.Message) error { 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 // Convert chat ID to int64
chatID, err := strconv.ParseInt(msg.Chat, 10, 64) chatID, err := strconv.ParseInt(msg.Chat, 10, 64)
if err != nil { if err != nil {
@ -266,11 +247,7 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error {
t.log.Error("Failed to send message", "error", err) t.log.Error("Failed to send message", "error", err)
return err return err
} }
defer func() { defer resp.Body.Close()
if err := resp.Body.Close(); err != nil {
t.log.Error("Error closing response body", "error", err)
}
}()
// Check response // Check response
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
@ -282,89 +259,4 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error {
t.log.Debug("Message sent successfully") t.log.Debug("Message sent successfully")
return nil 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)
}

View file

@ -1,132 +0,0 @@
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()

View file

@ -1,140 +0,0 @@
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")
}
}
})
}
}

View file

@ -29,7 +29,7 @@ func NewCoin() *CoinPlugin {
} }
// OnMessage handles incoming messages // OnMessage handles incoming messages
func (p *CoinPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { func (p *CoinPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
if !strings.Contains(strings.ToLower(msg.Text), "flip a coin") { if !strings.Contains(strings.ToLower(msg.Text), "flip a coin") {
return nil return nil
} }
@ -46,12 +46,5 @@ func (p *CoinPlugin) OnMessage(msg *model.Message, config map[string]interface{}
Channel: msg.Channel, Channel: msg.Channel,
} }
action := &model.MessageAction{ return []*model.Message{response}
Type: model.ActionSendMessage,
Message: response,
Chat: msg.Chat,
Channel: msg.Channel,
}
return []*model.MessageAction{action}
} }

View file

@ -32,7 +32,7 @@ func NewDice() *DicePlugin {
} }
// OnMessage handles incoming messages // OnMessage handles incoming messages
func (p *DicePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { func (p *DicePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
if !strings.HasPrefix(strings.TrimSpace(strings.ToLower(msg.Text)), "!dice") { if !strings.HasPrefix(strings.TrimSpace(strings.ToLower(msg.Text)), "!dice") {
return nil return nil
} }
@ -62,14 +62,7 @@ func (p *DicePlugin) OnMessage(msg *model.Message, config map[string]interface{}
Channel: msg.Channel, Channel: msg.Channel,
} }
action := &model.MessageAction{ return []*model.Message{response}
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 // rollDice parses a dice formula string and returns the result
@ -114,10 +107,9 @@ func (p *DicePlugin) rollDice(formula string) (int, error) {
return 0, fmt.Errorf("invalid modifier") return 0, fmt.Errorf("invalid modifier")
} }
switch matches[3] { if matches[3] == "+" {
case "+":
total += modifier total += modifier
case "-": } else if matches[3] == "-" {
total -= modifier total -= modifier
} }
} }

View file

@ -24,7 +24,7 @@ func NewLoquito() *LoquitoPlugin {
} }
// OnMessage handles incoming messages // OnMessage handles incoming messages
func (p *LoquitoPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { func (p *LoquitoPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
if !strings.Contains(strings.ToLower(msg.Text), "lo quito") { if !strings.Contains(strings.ToLower(msg.Text), "lo quito") {
return nil return nil
} }
@ -36,12 +36,5 @@ func (p *LoquitoPlugin) OnMessage(msg *model.Message, config map[string]interfac
Channel: msg.Channel, Channel: msg.Channel,
} }
action := &model.MessageAction{ return []*model.Message{response}
Type: model.ActionSendMessage,
Message: response,
Chat: msg.Chat,
Channel: msg.Channel,
}
return []*model.MessageAction{action}
} }

View file

@ -24,12 +24,11 @@ func New() *PingPlugin {
} }
// OnMessage handles incoming messages // OnMessage handles incoming messages
func (p *PingPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { func (p *PingPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
if !strings.EqualFold(strings.TrimSpace(msg.Text), "ping") { if !strings.EqualFold(strings.TrimSpace(msg.Text), "ping") {
return nil return nil
} }
// Create the response message
response := &model.Message{ response := &model.Message{
Text: "pong", Text: "pong",
Chat: msg.Chat, Chat: msg.Chat,
@ -37,13 +36,5 @@ func (p *PingPlugin) OnMessage(msg *model.Message, config map[string]interface{}
Channel: msg.Channel, Channel: msg.Channel,
} }
// Create an action to send the message return []*model.Message{response}
action := &model.MessageAction{
Type: model.ActionSendMessage,
Message: response,
Chat: msg.Chat,
Channel: msg.Channel,
}
return []*model.MessageAction{action}
} }

View file

@ -1,7 +1,6 @@
package plugin package plugin
import ( import (
"maps"
"sync" "sync"
"git.nakama.town/fmartingr/butterrobot/internal/model" "git.nakama.town/fmartingr/butterrobot/internal/model"
@ -42,7 +41,9 @@ func GetAvailablePlugins() map[string]model.Plugin {
// Create a copy to avoid race conditions // Create a copy to avoid race conditions
result := make(map[string]model.Plugin, len(plugins)) result := make(map[string]model.Plugin, len(plugins))
maps.Copy(result, plugins) for id, plugin := range plugins {
result[id] = plugin
}
return result return result
} }
@ -76,6 +77,6 @@ func (p *BasePlugin) RequiresConfig() bool {
} }
// OnMessage is the default implementation that does nothing // OnMessage is the default implementation that does nothing
func (p *BasePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction { func (p *BasePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
return nil return nil
} }

View file

@ -1,200 +0,0 @@
package reminder
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
"git.nakama.town/fmartingr/butterrobot/internal/model"
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
)
// Duration regex patterns to match reminders
var (
remindMePattern = regexp.MustCompile(`(?i)^!remindme\s(\d+)(y|mo|d|h|m|s)$`)
)
// ReminderCreator is an interface for creating reminders
type ReminderCreator interface {
CreateReminder(platform, channelID, messageID, replyToID, userID, username, content string, triggerAt time.Time) (*model.Reminder, error)
}
// Reminder is a plugin that sets reminders for messages
type Reminder struct {
plugin.BasePlugin
creator ReminderCreator
}
// New creates a new Reminder plugin
func New(creator ReminderCreator) *Reminder {
return &Reminder{
BasePlugin: plugin.BasePlugin{
ID: "reminder.remindme",
Name: "Remind Me",
Help: "Reply to a message with `!remindme <duration>` to set a reminder (e.g., `!remindme 2d` for 2 days, `!remindme 1y` for 1 year).",
ConfigRequired: false,
},
creator: creator,
}
}
// OnMessage processes incoming messages
func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
// Only process replies to messages
if msg.ReplyTo == "" {
return nil
}
// Check if the message is a reminder command
match := remindMePattern.FindStringSubmatch(msg.Text)
if match == nil {
return nil
}
// Parse the duration
amount, err := strconv.Atoi(match[1])
if err != nil {
errorMsg := &model.Message{
Text: "Invalid duration format. Please use a number followed by y (years), mo (months), d (days), h (hours), m (minutes), or s (seconds).",
Chat: msg.Chat,
Channel: msg.Channel,
Author: "bot",
FromBot: true,
Date: time.Now(),
ReplyTo: msg.ID,
}
return []*model.MessageAction{
{
Type: model.ActionSendMessage,
Message: errorMsg,
Chat: msg.Chat,
Channel: msg.Channel,
},
}
}
// Calculate the trigger time
var duration time.Duration
unit := match[2]
switch strings.ToLower(unit) {
case "y":
duration = time.Duration(amount) * 365 * 24 * time.Hour
case "mo":
duration = time.Duration(amount) * 30 * 24 * time.Hour
case "d":
duration = time.Duration(amount) * 24 * time.Hour
case "h":
duration = time.Duration(amount) * time.Hour
case "m":
duration = time.Duration(amount) * time.Minute
case "s":
duration = time.Duration(amount) * time.Second
default:
errorMsg := &model.Message{
Text: "Invalid duration unit. Please use y (years), mo (months), d (days), h (hours), m (minutes), or s (seconds).",
Chat: msg.Chat,
Channel: msg.Channel,
Author: "bot",
FromBot: true,
Date: time.Now(),
ReplyTo: msg.ID,
}
return []*model.MessageAction{
{
Type: model.ActionSendMessage,
Message: errorMsg,
Chat: msg.Chat,
Channel: msg.Channel,
},
}
}
triggerAt := time.Now().Add(duration)
// Determine the username for the reminder
username := msg.Author
if username == "" {
// Try to extract username from message raw data
if authorData, ok := msg.Raw["author"].(map[string]interface{}); ok {
if name, ok := authorData["username"].(string); ok {
username = name
} else if name, ok := authorData["name"].(string); ok {
username = name
}
}
}
// Create the reminder
_, err = r.creator.CreateReminder(
msg.Channel.Platform,
msg.Chat,
msg.ID,
msg.ReplyTo,
msg.Author,
username,
"", // No additional content for now
triggerAt,
)
if err != nil {
errorMsg := &model.Message{
Text: fmt.Sprintf("Failed to create reminder: %v", err),
Chat: msg.Chat,
Channel: msg.Channel,
Author: "bot",
FromBot: true,
Date: time.Now(),
ReplyTo: msg.ID,
}
return []*model.MessageAction{
{
Type: model.ActionSendMessage,
Message: errorMsg,
Chat: msg.Chat,
Channel: msg.Channel,
},
}
}
// Format the acknowledgment message
var confirmText string
switch strings.ToLower(unit) {
case "y":
confirmText = fmt.Sprintf("I'll remind you about this message in %d year(s) on %s", amount, triggerAt.Format("Mon, Jan 2, 2006 at 15:04"))
case "mo":
confirmText = fmt.Sprintf("I'll remind you about this message in %d month(s) on %s", amount, triggerAt.Format("Mon, Jan 2 at 15:04"))
case "d":
confirmText = fmt.Sprintf("I'll remind you about this message in %d day(s) on %s", amount, triggerAt.Format("Mon, Jan 2 at 15:04"))
case "h":
confirmText = fmt.Sprintf("I'll remind you about this message in %d hour(s) at %s", amount, triggerAt.Format("15:04"))
case "m":
confirmText = fmt.Sprintf("I'll remind you about this message in %d minute(s) at %s", amount, triggerAt.Format("15:04"))
case "s":
confirmText = fmt.Sprintf("I'll remind you about this message in %d second(s)", amount)
}
// Create confirmation message
confirmMsg := &model.Message{
Text: confirmText,
Chat: msg.Chat,
Channel: msg.Channel,
Author: "bot",
FromBot: true,
Date: time.Now(),
ReplyTo: msg.ID,
}
return []*model.MessageAction{
{
Type: model.ActionSendMessage,
Message: confirmMsg,
Chat: msg.Chat,
Channel: msg.Channel,
},
}
}

View file

@ -1,175 +0,0 @@
package reminder
import (
"testing"
"time"
"git.nakama.town/fmartingr/butterrobot/internal/model"
)
// MockCreator is a mock implementation of ReminderCreator for testing
type MockCreator struct {
reminders []*model.Reminder
}
func (m *MockCreator) CreateReminder(platform, channelID, messageID, replyToID, userID, username, content string, triggerAt time.Time) (*model.Reminder, error) {
reminder := &model.Reminder{
ID: int64(len(m.reminders) + 1),
Platform: platform,
ChannelID: channelID,
MessageID: messageID,
ReplyToID: replyToID,
UserID: userID,
Username: username,
Content: content,
TriggerAt: triggerAt,
}
m.reminders = append(m.reminders, reminder)
return reminder, nil
}
func TestReminderOnMessage(t *testing.T) {
creator := &MockCreator{reminders: make([]*model.Reminder, 0)}
plugin := New(creator)
tests := []struct {
name string
message *model.Message
expectResponse bool
expectReminder bool
}{
{
name: "Valid reminder command - years",
message: &model.Message{
Text: "!remindme 1y",
ReplyTo: "original-message-id",
Author: "testuser",
Channel: &model.Channel{Platform: "test"},
},
expectResponse: true,
expectReminder: true,
},
{
name: "Valid reminder command - months",
message: &model.Message{
Text: "!remindme 3mo",
ReplyTo: "original-message-id",
Author: "testuser",
Channel: &model.Channel{Platform: "test"},
},
expectResponse: true,
expectReminder: true,
},
{
name: "Valid reminder command - days",
message: &model.Message{
Text: "!remindme 2d",
ReplyTo: "original-message-id",
Author: "testuser",
Channel: &model.Channel{Platform: "test"},
},
expectResponse: true,
expectReminder: true,
},
{
name: "Valid reminder command - hours",
message: &model.Message{
Text: "!remindme 5h",
ReplyTo: "original-message-id",
Author: "testuser",
Channel: &model.Channel{Platform: "test"},
},
expectResponse: true,
expectReminder: true,
},
{
name: "Valid reminder command - minutes",
message: &model.Message{
Text: "!remindme 30m",
ReplyTo: "original-message-id",
Author: "testuser",
Channel: &model.Channel{Platform: "test"},
},
expectResponse: true,
expectReminder: true,
},
{
name: "Valid reminder command - seconds",
message: &model.Message{
Text: "!remindme 60s",
ReplyTo: "original-message-id",
Author: "testuser",
Channel: &model.Channel{Platform: "test"},
},
expectResponse: true,
expectReminder: true,
},
{
name: "Not a reply",
message: &model.Message{
Text: "!remindme 2d",
ReplyTo: "",
Author: "testuser",
Channel: &model.Channel{Platform: "test"},
},
expectResponse: false,
expectReminder: false,
},
{
name: "Not a reminder command",
message: &model.Message{
Text: "hello world",
ReplyTo: "original-message-id",
Author: "testuser",
Channel: &model.Channel{Platform: "test"},
},
expectResponse: false,
expectReminder: false,
},
{
name: "Invalid duration format",
message: &model.Message{
Text: "!remindme abc",
ReplyTo: "original-message-id",
Author: "testuser",
Channel: &model.Channel{Platform: "test"},
},
expectResponse: false,
expectReminder: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
initialCount := len(creator.reminders)
actions := plugin.OnMessage(tt.message, nil)
if tt.expectResponse && len(actions) == 0 {
t.Errorf("Expected response action, but got none")
}
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 {
t.Errorf("Expected reminder to be created, but it wasn't")
}
if !tt.expectReminder && len(creator.reminders) != initialCount {
t.Errorf("Expected no reminder to be created, but got %d", len(creator.reminders)-initialCount)
}
})
}
}

View file

@ -1,81 +0,0 @@
package social
import (
"net/url"
"regexp"
"strings"
"git.nakama.town/fmartingr/butterrobot/internal/model"
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
)
// InstagramExpander transforms instagram.com links to ddinstagram.com links
type InstagramExpander struct {
plugin.BasePlugin
}
// New creates a new InstagramExpander instance
func NewInstagramExpander() *InstagramExpander {
return &InstagramExpander{
BasePlugin: plugin.BasePlugin{
ID: "social.instagram",
Name: "Instagram Link Expander",
Help: "Automatically converts instagram.com links to ddinstagram.com links and removes tracking parameters",
},
}
}
// OnMessage handles incoming messages
func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
// Skip empty messages
if strings.TrimSpace(msg.Text) == "" {
return nil
}
// Regex to match instagram.com links
// Match both http://instagram.com and https://instagram.com formats
// Also match www.instagram.com
instagramRegex := regexp.MustCompile(`https?://(www\.)?(instagram\.com)/[^\s]+`)
// Check if the message contains an Instagram link
if !instagramRegex.MatchString(msg.Text) {
return nil
}
// Replace instagram.com with ddinstagram.com in the message and clean query parameters
transformed := instagramRegex.ReplaceAllStringFunc(msg.Text, func(link string) string {
// Parse the URL
parsedURL, err := url.Parse(link)
if err != nil {
// If parsing fails, just do the simple replacement
link = strings.Replace(link, "instagram.com", "ddinstagram.com", 1)
return link
}
// Change the host
parsedURL.Host = strings.Replace(parsedURL.Host, "instagram.com", "ddinstagram.com", 1)
// Remove query parameters
parsedURL.RawQuery = ""
// Return the cleaned URL
return parsedURL.String()
})
// Create response message
response := &model.Message{
Text: transformed,
Chat: msg.Chat,
ReplyTo: msg.ID,
Channel: msg.Channel,
}
action := &model.MessageAction{
Type: model.ActionSendMessage,
Message: response,
Chat: msg.Chat,
Channel: msg.Channel,
}
return []*model.MessageAction{action}
}

View file

@ -1,86 +0,0 @@
package social
import (
"net/url"
"regexp"
"strings"
"git.nakama.town/fmartingr/butterrobot/internal/model"
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
)
// TwitterExpander transforms twitter.com links to fxtwitter.com links
type TwitterExpander struct {
plugin.BasePlugin
}
// New creates a new TwitterExpander instance
func NewTwitterExpander() *TwitterExpander {
return &TwitterExpander{
BasePlugin: plugin.BasePlugin{
ID: "social.twitter",
Name: "Twitter Link Expander",
Help: "Automatically converts twitter.com links to fxtwitter.com links and removes tracking parameters",
},
}
}
// OnMessage handles incoming messages
func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
// Skip empty messages
if strings.TrimSpace(msg.Text) == "" {
return nil
}
// Regex to match twitter.com links
// Match both http://twitter.com and https://twitter.com formats
// Also match www.twitter.com
twitterRegex := regexp.MustCompile(`https?://(www\.)?(twitter\.com|x\.com)/[^\s]+`)
// Check if the message contains a Twitter link
if !twitterRegex.MatchString(msg.Text) {
return nil
}
// Replace twitter.com with fxtwitter.com in the message and clean query parameters
transformed := twitterRegex.ReplaceAllStringFunc(msg.Text, func(link string) string {
// Parse the URL
parsedURL, err := url.Parse(link)
if err != nil {
// If parsing fails, just do the simple replacement
link = strings.Replace(link, "twitter.com", "fxtwitter.com", 1)
link = strings.Replace(link, "x.com", "fxtwitter.com", 1)
return link
}
// Change the host
if strings.Contains(parsedURL.Host, "twitter.com") {
parsedURL.Host = strings.Replace(parsedURL.Host, "twitter.com", "fxtwitter.com", 1)
} else if strings.Contains(parsedURL.Host, "x.com") {
parsedURL.Host = strings.Replace(parsedURL.Host, "x.com", "fxtwitter.com", 1)
}
// Remove query parameters
parsedURL.RawQuery = ""
// Return the cleaned URL
return parsedURL.String()
})
// Create response message
response := &model.Message{
Text: transformed,
Chat: msg.Chat,
ReplyTo: msg.ID,
Channel: msg.Channel,
}
action := &model.MessageAction{
Type: model.ActionSendMessage,
Message: response,
Chat: msg.Chat,
Channel: msg.Channel,
}
return []*model.MessageAction{action}
}

View file

@ -3,9 +3,6 @@ package queue
import ( import (
"log/slog" "log/slog"
"sync" "sync"
"time"
"git.nakama.town/fmartingr/butterrobot/internal/model"
) )
// Item represents a queue item // Item represents a queue item
@ -17,19 +14,14 @@ type Item struct {
// HandlerFunc defines a function that processes queue items // HandlerFunc defines a function that processes queue items
type HandlerFunc func(item Item) type HandlerFunc func(item Item)
// ReminderHandlerFunc defines a function that processes reminder items
type ReminderHandlerFunc func(reminder *model.Reminder)
// Queue represents a message queue // Queue represents a message queue
type Queue struct { type Queue struct {
items chan Item items chan Item
wg sync.WaitGroup wg sync.WaitGroup
quit chan struct{} quit chan struct{}
logger *slog.Logger logger *slog.Logger
running bool running bool
runMutex sync.Mutex runMutex sync.Mutex
reminderTicker *time.Ticker
reminderHandler ReminderHandlerFunc
} }
// New creates a new Queue instance // New creates a new Queue instance
@ -57,24 +49,6 @@ func (q *Queue) Start(handler HandlerFunc) {
go q.worker(handler) go q.worker(handler)
} }
// StartReminderScheduler starts the reminder scheduler
func (q *Queue) StartReminderScheduler(handler ReminderHandlerFunc) {
q.runMutex.Lock()
defer q.runMutex.Unlock()
if q.reminderTicker != nil {
return
}
q.reminderHandler = handler
// Check for reminders every minute
q.reminderTicker = time.NewTicker(1 * time.Minute)
q.wg.Add(1)
go q.reminderWorker()
}
// Stop stops processing queue items // Stop stops processing queue items
func (q *Queue) Stop() { func (q *Queue) Stop() {
q.runMutex.Lock() q.runMutex.Lock()
@ -85,12 +59,6 @@ func (q *Queue) Stop() {
} }
q.running = false q.running = false
// Stop reminder ticker if it exists
if q.reminderTicker != nil {
q.reminderTicker.Stop()
}
close(q.quit) close(q.quit)
q.wg.Wait() q.wg.Wait()
} }
@ -128,34 +96,4 @@ func (q *Queue) worker(handler HandlerFunc) {
return return
} }
} }
} }
// reminderWorker processes reminder items on a schedule
func (q *Queue) reminderWorker() {
defer q.wg.Done()
for {
select {
case <-q.reminderTicker.C:
// This is triggered every minute to check for pending reminders
q.logger.Debug("Checking for pending reminders")
if q.reminderHandler != nil {
// The handler is responsible for fetching and processing reminders
func() {
defer func() {
if r := recover(); r != nil {
q.logger.Error("Panic in reminder worker", "error", r)
}
}()
// Call the handler with a nil reminder to indicate it should check the database
q.reminderHandler(nil)
}()
}
case <-q.quit:
// Quit worker
return
}
}
}