Compare commits
No commits in common. "master" and "v0.2.2" have entirely different histories.
31 changed files with 130 additions and 1935 deletions
|
@ -3,7 +3,7 @@ when:
|
||||||
- push
|
- push
|
||||||
- pull_request
|
- pull_request
|
||||||
branch:
|
branch:
|
||||||
- master
|
- main
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
format:
|
format:
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
when:
|
when:
|
||||||
- event: tag
|
- event: tag
|
||||||
branch: master
|
branch: main
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Release
|
- name: Release
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
# Butter Robot
|
# Butter Robot
|
||||||
|
|
||||||

|
| Stable | 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
|
||||||
|
|
|
@ -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())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
|
@ -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.
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
@ -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")
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}}
|
|
|
@ -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>
|
||||||
|
|
|
@ -9,7 +9,6 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"runtime/debug"
|
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
@ -17,14 +16,10 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -36,7 +31,6 @@ type App struct {
|
||||||
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,15 +47,8 @@ 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,
|
||||||
|
@ -70,7 +57,6 @@ func New(cfg *config.Config, logger *slog.Logger) (*App, error) {
|
||||||
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 {
|
|
||||||
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)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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,10 +453,11 @@ 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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
@ -101,28 +100,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|
|
@ -4,26 +4,6 @@ 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
|
||||||
|
@ -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
|
||||||
|
@ -104,18 +84,3 @@ type User struct {
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,6 @@ type Plugin interface {
|
||||||
// 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
|
||||||
}
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
@ -113,9 +105,6 @@ func (t *TelegramPlatform) ParseIncomingMessage(r *http.Request) (*model.Message
|
||||||
} `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 {
|
||||||
|
@ -283,88 +260,3 @@ 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)
|
|
||||||
}
|
|
||||||
|
|
|
@ -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()
|
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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}
|
|
||||||
}
|
|
|
@ -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}
|
|
||||||
}
|
|
|
@ -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,9 +14,6 @@ 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
|
||||||
|
@ -28,8 +22,6 @@ type Queue 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()
|
||||||
}
|
}
|
||||||
|
@ -129,33 +97,3 @@ func (q *Queue) worker(handler HandlerFunc) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue