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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user