Files
dumpsterChat/cmd/server/main.go
T
hobokenchicken 000ce85816 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
2026-06-28 17:00:37 -04:00

215 lines
6.2 KiB
Go

package main
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"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"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
cfg := config.Load()
database, err := db.New(cfg)
if err != nil {
logger.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer database.Close()
if err := database.RunMigrations(); err != nil {
logger.Error("failed to run migrations", "error", err)
os.Exit(1)
}
// Session store for middleware
sessionStore := auth.NewSessionStore(database.DB, cfg)
// WebSocket hub
hub := gateway.NewHub(database.DB, logger)
go hub.Run()
// Giphy client (nil if no API key)
giphyClient := giphy.NewClient(cfg.Giphy.APIKey)
// Upload handler (nil if no MinIO config)
uploadHandler, err := upload.NewHandler(cfg)
if err != nil {
logger.Warn("upload handler not available", "error", err)
}
// Voice client (LiveKit)
voiceClient := voice.NewClient(cfg.LiveKit.APIKey, cfg.LiveKit.Secret, cfg.LiveKit.URL)
if voiceClient == nil {
logger.Warn("voice client not configured (missing LIVEKIT_API_KEY/SECRET)")
}
// Auth handler
authHandler := auth.NewHandler(database.DB, cfg)
r := chi.NewRouter()
r.Use(chimw.Logger)
r.Use(chimw.Recoverer)
r.Use(chimw.RequestID)
// Health check
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
// WebSocket endpoint
r.Get("/ws", func(w http.ResponseWriter, r *http.Request) {
gateway.ServeWS(database.DB, hub, logger, w, r)
})
// API routes
r.Route("/api/v1", func(r chi.Router) {
// Auth (public: register, login, logout)
r.Route("/auth", func(r chi.Router) {
authHandler.RegisterPublicRoutes(r)
})
// Protected routes
r.Group(func(r chi.Router) {
r.Use(middleware.Session(sessionStore, cfg))
r.Use(middleware.RequireAuth)
// Auth (protected: me, update profile)
authHandler.RegisterProtectedRoutes(r)
// Servers
r.Route("/servers", func(r chi.Router) {
server.NewHandler(database.DB).RegisterRoutes(r)
// Channels (nested under servers)
r.Route("/{serverID}/channels", func(r chi.Router) {
channel.NewHandler(database.DB).RegisterRoutes(r)
})
})
// Messages (under channels)
r.Route("/channels/{channelID}/messages", func(r chi.Router) {
message.NewHandler(database.DB, hub).RegisterRoutes(r)
})
// Giphy search
if giphyClient != nil {
r.Get("/gifs/search", func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, `{"error":"missing query"}`, http.StatusBadRequest)
return
}
gifs, err := giphyClient.Search(query, 20)
if err != nil {
http.Error(w, `{"error":"search failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(gifs)
})
r.Get("/gifs/trending", func(w http.ResponseWriter, r *http.Request) {
gifs, err := giphyClient.GetTrending(20)
if err != nil {
http.Error(w, `{"error":"trending failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(gifs)
})
}
// File upload
if uploadHandler != nil {
r.Post("/upload", uploadHandler.Upload)
}
// Voice
if voiceClient != nil {
r.Route("/voice", func(r chi.Router) {
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)
})
})
})
// File serving (public, for viewing uploaded files)
if uploadHandler != nil {
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 {
fileServer := http.FileServer(http.Dir(staticDir))
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
// If the file exists, serve it; otherwise serve index.html (SPA fallback)
path := staticDir + r.URL.Path
if _, err := os.Stat(path); os.IsNotExist(err) {
http.ServeFile(w, r, staticDir+"/index.html")
return
}
fileServer.ServeHTTP(w, r)
})
}
addr := fmt.Sprintf(":%s", cfg.Port)
logger.Info("starting server", "addr", addr)
if err := http.ListenAndServe(addr, r); err != nil {
logger.Error("server error", "error", err)
os.Exit(1)
}
}