refactor: python -> go
All checks were successful
ci/woodpecker/tag/release Pipeline was successful

This commit is contained in:
Felipe M 2025-04-20 13:54:22 +02:00
parent 9c78ea2d48
commit 7c684af8c3
Signed by: fmartingr
GPG key ID: CCFBC5637D4000A8
79 changed files with 3594 additions and 3257 deletions

563
internal/admin/admin.go Normal file
View file

@ -0,0 +1,563 @@
package admin
import (
"html/template"
"net/http"
"path/filepath"
"strconv"
"strings"
"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/plugin"
"github.com/gorilla/sessions"
)
const (
// Session store key
sessionKey = "butterrobot-session"
// Template directory
templateDir = "./internal/admin/templates"
)
// FlashMessage represents a flash message
type FlashMessage struct {
Category string
Message string
}
// TemplateData holds data for rendering templates
type TemplateData struct {
User *model.User
LoggedIn bool
Title string
Path string
Flash []FlashMessage
Plugins map[string]model.Plugin
Channels []*model.Channel
Channel *model.Channel
ChannelPlugin *model.ChannelPlugin
}
// Admin represents the admin interface
type Admin struct {
config *config.Config
db *db.Database
store *sessions.CookieStore
templates map[string]*template.Template
baseTemplate *template.Template
}
// New creates a new Admin instance
func New(cfg *config.Config, database *db.Database) *Admin {
// Create session store
store := sessions.NewCookieStore([]byte(cfg.SecretKey))
// Load templates
templates := make(map[string]*template.Template)
// Create a template function map with helper functions
funcMap := template.FuncMap{
"contains": strings.Contains,
}
// Create a custom template with functions
baseTemplate := template.New("_base.html").Funcs(funcMap)
baseTemplate = template.Must(baseTemplate.ParseFiles(filepath.Join(templateDir, "_base.html")))
// Parse and register all templates
templateFiles := []string{
"index.html",
"login.html",
"channel_list.html",
"channel_detail.html",
"plugin_list.html",
"channel_plugins_list.html",
}
for _, tf := range templateFiles {
// Create a clone of the base template
t, err := template.Must(baseTemplate.Clone()).ParseFiles(filepath.Join(templateDir, tf))
if err != nil {
panic(err)
}
templates[tf] = t
}
return &Admin{
config: cfg,
db: database,
store: store,
templates: templates,
baseTemplate: baseTemplate,
}
}
// RegisterRoutes registers admin routes on the given router
func (a *Admin) RegisterRoutes(mux *http.ServeMux) {
// Register admin routes
mux.HandleFunc("/admin/", a.handleIndex)
mux.HandleFunc("/admin/login", a.handleLogin)
mux.HandleFunc("/admin/logout", a.handleLogout)
mux.HandleFunc("/admin/plugins", a.handlePluginList)
mux.HandleFunc("/admin/channels", a.handleChannelList)
mux.HandleFunc("/admin/channels/", a.handleChannelDetail)
mux.HandleFunc("/admin/channelplugins", a.handleChannelPluginList)
mux.HandleFunc("/admin/channelplugins/", a.handleChannelPluginDetailOrDelete)
}
// getCurrentUser gets the current user from the session
func (a *Admin) getCurrentUser(r *http.Request) *model.User {
session, _ := a.store.Get(r, sessionKey)
// Check if user is logged in
userID, ok := session.Values["user_id"].(int64)
if !ok {
return nil
}
// Get user from database
user, err := a.db.GetUserByID(userID)
if err != nil {
return nil
}
return user
}
// isLoggedIn checks if the user is logged in
func (a *Admin) isLoggedIn(r *http.Request) bool {
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, _ := a.store.Get(r, sessionKey)
// Add flash message
flashes := session.Flashes()
if flashes == nil {
flashes = make([]interface{}, 0)
}
flash := FlashMessage{
Category: category,
Message: message,
}
session.AddFlash(flash)
session.Save(r, w)
}
// 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)
// Get flash messages
flashes := session.Flashes()
messages := make([]FlashMessage, 0, len(flashes))
for _, f := range flashes {
if flash, ok := f.(FlashMessage); ok {
messages = append(messages, flash)
}
}
// Save session to clear flashes
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
data.User = a.getCurrentUser(r)
data.LoggedIn = a.isLoggedIn(r)
data.Path = r.URL.Path
data.Flash = a.getFlashes(w, r)
// Get template
tmpl, ok := a.templates[templateName]
if !ok {
http.Error(w, "Template not found", http.StatusInternalServerError)
return
}
// Render template
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// handleIndex handles the admin index route
func (a *Admin) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/admin/" {
http.NotFound(w, r)
return
}
// Redirect to login if not logged in
if !a.isLoggedIn(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Redirect to channel list
http.Redirect(w, r, "/admin/channels", http.StatusSeeOther)
}
// handleLogin handles the login route
func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
// If already logged in, redirect to index
if a.isLoggedIn(r) {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
// Handle login form submission
if r.Method == http.MethodPost {
// Parse form
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// Check credentials
username := r.FormValue("username")
password := r.FormValue("password")
user, err := a.db.CheckCredentials(username, password)
if err != nil || user == nil {
a.addFlash(w, r, "Incorrect credentials", "danger")
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Set session
session, _ := a.store.Get(r, sessionKey)
session.Values["logged_in"] = true
session.Values["user_id"] = user.ID
// Set session expiration
session.Options.MaxAge = 3600 * 24 * 7 // 1 week
session.Save(r, w)
a.addFlash(w, r, "You were logged in", "success")
// Redirect to index
next := r.URL.Query().Get("next")
if next == "" {
next = "/admin/"
}
http.Redirect(w, r, next, http.StatusSeeOther)
return
}
// Render login template
a.render(w, r, "login.html", TemplateData{
Title: "Login",
})
}
// handleLogout handles the logout route
func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) {
// Clear session
session, _ := a.store.Get(r, sessionKey)
session.Values = make(map[interface{}]interface{})
session.Options.MaxAge = -1 // Delete session
session.Save(r, w)
a.addFlash(w, r, "You were logged out", "success")
// Redirect to login
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
}
// handlePluginList handles the plugin list route
func (a *Admin) handlePluginList(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 available plugins
plugins := plugin.GetAvailablePlugins()
// Render template
a.render(w, r, "plugin_list.html", TemplateData{
Title: "Plugins",
Plugins: plugins,
})
}
// handleChannelList handles the channel list route
func (a *Admin) handleChannelList(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 all channels
channels, err := a.db.GetAllChannels()
if err != nil {
http.Error(w, "Failed to get channels", http.StatusInternalServerError)
return
}
// Render template
a.render(w, r, "channel_list.html", TemplateData{
Title: "Channels",
Channels: channels,
})
}
// handleChannelDetail handles the channel detail route
func (a *Admin) handleChannelDetail(w http.ResponseWriter, r *http.Request) {
// Check if user is logged in
if !a.isLoggedIn(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Extract channel ID from path
path := r.URL.Path
if path == "/admin/channels/" {
http.Redirect(w, r, "/admin/channels", http.StatusSeeOther)
return
}
channelID := strings.TrimPrefix(path, "/admin/channels/")
if strings.Contains(channelID, "/") {
// Handle delete request
if strings.HasSuffix(path, "/delete") && r.Method == http.MethodPost {
channelID = strings.TrimSuffix(channelID, "/delete")
// Delete channel
id, err := strconv.ParseInt(channelID, 10, 64)
if err != nil {
http.Error(w, "Invalid channel ID", http.StatusBadRequest)
return
}
if err := a.db.DeleteChannel(id); err != nil {
http.Error(w, "Failed to delete channel", http.StatusInternalServerError)
return
}
a.addFlash(w, r, "Channel removed", "success")
http.Redirect(w, r, "/admin/channels", http.StatusSeeOther)
return
}
http.NotFound(w, r)
return
}
// Convert channel ID to int64
id, err := strconv.ParseInt(channelID, 10, 64)
if err != nil {
http.Error(w, "Invalid channel ID", http.StatusBadRequest)
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
}
// Check if the form was submitted
if r.FormValue("form_submitted") == "true" {
// Update channel
enabled := r.FormValue("enabled") == "true"
if err := a.db.UpdateChannel(id, enabled); err != nil {
http.Error(w, "Failed to update channel", http.StatusInternalServerError)
return
}
a.addFlash(w, r, "Channel updated", "success")
http.Redirect(w, r, "/admin/channels/"+channelID, http.StatusSeeOther)
return
}
}
// Get channel
channel, err := a.db.GetChannelByID(id)
if err != nil {
http.Error(w, "Channel not found", http.StatusNotFound)
return
}
// Get available plugins
plugins := plugin.GetAvailablePlugins()
// Render template
a.render(w, r, "channel_detail.html", TemplateData{
Title: "Channel: " + channel.PlatformChannelID,
Channel: channel,
Plugins: plugins,
})
}
// handleChannelPluginList handles the channel plugin list route
func (a *Admin) handleChannelPluginList(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
}
// Handle form submission
if r.Method == http.MethodPost {
// Parse form
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// Extract form data
channelID, err := strconv.ParseInt(r.FormValue("channel_id"), 10, 64)
if err != nil {
http.Error(w, "Invalid channel ID", http.StatusBadRequest)
return
}
pluginID := r.FormValue("plugin_id")
enabled := r.FormValue("enabled") == "y"
// Create channel plugin
config := make(map[string]interface{})
_, err = a.db.CreateChannelPlugin(channelID, pluginID, enabled, config)
if err == db.ErrDuplicated {
a.addFlash(w, r, "Plugin "+pluginID+" is already present on the channel", "danger")
} else if err != nil {
http.Error(w, "Failed to create channel plugin", http.StatusInternalServerError)
return
} else {
a.addFlash(w, r, "Plugin "+pluginID+" added to the channel", "success")
}
// Redirect back
referer := r.Header.Get("Referer")
if referer == "" {
referer = "/admin/channelplugins"
}
http.Redirect(w, r, referer, http.StatusSeeOther)
return
}
// Get all channels
channels, err := a.db.GetAllChannels()
if err != nil {
http.Error(w, "Failed to get channels", http.StatusInternalServerError)
return
}
// Render template
a.render(w, r, "channel_plugins_list.html", TemplateData{
Title: "Channel Plugins",
Channels: channels,
Plugins: plugin.GetAvailablePlugins(),
})
}
// handleChannelPluginDetailOrDelete handles the channel plugin detail or delete route
func (a *Admin) handleChannelPluginDetailOrDelete(w http.ResponseWriter, r *http.Request) {
// Check if user is logged in
if !a.isLoggedIn(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Extract channel plugin ID from path
path := r.URL.Path
if path == "/admin/channelplugins/" {
http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther)
return
}
channelPluginID := strings.TrimPrefix(path, "/admin/channelplugins/")
// Handle delete request
if strings.HasSuffix(channelPluginID, "/delete") && r.Method == http.MethodPost {
channelPluginID = strings.TrimSuffix(channelPluginID, "/delete")
// Delete channel plugin
id, err := strconv.ParseInt(channelPluginID, 10, 64)
if err != nil {
http.Error(w, "Invalid channel plugin ID", http.StatusBadRequest)
return
}
if err := a.db.DeleteChannelPlugin(id); err != nil {
http.Error(w, "Failed to delete channel plugin", http.StatusInternalServerError)
return
}
a.addFlash(w, r, "Plugin removed", "success")
// Redirect back
referer := r.Header.Get("Referer")
if referer == "" {
referer = "/admin/channelplugins"
}
http.Redirect(w, r, referer, http.StatusSeeOther)
return
}
// Handle update request
if r.Method == http.MethodPost {
// Parse form
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// Convert channel plugin ID to int64
id, err := strconv.ParseInt(channelPluginID, 10, 64)
if err != nil {
http.Error(w, "Invalid channel plugin ID", http.StatusBadRequest)
return
}
// Update channel plugin
enabled := r.FormValue("enabled") == "true"
if err := a.db.UpdateChannelPlugin(id, enabled); err != nil {
http.Error(w, "Failed to update channel plugin", http.StatusInternalServerError)
return
}
a.addFlash(w, r, "Plugin updated", "success")
// Redirect back
referer := r.Header.Get("Referer")
if referer == "" {
referer = "/admin/channelplugins"
}
http.Redirect(w, r, referer, http.StatusSeeOther)
return
}
// Redirect to channel plugins list
http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther)
}

