This commit is contained in:
parent
9c78ea2d48
commit
7c684af8c3
79 changed files with 3594 additions and 3257 deletions
563
internal/admin/admin.go
Normal file
563
internal/admin/admin.go
Normal 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)
|
||||
}
|
123
internal/admin/templates/_base.html
Normal file
123
internal/admin/templates/_base.html
Normal 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>
|
114
internal/admin/templates/channel_detail.html
Normal file
114
internal/admin/templates/channel_detail.html
Normal 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}}
|
64
internal/admin/templates/channel_list.html
Normal file
64
internal/admin/templates/channel_list.html
Normal 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}}
|
93
internal/admin/templates/channel_plugins_list.html
Normal file
93
internal/admin/templates/channel_plugins_list.html
Normal 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}}
|
15
internal/admin/templates/index.html
Normal file
15
internal/admin/templates/index.html
Normal 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}}
|
26
internal/admin/templates/login.html
Normal file
26
internal/admin/templates/login.html
Normal 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}}
|
45
internal/admin/templates/plugin_list.html
Normal file
45
internal/admin/templates/plugin_list.html
Normal 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
293
internal/app/app.go
Normal 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
59
internal/config/config.go
Normal 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
641
internal/db/db.go
Normal 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
86
internal/model/message.go
Normal 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
|
||||
}
|
46
internal/model/platform.go
Normal file
46
internal/model/platform.go
Normal 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
28
internal/model/plugin.go
Normal 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
32
internal/platform/init.go
Normal 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
|
||||
}
|
49
internal/platform/registry.go
Normal file
49
internal/platform/registry.go
Normal 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
|
||||
}
|
212
internal/platform/slack/slack.go
Normal file
212
internal/platform/slack/slack.go
Normal 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
|
||||
}
|
262
internal/platform/telegram/telegram.go
Normal file
262
internal/platform/telegram/telegram.go
Normal 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
|
||||
}
|
50
internal/plugin/fun/coin.go
Normal file
50
internal/plugin/fun/coin.go
Normal 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
118
internal/plugin/fun/dice.go
Normal 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
|
||||
}
|
40
internal/plugin/fun/loquito.go
Normal file
40
internal/plugin/fun/loquito.go
Normal 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}
|
||||
}
|
40
internal/plugin/ping/ping.go
Normal file
40
internal/plugin/ping/ping.go
Normal 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
82
internal/plugin/plugin.go
Normal 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
99
internal/queue/queue.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue