5127144709
- 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
198 lines
5.1 KiB
Go
198 lines
5.1 KiB
Go
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
|
|
}
|
|
|
|
|