View file

@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - ButterRobot Admin</title>
<link rel="stylesheet" href="https://unpkg.com/@tabler/core@latest/dist/css/tabler.min.css">
</head>
<body>
<div class="page">
<div class="sticky-top">
<header class="navbar navbar-expand-md navbar-light sticky-top d-print-none">
<div class="container-xl">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#navbar-menu">
<span class="navbar-toggler-icon"></span>
</button>
<h1 class="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pr-0 pr-md-3">
<a href="/admin/">
<h1>ButterRobot Admin</h1>
</a>
</h1>
<div class="navbar-nav flex-row order-md-last">
<div class="nav-item">
{{if not .LoggedIn}}
<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>
</a>
{{end}}
</div>
</div>
</div>
</header>
{{if .LoggedIn}}
<div class="navbar-expand-md">
<div class="collapse navbar-collapse" id="navbar-menu">
<div class="navbar navbar-light">
<div class="container-xl">
<ul class="navbar-nav">
<li class="nav-item {{if contains .Path "/channels"}}active{{end}}">
<a class="nav-link" href="/admin/channels">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="5" y1="9" x2="19" y2="9" />
<line x1="5" y1="15" x2="19" y2="15" />
<line x1="11" y1="4" x2="7" y2="20" />
<line x1="17" y1="4" x2="13" y2="20" /></svg>
</span>
<span class="nav-link-title">
Channels
</span>
</a>
</li>
<li class="nav-item {{if contains .Path "/plugins"}}active{{end}}">
<a class="nav-link" href="/admin/plugins">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M4 7h3a1 1 0 0 0 1 -1v-1a2 2 0 0 1 4 0v1a1 1 0 0 0 1 1h3a1 1 0 0 1 1 1v3a1 1 0 0 0 1 1h1a2 2 0 0 1 0 4h-1a1 1 0 0 0 -1 1v3a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-1a2 2 0 0 0 -4 0v1a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h1a2 2 0 0 0 0 -4h-1a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1" />
</svg>
</span>
<span class="nav-link-title">
Plugins
</span>
</a>
</li>
<li class="nav-item {{if contains .Path "/channelplugins"}}active{{end}}">
<a class="nav-link" href="/admin/channelplugins">
<span class="nav-link-icon d-md-none d-lg-inline-block">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M4 7h3a1 1 0 0 0 1 -1v-1a2 2 0 0 1 4 0v1a1 1 0 0 0 1 1h3a1 1 0 0 1 1 1v3a1 1 0 0 0 1 1h1a2 2 0 0 1 0 4h-1a1 1 0 0 0 -1 1v3a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-1a2 2 0 0 0 -4 0v1a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h1a2 2 0 0 0 0 -4h-1a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1" />
</svg>
</span>
<span class="nav-link-title">
Channel Plugins
</span>
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
{{end}}
</div>
{{range .Flash}}
<div class="card">
<div class="card-status-top bg-{{.Category}}"></div>
<div class="card-body">
<p>{{.Message}}</p>
</div>
</div>
{{end}}
<div class="content">
<div class="container-xl">
{{template "content" .}}
</div>
</div>
</div>
<script src="https://unpkg.com/@tabler/core@latest/dist/js/tabler.min.js"></script>
</body>
</html>

View file

@ -0,0 +1,114 @@
{{define "content"}}
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3 class="card-title">Channel #{{.Channel.ID}}</h3>
</div>
<div class="card-body">
<form method="post" action="/admin/channels/{{.Channel.ID}}">
<div class="mb-3">
<label class="form-label">Platform</label>
<input type="text" class="form-control" value="{{.Channel.Platform}}" readonly>
</div>
<div class="mb-3">
<label class="form-label">Channel ID</label>
<input type="text" class="form-control" value="{{.Channel.PlatformChannelID}}" readonly>
</div>
<div class="mb-3">
<label class="form-label">Channel Name</label>
<input type="text" class="form-control" value="{{.Channel.ChannelName}}" readonly>
</div>
<div class="mb-3">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="enabled" value="true" {{if .Channel.Enabled}}checked{{end}}>
<span class="form-check-label">Channel Enabled</span>
</label>
<!-- Add a hidden field to ensure a value is sent even when checkbox is unchecked -->
<input type="hidden" name="form_submitted" value="true">
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">Save</button>
<a href="/admin/channels" class="btn btn-link">Back to Channels</a>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3 class="card-title">Channel Plugins</h3>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>Plugin</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range $pluginID, $channelPlugin := .Channel.Plugins}}
<tr>
<td>{{$pluginID}}</td>
<td>
{{if $channelPlugin.Enabled}}
<span class="badge bg-success">Enabled</span>
{{else}}
<span class="badge bg-danger">Disabled</span>
{{end}}
</td>
<td>
<form method="post" action="/admin/channelplugins/{{$channelPlugin.ID}}" class="d-inline">
<input type="hidden" name="enabled" value="{{if $channelPlugin.Enabled}}false{{else}}true{{end}}">
<button type="submit" class="btn btn-sm {{if $channelPlugin.Enabled}}btn-danger{{else}}btn-success{{end}}">
{{if $channelPlugin.Enabled}}Disable{{else}}Enable{{end}}
</button>
</form>
<form method="post" action="/admin/channelplugins/{{$channelPlugin.ID}}/delete" class="d-inline">
<button type="submit" class="btn btn-danger btn-sm"
onclick="return confirm('Are you sure you want to remove this plugin?')">Remove</button>
</form>
</td>
</tr>
{{else}}
<tr>
<td colspan="3" class="text-center">No plugins for this channel</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<hr>
<h4>Add Plugin</h4>
<form method="post" action="/admin/channelplugins">
<input type="hidden" name="channel_id" value="{{.Channel.ID}}">
<div class="mb-3">
<label class="form-label">Plugin</label>
<select name="plugin_id" class="form-select" required>
<option value="">Select a plugin</option>
{{range $id, $plugin := .Plugins}}
<option value="{{$id}}">{{$plugin.GetName}} ({{$id}})</option>
{{end}}
</select>
</div>
<div class="mb-3">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="enabled" value="y">
<span class="form-check-label">Enable plugin</span>
</label>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">Add Plugin</button>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,64 @@
{{define "content"}}
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h3 class="card-title">Channels</h3>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>ID</th>
<th>Platform</th>
<th>Channel ID</th>
<th>Name</th>
<th>Enabled</th>
<th>Plugins</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Channels}}
<tr>
<td>{{.ID}}</td>
<td>{{.Platform}}</td>
<td>{{.PlatformChannelID}}</td>
<td>{{.ChannelName}}</td>
<td>
{{if .Enabled}}
<span class="badge bg-success">Enabled</span>
{{else}}
<span class="badge bg-danger">Disabled</span>
{{end}}
</td>
<td>
{{$count := len .Plugins}}
{{if eq $count 0}}
<span class="badge bg-yellow">No plugins</span>
{{else}}
<span class="badge bg-blue">{{$count}} plugins</span>
{{end}}
</td>
<td>
<a href="/admin/channels/{{.ID}}" class="btn btn-primary btn-sm">Edit</a>
<form method="post" action="/admin/channels/{{.ID}}/delete" class="d-inline">
<button type="submit" class="btn btn-danger btn-sm"
onclick="return confirm('Are you sure you want to delete this channel?')">Delete</button>
</form>
</td>
</tr>
{{else}}
<tr>
<td colspan="7" class="text-center">No channels found</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,93 @@
{{define "content"}}
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h3 class="card-title">Channel Plugins</h3>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>ID</th>
<th>Channel</th>
<th>Plugin</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Channels}}
{{range $pluginID, $channelPlugin := .Plugins}}
<tr>
<td>{{$channelPlugin.ID}}</td>
<td><a href="/admin/channels/{{.ID}}">{{.ChannelName}}</a></td>
<td>{{$pluginID}}</td>
<td>
{{if $channelPlugin.Enabled}}
<span class="badge bg-success">Enabled</span>
{{else}}
<span class="badge bg-danger">Disabled</span>
{{end}}
</td>
<td>
<form method="post" action="/admin/channelplugins/{{$channelPlugin.ID}}" class="d-inline">
<input type="hidden" name="enabled" value="{{if $channelPlugin.Enabled}}false{{else}}true{{end}}">
<button type="submit" class="btn btn-sm {{if $channelPlugin.Enabled}}btn-danger{{else}}btn-success{{end}}">
{{if $channelPlugin.Enabled}}Disable{{else}}Enable{{end}}
</button>
</form>
<form method="post" action="/admin/channelplugins/{{$channelPlugin.ID}}/delete" class="d-inline">
<button type="submit" class="btn btn-danger btn-sm"
onclick="return confirm('Are you sure you want to remove this plugin?')">Remove</button>
</form>
</td>
</tr>
{{end}}
{{else}}
<tr>
<td colspan="5" class="text-center">No channel plugins found</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<hr>
<h4>Add Plugin to Channel</h4>
<form method="post" action="/admin/channelplugins">
<div class="mb-3">
<label class="form-label">Channel</label>
<select name="channel_id" class="form-select" required>
<option value="">Select a channel</option>
{{range .Channels}}
<option value="{{.ID}}">{{.ChannelName}} ({{.Platform}}:{{.PlatformChannelID}})</option>
{{end}}
</select>
</div>
<div class="mb-3">
<label class="form-label">Plugin</label>
<select name="plugin_id" class="form-select" required>
<option value="">Select a plugin</option>
{{range $id, $plugin := .Plugins}}
<option value="{{$id}}">{{$plugin.GetName}} ({{$id}})</option>
{{end}}
</select>
</div>
<div class="mb-3">
<label class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="enabled" value="y">
<span class="form-check-label">Enable plugin</span>
</label>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">Add Plugin</button>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,15 @@
{{define "content"}}
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h3 class="card-title">ButterRobot Admin</h3>
</div>
<div class="card-body">
<p>Welcome to the ButterRobot admin interface.</p>
<p>Use the navigation above to manage channels and plugins.</p>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,26 @@
{{define "content"}}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3 class="card-title">Login</h3>
</div>
<div class="card-body">
<form method="post" action="/admin/login">
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" name="username" class="form-control" placeholder="Enter username" required>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-control" placeholder="Password" required>
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary">Sign in</button>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}

View file

@ -0,0 +1,45 @@
{{define "content"}}
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h3 class="card-title">Available Plugins</h3>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Help</th>
<th>Requires Config</th>
</tr>
</thead>
<tbody>
{{range $id, $plugin := .Plugins}}
<tr>
<td>{{$id}}</td>
<td>{{$plugin.GetName}}</td>
<td>{{$plugin.GetHelp}}</td>
<td>
{{if $plugin.RequiresConfig}}
<span class="badge bg-yellow">Yes</span>
{{else}}
<span class="badge bg-green">No</span>
{{end}}
</td>
</tr>
{{else}}
<tr>
<td colspan="4" class="text-center">No plugins found</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{{end}}

293
internal/app/app.go Normal file
View file

