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:
2026-06-28 17:00:37 -04:00
parent 01c67f9531
commit 000ce85816
16 changed files with 2479 additions and 1 deletions
+375
View File
@@ -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
}
+15
View File
@@ -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)
}