Compare commits

...

8 commits

Author SHA1 Message Date
c9edb57505
fix: make format
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-04-22 11:56:57 +02:00
763a451251
fix: lint errors 2025-04-22 11:56:33 +02:00
abcd3c3c44
docs: updated README 2025-04-22 11:41:56 +02:00
323ea4e8cd
fix(ci): updated woodpecker triggers
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-04-22 11:40:10 +02:00
72c6dd6982
feat: remindme plugin
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2025-04-22 11:29:39 +02:00
21e4c434fd
docs: updated plugin docs
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2025-04-21 18:10:30 +02:00
a0f12efd65
feat: show version in admin page 2025-04-21 18:08:40 +02:00
c920eb94a0
feat: added twitter and instagram link expanders
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2025-04-21 18:03:07 +02:00
21 changed files with 1078 additions and 108 deletions

View file

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

View file

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

View file

@ -1,9 +1,6 @@
# Butter Robot
| 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) |
![Status badge](https://woodpecker.local.fmartingr.dev/api/badges/5/status.svg)
Go framework to create bots for several platforms.
@ -13,7 +10,7 @@ Go framework to create bots for several platforms.
## Features
- Support for multiple chat platforms (Slack, Telegram)
- Support for multiple chat platforms (Slack (untested!), Telegram)
- Plugin system for easy extension
- Admin interface for managing channels and plugins
- Message queue for asynchronous processing

View file

@ -1,6 +1,18 @@
# Creating a Plugin
## Example
## Plugin Categories
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
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_:
@ -47,6 +59,92 @@ func (p *MarcoPlugin) OnMessage(msg *model.Message, config map[string]interface{
}
```
### 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}
}
```
## Registering Plugins
To use the plugin, register it in your application:
```go
@ -55,7 +153,10 @@ func (a *App) Run() error {
// ...
// Register plugins
plugin.Register(myplugin.New())
plugin.Register(ping.New()) // Development plugin
plugin.Register(fun.NewCoin()) // Fun plugin
plugin.Register(social.NewTwitter()) // Social media plugin
plugin.Register(myplugin.New()) // Your custom plugin
// ...
}

View file

@ -9,3 +9,12 @@
- Lo quito: What happens when you say _"lo quito"_...? (Spanish pun)
- Dice: Put `!dice` and wathever roll you want to perform.
- 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.
### Social Media
- Twitter Link Expander: Automatically converts twitter.com and x.com links to fxtwitter.com links and removes tracking parameters. This allows for better media embedding in chat platforms.
- Instagram Link Expander: Automatically converts instagram.com links to ddinstagram.com links and removes tracking parameters. This allows for better media embedding in chat platforms.

View file

@ -46,6 +46,7 @@ type TemplateData struct {
Channels []*model.Channel
Channel *model.Channel
ChannelPlugin *model.ChannelPlugin
Version string
}
// Admin represents the admin interface
@ -55,10 +56,11 @@ type Admin struct {
store *sessions.CookieStore
templates map[string]*template.Template
baseTemplate *template.Template
version string
}
// New creates a new Admin instance
func New(cfg *config.Config, database *db.Database) *Admin {
func New(cfg *config.Config, database *db.Database, version string) *Admin {
// Create session store with appropriate options
store := sessions.NewCookieStore([]byte(cfg.SecretKey))
store.Options = &sessions.Options{
@ -126,6 +128,7 @@ func New(cfg *config.Config, database *db.Database) *Admin {
store: store,
templates: templates,
baseTemplate: baseTemplate,
version: version,
}
}
@ -191,7 +194,7 @@ func (a *Admin) addFlash(w http.ResponseWriter, r *http.Request, message string,
}
// Map internal categories to Bootstrap alert classes
alertClass := category
var alertClass string
switch category {
case "success":
alertClass = "success"
@ -246,17 +249,6 @@ func (a *Admin) getFlashes(w http.ResponseWriter, r *http.Request) []FlashMessag
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
func (a *Admin) render(w http.ResponseWriter, r *http.Request, templateName string, data TemplateData) {
// Add current user data
@ -264,6 +256,7 @@ func (a *Admin) render(w http.ResponseWriter, r *http.Request, templateName stri
data.LoggedIn = a.isLoggedIn(r)
data.Path = r.URL.Path
data.Flash = a.getFlashes(w, r)
data.Version = a.version
// Get template
tmpl, ok := a.templates[templateName]
@ -330,7 +323,10 @@ func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
// Set session expiration
session.Options.MaxAge = 3600 * 24 * 7 // 1 week
session.Save(r, w)
err = session.Save(r, w)
if err != nil {
fmt.Printf("Error saving session: %v\n", err)
}
a.addFlash(w, r, "You were logged in", "success")

View file

@ -117,6 +117,19 @@
</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>
<script src="https://unpkg.com/@tabler/core@latest/dist/js/tabler.min.js"></script>

View file

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

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"time"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
@ -233,7 +234,11 @@ func (d *Database) GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, e
if err != nil {
return nil, err
}
defer rows.Close()
defer func() {
if err := rows.Close(); err != nil {
fmt.Printf("Error closing rows: %v\n", err)
}
}()
var plugins []*model.ChannelPlugin
@ -414,7 +419,11 @@ func (d *Database) GetAllChannels() ([]*model.Channel, error) {
if err != nil {
return nil, err
}
defer rows.Close()
defer func() {
if err := rows.Close(); err != nil {
fmt.Printf("Error closing rows: %v\n", err)
}
}()
var channels []*model.Channel
@ -453,11 +462,10 @@ func (d *Database) GetAllChannels() ([]*model.Channel, error) {
continue // Skip this channel if plugins can't be retrieved
}
if plugins != nil {
// Add plugins to channel
for _, plugin := range plugins {
channel.Plugins[plugin.PluginID] = plugin
}
}
channels = append(channels, channel)
}
@ -591,6 +599,124 @@ func (d *Database) UpdateUserPassword(userID int64, newPassword string) error {
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
func hashPassword(password string) (string, error) {
// Use bcrypt for secure password hashing

View file

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

View file

@ -8,6 +8,7 @@ import (
func init() {
// Register migrations
Register(1, "Initial schema with bcrypt passwords", migrateInitialSchemaUp, migrateInitialSchemaDown)
Register(2, "Add reminders table", migrateRemindersUp, migrateRemindersDown)
}
// Initial schema creation with bcrypt passwords - version 1
@ -100,3 +101,28 @@ func migrateInitialSchemaDown(db *sql.DB) error {
return nil
}
// Add reminders table - version 2
func migrateRemindersUp(db *sql.DB) error {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
channel_id TEXT NOT NULL,
message_id TEXT NOT NULL,
reply_to_id TEXT NOT NULL,
user_id TEXT NOT NULL,
username TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
trigger_at TIMESTAMP NOT NULL,
content TEXT NOT NULL,
processed BOOLEAN NOT NULL DEFAULT 0
)
`)
return err
}
func migrateRemindersDown(db *sql.DB) error {
_, err := db.Exec(`DROP TABLE IF EXISTS reminders`)
return err
}