@ -0,0 +1,293 @@
package app
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"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/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/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
}
// New creates a new App instance
func New(cfg *config.Config, logger *slog.Logger) (*App, error) {
// Initialize router
router := http.NewServeMux()
// Initialize database
database, err := db.New(cfg.DatabasePath)
if err != nil {
return nil, fmt.Errorf("failed to initialize database: %w", err)
}
// Initialize message queue
messageQueue := queue.New(logger)
// Initialize admin interface
adminInterface := admin.New(cfg, database)
return &App{
config: cfg,
logger: logger,
db: database,
router: router,
queue: messageQueue,
admin: adminInterface,
}, nil
}
// Run starts the application
func (a *App) Run() error {
// Initialize platforms
if err := platform.InitializePlatforms(a.config); err != nil {
return err
}
// Register built-in plugins
plugin.Register(ping.New())
plugin.Register(fun.NewCoin())
plugin.Register(fun.NewDice())
plugin.Register(fun.NewLoquito())
// Initialize routes
a.initializeRoutes()
// Start message queue worker
a.queue.Start(a.handleMessage)
// Create server
addr := fmt.Sprintf(":%s", a.config.Port)
srv := &http.Server{
Addr: addr,
Handler: a.router,
}
// Start server in a goroutine
go func() {
a.logger.Info("Server starting on", "addr", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
a.logger.Error("Server error", "error", err)
os.Exit(1)
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
a.logger.Info("Shutting down server...")
// Create shutdown context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Shutdown server
if err := srv.Shutdown(ctx); err != nil {
return err
}
// Stop message queue
a.queue.Stop()
// Close database connection
if err := a.db.Close(); err != nil {
return err
}
a.logger.Info("Server stopped")
return nil
}
// Initialize HTTP routes
func (a *App) initializeRoutes() {
// Health check endpoint
a.router.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{})
})
// Platform webhook endpoints
for name := range platform.GetAvailablePlatforms() {
a.logger.Info("Registering webhook endpoint for platform", "platform", name)
platformName := name // Create a copy to avoid closure issues
a.router.HandleFunc("/"+platformName+"/incoming/", a.handleIncomingWebhook)
}
// Register admin routes
a.admin.RegisterRoutes(a.router)
}
// Handle incoming webhook
func (a *App) handleIncomingWebhook(w http.ResponseWriter, r *http.Request) {
// Extract platform name from path
platformName := extractPlatformName(r.URL.Path)
// Check if platform exists
if _, err := platform.Get(platformName); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Unknown platform"})
return
}
// Read request body
body, err := io.ReadAll(r.Body)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Failed to read request body"})
return
}
// Queue message for processing
a.queue.Add(queue.Item{
Platform: platformName,
Request: map[string]any{
"path": r.URL.Path,
"json": json.RawMessage(body),
},
})
// Respond with success
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{})
}
// extractPlatformName extracts the platform name from the URL path
func extractPlatformName(path string) string {
// Remove leading slash
path = strings.TrimPrefix(path, "/")
// Split by slash
parts := strings.Split(path, "/")
// First part is the platform name
if len(parts) > 0 {
// Special case for Telegram with token in the URL
if parts[0] == "telegram" && len(parts) > 1 && parts[1] == "incoming" {
return "telegram"
}
return parts[0]
}
return ""
}
// Handle message processing
func (a *App) handleMessage(item queue.Item) {
// Get platform
p, err := platform.Get(item.Platform)
if err != nil {
a.logger.Error("Error getting platform", "error", err)
return
}
// Create a new request with the body
bodyJSON, ok := item.Request["json"].(json.RawMessage)
if !ok {
a.logger.Error("Invalid JSON in request")
return
}
reqPath, ok := item.Request["path"].(string)
if !ok {
a.logger.Error("Invalid path in request")
return
}
req, err := http.NewRequest("POST", reqPath, strings.NewReader(string(bodyJSON)))
if err != nil {
a.logger.Error("Error creating request", "error", err)
return
}
req.Header.Set("Content-Type", "application/json")
// Parse message
message, err := p.ParseIncomingMessage(req)
if err != nil {
a.logger.Error("Error parsing message", "error", err)
return
}
// Skip if message is from a bot
if message == nil || message.FromBot {
return
}
// Get or create channel
channel, err := a.db.GetChannelByPlatform(item.Platform, message.Chat)
if err == db.ErrNotFound {
channel, err = a.db.CreateChannel(item.Platform, message.Chat, false, message.Channel.ChannelRaw)
if err != nil {
a.logger.Error("Error creating channel", "error", err)
return
}
} else if err != nil {
a.logger.Error("Error getting channel", "error", err)
return
}
// Skip if channel is disabled
if !channel.Enabled {
return
}
// Process message with plugins
for pluginID, channelPlugin := range channel.Plugins {
if !channel.HasEnabledPlugin(pluginID) {
continue
}
// Get plugin
p, err := plugin.Get(pluginID)
if err != nil {
a.logger.Error("Error getting plugin", "error", err)
continue
}
// Process message
responses := p.OnMessage(message, channelPlugin.Config)
// Send responses
platform, err := platform.Get(item.Platform)
if err != nil {
a.logger.Error("Error getting platform", "error", err)
continue
}
for _, response := range responses {
if err := platform.SendMessage(response); err != nil {
a.logger.Error("Error sending message", "error", err)
}
}
}
}

59
internal/config/config.go Normal file
View file

@ -0,0 +1,59 @@
package config
import (
"os"
"strings"
)
// Config holds all application configuration
type Config struct {
Debug bool
Hostname string
Port string
LogLevel string
SecretKey string
DatabasePath string
SlackConfig SlackConfig
TelegramConfig TelegramConfig
}
// SlackConfig holds Slack platform configuration
type SlackConfig struct {
Token string
BotOAuthAccessToken string
}
// TelegramConfig holds Telegram platform configuration
type TelegramConfig struct {
Token string
}
// Load loads configuration from environment variables
func Load() (*Config, error) {
config := &Config{
Debug: getEnv("DEBUG", "n") == "y",
Hostname: getEnv("BUTTERROBOT_HOSTNAME", "butterrobot-dev.int.fmartingr.network"),
Port: getEnv("PORT", "8080"),
LogLevel: getEnv("LOG_LEVEL", "ERROR"),
SecretKey: getEnv("SECRET_KEY", "1234"),
DatabasePath: getEnv("DATABASE_PATH", "butterrobot.db"),
SlackConfig: SlackConfig{
Token: getEnv("SLACK_TOKEN", ""),
BotOAuthAccessToken: getEnv("SLACK_BOT_OAUTH_ACCESS_TOKEN", ""),
},
TelegramConfig: TelegramConfig{
Token: getEnv("TELEGRAM_TOKEN", ""),
},
}
return config, nil
}
// getEnv retrieves an environment variable value or returns a default value
func getEnv(key, defaultValue string) string {
value := os.Getenv(key)
if strings.TrimSpace(value) == "" {
return defaultValue
}
return value
}

641
internal/db/db.go Normal file
View file

