Files
dumpsterChat/internal/bot/handlers.go
T
hobokenchicken 4b32655e67 feat(bots): built-in bot runner + steamfree from UI
- BotRunner: server-side goroutine manager for built-in bot types
- steamfree bot embedded in server (polls Steam API, posts free games)
- bot_type + config JSONB columns on bots table
- Create/Update/Delete handlers manage runner lifecycle
- GET /bots/types returns registered bot types
- BotManager: type selector dropdown + config fields on create
- No SSH needed: create a 'Steam Free Games' bot from /bots/manage
2026-07-15 14:14:02 -04:00

656 lines
18 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"
)
// Handler handles bot CRUD and server-assignment routes.
type Handler struct {
db *sql.DB
runner *Runner
}
// NewHandler creates a new bot Handler.
func NewHandler(db *sql.DB, runner *Runner) *Handler {
return &Handler{db: db, runner: runner}
}
// RegisterRoutes registers authenticated bot routes under the given router.
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/store", h.Store)
r.Get("/types", h.ListTypes)
r.Post("/", h.Create)
r.Get("/", h.List)
r.Get("/{botID}", h.Get)
r.Patch("/{botID}", h.Update)
r.Delete("/{botID}", h.Delete)
r.Post("/{botID}/servers", h.AddToServer)
r.Delete("/{botID}/servers/{serverID}", h.RemoveFromServer)
r.Post("/{botID}/regenerate-token", h.RegenerateToken)
}
// ---- response / request types ----
type botResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Avatar *string `json:"avatar"`
Description string `json:"description"`
BotType string `json:"bot_type"`
Config json.RawMessage `json:"config"`
OwnerID string `json:"owner_id"`
CreatedAt string `json:"created_at"`
}
// botWithToken is returned only on create / regenerate-token.
type botWithToken struct {
botResponse
Token string `json:"token"`
}
type createBotRequest struct {
Name string `json:"name"`
Description string `json:"description"`
BotType string `json:"bot_type"`
Config json.RawMessage `json:"config"`
}
type updateBotRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Avatar *string `json:"avatar"`
BotType *string `json:"bot_type"`
Config json.RawMessage `json:"config"`
}
type addToServerRequest struct {
ServerID string `json:"server_id"`
}
// ---- helpers ----
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, msg string) {
http.Error(w, `{"error":"`+msg+`"}`, status)
}
// ---- handlers ----
// @Summary Create a bot
// @Description Create a new bot
// @Tags bots
// @Accept json
// @Produce json
// @Security SessionAuth
// @Param body body createBotRequest true "Bot data"
// @Success 201 {object} botWithToken
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Router /bots [post]
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized")
return
}
var req createBotRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request")
return
}
if req.Name == "" {
writeErr(w, http.StatusBadRequest, "name is required")
return
}
token := GenerateToken()
tokenHash := HashToken(token)
configJSON := req.Config
if configJSON == nil {
configJSON = json.RawMessage(`{}`)
}
var bot botWithToken
var avatar sql.NullString
var createdAt sql.NullString
var configOut sql.NullString
err := h.db.QueryRowContext(r.Context(), `
INSERT INTO bots (name, description, owner_id, token, bot_type, config)
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
RETURNING id, name, avatar, description, bot_type, config::text, owner_id, created_at::text
`, req.Name, req.Description, userID, tokenHash, req.BotType, string(configJSON)).Scan(
&bot.ID, &bot.Name, &avatar, &bot.Description, &bot.BotType, &configOut, &bot.OwnerID, &createdAt,
)
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to create bot")
return
}
if avatar.Valid {
bot.Avatar = &avatar.String
}
if configOut.Valid {
bot.Config = json.RawMessage(configOut.String)
}
bot.CreatedAt = createdAt.String
bot.Token = token
// Start built-in bot if type is set
if req.BotType != "" && h.runner != nil {
h.runner.Start(bot.ID, req.BotType, bot.Config)
}
writeJSON(w, http.StatusCreated, bot)
}
// @Summary List bots
// @Description List all bots owned by the current user
// @Tags bots
// @Produce json
// @Security SessionAuth
// @Success 200 {array} botResponse
// @Failure 401 {object} map[string]string
// @Router /bots [get]
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized")
return
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT id, name, avatar, description, owner_id, created_at::text
FROM bots WHERE owner_id = $1 ORDER BY created_at DESC
`, userID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "server error")
return
}
defer rows.Close()
bots := make([]botResponse, 0)
for rows.Next() {
var b botResponse
var avatar sql.NullString
var createdAt sql.NullString
if err := rows.Scan(&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt); err != nil {
writeErr(w, http.StatusInternalServerError, "server error")
return
}
if avatar.Valid {
b.Avatar = &avatar.String
}
b.CreatedAt = createdAt.String
bots = append(bots, b)
}
if err := rows.Err(); err != nil {
writeErr(w, http.StatusInternalServerError, "server error")
return
}
writeJSON(w, http.StatusOK, bots)
}
// @Summary Get a bot
// @Description Get a bot by ID (owner only)
// @Tags bots
// @Produce json
// @Security SessionAuth
// @Param botID path string true "Bot ID"
// @Success 200 {object} botResponse
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /bots/{botID} [get]
func (h *Handler) Get(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")
var b botResponse
var avatar sql.NullString
var createdAt sql.NullString
err := h.db.QueryRowContext(r.Context(), `
SELECT id, name, avatar, description, owner_id, created_at::text
FROM bots WHERE id = $1
`, botID).Scan(&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeErr(w, http.StatusNotFound, "bot not found")
} else {
writeErr(w, http.StatusInternalServerError, "server error")
}
return
}
if b.OwnerID != userID {
writeErr(w, http.StatusNotFound, "bot not found")
return
}
if avatar.Valid {
b.Avatar = &avatar.String
}
b.CreatedAt = createdAt.String
writeJSON(w, http.StatusOK, b)
}
// @Summary Update a bot
// @Description Update a bot's name, description, or avatar (owner only)
// @Tags bots
// @Accept json
// @Produce json
// @Security SessionAuth
// @Param botID path string true "Bot ID"
// @Param body body updateBotRequest true "Bot update data"
// @Success 200 {object} botResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /bots/{botID} [patch]
func (h *Handler) Update(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 ownership
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) {
writeErr(w, http.StatusNotFound, "bot not found")
} else {
writeErr(w, http.StatusInternalServerError, "server error")
}
return
}
if ownerID != userID {
writeErr(w, http.StatusNotFound, "bot not found")
return
}
var req updateBotRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request")
return
}
if req.Name == nil && req.Description == nil && req.Avatar == nil {
writeErr(w, http.StatusBadRequest, "nothing to update")
return
}
var b botResponse
var avatar sql.NullString
var createdAt sql.NullString
var configOut sql.NullString
err = h.db.QueryRowContext(r.Context(), `
UPDATE bots
SET name = COALESCE($1, name),
description = COALESCE($2, description),
avatar = COALESCE($3, avatar),
bot_type = COALESCE($5, bot_type),
config = COALESCE($6::jsonb, config)
WHERE id = $4
RETURNING id, name, avatar, description, bot_type, config::text, owner_id, created_at::text
`, req.Name, req.Description, req.Avatar, botID, req.BotType, string(req.Config)).Scan(
&b.ID, &b.Name, &avatar, &b.Description, &b.BotType, &configOut, &b.OwnerID, &createdAt,
)
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to update bot")
return
}
if avatar.Valid {
b.Avatar = &avatar.String
}
if configOut.Valid {
b.Config = json.RawMessage(configOut.String)
}
b.CreatedAt = createdAt.String
// Restart built-in bot if type/config changed
if req.BotType != nil && h.runner != nil {
h.runner.Stop(b.ID)
if *req.BotType != "" {
h.runner.Start(b.ID, *req.BotType, b.Config)
}
} else if req.Config != nil && h.runner != nil && b.BotType != "" {
h.runner.Stop(b.ID)
h.runner.Start(b.ID, b.BotType, b.Config)
}
writeJSON(w, http.StatusOK, b)
}
// @Summary Delete a bot
// @Description Delete a bot (owner only)
// @Tags bots
// @Security SessionAuth
// @Param botID path string true "Bot ID"
// @Success 204
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /bots/{botID} [delete]
func (h *Handler) 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")
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) {
writeErr(w, http.StatusNotFound, "bot not found")
} else {
writeErr(w, http.StatusInternalServerError, "server error")
}
return
}
if ownerID != userID {
writeErr(w, http.StatusNotFound, "bot not found")
return
}
_, err = h.db.ExecContext(r.Context(), `DELETE FROM bots WHERE id = $1`, botID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to delete bot")
return
}
// Stop built-in bot if running
if h.runner != nil {
h.runner.Stop(botID)
}
w.WriteHeader(http.StatusNoContent)
}
// @Summary Add bot to server
// @Description Add a bot to a server
// @Tags bots
// @Accept json
// @Produce json
// @Security SessionAuth
// @Param botID path string true "Bot ID"
// @Param body body addToServerRequest true "Server data"
// @Success 201 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /bots/{botID}/servers [post]
func (h *Handler) AddToServer(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
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) {
writeErr(w, http.StatusNotFound, "bot not found")
} else {
writeErr(w, http.StatusInternalServerError, "server error")
}
return
}
if ownerID != userID {
writeErr(w, http.StatusNotFound, "bot not found")
return
}
var req addToServerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request")
return
}
if req.ServerID == "" {
writeErr(w, http.StatusBadRequest, "server_id is required")
return
}
// Verify user is a member of the target server
var exists bool
err = h.db.QueryRowContext(r.Context(), `
SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2)
`, userID, req.ServerID).Scan(&exists)
if err != nil {
writeErr(w, http.StatusInternalServerError, "server error")
return
}
if !exists {
writeErr(w, http.StatusForbidden, "not a member of this server")
return
}
_, err = h.db.ExecContext(r.Context(), `
INSERT INTO bot_servers (bot_id, server_id, added_by)
VALUES ($1, $2, $3)
ON CONFLICT (bot_id, server_id) DO NOTHING
`, botID, req.ServerID, userID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to add bot to server")
return
}
writeJSON(w, http.StatusCreated, map[string]string{
"bot_id": botID,
"server_id": req.ServerID,
})
}
// @Summary Remove bot from server
// @Description Remove a bot from a server
// @Tags bots
// @Security SessionAuth
// @Param botID path string true "Bot ID"
// @Param serverID path string true "Server ID"
// @Success 204
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /bots/{botID}/servers/{serverID} [delete]
func (h *Handler) RemoveFromServer(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")
serverID := chi.URLParam(r, "serverID")
// Verify bot ownership
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) {
writeErr(w, http.StatusNotFound, "bot not found")
} else {
writeErr(w, http.StatusInternalServerError, "server error")
}
return
}
if ownerID != userID {
writeErr(w, http.StatusNotFound, "bot not found")
return
}
result, err := h.db.ExecContext(r.Context(), `
DELETE FROM bot_servers WHERE bot_id = $1 AND server_id = $2
`, botID, serverID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to remove bot from server")
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
writeErr(w, http.StatusNotFound, "bot not in this server")
return
}
w.WriteHeader(http.StatusNoContent)
}
// @Summary Regenerate bot token
// @Description Regenerate a bot's API token (owner only)
// @Tags bots
// @Produce json
// @Security SessionAuth
// @Param botID path string true "Bot ID"
// @Success 200 {object} botWithToken
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /bots/{botID}/regenerate-token [post]
func (h *Handler) RegenerateToken(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 ownership
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) {
writeErr(w, http.StatusNotFound, "bot not found")
} else {
writeErr(w, http.StatusInternalServerError, "server error")
}
return
}
if ownerID != userID {
writeErr(w, http.StatusNotFound, "bot not found")
return
}
token := GenerateToken()
tokenHash := HashToken(token)
var b botWithToken
var avatar sql.NullString
var createdAt sql.NullString
err = h.db.QueryRowContext(r.Context(), `
UPDATE bots SET token = $1
WHERE id = $2
RETURNING id, name, avatar, description, owner_id, created_at::text
`, tokenHash, botID).Scan(
&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt,
)
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to regenerate token")
return
}
if avatar.Valid {
b.Avatar = &avatar.String
}
b.CreatedAt = createdAt.String
b.Token = token
writeJSON(w, http.StatusOK, b)
}
// ---- Store (public listing) ----
type storeBotResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Avatar *string `json:"avatar"`
Description string `json:"description"`
ServerCount int `json:"server_count"`
OwnerID string `json:"owner_id"`
CreatedAt string `json:"created_at"`
}
// Store returns all bots with their server count (visible to any authenticated user).
func (h *Handler) Store(w http.ResponseWriter, r *http.Request) {
rows, err := h.db.QueryContext(r.Context(), `
SELECT b.id, b.name, b.avatar, b.description, b.owner_id, b.created_at::text,
COUNT(bs.server_id) AS server_count
FROM bots b
LEFT JOIN bot_servers bs ON bs.bot_id = b.id
GROUP BY b.id, b.name, b.avatar, b.description, b.owner_id, b.created_at
ORDER BY server_count DESC, b.name
`)
if err != nil {
writeErr(w, http.StatusInternalServerError, "server error")
return
}
defer rows.Close()
bots := make([]storeBotResponse, 0)
for rows.Next() {
var b storeBotResponse
var avatar sql.NullString
var createdAt sql.NullString
if err := rows.Scan(&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt, &b.ServerCount); err != nil {
writeErr(w, http.StatusInternalServerError, "server error")
return
}
if avatar.Valid {
b.Avatar = &avatar.String
}
b.CreatedAt = createdAt.String
bots = append(bots, b)
}
if err := rows.Err(); err != nil {
writeErr(w, http.StatusInternalServerError, "server error")
return
}
writeJSON(w, http.StatusOK, bots)
}
// ListTypes returns available built-in bot types.
func (h *Handler) ListTypes(w http.ResponseWriter, r *http.Request) {
if h.runner == nil {
writeJSON(w, http.StatusOK, []string{})
return
}
writeJSON(w, http.StatusOK, h.runner.RegisteredTypes())
}