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