@ -0,0 +1,641 @@
package db
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
_ "modernc.org/sqlite"
"git.nakama.town/fmartingr/butterrobot/internal/model"
)
var (
// ErrNotFound is returned when a record is not found
ErrNotFound = errors.New("record not found")
// ErrDuplicated is returned when a record already exists
ErrDuplicated = errors.New("record already exists")
)
// Database handles database operations
type Database struct {
db *sql.DB
}
// New creates a new Database instance
func New(dbPath string) (*Database, error) {
// Open database connection
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, err
}
// Initialize database
if err := initDatabase(db); err != nil {
return nil, err
}
return &Database{db: db}, nil
}
// Close closes the database connection
func (d *Database) Close() error {
return d.db.Close()
}
// GetChannelByID retrieves a channel by ID
func (d *Database) GetChannelByID(id int64) (*model.Channel, error) {
query := `
SELECT id, platform, platform_channel_id, enabled, channel_raw
FROM channels
WHERE id = ?
`
row := d.db.QueryRow(query, id)
var (
platform string
platformChannelID string
enabled bool
channelRawJSON string
)
err := row.Scan(&id, &platform, &platformChannelID, &enabled, &channelRawJSON)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
// Parse channel_raw JSON
var channelRaw map[string]interface{}
if err := json.Unmarshal([]byte(channelRawJSON), &channelRaw); err != nil {
return nil, err
}
// Create channel
channel := &model.Channel{
ID: id,
Platform: platform,
PlatformChannelID: platformChannelID,
Enabled: enabled,
ChannelRaw: channelRaw,
Plugins: make(map[string]*model.ChannelPlugin),
}
// Get channel plugins
plugins, err := d.GetChannelPlugins(id)
if err != nil && err != ErrNotFound {
return nil, err
}
for _, plugin := range plugins {
channel.Plugins[plugin.PluginID] = plugin
}
return channel, nil
}
// GetChannelByPlatform retrieves a channel by platform and platform channel ID
func (d *Database) GetChannelByPlatform(platform, platformChannelID string) (*model.Channel, error) {
query := `
SELECT id, platform, platform_channel_id, enabled, channel_raw
FROM channels
WHERE platform = ? AND platform_channel_id = ?
`
row := d.db.QueryRow(query, platform, platformChannelID)
var (
id int64
enabled bool
channelRawJSON string
)
err := row.Scan(&id, &platform, &platformChannelID, &enabled, &channelRawJSON)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
// Parse channel_raw JSON
var channelRaw map[string]interface{}
if err := json.Unmarshal([]byte(channelRawJSON), &channelRaw); err != nil {
return nil, err
}
// Create channel
channel := &model.Channel{
ID: id,
Platform: platform,
PlatformChannelID: platformChannelID,
Enabled: enabled,
ChannelRaw: channelRaw,
Plugins: make(map[string]*model.ChannelPlugin),
}
// Get channel plugins
plugins, err := d.GetChannelPlugins(id)
if err != nil && err != ErrNotFound {
return nil, err
}
for _, plugin := range plugins {
channel.Plugins[plugin.PluginID] = plugin
}
return channel, nil
}
// CreateChannel creates a new channel
func (d *Database) CreateChannel(platform, platformChannelID string, enabled bool, channelRaw map[string]interface{}) (*model.Channel, error) {
// Convert channelRaw to JSON
channelRawJSON, err := json.Marshal(channelRaw)
if err != nil {
return nil, err
}
// Insert channel
query := `
INSERT INTO channels (platform, platform_channel_id, enabled, channel_raw)
VALUES (?, ?, ?, ?)
`
result, err := d.db.Exec(query, platform, platformChannelID, enabled, string(channelRawJSON))
if err != nil {
return nil, err
}
// Get inserted ID
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
// Create channel
channel := &model.Channel{
ID: id,
Platform: platform,
PlatformChannelID: platformChannelID,
Enabled: enabled,
ChannelRaw: channelRaw,
Plugins: make(map[string]*model.ChannelPlugin),
}
return channel, nil
}
// UpdateChannel updates a channel's enabled status
func (d *Database) UpdateChannel(id int64, enabled bool) error {
query := `
UPDATE channels
SET enabled = ?
WHERE id = ?
`
_, err := d.db.Exec(query, enabled, id)
return err
}
// DeleteChannel deletes a channel
func (d *Database) DeleteChannel(id int64) error {
// First delete all channel plugins
if err := d.DeleteChannelPluginsByChannel(id); err != nil {
return err
}
// Then delete the channel
query := `
DELETE FROM channels
WHERE id = ?
`
_, err := d.db.Exec(query, id)
return err
}
// GetChannelPlugins retrieves all plugins for a channel
func (d *Database) GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, error) {
query := `
SELECT id, channel_id, plugin_id, enabled, config
FROM channel_plugin
WHERE channel_id = ?
`
rows, err := d.db.Query(query, channelID)
if err != nil {
return nil, err
}
defer rows.Close()
var plugins []*model.ChannelPlugin
for rows.Next() {
var (
id int64
channelID int64
pluginID string
enabled bool
configJSON string
)
if err := rows.Scan(&id, &channelID, &pluginID, &enabled, &configJSON); err != nil {
return nil, err
}
// Parse config JSON
var config map[string]interface{}
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return nil, err
}
plugin := &model.ChannelPlugin{
ID: id,
ChannelID: channelID,
PluginID: pluginID,
Enabled: enabled,
Config: config,
}
plugins = append(plugins, plugin)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(plugins) == 0 {
return nil, ErrNotFound
}
return plugins, nil
}
// GetChannelPluginByID retrieves a channel plugin by ID
func (d *Database) GetChannelPluginByID(id int64) (*model.ChannelPlugin, error) {
query := `
SELECT id, channel_id, plugin_id, enabled, config
FROM channel_plugin
WHERE id = ?
`
row := d.db.QueryRow(query, id)
var (
channelID int64
pluginID string
enabled bool
configJSON string
)
err := row.Scan(&id, &channelID, &pluginID, &enabled, &configJSON)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
// Parse config JSON
var config map[string]interface{}
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
return nil, err
}
return &model.ChannelPlugin{
ID: id,
ChannelID: channelID,
PluginID: pluginID,
Enabled: enabled,
Config: config,
}, nil
}
// CreateChannelPlugin creates a new channel plugin
func (d *Database) CreateChannelPlugin(channelID int64, pluginID string, enabled bool, config map[string]interface{}) (*model.ChannelPlugin, error) {
// Check if plugin already exists for this channel
query := `
SELECT COUNT(*)
FROM channel_plugin
WHERE channel_id = ? AND plugin_id = ?
`
var count int
err := d.db.QueryRow(query, channelID, pluginID).Scan(&count)
if err != nil {
return nil, err
}
if count > 0 {
return nil, ErrDuplicated
}
// Convert config to JSON
configJSON, err := json.Marshal(config)
if err != nil {
return nil, err
}
// Insert channel plugin
insertQuery := `
INSERT INTO channel_plugin (channel_id, plugin_id, enabled, config)
VALUES (?, ?, ?, ?)
`
result, err := d.db.Exec(insertQuery, channelID, pluginID, enabled, string(configJSON))
if err != nil {
return nil, err
}
// Get inserted ID
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
return &model.ChannelPlugin{
ID: id,
ChannelID: channelID,
PluginID: pluginID,
Enabled: enabled,
Config: config,
}, nil
}
// UpdateChannelPlugin updates a channel plugin's enabled status
func (d *Database) UpdateChannelPlugin(id int64, enabled bool) error {
query := `
UPDATE channel_plugin
SET enabled = ?
WHERE id = ?
`
_, err := d.db.Exec(query, enabled, id)
return err
}
// DeleteChannelPlugin deletes a channel plugin
func (d *Database) DeleteChannelPlugin(id int64) error {
query := `
DELETE FROM channel_plugin
WHERE id = ?
`
_, err := d.db.Exec(query, id)
return err
}
// DeleteChannelPluginsByChannel deletes all plugins for a channel
func (d *Database) DeleteChannelPluginsByChannel(channelID int64) error {
query := `
DELETE FROM channel_plugin
WHERE channel_id = ?
`
_, err := d.db.Exec(query, channelID)
return err
}
// GetAllChannels retrieves all channels
func (d *Database) GetAllChannels() ([]*model.Channel, error) {
query := `
SELECT id, platform, platform_channel_id, enabled, channel_raw
FROM channels
`
rows, err := d.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var channels []*model.Channel
for rows.Next() {
var (
id int64
platform string
platformChannelID string
enabled bool
channelRawJSON string
)
if err := rows.Scan(&id, &platform, &platformChannelID, &enabled, &channelRawJSON); err != nil {
return nil, err
}
// Parse channel_raw JSON
var channelRaw map[string]interface{}
if err := json.Unmarshal([]byte(channelRawJSON), &channelRaw); err != nil {
return nil, err
}
// Create channel
channel := &model.Channel{
ID: id,
Platform: platform,
PlatformChannelID: platformChannelID,
Enabled: enabled,
ChannelRaw: channelRaw,
Plugins: make(map[string]*model.ChannelPlugin),
}
// Get channel plugins
plugins, err := d.GetChannelPlugins(id)
if err != nil && err != ErrNotFound {
continue // Skip this channel if plugins can't be retrieved
}
if plugins != nil {
for _, plugin := range plugins {
channel.Plugins[plugin.PluginID] = plugin
}
}
channels = append(channels, channel)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(channels) == 0 {
channels = make([]*model.Channel, 0)
}
return channels, nil
}
// GetUserByID retrieves a user by ID
func (d *Database) GetUserByID(id int64) (*model.User, error) {
query := `
SELECT id, username, password
FROM users
WHERE id = ?
`
row := d.db.QueryRow(query, id)
var (
username string
password string
)
err := row.Scan(&id, &username, &password)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &model.User{
ID: id,
Username: username,
Password: password,
}, nil
}
// CreateUser creates a new user
func (d *Database) CreateUser(username, password string) (*model.User, error) {
// Hash password
hashedPassword := hashPassword(password)
// Insert user
query := `
INSERT INTO users (username, password)
VALUES (?, ?)
`
result, err := d.db.Exec(query, username, hashedPassword)
if err != nil {
return nil, err
}
// Get inserted ID
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
return &model.User{
ID: id,
Username: username,
Password: hashedPassword,
}, nil
}
// CheckCredentials checks if the username and password are valid
func (d *Database) CheckCredentials(username, password string) (*model.User, error) {
query := `
SELECT id, username, password
FROM users
WHERE username = ?
`
row := d.db.QueryRow(query, username)
var (
id int64
dbUsername string
dbPassword string
)
err := row.Scan(&id, &dbUsername, &dbPassword)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
// Check password
hashedPassword := hashPassword(password)
if dbPassword != hashedPassword {
return nil, errors.New("invalid credentials")
}
return &model.User{
ID: id,
Username: dbUsername,
Password: dbPassword,
}, nil
}
// Helper function to hash password
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 {
// 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
_, 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 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
}
}
return nil
}

86
internal/model/message.go Normal file
View file

