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
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// GenerateToken generates a 64-character hex token using crypto/rand.
|
||||
func GenerateToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// HashToken returns the SHA-256 hex digest of a token, suitable for storage.
|
||||
func HashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// VerifyToken checks whether the hash of the given token matches the stored hash.
|
||||
func VerifyToken(token, hash string) bool {
|
||||
return HashToken(token) == hash
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
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 ----
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -137,4 +137,50 @@ CREATE TABLE IF NOT EXISTS invites (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
avatar TEXT,
|
||||
description TEXT DEFAULT '',
|
||||
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token VARCHAR(64) NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bot_servers (
|
||||
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
|
||||
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
added_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (bot_id, server_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS slash_commands (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bot_id UUID NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
|
||||
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
name VARCHAR(32) NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
options JSONB DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (server_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webhooks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
token VARCHAR(64) NOT NULL,
|
||||
avatar TEXT,
|
||||
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bots_owner ON bots(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bots_token ON bots(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_bot_servers_bot ON bot_servers(bot_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_slash_commands_server ON slash_commands(server_id, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_channel ON webhooks(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_token ON webhooks(token);
|
||||
`
|
||||
|
||||
@@ -16,6 +16,8 @@ const (
|
||||
EventTypingStart = "TYPING_START"
|
||||
EventReactionAdd = "REACTION_ADD"
|
||||
EventReactionRemove = "REACTION_REMOVE"
|
||||
EventBotJoin = "BOT_JOIN"
|
||||
EventBotLeave = "BOT_LEAVE"
|
||||
EventVoiceJoin = "VOICE_JOIN"
|
||||
EventVoiceLeave = "VOICE_LEAVE"
|
||||
EventVoiceMute = "VOICE_MUTE"
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// Handler handles webhook CRUD and execution routes.
|
||||
type Handler struct {
|
||||
db *sql.DB
|
||||
hub *gateway.Hub
|
||||
}
|
||||
|
||||
// NewHandler creates a new webhook Handler.
|
||||
func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler {
|
||||
return &Handler{db: db, hub: hub}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers authenticated webhook routes.
|
||||
//
|
||||
// POST /channels/{channelID}/webhooks - create webhook
|
||||
// GET /channels/{channelID}/webhooks - list webhooks for channel
|
||||
// DELETE /webhooks/{webhookID} - delete webhook
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Post("/channels/{channelID}/webhooks", h.Create)
|
||||
r.Get("/channels/{channelID}/webhooks", h.List)
|
||||
r.Delete("/webhooks/{webhookID}", h.Delete)
|
||||
}
|
||||
|
||||
// RegisterPublicRoutes registers the public webhook execution route (no auth).
|
||||
//
|
||||
// POST /webhooks/{webhookID}/{token} - execute webhook
|
||||
func (h *Handler) RegisterPublicRoutes(r chi.Router) {
|
||||
r.Post("/webhooks/{webhookID}/{token}", h.Execute)
|
||||
}
|
||||
|
||||
// ---- types ----
|
||||
|
||||
type webhookResponse struct {
|
||||
ID string `json:"id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
Name string `json:"name"`
|
||||
Avatar *string `json:"avatar"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type webhookWithToken struct {
|
||||
webhookResponse
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type createWebhookRequest struct {
|
||||
Name string `json:"name"`
|
||||
Avatar *string `json:"avatar"`
|
||||
}
|
||||
|
||||
type executeWebhookRequest struct {
|
||||
Content string `json:"content"`
|
||||
Username *string `json:"username"`
|
||||
Avatar *string `json:"avatar"`
|
||||
}
|
||||
|
||||
// ---- 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 ----
|
||||
|
||||
// Create creates a new webhook for a channel.
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
channelID := chi.URLParam(r, "channelID")
|
||||
|
||||
// Verify channel exists and user is a member of its server
|
||||
serverID, err := h.serverIDForChannel(r.Context(), channelID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeErr(w, http.StatusNotFound, "channel not found")
|
||||
} else {
|
||||
writeErr(w, http.StatusInternalServerError, "server error")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
member, err := h.isMember(r.Context(), userID, serverID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "server error")
|
||||
return
|
||||
}
|
||||
if !member {
|
||||
writeErr(w, http.StatusForbidden, "not a member of this server")
|
||||
return
|
||||
}
|
||||
|
||||
var req createWebhookRequest
|
||||
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 := genToken()
|
||||
|
||||
var wh webhookWithToken
|
||||
var avatar sql.NullString
|
||||
var createdAt sql.NullString
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO webhooks (channel_id, name, token, avatar, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, channel_id, name, avatar, created_by, created_at::text
|
||||
`, channelID, req.Name, token, req.Avatar, userID).Scan(
|
||||
&wh.ID, &wh.ChannelID, &wh.Name, &avatar, &wh.CreatedBy, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "failed to create webhook")
|
||||
return
|
||||
}
|
||||
if avatar.Valid {
|
||||
wh.Avatar = &avatar.String
|
||||
}
|
||||
wh.CreatedAt = createdAt.String
|
||||
wh.Token = token
|
||||
|
||||
writeJSON(w, http.StatusCreated, wh)
|
||||
}
|
||||
|
||||
// List returns all webhooks for a channel.
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
channelID := chi.URLParam(r, "channelID")
|
||||
|
||||
serverID, err := h.serverIDForChannel(r.Context(), channelID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeErr(w, http.StatusNotFound, "channel not found")
|
||||
} else {
|
||||
writeErr(w, http.StatusInternalServerError, "server error")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
member, err := h.isMember(r.Context(), userID, serverID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "server error")
|
||||
return
|
||||
}
|
||||
if !member {
|
||||
writeErr(w, http.StatusForbidden, "not a member of this server")
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.db.QueryContext(r.Context(), `
|
||||
SELECT id, channel_id, name, avatar, created_by, created_at::text
|
||||
FROM webhooks WHERE channel_id = $1 ORDER BY created_at DESC
|
||||
`, channelID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "server error")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
webhooks := make([]webhookResponse, 0)
|
||||
for rows.Next() {
|
||||
var wh webhookResponse
|
||||
var avatar sql.NullString
|
||||
var createdAt sql.NullString
|
||||
if err := rows.Scan(&wh.ID, &wh.ChannelID, &wh.Name, &avatar, &wh.CreatedBy, &createdAt); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "server error")
|
||||
return
|
||||
}
|
||||
if avatar.Valid {
|
||||
wh.Avatar = &avatar.String
|
||||
}
|
||||
wh.CreatedAt = createdAt.String
|
||||
webhooks = append(webhooks, wh)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "server error")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, webhooks)
|
||||
}
|
||||
|
||||
// Delete removes a webhook.
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
webhookID := chi.URLParam(r, "webhookID")
|
||||
|
||||
var createdBy string
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT created_by FROM webhooks WHERE id = $1
|
||||
`, webhookID).Scan(&createdBy)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeErr(w, http.StatusNotFound, "webhook not found")
|
||||
} else {
|
||||
writeErr(w, http.StatusInternalServerError, "server error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if createdBy != userID {
|
||||
writeErr(w, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.db.ExecContext(r.Context(), `DELETE FROM webhooks WHERE id = $1`, webhookID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "failed to delete webhook")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Execute sends a message to a channel via webhook (no auth required).
|
||||
func (h *Handler) Execute(w http.ResponseWriter, r *http.Request) {
|
||||
webhookID := chi.URLParam(r, "webhookID")
|
||||
token := chi.URLParam(r, "token")
|
||||
|
||||
// Look up webhook and verify token
|
||||
var storedToken, channelID, webhookName string
|
||||
var webhookAvatar sql.NullString
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT token, channel_id, name, avatar FROM webhooks WHERE id = $1
|
||||
`, webhookID).Scan(&storedToken, &channelID, &webhookName, &webhookAvatar)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeErr(w, http.StatusNotFound, "webhook not found")
|
||||
} else {
|
||||
writeErr(w, http.StatusInternalServerError, "server error")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if token != storedToken {
|
||||
writeErr(w, http.StatusUnauthorized, "invalid token")
|
||||
return
|
||||
}
|
||||
|
||||
var req executeWebhookRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
if req.Content == "" {
|
||||
writeErr(w, http.StatusBadRequest, "content is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Determine display name and avatar for the message author
|
||||
displayName := webhookName
|
||||
if req.Username != nil && *req.Username != "" {
|
||||
displayName = *req.Username
|
||||
}
|
||||
avatarURL := ""
|
||||
if req.Avatar != nil && *req.Avatar != "" {
|
||||
avatarURL = *req.Avatar
|
||||
} else if webhookAvatar.Valid {
|
||||
avatarURL = webhookAvatar.String
|
||||
}
|
||||
|
||||
// Insert the message. We use the webhook_id as the author_id marker.
|
||||
// For bots/webhooks, author_id is set to a special value; alternatively
|
||||
// we can store it as the webhook id (which is a UUID but not a user).
|
||||
// The existing schema requires author_id FK to users, so we store the
|
||||
// webhook owner's created_by as the author to satisfy the FK constraint.
|
||||
var createdBy string
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
SELECT created_by FROM webhooks WHERE id = $1
|
||||
`, webhookID).Scan(&createdBy)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "server error")
|
||||
return
|
||||
}
|
||||
|
||||
// Create the message
|
||||
type msgResp struct {
|
||||
ID string `json:"id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
AuthorID string `json:"author_id"`
|
||||
AuthorName string `json:"author_username"`
|
||||
DisplayName *string `json:"author_display_name"`
|
||||
Content string `json:"content"`
|
||||
EditedAt *string `json:"edited_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
var msg msgResp
|
||||
var editedAt sql.NullString
|
||||
var createdAt sql.NullString
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO messages (channel_id, author_id, content)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, channel_id, author_id, content, edited_at::text, created_at::text
|
||||
`, channelID, createdBy, req.Content).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "failed to send message")
|
||||
return
|
||||
}
|
||||
if editedAt.Valid {
|
||||
msg.EditedAt = &editedAt.String
|
||||
}
|
||||
msg.CreatedAt = createdAt.String
|
||||
|
||||
// Override author display info with webhook identity
|
||||
msg.AuthorName = displayName
|
||||
if avatarURL != "" {
|
||||
msg.DisplayName = &displayName
|
||||
} else {
|
||||
msg.DisplayName = &displayName
|
||||
}
|
||||
|
||||
// Broadcast MESSAGE_CREATE via gateway
|
||||
if h.hub != nil {
|
||||
h.hub.BroadcastEvent(gateway.Event{
|
||||
Type: gateway.EventMessageCreate,
|
||||
Data: msg,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, msg)
|
||||
}
|
||||
|
||||
// ---- internal helpers ----
|
||||
|
||||
func (h *Handler) serverIDForChannel(ctx context.Context, channelID string) (string, error) {
|
||||
var serverID string
|
||||
err := h.db.QueryRowContext(ctx, `SELECT server_id FROM channels WHERE id = $1`, channelID).Scan(&serverID)
|
||||
return serverID, err
|
||||
}
|
||||
|
||||
func (h *Handler) isMember(ctx context.Context, userID, serverID string) (bool, error) {
|
||||
var exists bool
|
||||
err := h.db.QueryRowContext(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2)
|
||||
`, userID, serverID).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// genToken generates a 64-character hex token using crypto/rand.
|
||||
func genToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand failed: " + err.Error())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
Reference in New Issue
Block a user