From 000ce85816f578cc4c77f962c2c966b6a69ea9dd Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Sun, 28 Jun 2026 17:00:37 -0400 Subject: [PATCH] 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 --- cmd/server/main.go | 34 ++ examples/modbot/main.go | 194 ++++++++++ examples/welcome/main.go | 108 ++++++ internal/bot/auth.go | 27 ++ internal/bot/commands.go | 281 ++++++++++++++ internal/bot/handlers.go | 459 +++++++++++++++++++++++ internal/db/db.go | 46 +++ internal/gateway/events.go | 2 + internal/webhook/handlers.go | 375 ++++++++++++++++++ internal/webhook/token.go | 15 + web/src/App.tsx | 38 ++ web/src/components/BotManager.tsx | 359 ++++++++++++++++++ web/src/components/CommandManager.tsx | 224 +++++++++++ web/src/components/SlashCommandPopup.tsx | 116 ++++++ web/src/stores/bot.ts | 200 ++++++++++ web/tsconfig.app.tsbuildinfo | 2 +- 16 files changed, 2479 insertions(+), 1 deletion(-) create mode 100644 examples/modbot/main.go create mode 100644 examples/welcome/main.go create mode 100644 internal/bot/auth.go create mode 100644 internal/bot/commands.go create mode 100644 internal/bot/handlers.go create mode 100644 internal/webhook/handlers.go create mode 100644 internal/webhook/token.go create mode 100644 web/src/components/BotManager.tsx create mode 100644 web/src/components/CommandManager.tsx create mode 100644 web/src/components/SlashCommandPopup.tsx create mode 100644 web/src/stores/bot.ts diff --git a/cmd/server/main.go b/cmd/server/main.go index 80dcc25..4e5bc0f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -8,16 +8,20 @@ import ( "os" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/auth" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/bot" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/channel" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/giphy" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/invite" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/message" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/reaction" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/server" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/upload" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/voice" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/webhook" "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" ) @@ -148,6 +152,31 @@ func main() { voice.NewHandler(database.DB, voiceClient, hub).RegisterRoutes(r) }) } + + // Reactions + r.Route("/messages/{messageID}/reactions", func(r chi.Router) { + reaction.NewHandler(database.DB, hub).RegisterRoutes(r) + }) + + // Bots + r.Route("/bots", func(r chi.Router) { + bot.NewHandler(database.DB).RegisterRoutes(r) + }) + + // Bot slash commands + r.Route("/bots/{botID}/commands", func(r chi.Router) { + bot.NewCommandHandler(database.DB).RegisterCommandRoutes(r) + }) + + // Webhooks (protected: create/delete) + r.Route("/channels/{channelID}/webhooks", func(r chi.Router) { + webhook.NewHandler(database.DB, hub).RegisterRoutes(r) + }) + + // Invites + r.Route("/invites", func(r chi.Router) { + invite.NewHandler(database.DB).RegisterRoutes(r) + }) }) }) @@ -156,6 +185,11 @@ func main() { r.Get("/files/*", uploadHandler.Serve) } + // Public webhook execution (no auth required) + r.Post("/webhooks/{webhookID}/{token}", func(w http.ResponseWriter, r *http.Request) { + webhook.NewHandler(database.DB, hub).Execute(w, r) + }) + // Static file serving for production (SPA) staticDir := "/srv/web" if _, err := os.Stat(staticDir); err == nil { diff --git a/examples/modbot/main.go b/examples/modbot/main.go new file mode 100644 index 0000000..ec04942 --- /dev/null +++ b/examples/modbot/main.go @@ -0,0 +1,194 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/url" + "os" + "strings" + + "github.com/gorilla/websocket" +) + +type Event struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` +} + +type Message struct { + ID string `json:"id"` + ChannelID string `json:"channel_id"` + AuthorID string `json:"author_id"` + Username string `json:"username"` + Content string `json:"content"` +} + +var bannedWords = []string{ + "spam", + "scam", + "phishing", +} + +func main() { + token := os.Getenv("BOT_TOKEN") + if token == "" { + log.Fatal("BOT_TOKEN environment variable required") + } + + host := os.Getenv("DUMPSTER_HOST") + if host == "" { + host = "localhost:8080" + } + + u := url.URL{ + Scheme: "ws", + Host: host, + Path: "/ws/bot", + RawQuery: "token=" + token, + } + + log.Printf("Connecting to %s", u.String()) + + c, _, err := websocket.DefaultDialer.Dial(u.String(), nil) + if err != nil { + log.Fatal("dial:", err) + } + defer c.Close() + + log.Println("Modbot connected!") + + for { + _, message, err := c.ReadMessage() + if err != nil { + log.Println("read:", err) + break + } + + var event Event + if err := json.Unmarshal(message, &event); err != nil { + continue + } + + switch event.Type { + case "MESSAGE_CREATE": + var msg Message + if err := json.Unmarshal(event.Payload, &msg); err != nil { + continue + } + handleMessage(c, msg) + } + } +} + +func handleMessage(c *websocket.Conn, msg Message) { + content := strings.ToLower(msg.Content) + + // Check for banned words + for _, word := range bannedWords { + if strings.Contains(content, word) { + log.Printf("Deleting message from %s: contains banned word '%s'", msg.Username, word) + // Delete the message + deleteMsg := map[string]interface{}{ + "type": "DELETE_MESSAGE", + "payload": map[string]string{ + "channel_id": msg.ChannelID, + "message_id": msg.ID, + }, + } + data, _ := json.Marshal(deleteMsg) + c.WriteMessage(websocket.TextMessage, data) + + // Send warning + sendMessage(c, msg.ChannelID, fmt.Sprintf( + "โš ๏ธ <@%s>, your message was removed (contained banned content).", + msg.AuthorID, + )) + return + } + } + + // Handle slash commands + if strings.HasPrefix(msg.Content, "/kick") { + handleKick(c, msg) + } else if strings.HasPrefix(msg.Content, "/ban") { + handleBan(c, msg) + } else if strings.HasPrefix(msg.Content, "/purge") { + handlePurge(c, msg) + } +} + +func handleKick(c *websocket.Conn, msg Message) { + parts := strings.Fields(msg.Content) + if len(parts) < 2 { + sendMessage(c, msg.ChannelID, "Usage: /kick @user [reason]") + return + } + // Extract user ID from mention format <@user_id> + targetID := strings.TrimPrefix(parts[0], "<@") + targetID = strings.TrimSuffix(targetID, ">") + + reason := "No reason provided" + if len(parts) > 2 { + reason = strings.Join(parts[2:], " ") + } + + log.Printf("Kick request: %s -> %s (%s)", msg.Username, targetID, reason) + sendMessage(c, msg.ChannelID, fmt.Sprintf( + "๐Ÿ‘ข <@%s> has been kicked. Reason: %s", + targetID, reason, + )) +} + +func handleBan(c *websocket.Conn, msg Message) { + parts := strings.Fields(msg.Content) + if len(parts) < 2 { + sendMessage(c, msg.ChannelID, "Usage: /ban @user [reason]") + return + } + targetID := strings.TrimPrefix(parts[0], "<@") + targetID = strings.TrimSuffix(targetID, ">") + + reason := "No reason provided" + if len(parts) > 2 { + reason = strings.Join(parts[2:], " ") + } + + log.Printf("Ban request: %s -> %s (%s)", msg.Username, targetID, reason) + sendMessage(c, msg.ChannelID, fmt.Sprintf( + "๐Ÿ”จ <@%s> has been banned. Reason: %s", + targetID, reason, + )) +} + +func handlePurge(c *websocket.Conn, msg Message) { + parts := strings.Fields(msg.Content) + if len(parts) < 2 { + sendMessage(c, msg.ChannelID, "Usage: /purge ") + return + } + + count := 10 // default + fmt.Sscanf(parts[1], "%d", &count) + if count > 100 { + count = 100 + } + + log.Printf("Purge request: %s -> %d messages", msg.Username, count) + sendMessage(c, msg.ChannelID, fmt.Sprintf( + "๐Ÿงน Purging %d messages...", + count, + )) +} + +func sendMessage(c *websocket.Conn, channelID, content string) { + msg := map[string]interface{}{ + "type": "SEND_MESSAGE", + "payload": map[string]string{ + "channel_id": channelID, + "content": content, + }, + } + data, _ := json.Marshal(msg) + c.WriteMessage(websocket.TextMessage, data) +} diff --git a/examples/welcome/main.go b/examples/welcome/main.go new file mode 100644 index 0000000..9f0260b --- /dev/null +++ b/examples/welcome/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/url" + "os" + + "github.com/gorilla/websocket" +) + +type Event struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` +} + +type MemberEvent struct { + ServerID string `json:"server_id"` + UserID string `json:"user_id"` + Username string `json:"username"` +} + +func main() { + token := os.Getenv("BOT_TOKEN") + if token == "" { + log.Fatal("BOT_TOKEN environment variable required") + } + + host := os.Getenv("DUMPSTER_HOST") + if host == "" { + host = "localhost:8080" + } + + // Channel to send welcome messages to (set per server) + welcomeChannels := map[string]string{ + // server_id -> channel_id + // Configure these via env or config file + } + + u := url.URL{ + Scheme: "ws", + Host: host, + Path: "/ws/bot", + RawQuery: "token=" + token, + } + + log.Printf("Connecting to %s", u.String()) + + c, _, err := websocket.DefaultDialer.Dial(u.String(), nil) + if err != nil { + log.Fatal("dial:", err) + } + defer c.Close() + + log.Println("Welcome bot connected!") + + for { + _, message, err := c.ReadMessage() + if err != nil { + log.Println("read:", err) + break + } + + var event Event + if err := json.Unmarshal(message, &event); err != nil { + continue + } + + switch event.Type { + case "SERVER_MEMBER_ADD": + var member MemberEvent + if err := json.Unmarshal(event.Payload, &member); err != nil { + continue + } + handleMemberJoin(c, member, welcomeChannels) + } + } +} + +func handleMemberJoin(c *websocket.Conn, member MemberEvent, welcomeChannels map[string]string) { + channelID, ok := welcomeChannels[member.ServerID] + if !ok { + log.Printf("No welcome channel configured for server %s", member.ServerID) + return + } + + welcomeMsg := fmt.Sprintf( + "๐Ÿ‘‹ Welcome to the server, <@%s>! Glad to have you here.\n\n"+ + "Check out the rules in #rules and introduce yourself!", + member.UserID, + ) + + log.Printf("Sending welcome message for %s to channel %s", member.Username, channelID) + sendMessage(c, channelID, welcomeMsg) +} + +func sendMessage(c *websocket.Conn, channelID, content string) { + msg := map[string]interface{}{ + "type": "SEND_MESSAGE", + "payload": map[string]string{ + "channel_id": channelID, + "content": content, + }, + } + data, _ := json.Marshal(msg) + c.WriteMessage(websocket.TextMessage, data) +} diff --git a/internal/bot/auth.go b/internal/bot/auth.go new file mode 100644 index 0000000..3b26b12 --- /dev/null +++ b/internal/bot/auth.go @@ -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 +} diff --git a/internal/bot/commands.go b/internal/bot/commands.go new file mode 100644 index 0000000..078857c --- /dev/null +++ b/internal/bot/commands.go @@ -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 +} diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go new file mode 100644 index 0000000..3f43647 --- /dev/null +++ b/internal/bot/handlers.go @@ -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) +} diff --git a/internal/db/db.go b/internal/db/db.go index b2e98d2..5cc35c0 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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); ` diff --git a/internal/gateway/events.go b/internal/gateway/events.go index db70e10..d747591 100644 --- a/internal/gateway/events.go +++ b/internal/gateway/events.go @@ -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" diff --git a/internal/webhook/handlers.go b/internal/webhook/handlers.go new file mode 100644 index 0000000..23197cd --- /dev/null +++ b/internal/webhook/handlers.go @@ -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 +} diff --git a/internal/webhook/token.go b/internal/webhook/token.go new file mode 100644 index 0000000..2a6aef6 --- /dev/null +++ b/internal/webhook/token.go @@ -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) +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 95fbe6f..4c4d484 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,6 +3,8 @@ import { LoginForm } from './components/LoginForm.tsx'; import { Layout } from './components/Layout.tsx'; import { ChatArea } from './components/ChatArea.tsx'; import { UserSettings } from './components/UserSettings.tsx'; +import { BotManager } from './components/BotManager.tsx'; +import { CommandManager } from './components/CommandManager.tsx'; import { useAuthStore } from './stores/auth.ts'; function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -33,6 +35,42 @@ function App() { } /> + +
+
+ + โ† [BACK] + + BOT MANAGER +
+
+ +
+
+ + } + /> + +
+
+ + โ† [BACK] + + SLASH COMMANDS +
+
+ +
+
+ + } + /> s.bots); + const loading = useBotStore((s) => s.loading); + const error = useBotStore((s) => s.error); + const fetchBots = useBotStore((s) => s.fetchBots); + const createBot = useBotStore((s) => s.createBot); + const updateBot = useBotStore((s) => s.updateBot); + const deleteBot = useBotStore((s) => s.deleteBot); + const addToServer = useBotStore((s) => s.addToServer); + const regenerateToken = useBotStore((s) => s.regenerateToken); + + const servers = useServerStore((s) => s.servers); + const fetchServers = useServerStore((s) => s.fetchServers); + + const [showCreate, setShowCreate] = useState(false); + const [createName, setCreateName] = useState(''); + const [createDesc, setCreateDesc] = useState(''); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const [editDesc, setEditDesc] = useState(''); + const [tokenDisplay, setTokenDisplay] = useState<{ botId: string; token: string } | null>(null); + const [addToServerBotId, setAddToServerBotId] = useState(null); + const [selectedServer, setSelectedServer] = useState(''); + const [copied, setCopied] = useState(false); + + useEffect(() => { + fetchBots(); + fetchServers(); + }, [fetchBots, fetchServers]); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!createName.trim()) return; + try { + const result = await createBot(createName.trim(), createDesc.trim()); + setTokenDisplay({ botId: result.id, token: result.token }); + setCreateName(''); + setCreateDesc(''); + setShowCreate(false); + } catch { + // error handled in store + } + }; + + const handleUpdate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!editingId) return; + try { + await updateBot(editingId, { name: editName.trim(), description: editDesc.trim() }); + setEditingId(null); + } catch { + // error handled in store + } + }; + + const handleDelete = async (id: string, name: string) => { + if (!window.confirm(`Delete bot "${name}"? This cannot be undone.`)) return; + try { + await deleteBot(id); + } catch { + // error handled in store + } + }; + + const handleRegenerate = async (id: string) => { + if (!window.confirm('Regenerate token? The old token will be invalidated.')) return; + try { + const result = await regenerateToken(id); + setTokenDisplay({ botId: id, token: result.token }); + } catch { + // error handled in store + } + }; + + const handleAddToServer = async () => { + if (!addToServerBotId || !selectedServer) return; + try { + await addToServer(addToServerBotId, selectedServer); + setAddToServerBotId(null); + setSelectedServer(''); + } catch { + // error handled in store + } + }; + + const handleCopyToken = () => { + if (tokenDisplay) { + navigator.clipboard.writeText(tokenDisplay.token); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const startEdit = (bot: Bot) => { + setEditingId(bot.id); + setEditName(bot.name); + setEditDesc(bot.description); + }; + + return ( +
+
+
+
+            {'โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”\n'}
+            {'โ”‚      === BOT MANAGER ===         โ”‚\n'}
+            {'โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜'}
+          
+ + {error && ( +

ERR: {error}

+ )} + + {/* Token display overlay */} + {tokenDisplay && ( +
+

+ {'>'} TOKEN GENERATED โ€” copy it now, it won't be shown again: +

+
+ + {tokenDisplay.token} + + +
+ +
+ )} + + {/* Create bot form */} +
+ {!showCreate ? ( + + ) : ( +
+

{'>'} NEW BOT

+
+ + setCreateName(e.target.value.slice(0, 32))} + maxLength={32} + className="terminal-input w-full" + placeholder="my-cool-bot" + autoFocus + /> +
+
+ + setCreateDesc(e.target.value.slice(0, 256))} + maxLength={256} + className="terminal-input w-full" + placeholder="what does this bot do?" + /> +
+
+ + +
+
+ )} +
+ + {/* Bot list */} + {loading && bots.length === 0 && ( +

[loading bots...]

+ )} + {!loading && bots.length === 0 && ( +

[no bots created yet]

+ )} + +
+ {bots.map((bot) => ( +
+ {editingId === bot.id ? ( +
+
+ + setEditName(e.target.value.slice(0, 32))} + maxLength={32} + className="terminal-input w-full" + /> +
+
+ + setEditDesc(e.target.value.slice(0, 256))} + maxLength={256} + className="terminal-input w-full" + /> +
+
+ + +
+
+ ) : ( + <> +
+
+

+ {bot.avatar && ( + + )} + [{bot.name}] +

+ {bot.description && ( +

+ {bot.description} +

+ )} +
+ + ID:{bot.id.slice(0, 8)} + +
+
+ + + + + + [COMMANDS] + +
+ + )} +
+ ))} +
+ + {/* Add to server modal */} + {addToServerBotId && ( +
+
+

+ {'>'} ADD BOT TO SERVER +

+ +
+ + +
+
+
+ )} + + {/* Footer */} +
+ + {'<'} [BACK TO CHAT] + +
+
+
+
+ ); +} diff --git a/web/src/components/CommandManager.tsx b/web/src/components/CommandManager.tsx new file mode 100644 index 0000000..5e52354 --- /dev/null +++ b/web/src/components/CommandManager.tsx @@ -0,0 +1,224 @@ +import { useEffect, useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { useBotStore, type Bot, type SlashCommand } from '../stores/bot.ts'; +import { useServerStore } from '../stores/server.ts'; + +export function CommandManager() { + const { id: botId } = useParams<{ id: string }>(); + + const fetchCommands = useBotStore((s) => s.fetchCommands); + const createCommand = useBotStore((s) => s.createCommand); + const deleteCommand = useBotStore((s) => s.deleteCommand); + const bots = useBotStore((s) => s.bots); + const fetchBots = useBotStore((s) => s.fetchBots); + const error = useBotStore((s) => s.error); + + const servers = useServerStore((s) => s.servers); + const fetchServers = useServerStore((s) => s.fetchServers); + + const [commands, setCommands] = useState([]); + const [loading, setLoading] = useState(false); + const [bot, setBot] = useState(null); + + const [showAdd, setShowAdd] = useState(false); + const [cmdName, setCmdName] = useState(''); + const [cmdDesc, setCmdDesc] = useState(''); + const [cmdServer, setCmdServer] = useState(''); + + useEffect(() => { + fetchServers(); + fetchBots(); + }, [fetchServers, fetchBots]); + + useEffect(() => { + if (!botId) return; + const found = bots.find((b) => b.id === botId); + if (found) setBot(found); + }, [bots, botId]); + + const loadCommands = async () => { + if (!botId) return; + setLoading(true); + const cmds = await fetchCommands(botId); + setCommands(cmds); + setLoading(false); + }; + + useEffect(() => { + loadCommands(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [botId]); + + const handleAdd = async (e: React.FormEvent) => { + e.preventDefault(); + if (!botId || !cmdName.trim() || !cmdServer) return; + try { + const cmd = await createCommand(botId, cmdServer, cmdName.trim(), cmdDesc.trim()); + setCommands((prev) => [...prev, cmd]); + setCmdName(''); + setCmdDesc(''); + setCmdServer(''); + setShowAdd(false); + } catch { + // error in store + } + }; + + const handleDelete = async (cmdId: string) => { + if (!botId) return; + if (!window.confirm('Delete this command?')) return; + try { + await deleteCommand(botId, cmdId); + setCommands((prev) => prev.filter((c) => c.id !== cmdId)); + } catch { + // error in store + } + }; + + const getServerName = (serverId: string) => { + const s = servers.find((sv) => sv.id === serverId); + return s ? s.name : serverId.slice(0, 8); + }; + + return ( +
+
+
+
+            {'โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”\n'}
+            {'โ”‚   === SLASH COMMANDS ===         โ”‚\n'}
+            {'โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜'}
+          
+ + {bot && ( +

+ BOT: [{bot.name}] +

+ )} + + {error && ( +

ERR: {error}

+ )} + + {/* Add command form */} +
+ {!showAdd ? ( + + ) : ( +
+

{'>'} NEW SLASH COMMAND

+
+ + +
+
+ + setCmdName(e.target.value.replace(/\s/g, '').slice(0, 32))} + maxLength={32} + className="terminal-input w-full" + placeholder="mycommand" + autoFocus + /> + + /{cmdName || 'command'} + +
+
+ + setCmdDesc(e.target.value.slice(0, 256))} + maxLength={256} + className="terminal-input w-full" + placeholder="what does this command do?" + /> +
+
+ + +
+
+ )} +
+ + {/* Command list */} + {loading && ( +

[loading commands...]

+ )} + {!loading && commands.length === 0 && ( +

[no commands registered]

+ )} + +
+ {commands.map((cmd) => ( +
+
+

+ /{cmd.name} +

+ {cmd.description && ( +

{cmd.description}

+ )} +

+ server: {getServerName(cmd.server_id)} +

+
+ +
+ ))} +
+ + {/* Footer */} +
+ + {'<'} [BACK TO BOTS] + + + [CHAT] + +
+
+
+
+ ); +} diff --git a/web/src/components/SlashCommandPopup.tsx b/web/src/components/SlashCommandPopup.tsx new file mode 100644 index 0000000..5db0c05 --- /dev/null +++ b/web/src/components/SlashCommandPopup.tsx @@ -0,0 +1,116 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useBotStore, type SlashCommand } from '../stores/bot.ts'; + +interface SlashCommandPopupProps { + serverId: string; + filter: string; + onSelect: (command: SlashCommand) => void; + onClose: () => void; + position?: { bottom: number; left: number }; +} + +export function SlashCommandPopup({ + serverId, + filter, + onSelect, + onClose, + position, +}: SlashCommandPopupProps) { + const fetchServerCommands = useBotStore((s) => s.fetchServerCommands); + const [commands, setCommands] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + const listRef = useRef(null); + + useEffect(() => { + let cancelled = false; + fetchServerCommands(serverId).then((cmds) => { + if (!cancelled) setCommands(cmds); + }); + return () => { cancelled = true; }; + }, [serverId, fetchServerCommands]); + + const filtered = commands + .filter((c) => c.name.toLowerCase().includes(filter.toLowerCase())) + .slice(0, 8); + + useEffect(() => { + setSelectedIndex(0); + }, [filter]); + + const handleSelect = useCallback( + (cmd: SlashCommand) => { + onSelect(cmd); + }, + [onSelect] + ); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (filtered.length === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, filtered.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + if (filtered[selectedIndex]) { + handleSelect(filtered[selectedIndex]); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [filtered, selectedIndex, handleSelect, onClose]); + + if (filtered.length === 0) return null; + + return ( +
+
+ SLASH COMMANDS +
+ {filtered.map((cmd, index) => ( + + ))} +
+ ); +} diff --git a/web/src/stores/bot.ts b/web/src/stores/bot.ts new file mode 100644 index 0000000..6e9914f --- /dev/null +++ b/web/src/stores/bot.ts @@ -0,0 +1,200 @@ +import { create } from 'zustand'; +import { api } from '../lib/api.ts'; + +export interface Bot { + id: string; + name: string; + avatar: string | null; + description: string; + owner_id: string; + created_at: string; +} + +export interface SlashCommand { + id: string; + bot_id: string; + server_id: string; + name: string; + description: string; +} + +interface BotState { + bots: Bot[]; + loading: boolean; + error: string | null; + + fetchBots: () => Promise; + createBot: (name: string, description: string) => Promise; + updateBot: (id: string, data: { name?: string; description?: string; avatar?: string }) => Promise; + deleteBot: (id: string) => Promise; + addToServer: (botId: string, serverId: string) => Promise; + removeFromServer: (botId: string, serverId: string) => Promise; + regenerateToken: (id: string) => Promise<{ token: string }>; + + fetchCommands: (botId: string) => Promise; + createCommand: (botId: string, serverId: string, name: string, description: string) => Promise; + deleteCommand: (botId: string, commandId: string) => Promise; + fetchServerCommands: (serverId: string) => Promise; +} + +export const useBotStore = create((set) => ({ + bots: [], + loading: false, + error: null, + + fetchBots: async () => { + set({ loading: true, error: null }); + try { + const bots = await api.get('/bots'); + set({ bots, loading: false }); + } catch (error) { + set({ + loading: false, + error: error instanceof Error ? error.message : 'Failed to fetch bots', + }); + } + }, + + createBot: async (name, description) => { + set({ loading: true, error: null }); + try { + const result = await api.post('/bots', { name, description }); + set((state) => ({ + bots: [...state.bots, result], + loading: false, + })); + return result; + } catch (error) { + set({ + loading: false, + error: error instanceof Error ? error.message : 'Failed to create bot', + }); + throw error; + } + }, + + updateBot: async (id, data) => { + set({ loading: true, error: null }); + try { + const bot = await api.patch(`/bots/${id}`, data); + set((state) => ({ + bots: state.bots.map((b) => (b.id === id ? bot : b)), + loading: false, + })); + return bot; + } catch (error) { + set({ + loading: false, + error: error instanceof Error ? error.message : 'Failed to update bot', + }); + throw error; + } + }, + + deleteBot: async (id) => { + set({ loading: true, error: null }); + try { + await api.delete(`/bots/${id}`); + set((state) => ({ + bots: state.bots.filter((b) => b.id !== id), + loading: false, + })); + } catch (error) { + set({ + loading: false, + error: error instanceof Error ? error.message : 'Failed to delete bot', + }); + throw error; + } + }, + + addToServer: async (botId, serverId) => { + set({ error: null }); + try { + await api.post(`/bots/${botId}/servers`, { server_id: serverId }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to add bot to server', + }); + throw error; + } + }, + + removeFromServer: async (botId, serverId) => { + set({ error: null }); + try { + await api.delete(`/bots/${botId}/servers/${serverId}`); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to remove bot from server', + }); + throw error; + } + }, + + regenerateToken: async (id) => { + set({ loading: true, error: null }); + try { + const result = await api.post<{ token: string }>(`/bots/${id}/regenerate-token`); + set({ loading: false }); + return result; + } catch (error) { + set({ + loading: false, + error: error instanceof Error ? error.message : 'Failed to regenerate token', + }); + throw error; + } + }, + + fetchCommands: async (botId) => { + try { + return await api.get(`/bots/${botId}/commands`); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to fetch commands', + }); + return []; + } + }, + + createCommand: async (botId, serverId, name, description) => { + set({ error: null }); + try { + const cmd = await api.post(`/bots/${botId}/commands`, { + server_id: serverId, + name, + description, + }); + return cmd; + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to create command', + }); + throw error; + } + }, + + deleteCommand: async (botId, commandId) => { + set({ error: null }); + try { + await api.delete(`/bots/${botId}/commands/${commandId}`); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to delete command', + }); + throw error; + } + }, + + fetchServerCommands: async (serverId) => { + try { + return await api.get(`/servers/${serverId}/commands`); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to fetch server commands', + }); + return []; + } + }, +})); diff --git a/web/tsconfig.app.tsbuildinfo b/web/tsconfig.app.tsbuildinfo index 4341b60..753b7ba 100644 --- a/web/tsconfig.app.tsbuildinfo +++ b/web/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/MentionPopup.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ServerBar.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/MentionPopup.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/ServerBar.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"} \ No newline at end of file