diff --git a/.goreleaser.yml b/.goreleaser.yml index a3836e9..c89e189 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -93,7 +93,7 @@ docker_manifests: nfpms: - maintainer: Felipe Martin - description: A chatbot server with customizable commands and triggers + description: SMTP server to forward messages to shoutrrr endpoints homepage: https://git.nakama.town/fmartingr/butterrobot license: AGPL-3.0 formats: diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index 5b32d48..4353088 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -3,7 +3,7 @@ when: - push - pull_request branch: - - master + - main steps: format: diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml index 39dbf65..b24eb15 100644 --- a/.woodpecker/release.yml +++ b/.woodpecker/release.yml @@ -1,6 +1,6 @@ when: - event: tag - branch: master + branch: main steps: - name: Release @@ -13,4 +13,4 @@ steps: - "/var/run/docker.sock:/var/run/docker.sock" commands: - docker login -u fmartingr -p $GITEA_TOKEN git.nakama.town - - goreleaser release --clean --parallelism=2 + - goreleaser release --clean diff --git a/README.md b/README.md index 920d087..36ec708 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # 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. @@ -10,7 +13,7 @@ Go framework to create bots for several platforms. ## Features -- Support for multiple chat platforms (Slack (untested!), Telegram) +- Support for multiple chat platforms (Slack, Telegram) - Plugin system for easy extension - Admin interface for managing channels and plugins - Message queue for asynchronous processing @@ -19,12 +22,6 @@ Go framework to create bots for several platforms. [Go to documentation](./docs) -### Database Management - -ButterRobot includes an automatic database migration system. Migrations are applied automatically when the application starts, ensuring your database schema is always up to date. - -[Learn more about migrations](./docs/migrations.md) - ## Installation ### From Source diff --git a/cmd/butterrobot/main.go b/cmd/butterrobot/main.go index 3bc56cb..5cf57f9 100644 --- a/cmd/butterrobot/main.go +++ b/cmd/butterrobot/main.go @@ -1,15 +1,11 @@ package main import ( - "fmt" "log/slog" "os" - "runtime/debug" "git.nakama.town/fmartingr/butterrobot/internal/app" "git.nakama.town/fmartingr/butterrobot/internal/config" - - _ "golang.org/x/crypto/x509roots/fallback" ) func main() { @@ -23,26 +19,15 @@ func main() { os.Exit(1) } - // Handle version command - if len(os.Args) > 1 && os.Args[1] == "version" { - info, ok := debug.ReadBuildInfo() - if ok { - fmt.Printf("ButterRobot version %s\n", info.Main.Version) - } else { - fmt.Println("ButterRobot. Can't determine build information.") - } - return - } - // Initialize and run application application, err := app.New(cfg, logger) if err != nil { logger.Error("Failed to initialize application", "error", err) os.Exit(1) } - + if err := application.Run(); err != nil { logger.Error("Application error", "error", err) os.Exit(1) } -} +} \ No newline at end of file diff --git a/docs/creating-a-plugin.md b/docs/creating-a-plugin.md index 469491a..945d03c 100644 --- a/docs/creating-a-plugin.md +++ b/docs/creating-a-plugin.md @@ -1,18 +1,6 @@ # Creating a Plugin -## 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 +## Example This simple "Marco Polo" plugin will answer _Polo_ to the user that says _Marco_: @@ -59,92 +47,6 @@ 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 @@ -153,10 +55,7 @@ func (a *App) Run() error { // ... // Register plugins - 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 + plugin.Register(myplugin.New()) // ... } diff --git a/docs/migrations.md b/docs/migrations.md deleted file mode 100644 index 65fcd99..0000000 --- a/docs/migrations.md +++ /dev/null @@ -1,99 +0,0 @@ -# Database Migrations - -ButterRobot uses a simple database migration system to manage database schema changes. This document explains how the migration system works and how to extend it. - -## Automatic Migrations - -Migrations in ButterRobot are applied automatically when the application starts. This ensures your database schema is always up to date without requiring manual intervention. - -The migration system: -1. Checks which migrations have been applied -2. Applies any pending migrations in sequential order -3. Records each successful migration in the `schema_migrations` table - -## Initial State - -The initial migration (version 1) sets up the database with the following: - -- `channels` table for chat platforms -- `channel_plugin` table for plugins associated with channels -- `users` table for admin users with bcrypt password hashing -- Default admin user with username "admin" and password "admin" - -This migration represents the current state of the database schema. It is not backwards compatible with previous versions of ButterRobot. - -## Creating New Migrations - -To add a new migration, follow these steps: - -1. Open `/internal/migration/migrations.go` -2. Add a new migration version in the `init()` function: - -```go -Register(2, "Add example table", migrateAddExampleTableUp, migrateAddExampleTableDown) -``` - -3. Implement the up and down functions for your migration: - -```go -// Migration to add example table - version 2 -func migrateAddExampleTableUp(db *sql.DB) error { - _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS example ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - `) - return err -} - -func migrateAddExampleTableDown(db *sql.DB) error { - _, err := db.Exec(`DROP TABLE IF EXISTS example`) - return err -} -``` - -## Migration Guidelines - -1. **Incremental Changes**: Each migration should make a small, focused change to the database schema. -2. **Backward Compatibility**: Ensure migrations are backward compatible with existing code when possible. -3. **Test Thoroughly**: Test both up and down migrations before deploying. -4. **Document Changes**: Add comments explaining the purpose of each migration. -5. **Version Numbers**: Use sequential version numbers for migrations. - -## How Migrations Work - -The migration system tracks applied migrations in a `schema_migrations` table. When you run migrations, the system: - -1. Checks which migrations have been applied -2. Applies any pending migrations in order -3. Records each successful migration in the `schema_migrations` table - -When rolling back, it performs the down migrations in reverse order. - -## In Code Usage - -The application automatically runs pending migrations when starting up. This is done in the `initDatabase` function. - -You can also programmatically work with migrations: - -```go -// Get database instance -database, err := db.New(cfg.DatabasePath) -if err != nil { - // Handle error -} -defer database.Close() - -// Run migrations -if err := database.MigrateUp(); err != nil { - // Handle error -} - -// Check migration status -applied, pending, err := database.MigrationStatus() -if err != nil { - // Handle error -} -``` \ No newline at end of file diff --git a/docs/plugins.md b/docs/plugins.md index 84578e5..11e3d16 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -9,12 +9,3 @@ - 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 ` 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. diff --git a/go.mod b/go.mod index cd1bee5..ab85fc8 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,6 @@ go 1.24 require ( github.com/gorilla/sessions v1.4.0 - golang.org/x/crypto v0.37.0 - golang.org/x/crypto/x509roots/fallback v0.0.0-20250418111936-9c1aa6af88df modernc.org/sqlite v1.37.0 ) diff --git a/go.sum b/go.sum index 00c4a3c..248cd40 100644 --- a/go.sum +++ b/go.sum @@ -16,10 +16,6 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/crypto/x509roots/fallback v0.0.0-20250418111936-9c1aa6af88df h1:SwgTucX8ajPE0La2ELpYOIs8jVMoCMpAvYB6mDqP9vk= -golang.org/x/crypto/x509roots/fallback v0.0.0-20250418111936-9c1aa6af88df/go.mod h1:lxN5T34bK4Z/i6cMaU7frUU57VkDXFD4Kamfl/cp9oU= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 69c769b..d590995 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -2,8 +2,6 @@ package admin import ( "embed" - "encoding/gob" - "fmt" "html/template" "net/http" "strconv" @@ -30,11 +28,6 @@ type FlashMessage struct { Message string } -func init() { - // Register the FlashMessage type with gob package for session serialization - gob.Register(FlashMessage{}) -} - // TemplateData holds data for rendering templates type TemplateData struct { User *model.User @@ -46,7 +39,6 @@ type TemplateData struct { Channels []*model.Channel Channel *model.Channel ChannelPlugin *model.ChannelPlugin - Version string } // Admin represents the admin interface @@ -56,18 +48,12 @@ 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, version string) *Admin { - // Create session store with appropriate options +func New(cfg *config.Config, database *db.Database) *Admin { + // Create session store store := sessions.NewCookieStore([]byte(cfg.SecretKey)) - store.Options = &sessions.Options{ - Path: "/admin", - MaxAge: 3600 * 24 * 7, // 1 week - HttpOnly: true, - } // Load templates templates := make(map[string]*template.Template) @@ -93,7 +79,6 @@ func New(cfg *config.Config, database *db.Database, version string) *Admin { templateFiles := []string{ "index.html", "login.html", - "change_password.html", "channel_list.html", "channel_detail.html", "plugin_list.html", @@ -106,19 +91,19 @@ func New(cfg *config.Config, database *db.Database, version string) *Admin { if err != nil { panic(err) } - + // Create a clone of the base template t, err := baseTemplate.Clone() if err != nil { panic(err) } - + // Parse the template content t, err = t.Parse(string(content)) if err != nil { panic(err) } - + templates[tf] = t } @@ -128,7 +113,6 @@ func New(cfg *config.Config, database *db.Database, version string) *Admin { store: store, templates: templates, baseTemplate: baseTemplate, - version: version, } } @@ -138,7 +122,6 @@ func (a *Admin) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/admin/", a.handleIndex) mux.HandleFunc("/admin/login", a.handleLogin) mux.HandleFunc("/admin/logout", a.handleLogout) - mux.HandleFunc("/admin/change-password", a.handleChangePassword) mux.HandleFunc("/admin/plugins", a.handlePluginList) mux.HandleFunc("/admin/channels", a.handleChannelList) mux.HandleFunc("/admin/channels/", a.handleChannelDetail) @@ -148,11 +131,7 @@ func (a *Admin) RegisterRoutes(mux *http.ServeMux) { // getCurrentUser gets the current user from the session func (a *Admin) getCurrentUser(r *http.Request) *model.User { - session, err := a.store.Get(r, sessionKey) - if err != nil { - fmt.Printf("Error getting session for user retrieval: %v\n", err) - return nil - } + session, _ := a.store.Get(r, sessionKey) // Check if user is logged in userID, ok := session.Values["user_id"].(int64) @@ -163,7 +142,6 @@ func (a *Admin) getCurrentUser(r *http.Request) *model.User { // Get user from database user, err := a.db.GetUserByID(userID) if err != nil { - fmt.Printf("Error retrieving user from database: %v\n", err) return nil } @@ -172,63 +150,32 @@ func (a *Admin) getCurrentUser(r *http.Request) *model.User { // isLoggedIn checks if the user is logged in func (a *Admin) isLoggedIn(r *http.Request) bool { - session, err := a.store.Get(r, sessionKey) - if err != nil { - fmt.Printf("Error getting session for login check: %v\n", err) - return false - } + session, _ := a.store.Get(r, sessionKey) return session.Values["logged_in"] == true } // addFlash adds a flash message to the session func (a *Admin) addFlash(w http.ResponseWriter, r *http.Request, message string, category string) { - session, err := a.store.Get(r, sessionKey) - if err != nil { - // If there's an error getting the session, create a new one - session = sessions.NewSession(a.store, sessionKey) - session.Options = &sessions.Options{ - Path: "/admin", - MaxAge: 3600 * 24 * 7, // 1 week - HttpOnly: true, - } - } + session, _ := a.store.Get(r, sessionKey) - // Map internal categories to Bootstrap alert classes - var alertClass string - switch category { - case "success": - alertClass = "success" - case "danger": - alertClass = "danger" - case "warning": - alertClass = "warning" - case "info": - alertClass = "info" - default: - alertClass = "info" + // Add flash message + flashes := session.Flashes() + if flashes == nil { + flashes = make([]interface{}, 0) } flash := FlashMessage{ - Category: alertClass, + Category: category, Message: message, } session.AddFlash(flash) - err = session.Save(r, w) - if err != nil { - // Log the error or handle it appropriately - fmt.Printf("Error saving session: %v\n", err) - } + session.Save(r, w) } // getFlashes gets all flash messages from the session func (a *Admin) getFlashes(w http.ResponseWriter, r *http.Request) []FlashMessage { - session, err := a.store.Get(r, sessionKey) - if err != nil { - // If there's an error getting the session, return an empty slice - fmt.Printf("Error getting session for flashes: %v\n", err) - return []FlashMessage{} - } + session, _ := a.store.Get(r, sessionKey) // Get flash messages flashes := session.Flashes() @@ -241,14 +188,22 @@ func (a *Admin) getFlashes(w http.ResponseWriter, r *http.Request) []FlashMessag } // Save session to clear flashes - err = session.Save(r, w) - if err != nil { - fmt.Printf("Error saving session after getting flashes: %v\n", err) - } + session.Save(r, w) 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 @@ -256,7 +211,6 @@ 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] @@ -323,10 +277,7 @@ func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) { // Set session expiration session.Options.MaxAge = 3600 * 24 * 7 // 1 week - err = session.Save(r, w) - if err != nil { - fmt.Printf("Error saving session: %v\n", err) - } + session.Save(r, w) a.addFlash(w, r, "You were logged in", "success") @@ -348,19 +299,10 @@ func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) { // handleLogout handles the logout route func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) { // Clear session - session, err := a.store.Get(r, sessionKey) - if err != nil { - fmt.Printf("Error getting session for logout: %v\n", err) - http.Redirect(w, r, "/admin/login", http.StatusSeeOther) - return - } - + session, _ := a.store.Get(r, sessionKey) session.Values = make(map[interface{}]interface{}) session.Options.MaxAge = -1 // Delete session - err = session.Save(r, w) - if err != nil { - fmt.Printf("Error saving session for logout: %v\n", err) - } + session.Save(r, w) a.addFlash(w, r, "You were logged out", "success") @@ -368,74 +310,6 @@ func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/login", http.StatusSeeOther) } -// handleChangePassword handles the change password route -func (a *Admin) handleChangePassword(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 - } - - // Get current user - user := a.getCurrentUser(r) - if user == nil { - http.Redirect(w, r, "/admin/login", http.StatusSeeOther) - 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 - } - - // Get form values - currentPassword := r.FormValue("current_password") - newPassword := r.FormValue("new_password") - confirmPassword := r.FormValue("confirm_password") - - // Validate current password - _, err := a.db.CheckCredentials(user.Username, currentPassword) - if err != nil { - a.addFlash(w, r, "Current password is incorrect", "danger") - http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther) - return - } - - // Validate new password and confirmation - if newPassword == "" { - a.addFlash(w, r, "New password cannot be empty", "danger") - http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther) - return - } - - if newPassword != confirmPassword { - a.addFlash(w, r, "New passwords do not match", "danger") - http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther) - return - } - - // Update password - if err := a.db.UpdateUserPassword(user.ID, newPassword); err != nil { - a.addFlash(w, r, "Failed to update password: "+err.Error(), "danger") - http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther) - return - } - - // Success - a.addFlash(w, r, "Password changed successfully", "success") - http.Redirect(w, r, "/admin/", http.StatusSeeOther) - return - } - - // Render change password template - a.render(w, r, "change_password.html", TemplateData{ - Title: "Change Password", - }) -} - // handlePluginList handles the plugin list route func (a *Admin) handlePluginList(w http.ResponseWriter, r *http.Request) { // Check if user is logged in diff --git a/internal/admin/templates/_base.html b/internal/admin/templates/_base.html index 3ebdf85..d056ab5 100644 --- a/internal/admin/templates/_base.html +++ b/internal/admin/templates/_base.html @@ -28,10 +28,8 @@ Log in {{else}}
-
{{.User.Username}} - - Change Password | - Log out -
+
{{.User.Username}} - Log out
{{end}} @@ -102,14 +100,14 @@ {{end}} -
- {{range .Flash}} - diff --git a/internal/admin/templates/change_password.html b/internal/admin/templates/change_password.html deleted file mode 100644 index eed3dc5..0000000 --- a/internal/admin/templates/change_password.html +++ /dev/null @@ -1,30 +0,0 @@ -{{define "content"}} -
-
-
-
-