@ -0,0 +1,86 @@
package model
import (
"time"
)
// 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{}
}
// Channel represents a chat channel
type Channel struct {
ID int64
Platform string
PlatformChannelID string
ChannelRaw map[string]interface{}
Enabled bool
Plugins map[string]*ChannelPlugin
}
// HasEnabledPlugin checks if a plugin is enabled for this channel
func (c *Channel) HasEnabledPlugin(pluginID string) bool {
plugin, exists := c.Plugins[pluginID]
if !exists {
return false
}
return plugin.Enabled
}
// ChannelName returns the channel name
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
if title, ok := chat["title"].(string); ok && title != "" {
return title
}
if username, ok := chat["username"].(string); ok && username != "" {
return username
}
if firstName, ok := chat["first_name"].(string); ok && firstName != "" {
return firstName
}
}
return c.PlatformChannelID
}
// ChannelPlugin represents a plugin enabled for a channel
type ChannelPlugin struct {
ID int64
ChannelID int64
PluginID string
Enabled bool
Config map[string]interface{}
}
// User represents an admin user
type User struct {
ID int64
Username string
Password string
}

View file

@ -0,0 +1,46 @@
package model
import (
"errors"
"net/http"
"git.nakama.town/fmartingr/butterrobot/internal/config"
)
var (
// ErrPlatform is a general platform error
ErrPlatform = errors.New("platform error")
// ErrPlatformInit is an error during platform initialization
ErrPlatformInit = errors.New("platform initialization error")
// ErrPlatformAuth is an authentication error
ErrPlatformAuth = errors.New("platform authentication error")
// ErrPlatformNotFound is returned when a requested platform doesn't exist
ErrPlatformNotFound = errors.New("platform not found")
)
// AuthResponse represents a platform authentication response
type AuthResponse struct {
Data map[string]any
StatusCode int
}
// Platform defines the interface all chat platforms must implement
type Platform interface {
// Init initializes the platform
Init(cfg *config.Config) error
// ParseIncomingMessage parses the incoming HTTP request into a Message
ParseIncomingMessage(r *http.Request) (*Message, error)
// ParseChannelNameFromRaw extracts a human-readable channel name from raw data
ParseChannelNameFromRaw(channelRaw map[string]any) string
// ParseChannelFromMessage extracts channel data from a message
ParseChannelFromMessage(body []byte) (map[string]any, error)
// SendMessage sends a message through the platform
SendMessage(msg *Message) error
}

28
internal/model/plugin.go Normal file
View file

@ -0,0 +1,28 @@
package model
import (
"errors"
)
var (
// ErrPluginNotFound is returned when a requested plugin doesn't exist
ErrPluginNotFound = errors.New("plugin not found")
)
// Plugin defines the interface all chat plugins must implement
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
}

32
internal/platform/init.go Normal file
View file

@ -0,0 +1,32 @@
package platform
import (
"fmt"
"git.nakama.town/fmartingr/butterrobot/internal/config"
"git.nakama.town/fmartingr/butterrobot/internal/platform/slack"
"git.nakama.town/fmartingr/butterrobot/internal/platform/telegram"
)
// InitializePlatforms initializes all available platforms
func InitializePlatforms(cfg *config.Config) error {
// Initialize Slack platform
if cfg.SlackConfig.Token != "" && cfg.SlackConfig.BotOAuthAccessToken != "" {
slackPlatform := slack.New(&cfg.SlackConfig)
if err := slackPlatform.Init(cfg); err == nil {
Register("slack", slackPlatform)
}
}
// Initialize Telegram platform
if cfg.TelegramConfig.Token != "" {
telegramPlatform := telegram.New(&cfg.TelegramConfig)
if err := telegramPlatform.Init(cfg); err == nil {
Register("telegram", telegramPlatform)
}
} else {
return fmt.Errorf("telegram token is required")
}
return nil
}

View file

@ -0,0 +1,49 @@
package platform
import (
"sync"
"git.nakama.town/fmartingr/butterrobot/internal/model"
)
var (
// platforms holds all registered chat platforms
platforms = make(map[string]model.Platform)
// platformsMu protects the platforms map
platformsMu sync.RWMutex
)
// Register registers a platform with the given ID
func Register(id string, platform model.Platform) {
platformsMu.Lock()
defer platformsMu.Unlock()
platforms[id] = platform
}
// Get returns a platform by ID
func Get(id string) (model.Platform, error) {
platformsMu.RLock()
defer platformsMu.RUnlock()
platform, exists := platforms[id]
if !exists {
return nil, model.ErrPlatformNotFound
}
return platform, nil
}
// GetAvailablePlatforms returns all registered platforms
func GetAvailablePlatforms() map[string]model.Platform {
platformsMu.RLock()
defer platformsMu.RUnlock()
// Create a copy to avoid race conditions
result := make(map[string]model.Platform, len(platforms))
for id, platform := range platforms {
result[id] = platform
}
return result
}

View file

@ -0,0 +1,212 @@
package slack
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"strings"
"time"
"git.nakama.town/fmartingr/butterrobot/internal/config"
"git.nakama.town/fmartingr/butterrobot/internal/model"
)
// SlackPlatform implements the Platform interface for Slack
type SlackPlatform struct {
config *config.SlackConfig
}
// New creates a new SlackPlatform instance
func New(cfg *config.SlackConfig) *SlackPlatform {
return &SlackPlatform{
config: cfg,
}
}
// Init initializes the Slack platform
func (s *SlackPlatform) Init(_ *config.Config) error {
// Validate config
if s.config.Token == "" || s.config.BotOAuthAccessToken == "" {
return model.ErrPlatformInit
}
return nil
}
// ParseIncomingMessage parses an incoming Slack message
func (s *SlackPlatform) ParseIncomingMessage(r *http.Request) (*model.Message, error) {
// Read request body
body, err := ioutil.ReadAll(r.Body)
if err != nil {
return nil, err
}
defer r.Body.Close()
// Parse JSON
var requestData map[string]interface{}
if err := json.Unmarshal(body, &requestData); err != nil {
return nil, err
}
// Verify Slack request
// This is a simplified version, production should include signature verification
urlVerify, ok := requestData["type"]
if ok && urlVerify == "url_verification" {
return nil, errors.New("url verification") // Handle separately
}
// Process event
event, ok := requestData["event"].(map[string]interface{})
if !ok {
return nil, errors.New("invalid event")
}
// Create message
msg := &model.Message{
Raw: requestData,
}
// Get text
if text, ok := event["text"].(string); ok {
msg.Text = text
}
// Get channel
if channel, ok := event["channel"].(string); ok {
msg.Chat = channel
// Create Channel object
channelRaw, err := s.ParseChannelFromMessage(body)
if err != nil {
return nil, err
}
msg.Channel = &model.Channel{
Platform: "slack",
PlatformChannelID: channel,
ChannelRaw: channelRaw,
}
}
// Check if from bot
if botID, ok := event["bot_id"].(string); ok && botID != "" {
msg.FromBot = true
}
// Get user
if user, ok := event["user"].(string); ok {
msg.Author = user
}
// Get timestamp
if ts, ok := event["ts"].(string); ok {
// Convert Unix timestamp
parts := strings.Split(ts, ".")
if len(parts) > 0 {
if sec, err := parseInt64(parts[0]); err == nil {
msg.Date = time.Unix(sec, 0)
msg.ID = ts
}
}
}
return msg, nil
}
// ParseChannelNameFromRaw extracts a human-readable channel name from raw data
func (s *SlackPlatform) ParseChannelNameFromRaw(channelRaw map[string]interface{}) string {
// Extract name from channel raw data
if name, ok := channelRaw["name"].(string); ok {
return name
}
// Fallback to ID if available
if id, ok := channelRaw["id"].(string); ok {
return id
}
return "unknown"
}
// ParseChannelFromMessage extracts channel data from a message
func (s *SlackPlatform) ParseChannelFromMessage(body []byte) (map[string]any, error) {
// Parse JSON
var requestData map[string]interface{}
if err := json.Unmarshal(body, &requestData); err != nil {
return nil, err
}
// Extract channel info from event
event, ok := requestData["event"].(map[string]interface{})
if !ok {
return nil, errors.New("invalid event data")
}
channelID, ok := event["channel"].(string)
if !ok {
return nil, errors.New("channel ID not found")
}
// In a real implementation, you might want to fetch more details about the channel
// using the Slack API, but for simplicity we'll just return the ID
channelRaw := map[string]interface{}{
"id": channelID,
}
return channelRaw, nil
}
// SendMessage sends a message to Slack
func (s *SlackPlatform) SendMessage(msg *model.Message) error {
if s.config.BotOAuthAccessToken == "" {
return errors.New("bot token not configured")
}
// Prepare payload
payload := map[string]interface{}{
"channel": msg.Chat,
"text": msg.Text,
}
// Add thread_ts if it's a reply
if msg.ReplyTo != "" {
payload["thread_ts"] = msg.ReplyTo
}
// Convert payload to JSON
data, err := json.Marshal(payload)
if err != nil {
return err
}
// Send HTTP request
req, err := http.NewRequest("POST", "https://slack.com/api/chat.postMessage", strings.NewReader(string(data)))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.config.BotOAuthAccessToken))
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// Check response
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("slack API error: %d", resp.StatusCode)
}
return nil
}
// Helper function to parse int64
func parseInt64(s string) (int64, error) {
var n int64
_, err := fmt.Sscanf(s, "%d", &n)
return n, err
}

