Compare commits

...

5 commits

Author SHA1 Message Date
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
e0ae0c2a0b
fix: missing ca-certs
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2025-04-21 17:12:29 +02:00
6aedfc794f
feat: allow password change
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2025-04-21 15:44:45 +02:00
13 changed files with 516 additions and 39 deletions

View file

@ -93,7 +93,7 @@ docker_manifests:
nfpms:
- maintainer: Felipe Martin <me@fmartingr.com>
description: SMTP server to forward messages to shoutrrr endpoints
description: A chatbot server with customizable commands and triggers
homepage: https://git.nakama.town/fmartingr/butterrobot
license: AGPL-3.0
formats:

View file

@ -8,6 +8,8 @@ import (
"git.nakama.town/fmartingr/butterrobot/internal/app"
"git.nakama.town/fmartingr/butterrobot/internal/config"
_ "golang.org/x/crypto/x509roots/fallback"
)
func main() {

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,8 @@
- 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.
### 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.

1
go.mod
View file

@ -5,6 +5,7 @@ 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
)

2
go.sum
View file

@ -18,6 +18,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
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=

View file

@ -2,6 +2,8 @@ package admin
import (
"embed"
"encoding/gob"
"fmt"
"html/template"
"net/http"
"strconv"
@ -28,6 +30,11 @@ 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
@ -39,6 +46,7 @@ type TemplateData struct {
Channels []*model.Channel
Channel *model.Channel
ChannelPlugin *model.ChannelPlugin
Version string
}
// Admin represents the admin interface
@ -48,12 +56,18 @@ 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 {
// Create session store
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{
Path: "/admin",
MaxAge: 3600 * 24 * 7, // 1 week
HttpOnly: true,
}
// Load templates
templates := make(map[string]*template.Template)
@ -79,6 +93,7 @@ func New(cfg *config.Config, database *db.Database) *Admin {
templateFiles := []string{
"index.html",
"login.html",
"change_password.html",
"channel_list.html",
"channel_detail.html",
"plugin_list.html",
@ -113,6 +128,7 @@ func New(cfg *config.Config, database *db.Database) *Admin {
store: store,
templates: templates,
baseTemplate: baseTemplate,
version: version,
}
}
@ -122,6 +138,7 @@ 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)
@ -131,7 +148,11 @@ 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, _ := a.store.Get(r, sessionKey)
session, err := a.store.Get(r, sessionKey)
if err != nil {
fmt.Printf("Error getting session for user retrieval: %v\n", err)
return nil
}
// Check if user is logged in
userID, ok := session.Values["user_id"].(int64)
@ -142,6 +163,7 @@ 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
}
@ -150,32 +172,63 @@ 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, _ := a.store.Get(r, sessionKey)
session, err := a.store.Get(r, sessionKey)
if err != nil {
fmt.Printf("Error getting session for login check: %v\n", err)
return false
}
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, _ := a.store.Get(r, sessionKey)
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,
}
}
// Add flash message
flashes := session.Flashes()
if flashes == nil {
flashes = make([]interface{}, 0)
// Map internal categories to Bootstrap alert classes
alertClass := category
switch category {
case "success":
alertClass = "success"
case "danger":
alertClass = "danger"
case "warning":
alertClass = "warning"
case "info":
alertClass = "info"
default:
alertClass = "info"
}
flash := FlashMessage{
Category: category,
Category: alertClass,
Message: message,
}
session.AddFlash(flash)
session.Save(r, w)
err = session.Save(r, w)
if err != nil {
// Log the error or handle it appropriately
fmt.Printf("Error saving session: %v\n", err)
}
}
// getFlashes gets all flash messages from the session
func (a *Admin) getFlashes(w http.ResponseWriter, r *http.Request) []FlashMessage {
session, _ := a.store.Get(r, sessionKey)
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{}
}
// Get flash messages
flashes := session.Flashes()
@ -188,7 +241,10 @@ func (a *Admin) getFlashes(w http.ResponseWriter, r *http.Request) []FlashMessag
}
// Save session to clear flashes
session.Save(r, w)
err = session.Save(r, w)
if err != nil {
fmt.Printf("Error saving session after getting flashes: %v\n", err)
}
return messages
}
@ -211,6 +267,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]
@ -299,10 +356,19 @@ 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, _ := a.store.Get(r, sessionKey)
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.Values = make(map[interface{}]interface{})
session.Options.MaxAge = -1 // Delete session
session.Save(r, w)
err = session.Save(r, w)
if err != nil {
fmt.Printf("Error saving session for logout: %v\n", err)
}
a.addFlash(w, r, "You were logged out", "success")
@ -310,6 +376,74 @@ 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

View file

@ -28,8 +28,10 @@
<a href="/admin/login">Log in</a>
{{else}}
<div class="d-none d-xl-block pl-2">
<div>{{.User.Username}} - <a class="mt-1 small"
href="/admin/logout">Log out</a></div>
<div>{{.User.Username}} -
<a class="mt-1 small" href="/admin/change-password">Change Password</a> |
<a class="mt-1 small" href="/admin/logout">Log out</a>
</div>
</div>
</a>
{{end}}
@ -100,14 +102,14 @@
{{end}}
</div>
{{range .Flash}}
<div class="card">
<div class="card-status-top bg-{{.Category}}"></div>
<div class="card-body">
<p>{{.Message}}</p>
<div class="container-xl mt-3">
{{range .Flash}}
<div class="alert alert-{{.Category}} alert-dismissible" role="alert">
{{.Message}}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{{end}}
</div>
{{end}}
<div class="content">
<div class="container-xl">
@ -115,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

@ -0,0 +1,30 @@
{{define "content"}}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3 class="card-title">Change Password</h3>
</div>
<div class="card-body">
<form method="post" action="/admin/change-password">
<div class="mb-3">
<label class="form-label">Current Password</label>
<input type="password" name="current_password" class="form-control" placeholder="Current Password" required>
</div>
<div class="mb-3">
<label class="form-label">New Password</label>
<input type="password" name="new_password" class="form-control" placeholder="New Password" required>
</div>
<div class="mb-3">
<label class="form-label">Confirm New Password</label>
<input type="password" name="confirm_password" class="form-control" placeholder="Confirm New Password" required>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">Change Password</button>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -9,6 +9,7 @@ import (
"net/http"
"os"
"os/signal"
"runtime/debug"
"strings"
"syscall"
"time"
@ -20,17 +21,19 @@ import (
"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/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
config *config.Config
logger *slog.Logger
db *db.Database
router *http.ServeMux
queue *queue.Queue
admin *admin.Admin
version string
}
// New creates a new App instance
@ -47,16 +50,24 @@ 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,
logger: logger,
db: database,
router: router,
queue: messageQueue,
admin: adminInterface,
config: cfg,
logger: logger,
db: database,
router: router,
queue: messageQueue,
admin: adminInterface,
version: version,
}, nil
}
@ -72,6 +83,8 @@ 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())
// Initialize routes
a.initializeRoutes()

View file

@ -572,6 +572,25 @@ 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
}
// Helper function to hash password
func hashPassword(password string) (string, error) {
// Use bcrypt for secure password hashing

View file

@ -0,0 +1,76 @@
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
if strings.Contains(parsedURL.Host, "instagram.com") {
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}
}