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 ---- // @Summary Create a slash command // @Description Create a new slash command for a bot // @Tags bot commands // @Accept json // @Produce json // @Security SessionAuth // @Param botID path string true "Bot ID" // @Param body body createCommandRequest true "Command data" // @Success 201 {object} slashCommandResponse // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 409 {object} map[string]string // @Router /bots/{botID}/commands [post] 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) } // @Summary List bot commands // @Description List all slash commands for a bot // @Tags bot commands // @Produce json // @Security SessionAuth // @Param botID path string true "Bot ID" // @Success 200 {array} slashCommandResponse // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /bots/{botID}/commands [get] 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) } // @Summary Delete a slash command // @Description Delete a slash command // @Tags bot commands // @Security SessionAuth // @Param botID path string true "Bot ID" // @Param cmdID path string true "Command ID" // @Success 204 // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /bots/{botID}/commands/{cmdID} [delete] 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) } // @Summary List server commands // @Description List all slash commands registered in a server (public) // @Tags bot commands // @Produce json // @Param serverID path string true "Server ID" // @Success 200 {array} slashCommandResponse // @Failure 500 {object} map[string]string // @Router /bots/servers/{serverID}/commands [get] 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 errNotFound } 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 }