View file

@ -0,0 +1,262 @@
package telegram
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strconv"
"time"
"git.nakama.town/fmartingr/butterrobot/internal/config"
"git.nakama.town/fmartingr/butterrobot/internal/model"
)
// TelegramPlatform implements the Platform interface for Telegram
type TelegramPlatform struct {
config *config.TelegramConfig
apiURL string
log *slog.Logger
}
// New creates a new TelegramPlatform instance
func New(cfg *config.TelegramConfig) *TelegramPlatform {
return &TelegramPlatform{
config: cfg,
apiURL: "https://api.telegram.org/bot" + cfg.Token,
log: slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})).With(slog.String("platform", "telegram")),
}
}
// Init initializes the Telegram platform
func (t *TelegramPlatform) Init(cfg *config.Config) error {
if t.config.Token == "" {
t.log.Error("Missing Telegram token")
return model.ErrPlatformInit
}
// Set webhook URL based on hostname
webhookURL := fmt.Sprintf("https://%s/telegram/incoming/%s", cfg.Hostname, t.config.Token)
t.log.Info("Setting Telegram webhook", "url", webhookURL)
// Create webhook setup request
url := fmt.Sprintf("%s/setWebhook", t.apiURL)
payload := map[string]interface{}{
"url": webhookURL,
"max_connections": 40,
"allowed_updates": []string{"message"},
}
data, err := json.Marshal(payload)
if err != nil {
t.log.Error("Failed to marshal webhook payload", "error", err)
return fmt.Errorf("failed to marshal webhook payload: %w", err)
}
resp, err := http.Post(url, "application/json", bytes.NewBuffer(data))
if err != nil {
t.log.Error("Failed to set webhook", "error", err)
return fmt.Errorf("failed to set webhook: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
errMsg := string(bodyBytes)
t.log.Error("Telegram API error", "status", resp.StatusCode, "response", errMsg)
return fmt.Errorf("telegram API error: %d - %s", resp.StatusCode, errMsg)
}
t.log.Info("Telegram webhook successfully set")
return nil
}
// ParseIncomingMessage parses an incoming Telegram message
func (t *TelegramPlatform) ParseIncomingMessage(r *http.Request) (*model.Message, error) {
t.log.Debug("Parsing incoming Telegram message")
// Read request body
body, err := io.ReadAll(r.Body)
if err != nil {
t.log.Error("Failed to read request body", "error", err)
return nil, err
}
defer r.Body.Close()
// Parse JSON
var update struct {
Message struct {
MessageID int `json:"message_id"`
From struct {
ID int `json:"id"`
IsBot bool `json:"is_bot"`
Username string `json:"username"`
FirstName string `json:"first_name"`
} `json:"from"`
Chat struct {
ID int64 `json:"id"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
Username string `json:"username,omitempty"`
} `json:"chat"`
Date int `json:"date"`
Text string `json:"text"`
} `json:"message"`
}
if err := json.Unmarshal(body, &update); err != nil {
t.log.Error("Failed to unmarshal update", "error", err)
return nil, err
}
// Convert to raw map for storage
var raw map[string]interface{}
if err := json.Unmarshal(body, &raw); err != nil {
t.log.Error("Failed to unmarshal raw data", "error", err)
return nil, err
}
// Create message
msg := &model.Message{
Text: update.Message.Text,
Chat: strconv.FormatInt(update.Message.Chat.ID, 10),
Author: update.Message.From.Username,
FromBot: update.Message.From.IsBot,
Date: time.Unix(int64(update.Message.Date), 0),
ID: strconv.Itoa(update.Message.MessageID),
Raw: raw,
}
t.log.Debug("Parsed message",
"id", msg.ID,
"chat", msg.Chat,
"author", msg.Author,
"from_bot", msg.FromBot,
"text_length", len(msg.Text))
// Create Channel object
channelRaw, err := t.ParseChannelFromMessage(body)
if err != nil {
t.log.Error("Failed to parse channel data", "error", err)
return nil, err
}
msg.Channel = &model.Channel{
Platform: "telegram",
PlatformChannelID: msg.Chat,
ChannelRaw: channelRaw,
}
return msg, nil
}
// ParseChannelNameFromRaw extracts a human-readable channel name from raw data
func (t *TelegramPlatform) ParseChannelNameFromRaw(channelRaw map[string]interface{}) string {
// Try to get the title first (for groups)
if chatInfo, ok := channelRaw["chat"].(map[string]interface{}); ok {
if title, ok := chatInfo["title"].(string); ok && title != "" {
return title
}
// For private chats, use username
if username, ok := chatInfo["username"].(string); ok && username != "" {
return username
}
// Fallback to first_name if available
if firstName, ok := chatInfo["first_name"].(string); ok && firstName != "" {
return firstName
}
// Last resort: use the ID
if id, ok := chatInfo["id"].(float64); ok {
return strconv.FormatInt(int64(id), 10)
}
}
return "unknown"
}
// ParseChannelFromMessage extracts channel data from a message
func (t *TelegramPlatform) ParseChannelFromMessage(body []byte) (map[string]any, error) {
// Parse JSON to extract chat info
var update struct {
Message struct {
Chat map[string]any `json:"chat"`
} `json:"message"`
}
if err := json.Unmarshal(body, &update); err != nil {
return nil, err
}
if update.Message.Chat == nil {
return nil, errors.New("chat information not found")
}
return map[string]any{
"chat": update.Message.Chat,
}, nil
}
// SendMessage sends a message to Telegram
func (t *TelegramPlatform) SendMessage(msg *model.Message) error {
// Convert chat ID to int64
chatID, err := strconv.ParseInt(msg.Chat, 10, 64)
if err != nil {
t.log.Error("Failed to parse chat ID", "chat", msg.Chat, "error", err)
return err
}
// Prepare payload
payload := map[string]interface{}{
"chat_id": chatID,
"text": msg.Text,
}
// Add reply if needed
if msg.ReplyTo != "" {
replyToID, err := strconv.Atoi(msg.ReplyTo)
if err == nil {
payload["reply_to_message_id"] = replyToID
} else {
t.log.Warn("Failed to parse reply_to ID", "reply_to", msg.ReplyTo, "error", err)
}
}
t.log.Debug("Sending message to Telegram", "chat_id", chatID, "length", len(msg.Text))
// Convert payload to JSON
data, err := json.Marshal(payload)
if err != nil {
t.log.Error("Failed to marshal message payload", "error", err)
return err
}
// Send HTTP request
resp, err := http.Post(
t.apiURL+"/sendMessage",
"application/json",
bytes.NewBuffer(data),
)
if err != nil {
t.log.Error("Failed to send message", "error", err)
return err
}
defer resp.Body.Close()
// Check response
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
errMsg := string(bodyBytes)
t.log.Error("Telegram API error", "status", resp.StatusCode, "response", errMsg)
return fmt.Errorf("telegram API error: %d - %s", resp.StatusCode, errMsg)
}
t.log.Debug("Message sent successfully")
return nil
}

View file

@ -0,0 +1,50 @@
package fun
import (
"math/rand"
"strings"
"time"
"git.nakama.town/fmartingr/butterrobot/internal/model"
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
)
// CoinPlugin flips a coin
type CoinPlugin struct {
plugin.BasePlugin
rand *rand.Rand
}
// NewCoin creates a new CoinPlugin instance
func NewCoin() *CoinPlugin {
source := rand.NewSource(time.Now().UnixNano())
return &CoinPlugin{
BasePlugin: plugin.BasePlugin{
ID: "fun.coin",
Name: "Coin Flip",
Help: "Flips a coin when you type 'flip a coin'",
},
rand: rand.New(source),
}
}
// OnMessage handles incoming messages
func (p *CoinPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
if !strings.Contains(strings.ToLower(msg.Text), "flip a coin") {
return nil
}
result := "Heads"
if p.rand.Intn(2) == 0 {
result = "Tails"
}
response := &model.Message{
Text: result,
Chat: msg.Chat,
ReplyTo: msg.ID,
Channel: msg.Channel,
}
return []*model.Message{response}
}

118
internal/plugin/fun/dice.go Normal file
View file

@ -0,0 +1,118 @@
package fun
import (
"fmt"
"math/rand"
"regexp"
"strconv"
"strings"
"time"
"git.nakama.town/fmartingr/butterrobot/internal/model"
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
)
// DicePlugin rolls dice based on standard dice notation
type DicePlugin struct {
plugin.BasePlugin
rand *rand.Rand
}
// NewDice creates a new DicePlugin instance
func NewDice() *DicePlugin {
source := rand.NewSource(time.Now().UnixNano())
return &DicePlugin{
BasePlugin: plugin.BasePlugin{
ID: "fun.dice",
Name: "Dice Roller",
Help: "Rolls dice when you type '!dice [formula]' (default: 1d20)",
},
rand: rand.New(source),
}
}
// OnMessage handles incoming messages
func (p *DicePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
if !strings.HasPrefix(strings.TrimSpace(strings.ToLower(msg.Text)), "!dice") {
return nil
}
// Extract dice formula
formula := strings.TrimSpace(strings.TrimPrefix(msg.Text, "!dice"))
formula = strings.TrimSpace(strings.TrimPrefix(formula, "!dice"))
if formula == "" {
formula = "1d20" // Default formula
}
// Parse and roll the dice
result, err := p.rollDice(formula)
responseText := ""
if err != nil {
responseText = fmt.Sprintf("Error: %s", err.Error())
} else {
responseText = fmt.Sprintf("%d", result)
}
response := &model.Message{
Text: responseText,
Chat: msg.Chat,
ReplyTo: msg.ID,
Channel: msg.Channel,
}
return []*model.Message{response}
}
// rollDice parses a dice formula string and returns the result
func (p *DicePlugin) rollDice(formula string) (int, error) {
// Support basic dice notation like "2d6", "1d20+5", etc.
diceRegex := regexp.MustCompile(`^(\d+)d(\d+)(?:([+-])(\d+))?$`)
matches := diceRegex.FindStringSubmatch(formula)
if matches == nil {
return 0, fmt.Errorf("invalid dice formula: %s", formula)
}
// Parse number of dice
numDice, err := strconv.Atoi(matches[1])
if err != nil || numDice < 1 {
return 0, fmt.Errorf("invalid number of dice")
}
if numDice > 100 {
return 0, fmt.Errorf("too many dice (max 100)")
}
// Parse number of sides
sides, err := strconv.Atoi(matches[2])
if err != nil || sides < 1 {
return 0, fmt.Errorf("invalid number of sides")
}
if sides > 1000 {
return 0, fmt.Errorf("too many sides (max 1000)")
}
// Roll the dice
total := 0
for i := 0; i < numDice; i++ {
roll := p.rand.Intn(sides) + 1
total += roll
}
// Apply modifier if present
if len(matches) > 3 && matches[3] != "" {
modifier, err := strconv.Atoi(matches[4])
if err != nil {
return 0, fmt.Errorf("invalid modifier")
}
if matches[3] == "+" {
total += modifier
} else if matches[3] == "-" {
total -= modifier
}
}
return total, nil
}

View file

@ -0,0 +1,40 @@
package fun
import (
"strings"
"git.nakama.town/fmartingr/butterrobot/internal/model"
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
)
// LoquitoPlugin replies with "Loquito tu." when someone says "lo quito"
type LoquitoPlugin struct {
plugin.BasePlugin
}
// NewLoquito creates a new LoquitoPlugin instance
func NewLoquito() *LoquitoPlugin {
return &LoquitoPlugin{
BasePlugin: plugin.BasePlugin{
ID: "fun.loquito",
Name: "Loquito Reply",
Help: "Replies with 'Loquito tu.' when someone says 'lo quito'",
},
}
}
// OnMessage handles incoming messages
func (p *LoquitoPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
if !strings.Contains(strings.ToLower(msg.Text), "lo quito") {
return nil
}
response := &model.Message{
Text: "Loquito tu.",
Chat: msg.Chat,
ReplyTo: msg.ID,
Channel: msg.Channel,
}
return []*model.Message{response}
}

View file

@ -0,0 +1,40 @@
package ping
import (
"strings"
"git.nakama.town/fmartingr/butterrobot/internal/model"
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
)
// PingPlugin is a simple ping/pong plugin
type PingPlugin struct {
plugin.BasePlugin
}
// New creates a new PingPlugin instance
func New() *PingPlugin {
return &PingPlugin{
BasePlugin: plugin.BasePlugin{
ID: "dev.ping",
Name: "Ping",
Help: "Responds to 'ping' with 'pong'",
},
}
}
// OnMessage handles incoming messages
func (p *PingPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
if !strings.EqualFold(strings.TrimSpace(msg.Text), "ping") {
return nil
}
response := &model.Message{
Text: "pong",
Chat: msg.Chat,
ReplyTo: msg.ID,
Channel: msg.Channel,
}
return []*model.Message{response}
}

82
internal/plugin/plugin.go Normal file
View file

@ -0,0 +1,82 @@
package plugin
import (
"sync"
"git.nakama.town/fmartingr/butterrobot/internal/model"
)
var (
// plugins holds all registered plugins
plugins = make(map[string]model.Plugin)
// pluginsMu protects the plugins map
pluginsMu sync.RWMutex
)
// Register registers a plugin with the given ID
func Register(plugin model.Plugin) {
pluginsMu.Lock()
defer pluginsMu.Unlock()
plugins[plugin.GetID()] = plugin
}
// Get returns a plugin by ID
func Get(id string) (model.Plugin, error) {
pluginsMu.RLock()
defer pluginsMu.RUnlock()
plugin, exists := plugins[id]
if !exists {
return nil, model.ErrPluginNotFound
}
return plugin, nil
}
// GetAvailablePlugins returns all registered plugins
func GetAvailablePlugins() map[string]model.Plugin {
pluginsMu.RLock()
defer pluginsMu.RUnlock()
// Create a copy to avoid race conditions
result := make(map[string]model.Plugin, len(plugins))
for id, plugin := range plugins {
result[id] = plugin
}
return result
}
// BasePlugin provides a common base for plugins
type BasePlugin struct {
ID string
Name string
Help string
ConfigRequired bool
}
// GetID returns the plugin ID
func (p *BasePlugin) GetID() string {
return p.ID
}
// GetName returns the plugin name
func (p *BasePlugin) GetName() string {
return p.Name
}
// GetHelp returns the plugin help text
func (p *BasePlugin) GetHelp() string {
return p.Help
}
// RequiresConfig indicates if the plugin requires configuration
func (p *BasePlugin) RequiresConfig() bool {
return p.ConfigRequired
}
// OnMessage is the default implementation that does nothing
func (p *BasePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
return nil
}

99
internal/queue/queue.go Normal file
View file

@ -0,0 +1,99 @@
package queue
import (
"log/slog"
"sync"
)
// Item represents a queue item
type Item struct {
Platform string
Request map[string]interface{}
}
// HandlerFunc defines a function that processes queue items
type HandlerFunc func(item Item)
// 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
}
// New creates a new Queue instance
func New(logger *slog.Logger) *Queue {
return &Queue{
items: make(chan Item, 100),
quit: make(chan struct{}),
logger: logger,
}
}
// Start starts processing queue items
func (q *Queue) Start(handler HandlerFunc) {
q.runMutex.Lock()
defer q.runMutex.Unlock()
if q.running {
return
}
q.running = true
// Start worker
q.wg.Add(1)
go q.worker(handler)
}
// Stop stops processing queue items
func (q *Queue) Stop() {
q.runMutex.Lock()
defer q.runMutex.Unlock()
if !q.running {
return
}
q.running = false
close(q.quit)
q.wg.Wait()
}
// Add adds an item to the queue
func (q *Queue) Add(item Item) {
select {
case q.items <- item:
// Item added successfully
default:
// Queue is full
q.logger.Info("Queue is full, dropping message")
}
}
// worker processes queue items
func (q *Queue) worker(handler HandlerFunc) {
defer q.wg.Done()
for {
select {
case item := <-q.items:
// Process item
func() {
defer func() {
if r := recover(); r != nil {
q.logger.Error("Panic in queue worker", "error", r)
}
}()
handler(item)
}()
case <-q.quit:
// Quit worker
return
}
}
}