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:
+6
-1
@@ -80,6 +80,11 @@ func main() {
|
|||||||
hub := gateway.NewHub(database.DB, logger)
|
hub := gateway.NewHub(database.DB, logger)
|
||||||
go hub.Run()
|
go hub.Run()
|
||||||
|
|
||||||
|
// Built-in bot runner
|
||||||
|
botRunner := bot.NewRunner(database.DB, hub, logger)
|
||||||
|
botRunner.Register("steamfree", bot.SteamFreeBot)
|
||||||
|
go botRunner.StartAll()
|
||||||
|
|
||||||
// Giphy client (nil if no API key)
|
// Giphy client (nil if no API key)
|
||||||
giphyClient := giphy.NewClient(cfg.Giphy.APIKey)
|
giphyClient := giphy.NewClient(cfg.Giphy.APIKey)
|
||||||
|
|
||||||
@@ -350,7 +355,7 @@ func main() {
|
|||||||
|
|
||||||
// Bots + slash commands
|
// Bots + slash commands
|
||||||
r.Route("/bots", func(r chi.Router) {
|
r.Route("/bots", func(r chi.Router) {
|
||||||
bot.NewHandler(database.DB).RegisterRoutes(r)
|
bot.NewHandler(database.DB, botRunner).RegisterRoutes(r)
|
||||||
bot.NewCommandHandler(database.DB).RegisterCommandRoutes(r)
|
bot.NewCommandHandler(database.DB).RegisterCommandRoutes(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+77
-23
@@ -12,17 +12,19 @@ import (
|
|||||||
|
|
||||||
// Handler handles bot CRUD and server-assignment routes.
|
// Handler handles bot CRUD and server-assignment routes.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
|
runner *Runner
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new bot Handler.
|
// NewHandler creates a new bot Handler.
|
||||||
func NewHandler(db *sql.DB) *Handler {
|
func NewHandler(db *sql.DB, runner *Runner) *Handler {
|
||||||
return &Handler{db: db}
|
return &Handler{db: db, runner: runner}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterRoutes registers authenticated bot routes under the given router.
|
// RegisterRoutes registers authenticated bot routes under the given router.
|
||||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
r.Get("/store", h.Store)
|
r.Get("/store", h.Store)
|
||||||
|
r.Get("/types", h.ListTypes)
|
||||||
r.Post("/", h.Create)
|
r.Post("/", h.Create)
|
||||||
r.Get("/", h.List)
|
r.Get("/", h.List)
|
||||||
r.Get("/{botID}", h.Get)
|
r.Get("/{botID}", h.Get)
|
||||||
@@ -36,12 +38,14 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
|||||||
// ---- response / request types ----
|
// ---- response / request types ----
|
||||||
|
|
||||||
type botResponse struct {
|
type botResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Avatar *string `json:"avatar"`
|
Avatar *string `json:"avatar"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
OwnerID string `json:"owner_id"`
|
BotType string `json:"bot_type"`
|
||||||
CreatedAt string `json:"created_at"`
|
Config json.RawMessage `json:"config"`
|
||||||
|
OwnerID string `json:"owner_id"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// botWithToken is returned only on create / regenerate-token.
|
// botWithToken is returned only on create / regenerate-token.
|
||||||
@@ -51,14 +55,18 @@ type botWithToken struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type createBotRequest struct {
|
type createBotRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
BotType string `json:"bot_type"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateBotRequest struct {
|
type updateBotRequest struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Avatar *string `json:"avatar"`
|
Avatar *string `json:"avatar"`
|
||||||
|
BotType *string `json:"bot_type"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type addToServerRequest struct {
|
type addToServerRequest struct {
|
||||||
@@ -110,15 +118,21 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
token := GenerateToken()
|
token := GenerateToken()
|
||||||
tokenHash := HashToken(token)
|
tokenHash := HashToken(token)
|
||||||
|
|
||||||
|
configJSON := req.Config
|
||||||
|
if configJSON == nil {
|
||||||
|
configJSON = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
var bot botWithToken
|
var bot botWithToken
|
||||||
var avatar sql.NullString
|
var avatar sql.NullString
|
||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
|
var configOut sql.NullString
|
||||||
err := h.db.QueryRowContext(r.Context(), `
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
INSERT INTO bots (name, description, owner_id, token)
|
INSERT INTO bots (name, description, owner_id, token, bot_type, config)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
||||||
RETURNING id, name, avatar, description, owner_id, created_at::text
|
RETURNING id, name, avatar, description, bot_type, config::text, owner_id, created_at::text
|
||||||
`, req.Name, req.Description, userID, tokenHash).Scan(
|
`, req.Name, req.Description, userID, tokenHash, req.BotType, string(configJSON)).Scan(
|
||||||
&bot.ID, &bot.Name, &avatar, &bot.Description, &bot.OwnerID, &createdAt,
|
&bot.ID, &bot.Name, &avatar, &bot.Description, &bot.BotType, &configOut, &bot.OwnerID, &createdAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, "failed to create bot")
|
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 {
|
if avatar.Valid {
|
||||||
bot.Avatar = &avatar.String
|
bot.Avatar = &avatar.String
|
||||||
}
|
}
|
||||||
|
if configOut.Valid {
|
||||||
|
bot.Config = json.RawMessage(configOut.String)
|
||||||
|
}
|
||||||
bot.CreatedAt = createdAt.String
|
bot.CreatedAt = createdAt.String
|
||||||
bot.Token = token
|
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)
|
writeJSON(w, http.StatusCreated, bot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,15 +304,18 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
var b botResponse
|
var b botResponse
|
||||||
var avatar sql.NullString
|
var avatar sql.NullString
|
||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
|
var configOut sql.NullString
|
||||||
err = h.db.QueryRowContext(r.Context(), `
|
err = h.db.QueryRowContext(r.Context(), `
|
||||||
UPDATE bots
|
UPDATE bots
|
||||||
SET name = COALESCE($1, name),
|
SET name = COALESCE($1, name),
|
||||||
description = COALESCE($2, description),
|
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
|
WHERE id = $4
|
||||||
RETURNING id, name, avatar, description, owner_id, created_at::text
|
RETURNING id, name, avatar, description, bot_type, config::text, owner_id, created_at::text
|
||||||
`, req.Name, req.Description, req.Avatar, botID).Scan(
|
`, req.Name, req.Description, req.Avatar, botID, req.BotType, string(req.Config)).Scan(
|
||||||
&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt,
|
&b.ID, &b.Name, &avatar, &b.Description, &b.BotType, &configOut, &b.OwnerID, &createdAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, "failed to update bot")
|
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 {
|
if avatar.Valid {
|
||||||
b.Avatar = &avatar.String
|
b.Avatar = &avatar.String
|
||||||
}
|
}
|
||||||
|
if configOut.Valid {
|
||||||
|
b.Config = json.RawMessage(configOut.String)
|
||||||
|
}
|
||||||
b.CreatedAt = createdAt.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)
|
writeJSON(w, http.StatusOK, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +385,11 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop built-in bot if running
|
||||||
|
if h.runner != nil {
|
||||||
|
h.runner.Stop(botID)
|
||||||
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,3 +643,13 @@ func (h *Handler) Store(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
writeJSON(w, http.StatusOK, bots)
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
-- 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;
|
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;
|
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 != '';
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,17 @@ import { Link } from 'react-router-dom';
|
|||||||
import { useBotStore, type Bot } from '../stores/bot.ts';
|
import { useBotStore, type Bot } from '../stores/bot.ts';
|
||||||
import { useServerStore } from '../stores/server.ts';
|
import { useServerStore } from '../stores/server.ts';
|
||||||
|
|
||||||
|
// ponytail: config fields per bot type, add new types here
|
||||||
|
const BOT_TYPE_CONFIGS: Record<string, { label: string; fields: { key: string; label: string; type: 'text' | 'number' | 'channel'; placeholder: string }[] }> = {
|
||||||
|
steamfree: {
|
||||||
|
label: 'Steam Free Games',
|
||||||
|
fields: [
|
||||||
|
{ key: 'channel_id', label: 'CHANNEL', type: 'channel', placeholder: 'select channel' },
|
||||||
|
{ key: 'poll_minutes', label: 'POLL INTERVAL (min)', type: 'number', placeholder: '30' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export function BotManager() {
|
export function BotManager() {
|
||||||
const bots = useBotStore((s) => s.bots);
|
const bots = useBotStore((s) => s.bots);
|
||||||
const loading = useBotStore((s) => s.loading);
|
const loading = useBotStore((s) => s.loading);
|
||||||
@@ -20,6 +31,8 @@ export function BotManager() {
|
|||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [createName, setCreateName] = useState('');
|
const [createName, setCreateName] = useState('');
|
||||||
const [createDesc, setCreateDesc] = useState('');
|
const [createDesc, setCreateDesc] = useState('');
|
||||||
|
const [createType, setCreateType] = useState('');
|
||||||
|
const [createConfig, setCreateConfig] = useState<Record<string, string>>({});
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
const [editDesc, setEditDesc] = useState('');
|
const [editDesc, setEditDesc] = useState('');
|
||||||
@@ -37,10 +50,19 @@ export function BotManager() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!createName.trim()) return;
|
if (!createName.trim()) return;
|
||||||
try {
|
try {
|
||||||
const result = await createBot(createName.trim(), createDesc.trim());
|
// Build config object with proper types
|
||||||
|
const cfg: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(createConfig)) {
|
||||||
|
if (v === '') continue;
|
||||||
|
const fieldDef = BOT_TYPE_CONFIGS[createType]?.fields.find((f) => f.key === k);
|
||||||
|
cfg[k] = fieldDef?.type === 'number' ? parseInt(v, 10) : v;
|
||||||
|
}
|
||||||
|
const result = await createBot(createName.trim(), createDesc.trim(), createType || undefined, Object.keys(cfg).length ? cfg : undefined);
|
||||||
setTokenDisplay({ botId: result.id, token: result.token });
|
setTokenDisplay({ botId: result.id, token: result.token });
|
||||||
setCreateName('');
|
setCreateName('');
|
||||||
setCreateDesc('');
|
setCreateDesc('');
|
||||||
|
setCreateType('');
|
||||||
|
setCreateConfig({});
|
||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
} catch {
|
} catch {
|
||||||
// error handled in store
|
// error handled in store
|
||||||
@@ -102,6 +124,8 @@ export function BotManager() {
|
|||||||
setEditDesc(bot.description);
|
setEditDesc(bot.description);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const typeConfig = createType ? BOT_TYPE_CONFIGS[createType] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto">
|
<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="max-w-3xl mx-auto">
|
||||||
@@ -180,13 +204,48 @@ export function BotManager() {
|
|||||||
placeholder="what does this bot do?"
|
placeholder="what does this bot do?"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-gb-fg-f mb-1 font-mono text-xs">TYPE:</label>
|
||||||
|
<select
|
||||||
|
value={createType}
|
||||||
|
onChange={(e) => { setCreateType(e.target.value); setCreateConfig({}); }}
|
||||||
|
className="terminal-input w-full"
|
||||||
|
>
|
||||||
|
<option value="">External (connects via WebSocket)</option>
|
||||||
|
{Object.entries(BOT_TYPE_CONFIGS).map(([key, cfg]) => (
|
||||||
|
<option key={key} value={key}>{cfg.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{typeConfig && typeConfig.fields.map((field) => (
|
||||||
|
<div key={field.key}>
|
||||||
|
<label className="block text-gb-fg-f mb-1 font-mono text-xs">{field.label}:</label>
|
||||||
|
{field.type === 'channel' ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={createConfig[field.key] || ''}
|
||||||
|
onChange={(e) => setCreateConfig((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||||
|
placeholder="channel ID (from URL)"
|
||||||
|
className="terminal-input w-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={field.type}
|
||||||
|
value={createConfig[field.key] || ''}
|
||||||
|
onChange={(e) => setCreateConfig((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className="terminal-input w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="submit" className="terminal-button" disabled={loading || !createName.trim()}>
|
<button type="submit" className="terminal-button" disabled={loading || !createName.trim()}>
|
||||||
{loading ? '[CREATING...]' : '[SAVE]'}
|
{loading ? '[CREATING...]' : '[SAVE]'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setShowCreate(false); setCreateName(''); setCreateDesc(''); }}
|
onClick={() => { setShowCreate(false); setCreateName(''); setCreateDesc(''); setCreateType(''); setCreateConfig({}); }}
|
||||||
className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm"
|
className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm"
|
||||||
>
|
>
|
||||||
[CANCEL]
|
[CANCEL]
|
||||||
@@ -255,6 +314,9 @@ export function BotManager() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
[{bot.name}]
|
[{bot.name}]
|
||||||
|
{bot.bot_type && (
|
||||||
|
<span className="text-gb-aqua text-xs ml-2">({BOT_TYPE_CONFIGS[bot.bot_type]?.label || bot.bot_type})</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
{bot.description && (
|
{bot.description && (
|
||||||
<p className="text-gb-fg-f font-mono text-xs mt-1 truncate">
|
<p className="text-gb-fg-f font-mono text-xs mt-1 truncate">
|
||||||
|
|||||||
+23
-4
@@ -6,6 +6,8 @@ export interface Bot {
|
|||||||
name: string;
|
name: string;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
description: string;
|
description: string;
|
||||||
|
bot_type: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
owner_id: string;
|
owner_id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
@@ -31,13 +33,15 @@ export interface StoreBot {
|
|||||||
interface BotState {
|
interface BotState {
|
||||||
bots: Bot[];
|
bots: Bot[];
|
||||||
storeBots: StoreBot[];
|
storeBots: StoreBot[];
|
||||||
|
botTypes: string[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
fetchBots: () => Promise<void>;
|
fetchBots: () => Promise<void>;
|
||||||
fetchStoreBots: () => Promise<void>;
|
fetchStoreBots: () => Promise<void>;
|
||||||
createBot: (name: string, description: string) => Promise<Bot & { token: string }>;
|
fetchBotTypes: () => Promise<void>;
|
||||||
updateBot: (id: string, data: { name?: string; description?: string; avatar?: string }) => Promise<Bot>;
|
createBot: (name: string, description: string, botType?: string, config?: Record<string, unknown>) => Promise<Bot & { token: string }>;
|
||||||
|
updateBot: (id: string, data: { name?: string; description?: string; avatar?: string; bot_type?: string; config?: Record<string, unknown> }) => Promise<Bot>;
|
||||||
deleteBot: (id: string) => Promise<void>;
|
deleteBot: (id: string) => Promise<void>;
|
||||||
addToServer: (botId: string, serverId: string) => Promise<void>;
|
addToServer: (botId: string, serverId: string) => Promise<void>;
|
||||||
removeFromServer: (botId: string, serverId: string) => Promise<void>;
|
removeFromServer: (botId: string, serverId: string) => Promise<void>;
|
||||||
@@ -52,6 +56,7 @@ interface BotState {
|
|||||||
export const useBotStore = create<BotState>((set) => ({
|
export const useBotStore = create<BotState>((set) => ({
|
||||||
bots: [],
|
bots: [],
|
||||||
storeBots: [],
|
storeBots: [],
|
||||||
|
botTypes: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
@@ -81,10 +86,24 @@ export const useBotStore = create<BotState>((set) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
createBot: async (name, description) => {
|
fetchBotTypes: async () => {
|
||||||
|
try {
|
||||||
|
const types = await api.get<string[]>('/bots/types');
|
||||||
|
set({ botTypes: types });
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createBot: async (name, description, botType, config) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const result = await api.post<Bot & { token: string }>('/bots', { name, description });
|
const result = await api.post<Bot & { token: string }>('/bots', {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
bot_type: botType || '',
|
||||||
|
config: config || {},
|
||||||
|
});
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
bots: [...state.bots, result],
|
bots: [...state.bots, result],
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user