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:
@@ -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 {
|
||||
|
||||
@@ -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 <number>")
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/bots"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div className="h-full w-full flex flex-col bg-gb-bg">
|
||||
<header className="flex items-center gap-4 px-4 py-2 border-b border-gb-bg-t bg-gb-bg-s">
|
||||
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||
← [BACK]
|
||||
</Link>
|
||||
<span className="text-gb-orange font-mono text-sm">BOT MANAGER</span>
|
||||
</header>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<BotManager />
|
||||
</div>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/bots/:id/commands"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div className="h-full w-full flex flex-col bg-gb-bg">
|
||||
<header className="flex items-center gap-4 px-4 py-2 border-b border-gb-bg-t bg-gb-bg-s">
|
||||
<Link to="/bots" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||
← [BACK]
|
||||
</Link>
|
||||
<span className="text-gb-orange font-mono text-sm">SLASH COMMANDS</span>
|
||||
</header>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<CommandManager />
|
||||
</div>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useBotStore, type Bot } from '../stores/bot.ts';
|
||||
import { useServerStore } from '../stores/server.ts';
|
||||
|
||||
export function BotManager() {
|
||||
const bots = useBotStore((s) => 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<string | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editDesc, setEditDesc] = useState('');
|
||||
const [tokenDisplay, setTokenDisplay] = useState<{ botId: string; token: string } | null>(null);
|
||||
const [addToServerBotId, setAddToServerBotId] = useState<string | null>(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 (
|
||||
<div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="border border-gb-bg-t p-6">
|
||||
<pre className="text-gb-orange font-mono text-center mb-6">
|
||||
{'┌──────────────────────────────────┐\n'}
|
||||
{'│ === BOT MANAGER === │\n'}
|
||||
{'└──────────────────────────────────┘'}
|
||||
</pre>
|
||||
|
||||
{error && (
|
||||
<p className="text-gb-red text-sm font-mono mb-4">ERR: {error}</p>
|
||||
)}
|
||||
|
||||
{/* Token display overlay */}
|
||||
{tokenDisplay && (
|
||||
<div className="border border-gb-green bg-gb-bg-s p-4 mb-4">
|
||||
<p className="text-gb-green text-sm font-mono mb-2">
|
||||
{'>'} TOKEN GENERATED — copy it now, it won't be shown again:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-gb-fg bg-gb-bg px-2 py-1 border border-gb-bg-t flex-1 text-xs break-all">
|
||||
{tokenDisplay.token}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyToken}
|
||||
className="terminal-button text-xs shrink-0"
|
||||
>
|
||||
{copied ? '[COPIED]' : '[COPY]'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTokenDisplay(null)}
|
||||
className="text-gb-fg-f hover:text-gb-aqua font-mono text-xs mt-2"
|
||||
>
|
||||
[DISMISS]
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create bot form */}
|
||||
<div className="mb-6">
|
||||
{!showCreate ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="terminal-button"
|
||||
>
|
||||
[CREATE BOT]
|
||||
</button>
|
||||
) : (
|
||||
<form onSubmit={handleCreate} className="border border-gb-bg-t p-4 space-y-3">
|
||||
<p className="text-gb-aqua font-mono text-sm">{'>'} NEW BOT</p>
|
||||
<div>
|
||||
<label className="block text-gb-fg-f mb-1 font-mono text-xs">NAME:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value.slice(0, 32))}
|
||||
maxLength={32}
|
||||
className="terminal-input w-full"
|
||||
placeholder="my-cool-bot"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-gb-fg-f mb-1 font-mono text-xs">DESCRIPTION:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createDesc}
|
||||
onChange={(e) => setCreateDesc(e.target.value.slice(0, 256))}
|
||||
maxLength={256}
|
||||
className="terminal-input w-full"
|
||||
placeholder="what does this bot do?"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="terminal-button" disabled={loading || !createName.trim()}>
|
||||
{loading ? '[CREATING...]' : '[SAVE]'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowCreate(false); setCreateName(''); setCreateDesc(''); }}
|
||||
className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm"
|
||||
>
|
||||
[CANCEL]
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bot list */}
|
||||
{loading && bots.length === 0 && (
|
||||
<p className="text-gb-fg-f font-mono text-sm">[loading bots...]</p>
|
||||
)}
|
||||
{!loading && bots.length === 0 && (
|
||||
<p className="text-gb-fg-f font-mono text-sm">[no bots created yet]</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{bots.map((bot) => (
|
||||
<div key={bot.id} className="border border-gb-bg-t p-4">
|
||||
{editingId === bot.id ? (
|
||||
<form onSubmit={handleUpdate} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-gb-fg-f mb-1 font-mono text-xs">NAME:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value.slice(0, 32))}
|
||||
maxLength={32}
|
||||
className="terminal-input w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-gb-fg-f mb-1 font-mono text-xs">DESCRIPTION:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editDesc}
|
||||
onChange={(e) => setEditDesc(e.target.value.slice(0, 256))}
|
||||
maxLength={256}
|
||||
className="terminal-input w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="terminal-button text-xs" disabled={loading}>
|
||||
{loading ? '[SAVING...]' : '[SAVE]'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingId(null)}
|
||||
className="text-gb-fg-f hover:text-gb-aqua font-mono text-xs"
|
||||
>
|
||||
[CANCEL]
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-gb-green font-mono text-sm truncate">
|
||||
{bot.avatar && (
|
||||
<img
|
||||
src={bot.avatar}
|
||||
alt=""
|
||||
className="inline w-5 h-5 mr-1 align-middle border border-gb-bg-t"
|
||||
/>
|
||||
)}
|
||||
[{bot.name}]
|
||||
</p>
|
||||
{bot.description && (
|
||||
<p className="text-gb-fg-f font-mono text-xs mt-1 truncate">
|
||||
{bot.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-gb-fg-f font-mono text-xs shrink-0">
|
||||
ID:{bot.id.slice(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEdit(bot)}
|
||||
className="terminal-button text-xs"
|
||||
>
|
||||
[EDIT]
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(bot.id, bot.name)}
|
||||
className="terminal-button text-xs hover:!text-gb-red"
|
||||
>
|
||||
[DELETE]
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAddToServerBotId(bot.id); setSelectedServer(''); }}
|
||||
className="terminal-button text-xs"
|
||||
>
|
||||
[ADD TO SERVER]
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRegenerate(bot.id)}
|
||||
className="terminal-button text-xs"
|
||||
>
|
||||
[REGENERATE TOKEN]
|
||||
</button>
|
||||
<Link
|
||||
to={`/bots/${bot.id}/commands`}
|
||||
className="terminal-button text-xs"
|
||||
>
|
||||
[COMMANDS]
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add to server modal */}
|
||||
{addToServerBotId && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="border border-gb-bg-t bg-gb-bg p-6 max-w-md w-full mx-4">
|
||||
<p className="text-gb-orange font-mono text-sm mb-4">
|
||||
{'>'} ADD BOT TO SERVER
|
||||
</p>
|
||||
<select
|
||||
value={selectedServer}
|
||||
onChange={(e) => setSelectedServer(e.target.value)}
|
||||
className="terminal-input w-full mb-4"
|
||||
>
|
||||
<option value="">-- select server --</option>
|
||||
{servers.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddToServer}
|
||||
className="terminal-button text-xs"
|
||||
disabled={!selectedServer}
|
||||
>
|
||||
[ADD]
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAddToServerBotId(null); setSelectedServer(''); }}
|
||||
className="text-gb-fg-f hover:text-gb-aqua font-mono text-xs"
|
||||
>
|
||||
[CANCEL]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 pt-4 border-t border-gb-bg-t">
|
||||
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||
{'<'} [BACK TO CHAT]
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<SlashCommand[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [bot, setBot] = useState<Bot | null>(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 (
|
||||
<div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="border border-gb-bg-t p-6">
|
||||
<pre className="text-gb-orange font-mono text-center mb-6">
|
||||
{'┌──────────────────────────────────┐\n'}
|
||||
{'│ === SLASH COMMANDS === │\n'}
|
||||
{'└──────────────────────────────────┘'}
|
||||
</pre>
|
||||
|
||||
{bot && (
|
||||
<p className="text-gb-fg-s font-mono text-sm mb-4">
|
||||
BOT: <span className="text-gb-green">[{bot.name}]</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-gb-red text-sm font-mono mb-4">ERR: {error}</p>
|
||||
)}
|
||||
|
||||
{/* Add command form */}
|
||||
<div className="mb-6">
|
||||
{!showAdd ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="terminal-button"
|
||||
>
|
||||
[ADD COMMAND]
|
||||
</button>
|
||||
) : (
|
||||
<form onSubmit={handleAdd} className="border border-gb-bg-t p-4 space-y-3">
|
||||
<p className="text-gb-aqua font-mono text-sm">{'>'} NEW SLASH COMMAND</p>
|
||||
<div>
|
||||
<label className="block text-gb-fg-f mb-1 font-mono text-xs">SERVER:</label>
|
||||
<select
|
||||
value={cmdServer}
|
||||
onChange={(e) => setCmdServer(e.target.value)}
|
||||
className="terminal-input w-full"
|
||||
>
|
||||
<option value="">-- select server --</option>
|
||||
{servers.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-gb-fg-f mb-1 font-mono text-xs">COMMAND NAME:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cmdName}
|
||||
onChange={(e) => setCmdName(e.target.value.replace(/\s/g, '').slice(0, 32))}
|
||||
maxLength={32}
|
||||
className="terminal-input w-full"
|
||||
placeholder="mycommand"
|
||||
autoFocus
|
||||
/>
|
||||
<span className="text-gb-fg-f text-xs font-mono mt-1 block">
|
||||
/{cmdName || 'command'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-gb-fg-f mb-1 font-mono text-xs">DESCRIPTION:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cmdDesc}
|
||||
onChange={(e) => setCmdDesc(e.target.value.slice(0, 256))}
|
||||
maxLength={256}
|
||||
className="terminal-input w-full"
|
||||
placeholder="what does this command do?"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="terminal-button text-xs"
|
||||
disabled={!cmdName.trim() || !cmdServer}
|
||||
>
|
||||
[SAVE]
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowAdd(false); setCmdName(''); setCmdDesc(''); setCmdServer(''); }}
|
||||
className="text-gb-fg-f hover:text-gb-aqua font-mono text-xs"
|
||||
>
|
||||
[CANCEL]
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Command list */}
|
||||
{loading && (
|
||||
<p className="text-gb-fg-f font-mono text-sm">[loading commands...]</p>
|
||||
)}
|
||||
{!loading && commands.length === 0 && (
|
||||
<p className="text-gb-fg-f font-mono text-sm">[no commands registered]</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{commands.map((cmd) => (
|
||||
<div
|
||||
key={cmd.id}
|
||||
className="border border-gb-bg-t p-3 flex items-start justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-gb-green font-mono text-sm">
|
||||
/{cmd.name}
|
||||
</p>
|
||||
{cmd.description && (
|
||||
<p className="text-gb-fg-f font-mono text-xs mt-1">{cmd.description}</p>
|
||||
)}
|
||||
<p className="text-gb-fg-f font-mono text-xs mt-1">
|
||||
server: <span className="text-gb-aqua">{getServerName(cmd.server_id)}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(cmd.id)}
|
||||
className="terminal-button text-xs hover:!text-gb-red shrink-0"
|
||||
>
|
||||
[DELETE]
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 pt-4 border-t border-gb-bg-t flex justify-between">
|
||||
<Link to="/bots" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||
{'<'} [BACK TO BOTS]
|
||||
</Link>
|
||||
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||
[CHAT]
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<SlashCommand[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute bg-gb-bg border border-gb-bg-t shadow-lg z-50 min-w-[240px] max-h-[200px] overflow-y-auto"
|
||||
style={{
|
||||
bottom: position?.bottom ?? 48,
|
||||
left: position?.left ?? 16,
|
||||
}}
|
||||
>
|
||||
<div className="px-2 py-1 text-xs text-gb-fg-f font-mono border-b border-gb-bg-t">
|
||||
SLASH COMMANDS
|
||||
</div>
|
||||
{filtered.map((cmd, index) => (
|
||||
<button
|
||||
key={cmd.id}
|
||||
onClick={() => handleSelect(cmd)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`
|
||||
w-full px-2 py-1 text-left text-xs font-mono flex items-center gap-2
|
||||
transition-colors
|
||||
${
|
||||
index === selectedIndex
|
||||
? 'bg-gb-orange text-gb-bg'
|
||||
: 'text-gb-fg hover:bg-gb-bg-s'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="text-gb-green">/</span>
|
||||
<span className="font-bold">{cmd.name}</span>
|
||||
{cmd.description && (
|
||||
<span
|
||||
className={`truncate ${
|
||||
index === selectedIndex ? 'text-gb-bg-t' : 'text-gb-fg-f'
|
||||
}`}
|
||||
>
|
||||
— {cmd.description}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
createBot: (name: string, description: string) => Promise<Bot & { token: string }>;
|
||||
updateBot: (id: string, data: { name?: string; description?: string; avatar?: string }) => Promise<Bot>;
|
||||
deleteBot: (id: string) => Promise<void>;
|
||||
addToServer: (botId: string, serverId: string) => Promise<void>;
|
||||
removeFromServer: (botId: string, serverId: string) => Promise<void>;
|
||||
regenerateToken: (id: string) => Promise<{ token: string }>;
|
||||
|
||||
fetchCommands: (botId: string) => Promise<SlashCommand[]>;
|
||||
createCommand: (botId: string, serverId: string, name: string, description: string) => Promise<SlashCommand>;
|
||||
deleteCommand: (botId: string, commandId: string) => Promise<void>;
|
||||
fetchServerCommands: (serverId: string) => Promise<SlashCommand[]>;
|
||||
}
|
||||
|
||||
export const useBotStore = create<BotState>((set) => ({
|
||||
bots: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchBots: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const bots = await api.get<Bot[]>('/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<Bot & { token: string }>('/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<Bot>(`/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<SlashCommand[]>(`/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<SlashCommand>(`/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<SlashCommand[]>(`/servers/${serverId}/commands`);
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch server commands',
|
||||
});
|
||||
return [];
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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"}
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user