72ca99b58d
- Added @Summary/@Router/@Param/@Success/@Security annotations to all API handlers - Regenerated docs/swagger.json, docs/swagger.yaml, docs/docs.go with full endpoint definitions - Fixed generated docs.go to remove unsupported LeftDelim/RightDelim fields
550 lines
14 KiB
Go
550 lines
14 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
|
|
}
|
|
|
|
// NewHandler creates a new bot Handler.
|
|
func NewHandler(db *sql.DB) *Handler {
|
|
return &Handler{db: db}
|
|
}
|
|
|
|
// RegisterRoutes registers authenticated bot routes under the given router.
|
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
|
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"`
|
|
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"`
|
|
}
|
|
|
|
type updateBotRequest struct {
|
|
Name *string `json:"name"`
|
|
Description *string `json:"description"`
|
|
Avatar *string `json:"avatar"`
|
|
}
|
|
|
|
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)
|
|
|
|
var bot botWithToken
|
|
var avatar sql.NullString
|
|
var createdAt sql.NullString
|
|
err := h.db.QueryRowContext(r.Context(), `
|
|
INSERT INTO bots (name, description, owner_id, token)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, name, avatar, description, owner_id, created_at::text
|
|
`, req.Name, req.Description, userID, tokenHash).Scan(
|
|
&bot.ID, &bot.Name, &avatar, &bot.Description, &bot.OwnerID, &createdAt,
|
|
)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to create bot")
|
|
return
|
|
}
|
|
if avatar.Valid {
|
|
bot.Avatar = &avatar.String
|
|
}
|
|
bot.CreatedAt = createdAt.String
|
|
bot.Token = token
|
|
|
|
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.StatusForbidden, "forbidden")
|
|
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.StatusForbidden, "forbidden")
|
|
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
|
|
err = h.db.QueryRowContext(r.Context(), `
|
|
UPDATE bots
|
|
SET name = COALESCE($1, name),
|
|
description = COALESCE($2, description),
|
|
avatar = COALESCE($3, avatar)
|
|
WHERE id = $4
|
|
RETURNING id, name, avatar, description, owner_id, created_at::text
|
|
`, req.Name, req.Description, req.Avatar, botID).Scan(
|
|
&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt,
|
|
)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "failed to update bot")
|
|
return
|
|
}
|
|
if avatar.Valid {
|
|
b.Avatar = &avatar.String
|
|
}
|
|
b.CreatedAt = createdAt.String
|
|
|
|
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.StatusForbidden, "forbidden")
|
|
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
|
|
}
|
|
|
|
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.StatusForbidden, "forbidden")
|
|
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.StatusForbidden, "forbidden")
|
|
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.StatusForbidden, "forbidden")
|
|
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)
|
|
}
|