Files
dumpsterChat/internal/bot/commands.go
T
hobokenchicken 000ce85816 Phase 4: Bots & Extensibility
Backend:
- internal/bot/auth.go: bot token generation and verification
- internal/bot/handlers.go: bot CRUD (create, list, get, update, delete, server management, token regen)
- internal/bot/commands.go: slash command registration and management
- internal/webhook/handlers.go: webhook CRUD and execution endpoint
- internal/webhook/token.go: webhook token generation
- internal/db/db.go: bots, bot_servers, slash_commands, webhooks tables
- internal/gateway/events.go: BOT_JOIN, BOT_LEAVE event constants
- cmd/server/main.go: wired bot, webhook, invite routes

Frontend:
- stores/bot.ts: Zustand store for bot management
- BotManager.tsx: bot list, create, edit, delete, add to server, token display
- CommandManager.tsx: slash command CRUD per bot
- SlashCommandPopup.tsx: / command autocomplete popup
- App.tsx: /bots and /bots/:id/commands routes

Examples:
- examples/modbot/: auto-delete banned words, /kick, /ban, /purge commands
- examples/welcome/: welcome message on member join
2026-06-28 17:00:37 -04:00

282 lines
7.8 KiB
Go

package bot
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"github.com/go-chi/chi/v5"
)
// CommandHandler handles slash command CRUD routes.
type CommandHandler struct {
db *sql.DB
}
// NewCommandHandler creates a new CommandHandler.
func NewCommandHandler(db *sql.DB) *CommandHandler {
return &CommandHandler{db: db}
}
// RegisterCommandRoutes registers slash-command routes under the given router.
// Authenticated routes (bot-scoped):
//
// POST /{botID}/commands - create slash command
// GET /{botID}/commands - list bot's commands
// DELETE /{botID}/commands/{cmdID} - delete command
//
// Public route (for autocomplete):
//
// GET /servers/{serverID}/commands - list all commands for a server
func (h *CommandHandler) RegisterCommandRoutes(r chi.Router) {
r.Post("/{botID}/commands", h.Create)
r.Get("/{botID}/commands", h.ListByBot)
r.Delete("/{botID}/commands/{cmdID}", h.Delete)
r.Get("/servers/{serverID}/commands", h.ListByServer)
}
// ---- types ----
type slashCommandResponse struct {
ID string `json:"id"`
BotID string `json:"bot_id"`
ServerID string `json:"server_id"`
Name string `json:"name"`
Description string `json:"description"`
Options interface{} `json:"options"`
CreatedAt string `json:"created_at"`
}
type createCommandRequest struct {
ServerID string `json:"server_id"`
Name string `json:"name"`
Description string `json:"description"`
Options interface{} `json:"options"`
}
// ---- handlers ----
func (h *CommandHandler) Create(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized")
return
}
botID := chi.URLParam(r, "botID")
// Verify bot ownership
if err := h.verifyBotOwnership(r, userID, botID); err != nil {
if err == errNotFound {
writeErr(w, http.StatusNotFound, "bot not found")
} else if err == errForbidden {
writeErr(w, http.StatusForbidden, "forbidden")
} else {
writeErr(w, http.StatusInternalServerError, "server error")
}
return
}
var req createCommandRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request")
return
}
if req.ServerID == "" || req.Name == "" {
writeErr(w, http.StatusBadRequest, "server_id and name are required")
return
}
// Verify bot is added to the target server
added, err := h.isBotOnServer(r, botID, req.ServerID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "server error")
return
}
if !added {
writeErr(w, http.StatusForbidden, "bot is not added to this server")
return
}
// Marshal options to JSON for storage
optionsJSON := "[]"
if req.Options != nil {
b, err := json.Marshal(req.Options)
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid options")
return
}
optionsJSON = string(b)
}
var cmd slashCommandResponse
var createdAt sql.NullString
err = h.db.QueryRowContext(r.Context(), `
INSERT INTO slash_commands (bot_id, server_id, name, description, options)
VALUES ($1, $2, $3, $4, $5::jsonb)
RETURNING id, bot_id, server_id, name, description, options, created_at::text
`, botID, req.ServerID, req.Name, req.Description, optionsJSON).Scan(
&cmd.ID, &cmd.BotID, &cmd.ServerID, &cmd.Name, &cmd.Description,
&cmd.Options, &createdAt,
)
if err != nil {
// Likely a UNIQUE(server_id, name) violation
writeErr(w, http.StatusConflict, "command name already exists in this server")
return
}
cmd.CreatedAt = createdAt.String
writeJSON(w, http.StatusCreated, cmd)
}
func (h *CommandHandler) ListByBot(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized")
return
}
botID := chi.URLParam(r, "botID")
if err := h.verifyBotOwnership(r, userID, botID); err != nil {
if err == errNotFound {
writeErr(w, http.StatusNotFound, "bot not found")
} else if err == errForbidden {
writeErr(w, http.StatusForbidden, "forbidden")
} else {
writeErr(w, http.StatusInternalServerError, "server error")
}
return
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT id, bot_id, server_id, name, description, options, created_at::text
FROM slash_commands WHERE bot_id = $1 ORDER BY name
`, botID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "server error")
return
}
defer rows.Close()
cmds := make([]slashCommandResponse, 0)
for rows.Next() {
var cmd slashCommandResponse
var createdAt sql.NullString
if err := rows.Scan(&cmd.ID, &cmd.BotID, &cmd.ServerID, &cmd.Name, &cmd.Description, &cmd.Options, &createdAt); err != nil {
writeErr(w, http.StatusInternalServerError, "server error")
return
}
cmd.CreatedAt = createdAt.String
cmds = append(cmds, cmd)
}
if err := rows.Err(); err != nil {
writeErr(w, http.StatusInternalServerError, "server error")
return
}
writeJSON(w, http.StatusOK, cmds)
}
func (h *CommandHandler) Delete(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized")
return
}
botID := chi.URLParam(r, "botID")
cmdID := chi.URLParam(r, "cmdID")
if err := h.verifyBotOwnership(r, userID, botID); err != nil {
if err == errNotFound {
writeErr(w, http.StatusNotFound, "bot not found")
} else if err == errForbidden {
writeErr(w, http.StatusForbidden, "forbidden")
} else {
writeErr(w, http.StatusInternalServerError, "server error")
}
return
}
result, err := h.db.ExecContext(r.Context(), `
DELETE FROM slash_commands WHERE id = $1 AND bot_id = $2
`, cmdID, botID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to delete command")
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
writeErr(w, http.StatusNotFound, "command not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
// ListByServer returns all slash commands registered in a server (public, no auth).
func (h *CommandHandler) ListByServer(w http.ResponseWriter, r *http.Request) {
serverID := chi.URLParam(r, "serverID")
rows, err := h.db.QueryContext(r.Context(), `
SELECT id, bot_id, server_id, name, description, options, created_at::text
FROM slash_commands WHERE server_id = $1 ORDER BY name
`, serverID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "server error")
return
}
defer rows.Close()
cmds := make([]slashCommandResponse, 0)
for rows.Next() {
var cmd slashCommandResponse
var createdAt sql.NullString
if err := rows.Scan(&cmd.ID, &cmd.BotID, &cmd.ServerID, &cmd.Name, &cmd.Description, &cmd.Options, &createdAt); err != nil {
writeErr(w, http.StatusInternalServerError, "server error")
return
}
cmd.CreatedAt = createdAt.String
cmds = append(cmds, cmd)
}
if err := rows.Err(); err != nil {
writeErr(w, http.StatusInternalServerError, "server error")
return
}
writeJSON(w, http.StatusOK, cmds)
}
// ---- internal helpers ----
var (
errNotFound = errors.New("not found")
errForbidden = errors.New("forbidden")
)
func (h *CommandHandler) verifyBotOwnership(r *http.Request, userID, botID string) error {
var ownerID string
err := h.db.QueryRowContext(r.Context(), `SELECT owner_id FROM bots WHERE id = $1`, botID).Scan(&ownerID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return errNotFound
}
return err
}
if ownerID != userID {
return errForbidden
}
return nil
}
func (h *CommandHandler) isBotOnServer(r *http.Request, botID, serverID string) (bool, error) {
var exists bool
err := h.db.QueryRowContext(r.Context(), `
SELECT EXISTS(SELECT 1 FROM bot_servers WHERE bot_id = $1 AND server_id = $2)
`, botID, serverID).Scan(&exists)
return exists, err
}