View file

@ -84,3 +84,18 @@ type User struct {
Username string
Password string
}
// Reminder represents a scheduled reminder
type Reminder struct {
ID int64
Platform string
ChannelID string
MessageID string
ReplyToID string
UserID string
Username string
CreatedAt time.Time
TriggerAt time.Time
Content string
Processed bool
}

View file

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

View file

@ -62,7 +62,11 @@ func (t *TelegramPlatform) Init(cfg *config.Config) error {
t.log.Error("Failed to set webhook", "error", err)
return fmt.Errorf("failed to set webhook: %w", err)
}
defer resp.Body.Close()
defer func() {
if err := resp.Body.Close(); err != nil {
t.log.Error("Error closing response body", "error", err)
}
}()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
@ -85,7 +89,11 @@ func (t *TelegramPlatform) ParseIncomingMessage(r *http.Request) (*model.Message
t.log.Error("Failed to read request body", "error", err)
return nil, err
}
defer r.Body.Close()
defer func() {
if err := r.Body.Close(); err != nil {
t.log.Error("Error closing request body", "error", err)
}
}()
// Parse JSON
var update struct {
@ -105,6 +113,9 @@ func (t *TelegramPlatform) ParseIncomingMessage(r *http.Request) (*model.Message
} `json:"chat"`
Date int `json:"date"`
Text string `json:"text"`
ReplyToMessage struct {
MessageID int `json:"message_id"`
} `json:"reply_to_message"`
} `json:"message"`
}
@ -128,6 +139,7 @@ func (t *TelegramPlatform) ParseIncomingMessage(r *http.Request) (*model.Message
FromBot: update.Message.From.IsBot,
Date: time.Unix(int64(update.Message.Date), 0),
ID: strconv.Itoa(update.Message.MessageID),
ReplyTo: strconv.Itoa(update.Message.ReplyToMessage.MessageID),
Raw: raw,
}
@ -247,7 +259,11 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error {
t.log.Error("Failed to send message", "error", err)
return err
}
defer resp.Body.Close()
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 {

View file

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

View file

@ -0,0 +1,171 @@
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.Message {
// 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 {
return []*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,
},
}
}
// 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:
return []*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,
},
}
}
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 {
return []*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,
},
}
}
// 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)
}
return []*model.Message{
{
Text: confirmText,
Chat: msg.Chat,
Channel: msg.Channel,
Author: "bot",
FromBot: true,
Date: time.Now(),
ReplyTo: msg.ID,
},
}
}

View file

@ -0,0 +1,164 @@
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)
responses := plugin.OnMessage(tt.message, nil)
if tt.expectResponse && len(responses) == 0 {
t.Errorf("Expected response, but got none")
}
if !tt.expectResponse && len(responses) > 0 {
t.Errorf("Expected no response, but got %d", len(responses))
}
if tt.expectReminder && len(creator.reminders) != initialCount+1 {
t.Errorf("Expected reminder to be created, but it wasn't")
}
if !tt.expectReminder && len(creator.reminders) != initialCount {
t.Errorf("Expected no reminder to be created, but got %d", len(creator.reminders)-initialCount)
}
})
}
}

View file

@ -0,0 +1,74 @@
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.Message {
// 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,
}
return []*model.Message{response}
}

View file

@ -0,0 +1,79 @@
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.Message {
// 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,
}
return []*model.Message{response}
}

View file

@ -3,6 +3,9 @@ package queue
import (
"log/slog"
"sync"
"time"
"git.nakama.town/fmartingr/butterrobot/internal/model"
)
// Item represents a queue item
@ -14,6 +17,9 @@ type Item struct {
// HandlerFunc defines a function that processes queue items
type HandlerFunc func(item Item)
// ReminderHandlerFunc defines a function that processes reminder items
type ReminderHandlerFunc func(reminder *model.Reminder)
// Queue represents a message queue
type Queue struct {
items chan Item
@ -22,6 +28,8 @@ type Queue struct {
logger *slog.Logger
running bool
runMutex sync.Mutex
reminderTicker *time.Ticker
reminderHandler ReminderHandlerFunc
}
// New creates a new Queue instance
@ -49,6 +57,24 @@ func (q *Queue) Start(handler HandlerFunc) {
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
func (q *Queue) Stop() {
q.runMutex.Lock()
@ -59,6 +85,12 @@ func (q *Queue) Stop() {
}
q.running = false
// Stop reminder ticker if it exists
if q.reminderTicker != nil {
q.reminderTicker.Stop()
}
close(q.quit)
q.wg.Wait()
}
@ -97,3 +129,33 @@ 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
}
}
}