Change Password

-
-
-
-
- - -
-
- - -
-
- - -
- -
-
-
-
-
-{{end}} \ No newline at end of file diff --git a/internal/app/app.go b/internal/app/app.go index 7403396..8d4ffcd 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -9,7 +9,6 @@ import ( "net/http" "os" "os/signal" - "runtime/debug" "strings" "syscall" "time" @@ -17,25 +16,21 @@ 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" ) // App represents the application type App struct { - config *config.Config - logger *slog.Logger - db *db.Database - router *http.ServeMux - queue *queue.Queue - admin *admin.Admin - version string + config *config.Config + logger *slog.Logger + db *db.Database + router *http.ServeMux + queue *queue.Queue + admin *admin.Admin } // New creates a new App instance @@ -52,24 +47,16 @@ 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, version) + adminInterface := admin.New(cfg, database) return &App{ - config: cfg, - logger: logger, - db: database, - router: router, - queue: messageQueue, - admin: adminInterface, - version: version, + config: cfg, + logger: logger, + db: database, + router: router, + queue: messageQueue, + admin: adminInterface, }, nil } @@ -85,12 +72,6 @@ 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() @@ -98,9 +79,6 @@ 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{ @@ -152,9 +130,7 @@ 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) - if err := json.NewEncoder(w).Encode(map[string]interface{}{}); err != nil { - a.logger.Error("Error encoding response", "error", err) - } + json.NewEncoder(w).Encode(map[string]interface{}{}) }) // Platform webhook endpoints @@ -177,9 +153,7 @@ 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) - if err := json.NewEncoder(w).Encode(map[string]string{"error": "Unknown platform"}); err != nil { - a.logger.Error("Error encoding response", "error", err) - } + json.NewEncoder(w).Encode(map[string]string{"error": "Unknown platform"}) return } @@ -188,9 +162,7 @@ func (a *App) handleIncomingWebhook(w http.ResponseWriter, r *http.Request) { if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - 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) - } + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to read request body"}) return } @@ -206,9 +178,7 @@ func (a *App) handleIncomingWebhook(w http.ResponseWriter, r *http.Request) { // Respond with success w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(map[string]any{}); err != nil { - a.logger.Error("Error encoding response", "error", err) - } + json.NewEncoder(w).Encode(map[string]any{}) } // extractPlatformName extracts the platform name from the URL path @@ -321,73 +291,3 @@ 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) - } -} diff --git a/internal/db/db.go b/internal/db/db.go index bdf9eaf..e288bb3 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1,16 +1,14 @@ package db import ( + "crypto/sha256" "database/sql" + "encoding/hex" "encoding/json" "errors" - "fmt" - "time" - "golang.org/x/crypto/bcrypt" _ "modernc.org/sqlite" - "git.nakama.town/fmartingr/butterrobot/internal/migration" "git.nakama.town/fmartingr/butterrobot/internal/model" ) @@ -234,11 +232,7 @@ func (d *Database) GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, e if err != nil { return nil, err } - defer func() { - if err := rows.Close(); err != nil { - fmt.Printf("Error closing rows: %v\n", err) - } - }() + defer rows.Close() var plugins []*model.ChannelPlugin @@ -419,11 +413,7 @@ func (d *Database) GetAllChannels() ([]*model.Channel, error) { if err != nil { return nil, err } - defer func() { - if err := rows.Close(); err != nil { - fmt.Printf("Error closing rows: %v\n", err) - } - }() + defer rows.Close() var channels []*model.Channel @@ -462,9 +452,10 @@ func (d *Database) GetAllChannels() ([]*model.Channel, error) { continue // Skip this channel if plugins can't be retrieved } - // Add plugins to channel - for _, plugin := range plugins { - channel.Plugins[plugin.PluginID] = plugin + if plugins != nil { + for _, plugin := range plugins { + channel.Plugins[plugin.PluginID] = plugin + } } channels = append(channels, channel) @@ -514,10 +505,7 @@ func (d *Database) GetUserByID(id int64) (*model.User, error) { // CreateUser creates a new user func (d *Database) CreateUser(username, password string) (*model.User, error) { // Hash password - hashedPassword, err := hashPassword(password) - if err != nil { - return nil, err - } + hashedPassword := hashPassword(password) // Insert user query := ` @@ -567,9 +555,9 @@ func (d *Database) CheckCredentials(username, password string) (*model.User, err return nil, err } - // Check password with bcrypt - err = bcrypt.CompareHashAndPassword([]byte(dbPassword), []byte(password)) - if err != nil { + // Check password + hashedPassword := hashPassword(password) + if dbPassword != hashedPassword { return nil, errors.New("invalid credentials") } @@ -580,198 +568,74 @@ func (d *Database) CheckCredentials(username, password string) (*model.User, err }, nil } -// UpdateUserPassword updates a user's password -func (d *Database) UpdateUserPassword(userID int64, newPassword string) error { - // Hash the new password - hashedPassword, err := hashPassword(newPassword) - if err != nil { - return err - } - - // Update the user's password - query := ` - UPDATE users - SET password = ? - WHERE id = ? - ` - - _, err = d.db.Exec(query, hashedPassword, userID) - 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 - // The cost parameter is the computational cost, higher is more secure but slower - // Recommended minimum is 12 - hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), 12) - if err != nil { - return "", err - } - return string(hashedBytes), nil +func hashPassword(password string) string { + // In a real implementation, use a proper password hashing library like bcrypt + // This is a simplified version for demonstration + hasher := sha256.New() + hasher.Write([]byte(password)) + return hex.EncodeToString(hasher.Sum(nil)) } // Initialize database tables func initDatabase(db *sql.DB) error { - // Ensure migration table exists - if err := migration.EnsureMigrationTable(db); err != nil { - return fmt.Errorf("failed to create migration table: %w", err) - } - - // Get applied migrations - applied, err := migration.GetAppliedMigrations(db) + // Create channels table + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + platform_channel_id TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT 0, + channel_raw TEXT NOT NULL, + UNIQUE(platform, platform_channel_id) + ) + `) if err != nil { - return fmt.Errorf("failed to get applied migrations: %w", err) + return err } - // Get all migration versions - allMigrations := make([]int, 0, len(migration.Migrations)) - for version := range migration.Migrations { - allMigrations = append(allMigrations, version) + // Create channel_plugin table + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS channel_plugin ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id INTEGER NOT NULL, + plugin_id TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT 0, + config TEXT NOT NULL DEFAULT '{}', + UNIQUE(channel_id, plugin_id), + FOREIGN KEY (channel_id) REFERENCES channels (id) ON DELETE CASCADE + ) + `) + if err != nil { + return err } - // Create a map of applied migrations for quick lookup - appliedMap := make(map[int]bool) - for _, version := range applied { - appliedMap[version] = true + // Create users table + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL + ) + `) + if err != nil { + return err } - // Count pending migrations - pendingCount := 0 - for _, version := range allMigrations { - if !appliedMap[version] { - pendingCount++ + // Create default admin user if it doesn't exist + var count int + err = db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) + if err != nil { + return err + } + + if count == 0 { + hashedPassword := hashPassword("admin") + _, err = db.Exec("INSERT INTO users (username, password) VALUES (?, ?)", "admin", hashedPassword) + if err != nil { + return err } } - // Run migrations if needed - if pendingCount > 0 { - fmt.Printf("Running %d pending database migrations...\n", pendingCount) - if err := migration.Migrate(db); err != nil { - return fmt.Errorf("migration failed: %w", err) - } - fmt.Println("Database migrations completed successfully.") - } else { - fmt.Println("Database schema is up to date.") - } - return nil } diff --git a/internal/migration/migration.go b/internal/migration/migration.go deleted file mode 100644 index 63da5d8..0000000 --- a/internal/migration/migration.go +++ /dev/null @@ -1,223 +0,0 @@ -package migration - -import ( - "database/sql" - "fmt" - "sort" - "time" -) - -// Migration represents a database migration -type Migration struct { - Version int - Description string - Up func(db *sql.DB) error - Down func(db *sql.DB) error -} - -// Migrations is a collection of registered migrations -var Migrations = make(map[int]Migration) - -// Register adds a migration to the list of available migrations -func Register(version int, description string, up, down func(db *sql.DB) error) { - if _, exists := Migrations[version]; exists { - panic(fmt.Sprintf("migration version %d already exists", version)) - } - - Migrations[version] = Migration{ - Version: version, - Description: description, - Up: up, - Down: down, - } -} - -// EnsureMigrationTable creates the migration table if it doesn't exist -func EnsureMigrationTable(db *sql.DB) error { - _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS schema_migrations ( - version INTEGER PRIMARY KEY, - applied_at TIMESTAMP NOT NULL - ) - `) - return err -} - -// GetAppliedMigrations returns a list of applied migration versions -func GetAppliedMigrations(db *sql.DB) ([]int, error) { - rows, err := db.Query("SELECT version FROM schema_migrations ORDER BY version") - if err != nil { - return nil, err - } - defer func() { - if err := rows.Close(); err != nil { - fmt.Printf("Error closing rows: %v\n", err) - } - }() - - var versions []int - for rows.Next() { - var version int - if err := rows.Scan(&version); err != nil { - return nil, err - } - versions = append(versions, version) - } - - return versions, rows.Err() -} - -// IsApplied checks if a migration version has been applied -func IsApplied(db *sql.DB, version int) (bool, error) { - var count int - err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", version).Scan(&count) - if err != nil { - return false, err - } - return count > 0, nil -} - -// MarkAsApplied marks a migration as applied -func MarkAsApplied(db *sql.DB, version int) error { - _, err := db.Exec( - "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", - version, time.Now(), - ) - return err -} - -// RemoveApplied removes a migration from the applied list -func RemoveApplied(db *sql.DB, version int) error { - _, err := db.Exec("DELETE FROM schema_migrations WHERE version = ?", version) - return err -} - -// Migrate runs pending migrations up to the latest version -func Migrate(db *sql.DB) error { - // Ensure migration table exists - if err := EnsureMigrationTable(db); err != nil { - return fmt.Errorf("failed to create migration table: %w", err) - } - - // Get applied migrations - applied, err := GetAppliedMigrations(db) - if err != nil { - return fmt.Errorf("failed to get applied migrations: %w", err) - } - - // Create a map of applied migrations for quick lookup - appliedMap := make(map[int]bool) - for _, version := range applied { - appliedMap[version] = true - } - - // Get all migration versions and sort them - var versions []int - for version := range Migrations { - versions = append(versions, version) - } - sort.Ints(versions) - - // Apply each pending migration - for _, version := range versions { - if !appliedMap[version] { - migration := Migrations[version] - fmt.Printf("Applying migration %d: %s...\n", version, migration.Description) - - // Start transaction for the migration - tx, err := db.Begin() - if err != nil { - return fmt.Errorf("failed to begin transaction for migration %d: %w", version, err) - } - - // Apply the migration - if err := migration.Up(db); err != nil { - 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) - } - - // Mark as applied - if _, err := tx.Exec( - "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", - version, time.Now(), - ); err != nil { - 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) - } - - // Commit the transaction - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit migration %d: %w", version, err) - } - - fmt.Printf("Migration %d applied successfully\n", version) - } - } - - return nil -} - -// MigrateDown rolls back migrations down to the specified version -// If version is -1, it will roll back all migrations -func MigrateDown(db *sql.DB, targetVersion int) error { - // Ensure migration table exists - if err := EnsureMigrationTable(db); err != nil { - return fmt.Errorf("failed to create migration table: %w", err) - } - - // Get applied migrations - applied, err := GetAppliedMigrations(db) - if err != nil { - return fmt.Errorf("failed to get applied migrations: %w", err) - } - - // Sort in descending order to roll back newest first - sort.Sort(sort.Reverse(sort.IntSlice(applied))) - - // Roll back each migration until target version - for _, version := range applied { - if targetVersion == -1 || version > targetVersion { - migration, exists := Migrations[version] - if !exists { - return fmt.Errorf("migration %d is applied but not found in codebase", version) - } - - fmt.Printf("Rolling back migration %d: %s...\n", version, migration.Description) - - // Start transaction for the rollback - tx, err := db.Begin() - if err != nil { - return fmt.Errorf("failed to begin transaction for rollback %d: %w", version, err) - } - - // Apply the down migration - if err := migration.Down(db); err != nil { - 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 { - 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) - } - - // Commit the transaction - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit rollback %d: %w", version, err) - } - - fmt.Printf("Migration %d rolled back successfully\n", version) - } - } - - return nil -} diff --git a/internal/migration/migrations.go b/internal/migration/migrations.go deleted file mode 100644 index 8db229b..0000000 --- a/internal/migration/migrations.go +++ /dev/null @@ -1,128 +0,0 @@ -package migration - -import ( - "database/sql" - "golang.org/x/crypto/bcrypt" -) - -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 -func migrateInitialSchemaUp(db *sql.DB) error { - // Create channels table - _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - platform TEXT NOT NULL, - platform_channel_id TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT 0, - channel_raw TEXT NOT NULL, - UNIQUE(platform, platform_channel_id) - ) - `) - if err != nil { - return err - } - - // Create channel_plugin table - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS channel_plugin ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - channel_id INTEGER NOT NULL, - plugin_id TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT 0, - config TEXT NOT NULL DEFAULT '{}', - UNIQUE(channel_id, plugin_id), - FOREIGN KEY (channel_id) REFERENCES channels (id) ON DELETE CASCADE - ) - `) - if err != nil { - return err - } - - // Create users table with bcrypt passwords - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE, - password TEXT NOT NULL - ) - `) - if err != nil { - return err - } - - // Create default admin user with bcrypt password - hashedPassword, err := bcrypt.GenerateFromPassword([]byte("admin"), 12) - if err != nil { - return err - } - - // Check if users table is empty before inserting - var count int - err = db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count) - if err != nil { - return err - } - - if count == 0 { - _, err = db.Exec( - "INSERT INTO users (username, password) VALUES (?, ?)", - "admin", string(hashedPassword), - ) - if err != nil { - return err - } - } - - return nil -} - -func migrateInitialSchemaDown(db *sql.DB) error { - // Drop tables in reverse order of dependencies - _, err := db.Exec(`DROP TABLE IF EXISTS channel_plugin`) - if err != nil { - return err - } - - _, err = db.Exec(`DROP TABLE IF EXISTS channels`) - if err != nil { - return err - } - - _, err = db.Exec(`DROP TABLE IF EXISTS users`) - if err != nil { - return err - } - - 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 -} diff --git a/internal/model/message.go b/internal/model/message.go index e6f86f6..fe8c5e4 100644 --- a/internal/model/message.go +++ b/internal/model/message.go @@ -6,25 +6,25 @@ import ( // Message represents a chat message type Message struct { - Text string - Chat string - Channel *Channel - Author string - FromBot bool - Date time.Time - ID string - ReplyTo string - Raw map[string]interface{} + Text string + Chat string + Channel *Channel + Author string + FromBot bool + Date time.Time + ID string + ReplyTo string + Raw map[string]interface{} } // Channel represents a chat channel type Channel struct { - ID int64 - Platform string + ID int64 + Platform string PlatformChannelID string - ChannelRaw map[string]interface{} - Enabled bool - Plugins map[string]*ChannelPlugin + ChannelRaw map[string]interface{} + Enabled bool + Plugins map[string]*ChannelPlugin } // HasEnabledPlugin checks if a plugin is enabled for this channel @@ -40,18 +40,18 @@ func (c *Channel) HasEnabledPlugin(pluginID string) bool { func (c *Channel) ChannelName() string { // In a real implementation, this would use the platform-specific // ParseChannelNameFromRaw function - + // For simplicity, we'll just use the PlatformChannelID if we can't extract a name // Check if ChannelRaw has a name field if c.ChannelRaw == nil { return c.PlatformChannelID } - + // Check common name fields in ChannelRaw if name, ok := c.ChannelRaw["name"].(string); ok && name != "" { return name } - + // Check for nested objects like "chat" (used by Telegram) if chat, ok := c.ChannelRaw["chat"].(map[string]interface{}); ok { // Try different fields in order of preference @@ -65,7 +65,7 @@ func (c *Channel) ChannelName() string { return firstName } } - + return c.PlatformChannelID } @@ -83,19 +83,4 @@ type User struct { ID int64 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 -} +} \ No newline at end of file diff --git a/internal/model/plugin.go b/internal/model/plugin.go index 9f2b34a..ffc3c2f 100644 --- a/internal/model/plugin.go +++ b/internal/model/plugin.go @@ -13,16 +13,16 @@ var ( type Plugin interface { // GetID returns the plugin ID GetID() string - + // GetName returns the plugin name GetName() string - + // GetHelp returns the plugin help text GetHelp() string - + // RequiresConfig indicates if the plugin requires configuration RequiresConfig() bool - + // OnMessage processes an incoming message and returns response messages OnMessage(msg *Message, config map[string]interface{}) []*Message -} +} \ No newline at end of file diff --git a/internal/platform/slack/slack.go b/internal/platform/slack/slack.go index 9c12b1f..3683ada 100644 --- a/internal/platform/slack/slack.go +++ b/internal/platform/slack/slack.go @@ -4,7 +4,7 @@ import ( "encoding/json" "errors" "fmt" - "io" + "io/ioutil" "net/http" "strings" "time" @@ -37,15 +37,11 @@ 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 := io.ReadAll(r.Body) + body, err := ioutil.ReadAll(r.Body) if err != nil { return nil, err } - defer func() { - if err := r.Body.Close(); err != nil { - fmt.Printf("Error closing request body: %v\n", err) - } - }() + defer r.Body.Close() // Parse JSON var requestData map[string]interface{} @@ -198,11 +194,7 @@ func (s *SlackPlatform) SendMessage(msg *model.Message) error { if err != nil { return err } - defer func() { - if err := resp.Body.Close(); err != nil { - fmt.Printf("Error closing response body: %v\n", err) - } - }() + defer resp.Body.Close() // Check response if resp.StatusCode != http.StatusOK { diff --git a/internal/platform/telegram/telegram.go b/internal/platform/telegram/telegram.go index 0edb729..a9ff2db 100644 --- a/internal/platform/telegram/telegram.go +++ b/internal/platform/telegram/telegram.go @@ -62,11 +62,7 @@ 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 func() { - if err := resp.Body.Close(); err != nil { - t.log.Error("Error closing response body", "error", err) - } - }() + defer resp.Body.Close() if resp.StatusCode != http.StatusOK { 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) return nil, err } - defer func() { - if err := r.Body.Close(); err != nil { - t.log.Error("Error closing request body", "error", err) - } - }() + defer r.Body.Close() // Parse JSON var update struct { @@ -111,11 +103,8 @@ func (t *TelegramPlatform) ParseIncomingMessage(r *http.Request) (*model.Message Title string `json:"title,omitempty"` Username string `json:"username,omitempty"` } `json:"chat"` - Date int `json:"date"` - Text string `json:"text"` - ReplyToMessage struct { - MessageID int `json:"message_id"` - } `json:"reply_to_message"` + Date int `json:"date"` + Text string `json:"text"` } `json:"message"` } @@ -139,7 +128,6 @@ 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, } @@ -259,11 +247,7 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error { t.log.Error("Failed to send message", "error", err) return err } - defer func() { - if err := resp.Body.Close(); err != nil { - t.log.Error("Error closing response body", "error", err) - } - }() + defer resp.Body.Close() // Check response if resp.StatusCode != http.StatusOK { @@ -275,4 +259,4 @@ func (t *TelegramPlatform) SendMessage(msg *model.Message) error { t.log.Debug("Message sent successfully") return nil -} +} \ No newline at end of file diff --git a/internal/plugin/fun/dice.go b/internal/plugin/fun/dice.go index 2d5533b..00fc7cc 100644 --- a/internal/plugin/fun/dice.go +++ b/internal/plugin/fun/dice.go @@ -107,10 +107,9 @@ func (p *DicePlugin) rollDice(formula string) (int, error) { return 0, fmt.Errorf("invalid modifier") } - switch matches[3] { - case "+": + if matches[3] == "+" { total += modifier - case "-": + } else if matches[3] == "-" { total -= modifier } } diff --git a/internal/plugin/reminder/reminder.go b/internal/plugin/reminder/reminder.go deleted file mode 100644 index 5eb47f9..0000000 --- a/internal/plugin/reminder/reminder.go +++ /dev/null @@ -1,171 +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 ` 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, - }, - } -} diff --git a/internal/plugin/reminder/reminder_test.go b/internal/plugin/reminder/reminder_test.go deleted file mode 100644 index 3070918..0000000 --- a/internal/plugin/reminder/reminder_test.go +++ /dev/null @@ -1,164 +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) - 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) - } - }) - } -} diff --git a/internal/plugin/social/instagram.go b/internal/plugin/social/instagram.go deleted file mode 100644 index 7ff74a5..0000000 --- a/internal/plugin/social/instagram.go +++ /dev/null @@ -1,74 +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.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} -} diff --git a/internal/plugin/social/twitter.go b/internal/plugin/social/twitter.go deleted file mode 100644 index 837b6c9..0000000 --- a/internal/plugin/social/twitter.go +++ /dev/null @@ -1,79 +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.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} -} diff --git a/internal/queue/queue.go b/internal/queue/queue.go index 692816e..668bf60 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -3,9 +3,6 @@ package queue import ( "log/slog" "sync" - "time" - - "git.nakama.town/fmartingr/butterrobot/internal/model" ) // Item represents a queue item @@ -17,19 +14,14 @@ 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 - wg sync.WaitGroup - quit chan struct{} - logger *slog.Logger - running bool - runMutex sync.Mutex - reminderTicker *time.Ticker - reminderHandler ReminderHandlerFunc + items chan Item + wg sync.WaitGroup + quit chan struct{} + logger *slog.Logger + running bool + runMutex sync.Mutex } // New creates a new Queue instance @@ -57,24 +49,6 @@ 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() @@ -85,12 +59,6 @@ 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() } @@ -128,34 +96,4 @@ func (q *Queue) worker(handler HandlerFunc) { return } } -} - -// reminderWorker processes reminder items on a schedule -func (q *Queue) reminderWorker() { - defer q.wg.Done() - - for { - select { - case <-q.reminderTicker.C: - // This is triggered every minute to check for pending reminders - q.logger.Debug("Checking for pending reminders") - - if q.reminderHandler != nil { - // The handler is responsible for fetching and processing reminders - func() { - defer func() { - if r := recover(); r != nil { - q.logger.Error("Panic in reminder worker", "error", r) - } - }() - - // Call the handler with a nil reminder to indicate it should check the database - q.reminderHandler(nil) - }() - } - case <-q.quit: - // Quit worker - return - } - } -} +} \ No newline at end of file