feat(bots): built-in bot runner + steamfree from UI

- BotRunner: server-side goroutine manager for built-in bot types
- steamfree bot embedded in server (polls Steam API, posts free games)
- bot_type + config JSONB columns on bots table
- Create/Update/Delete handlers manage runner lifecycle
- GET /bots/types returns registered bot types
- BotManager: type selector dropdown + config fields on create
- No SSH needed: create a 'Steam Free Games' bot from /bots/manage
This commit is contained in:
2026-07-15 14:14:02 -04:00
parent c839e67c47
commit 4b32655e67
8 changed files with 488 additions and 30 deletions
+77 -23
View File
@@ -12,17 +12,19 @@ import (
// Handler handles bot CRUD and server-assignment routes.
type Handler struct {
db *sql.DB
db *sql.DB
runner *Runner
}
// NewHandler creates a new bot Handler.
func NewHandler(db *sql.DB) *Handler {
return &Handler{db: db}
func NewHandler(db *sql.DB, runner *Runner) *Handler {
return &Handler{db: db, runner: runner}
}
// RegisterRoutes registers authenticated bot routes under the given router.
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/store", h.Store)
r.Get("/types", h.ListTypes)
r.Post("/", h.Create)
r.Get("/", h.List)
r.Get("/{botID}", h.Get)
@@ -36,12 +38,14 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
// ---- 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"`
ID string `json:"id"`
Name string `json:"name"`
Avatar *string `json:"avatar"`
Description string `json:"description"`
BotType string `json:"bot_type"`
Config json.RawMessage `json:"config"`
OwnerID string `json:"owner_id"`
CreatedAt string `json:"created_at"`
}
// botWithToken is returned only on create / regenerate-token.
@@ -51,14 +55,18 @@ type botWithToken struct {
}
type createBotRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Name string `json:"name"`
Description string `json:"description"`
BotType string `json:"bot_type"`
Config json.RawMessage `json:"config"`
}
type updateBotRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Avatar *string `json:"avatar"`
Name *string `json:"name"`
Description *string `json:"description"`
Avatar *string `json:"avatar"`
BotType *string `json:"bot_type"`
Config json.RawMessage `json:"config"`
}
type addToServerRequest struct {
@@ -110,15 +118,21 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
token := GenerateToken()
tokenHash := HashToken(token)
configJSON := req.Config
if configJSON == nil {
configJSON = json.RawMessage(`{}`)
}
var bot botWithToken
var avatar sql.NullString
var createdAt sql.NullString
var configOut 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,
INSERT INTO bots (name, description, owner_id, token, bot_type, config)
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
RETURNING id, name, avatar, description, bot_type, config::text, owner_id, created_at::text
`, req.Name, req.Description, userID, tokenHash, req.BotType, string(configJSON)).Scan(
&bot.ID, &bot.Name, &avatar, &bot.Description, &bot.BotType, &configOut, &bot.OwnerID, &createdAt,
)
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to create bot")
@@ -127,9 +141,17 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
if avatar.Valid {
bot.Avatar = &avatar.String
}
if configOut.Valid {
bot.Config = json.RawMessage(configOut.String)
}
bot.CreatedAt = createdAt.String
bot.Token = token
// Start built-in bot if type is set
if req.BotType != "" && h.runner != nil {
h.runner.Start(bot.ID, req.BotType, bot.Config)
}
writeJSON(w, http.StatusCreated, bot)
}
@@ -282,15 +304,18 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
var b botResponse
var avatar sql.NullString
var createdAt sql.NullString
var configOut sql.NullString
err = h.db.QueryRowContext(r.Context(), `
UPDATE bots
SET name = COALESCE($1, name),
description = COALESCE($2, description),
avatar = COALESCE($3, avatar)
avatar = COALESCE($3, avatar),
bot_type = COALESCE($5, bot_type),
config = COALESCE($6::jsonb, config)
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,
RETURNING id, name, avatar, description, bot_type, config::text, owner_id, created_at::text
`, req.Name, req.Description, req.Avatar, botID, req.BotType, string(req.Config)).Scan(
&b.ID, &b.Name, &avatar, &b.Description, &b.BotType, &configOut, &b.OwnerID, &createdAt,
)
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to update bot")
@@ -299,8 +324,22 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
if avatar.Valid {
b.Avatar = &avatar.String
}
if configOut.Valid {
b.Config = json.RawMessage(configOut.String)
}
b.CreatedAt = createdAt.String
// Restart built-in bot if type/config changed
if req.BotType != nil && h.runner != nil {
h.runner.Stop(b.ID)
if *req.BotType != "" {
h.runner.Start(b.ID, *req.BotType, b.Config)
}
} else if req.Config != nil && h.runner != nil && b.BotType != "" {
h.runner.Stop(b.ID)
h.runner.Start(b.ID, b.BotType, b.Config)
}
writeJSON(w, http.StatusOK, b)
}
@@ -346,6 +385,11 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
return
}
// Stop built-in bot if running
if h.runner != nil {
h.runner.Stop(botID)
}
w.WriteHeader(http.StatusNoContent)
}
@@ -599,3 +643,13 @@ func (h *Handler) Store(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, bots)
}
// ListTypes returns available built-in bot types.
func (h *Handler) ListTypes(w http.ResponseWriter, r *http.Request) {
if h.runner == nil {
writeJSON(w, http.StatusOK, []string{})
return
}
writeJSON(w, http.StatusOK, h.runner.RegisteredTypes())
}
+197
View File
@@ -0,0 +1,197 @@
package bot
import (
"context"
"database/sql"
"encoding/json"
"log/slog"
"sync"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
)
// BotFunc is the signature for a built-in bot type's run function.
// It blocks until ctx is cancelled. Use send to post messages.
type BotFunc func(ctx context.Context, config json.RawMessage, send SendMessageFunc)
// SendMessageFunc posts a message to a channel as this bot.
type SendMessageFunc func(channelID, content string)
// Runner manages server-side bot goroutines.
type Runner struct {
db *sql.DB
hub *gateway.Hub
logger *slog.Logger
mu sync.Mutex
bots map[string]context.CancelFunc // botID -> cancel
types map[string]BotFunc // type name -> runner
}
func NewRunner(db *sql.DB, hub *gateway.Hub, logger *slog.Logger) *Runner {
return &Runner{
db: db,
hub: hub,
logger: logger,
bots: make(map[string]context.CancelFunc),
types: make(map[string]BotFunc),
}
}
// Register adds a built-in bot type.
func (r *Runner) Register(name string, fn BotFunc) {
r.types[name] = fn
}
// StartAll loads all bots with a bot_type set and starts them.
func (r *Runner) StartAll() {
rows, err := r.db.QueryContext(context.Background(),
`SELECT id, bot_type, config::text FROM bots WHERE bot_type != ''`)
if err != nil {
r.logger.Error("failed to load bot configs", "error", err)
return
}
defer rows.Close()
for rows.Next() {
var id, botType, configStr string
if err := rows.Scan(&id, &botType, &configStr); err != nil {
continue
}
r.Start(id, botType, json.RawMessage(configStr))
}
}
// Start starts a single bot by ID. Safe to call multiple times (restarts).
func (r *Runner) Start(botID, botType string, config json.RawMessage) {
r.mu.Lock()
// Stop existing if running
if cancel, ok := r.bots[botID]; ok {
cancel()
delete(r.bots, botID)
}
fn, ok := r.types[botType]
if !ok {
r.mu.Unlock()
r.logger.Warn("unknown bot type", "type", botType, "bot_id", botID)
return
}
ctx, cancel := context.WithCancel(context.Background())
r.bots[botID] = cancel
r.mu.Unlock()
send := r.makeSender(botID)
r.logger.Info("starting built-in bot", "bot_id", botID, "type", botType)
go func() {
defer func() {
if rec := recover(); rec != nil {
r.logger.Error("bot panic", "bot_id", botID, "type", botType, "panic", rec)
}
}()
fn(ctx, config, send)
r.logger.Info("bot stopped", "bot_id", botID, "type", botType)
}()
}
// Stop stops a single bot by ID.
func (r *Runner) Stop(botID string) {
r.mu.Lock()
defer r.mu.Unlock()
if cancel, ok := r.bots[botID]; ok {
cancel()
delete(r.bots, botID)
}
}
// IsRunning checks if a bot is currently running.
func (r *Runner) IsRunning(botID string) bool {
r.mu.Lock()
defer r.mu.Unlock()
_, ok := r.bots[botID]
return ok
}
// makeSender returns a SendMessageFunc that inserts into DB and broadcasts.
func (r *Runner) makeSender(botID string) SendMessageFunc {
return func(channelID, content string) {
if len(content) > 4000 {
content = content[:4000]
}
// Look up bot info + server
var botName, ownerID string
err := r.db.QueryRowContext(context.Background(),
`SELECT name, owner_id FROM bots WHERE id = $1`, botID,
).Scan(&botName, &ownerID)
if err != nil {
r.logger.Error("send: bot not found", "bot_id", botID, "error", err)
return
}
serverID, err := r.hub.ServerIDForChannel(context.Background(), channelID)
if err != nil {
r.logger.Error("send: channel not found", "channel_id", channelID, "error", err)
return
}
// Verify bot is in this server
var inServer bool
err = r.db.QueryRowContext(context.Background(),
`SELECT EXISTS(SELECT 1 FROM bot_servers WHERE bot_id = $1 AND server_id = $2)`,
botID, serverID,
).Scan(&inServer)
if err != nil || !inServer {
r.logger.Warn("send: bot not in server", "bot_id", botID, "server_id", serverID)
return
}
var msgID, createdAt string
err = r.db.QueryRowContext(context.Background(),
`INSERT INTO messages (channel_id, author_id, content, bot_id)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at::text`,
channelID, ownerID, content, botID,
).Scan(&msgID, &createdAt)
if err != nil {
r.logger.Error("send: insert failed", "error", err)
return
}
r.hub.BroadcastToServer(serverID, gateway.Event{
Type: gateway.EventMessageCreate,
Data: map[string]interface{}{
"id": msgID,
"channel_id": channelID,
"author_id": ownerID,
"author_username": botName,
"author_display_name": nil,
"author_bot": true,
"bot_id": botID,
"bot_name": botName,
"content": content,
"reply_to": nil,
"edited_at": nil,
"pinned": false,
"created_at": createdAt,
"embeds": []interface{}{},
"reactions": []interface{}{},
},
})
}
}
// RegisteredTypes returns the list of available bot type names.
func (r *Runner) RegisteredTypes() []string {
r.mu.Lock()
defer r.mu.Unlock()
names := make([]string, 0, len(r.types))
for k := range r.types {
names = append(names, k)
}
return names
}
+116
View File
@@ -0,0 +1,116 @@
package bot
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// SteamFreeConfig is the config shape for bot_type "steamfree".
type SteamFreeConfig struct {
ChannelID string `json:"channel_id"`
PollMinutes int `json:"poll_minutes"`
}
// SteamFreeBot polls Steam's featured categories for 100%-off games.
func SteamFreeBot(ctx context.Context, raw json.RawMessage, send SendMessageFunc) {
var cfg SteamFreeConfig
if err := json.Unmarshal(raw, &cfg); err != nil || cfg.ChannelID == "" {
return
}
if cfg.PollMinutes <= 0 {
cfg.PollMinutes = 30
}
seen := map[int]bool{}
ticker := time.NewTicker(time.Duration(cfg.PollMinutes) * time.Minute)
defer ticker.Stop()
// Poll immediately on start
pollSteam(cfg.ChannelID, seen, send)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
pollSteam(cfg.ChannelID, seen, send)
}
}
}
func pollSteam(channelID string, seen map[int]bool, send SendMessageFunc) {
games, err := fetchFreeGames()
if err != nil {
return // ponytail: silent on error, logs add noise
}
for _, g := range games {
if seen[g.ID] {
continue
}
seen[g.ID] = true
msg := fmt.Sprintf(
"🎮 **FREE ON STEAM** 🎮\n**%s**\n~~$%.2f~~ → **FREE**\nhttps://store.steampowered.com/app/%d",
g.Name, float64(g.OriginalPrice)/100, g.ID,
)
send(channelID, msg)
time.Sleep(500 * time.Millisecond)
}
}
type featuredCategories struct {
Specials struct {
Items []struct {
ID int `json:"id"`
Name string `json:"name"`
DiscountPct int `json:"discount_percent"`
FinalPrice int `json:"final_price"`
OriginalPrice int `json:"original_price"`
} `json:"items"`
} `json:"specials"`
}
func fetchFreeGames() ([]struct {
ID int `json:"id"`
Name string `json:"name"`
DiscountPct int `json:"discount_percent"`
FinalPrice int `json:"final_price"`
OriginalPrice int `json:"original_price"`
}, error) {
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get("https://store.steampowered.com/api/featuredcategories?cc=us&l=english")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("steam: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var cats featuredCategories
if err := json.Unmarshal(body, &cats); err != nil {
return nil, err
}
var free []struct {
ID int `json:"id"`
Name string `json:"name"`
DiscountPct int `json:"discount_percent"`
FinalPrice int `json:"final_price"`
OriginalPrice int `json:"original_price"`
}
for _, item := range cats.Specials.Items {
if item.DiscountPct == 100 && item.OriginalPrice > 0 {
free = append(free, item)
}
}
return free, nil
}
+5
View File
@@ -569,5 +569,10 @@ CREATE INDEX IF NOT EXISTS idx_feature_request_votes_fr ON feature_request_votes
-- Bot messages: track which bot authored a message
ALTER TABLE messages ADD COLUMN IF NOT EXISTS bot_id UUID REFERENCES bots(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_messages_bot ON messages(bot_id) WHERE bot_id IS NOT NULL;
-- Built-in bot types: bot_type + config for server-managed bots
ALTER TABLE bots ADD COLUMN IF NOT EXISTS bot_type VARCHAR(32) DEFAULT '';
ALTER TABLE bots ADD COLUMN IF NOT EXISTS config JSONB DEFAULT '{}';
CREATE INDEX IF NOT EXISTS idx_bots_type ON bots(bot_type) WHERE bot_type != '';
`