Compare commits

..

No commits in common. "c9edb57505314aa573d3bdddd1aa2e96abdc76de" and "323ea4e8cdfc7ac6c2226317629fbd1ff0782f7e" have entirely different histories.

11 changed files with 55 additions and 87 deletions

View file

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

View file

@ -194,7 +194,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"
@ -249,6 +249,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
@ -323,10 +334,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")

View file

@ -152,9 +152,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
@ -177,9 +175,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
} }
@ -188,9 +184,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
} }
@ -206,9 +200,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

View file

@ -234,11 +234,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
@ -419,11 +415,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
@ -462,9 +454,10 @@ func (d *Database) GetAllChannels() ([]*model.Channel, error) {
continue // Skip this channel if plugins can't be retrieved continue // Skip this channel if plugins can't be retrieved
} }
// Add plugins to channel if plugins != nil {
for _, plugin := range plugins { for _, plugin := range plugins {
channel.Plugins[plugin.PluginID] = plugin channel.Plugins[plugin.PluginID] = plugin
}
} }
channels = append(channels, channel) channels = append(channels, channel)
@ -653,11 +646,7 @@ func (d *Database) GetPendingReminders() ([]*model.Reminder, 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 reminders []*model.Reminder var reminders []*model.Reminder

View file

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

View file

@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io/ioutil"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@ -37,15 +37,11 @@ func (s *SlackPlatform) Init(_ *config.Config) error {
// ParseIncomingMessage parses an incoming Slack message // ParseIncomingMessage parses an incoming Slack message
func (s *SlackPlatform) ParseIncomingMessage(r *http.Request) (*model.Message, error) { func (s *SlackPlatform) ParseIncomingMessage(r *http.Request) (*model.Message, error) {
// Read request body // Read request body
body, err := io.ReadAll(r.Body) body, err := ioutil.ReadAll(r.Body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer func() { defer r.Body.Close()
if err := r.Body.Close(); err != nil {
fmt.Printf("Error closing request body: %v\n", err)
}
}()
// Parse JSON // Parse JSON
var requestData map[string]interface{} var requestData map[string]interface{}
@ -198,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 {

View file

@ -62,11 +62,7 @@ func (t *TelegramPlatform) Init(cfg *config.Config) error {
t.log.Error("Failed to set webhook", "error", err) t.log.Error("Failed to set webhook", "error", err)
return fmt.Errorf("failed to set webhook: %w", err) return fmt.Errorf("failed to set webhook: %w", err)
} }
defer func() { defer resp.Body.Close()
if err := resp.Body.Close(); err != nil {
t.log.Error("Error closing response body", "error", err)
}
}()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body) bodyBytes, _ := io.ReadAll(resp.Body)
@ -89,11 +85,7 @@ func (t *TelegramPlatform) ParseIncomingMessage(r *http.Request) (*model.Message
t.log.Error("Failed to read request body", "error", err) t.log.Error("Failed to read request body", "error", err)
return nil, err return nil, err
} }
defer func() { defer r.Body.Close()
if err := r.Body.Close(); err != nil {
t.log.Error("Error closing request body", "error", err)
}
}()
// Parse JSON // Parse JSON
var update struct { var update struct {
@ -259,11 +251,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 {

View file

@ -107,10 +107,9 @@ func (p *DicePlugin) rollDice(formula string) (int, error) {
return 0, fmt.Errorf("invalid modifier") return 0, fmt.Errorf("invalid modifier")
} }
switch matches[3] { if matches[3] == "+" {
case "+":
total += modifier total += modifier
case "-": } else if matches[3] == "-" {
total -= modifier total -= modifier
} }
} }

View file

@ -44,7 +44,14 @@ func New(creator ReminderCreator) *Reminder {
func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
// Only process replies to messages // Only process replies to messages
if msg.ReplyTo == "" { if msg.ReplyTo == "" {
return nil return []*model.Message{
{
Text: "Please reply to a message with `!remindme <duration>` to set a reminder.",
Chat: msg.Chat,
Channel: msg.Channel,
ReplyTo: msg.ID,
},
}
} }
// Check if the message is a reminder command // Check if the message is a reminder command

View file

@ -161,4 +161,4 @@ func TestReminderOnMessage(t *testing.T) {
} }
}) })
} }
} }

View file

@ -53,7 +53,9 @@ func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]inte
} }
// Change the host // Change the host
parsedURL.Host = strings.Replace(parsedURL.Host, "instagram.com", "ddinstagram.com", 1) if strings.Contains(parsedURL.Host, "instagram.com") {
parsedURL.Host = strings.Replace(parsedURL.Host, "instagram.com", "ddinstagram.com", 1)
}
// Remove query parameters // Remove query parameters
parsedURL.RawQuery = "" parsedURL.RawQuery = ""