feat(bot): SteamFree bot — posts free Steam games
Polls Steam featured categories API every 30m, filters for 100% discounts on games that had a real price, posts new finds to the configured channel. Env: BOT_TOKEN, DUMPSTER_HOST, CHANNEL_ID, POLL_MINUTES.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// ---- config from env ----
|
||||
|
||||
var (
|
||||
botToken string
|
||||
host string
|
||||
channelID string
|
||||
pollMinutes int
|
||||
seen = map[string]bool{} // appid -> posted
|
||||
)
|
||||
|
||||
// ---- dumpsterChat WS protocol ----
|
||||
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
// ---- Steam API response ----
|
||||
|
||||
type FeaturedCategories struct {
|
||||
Specials FeaturedList `json:"specials"`
|
||||
}
|
||||
|
||||
type FeaturedList struct {
|
||||
Items []FeaturedItem `json:"items"`
|
||||
}
|
||||
|
||||
type FeaturedItem struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DiscountPct int `json:"discount_percent"`
|
||||
FinalPrice int `json:"final_price"` // in cents
|
||||
OriginalPrice int `json:"original_price"` // in cents
|
||||
}
|
||||
|
||||
func main() {
|
||||
botToken = os.Getenv("BOT_TOKEN")
|
||||
if botToken == "" {
|
||||
log.Fatal("BOT_TOKEN required (create a bot in the dumpsterChat store)")
|
||||
}
|
||||
host = os.Getenv("DUMPSTER_HOST")
|
||||
if host == "" {
|
||||
host = "localhost:8080"
|
||||
}
|
||||
channelID = os.Getenv("CHANNEL_ID")
|
||||
if channelID == "" {
|
||||
log.Fatal("CHANNEL_ID required (the channel to post free games to)")
|
||||
}
|
||||
pollMinutes = 30
|
||||
if m := os.Getenv("POLL_MINUTES"); m != "" {
|
||||
if v, err := strconv.Atoi(m); err == nil && v > 0 {
|
||||
pollMinutes = v
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to dumpsterChat
|
||||
u := url.URL{
|
||||
Scheme: "ws",
|
||||
Host: host,
|
||||
Path: "/ws/bot",
|
||||
RawQuery: "token=" + botToken,
|
||||
}
|
||||
|
||||
log.Printf("SteamFree bot connecting to %s", u.String())
|
||||
|
||||
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||
if err != nil {
|
||||
log.Fatal("dial:", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
log.Println("SteamFree bot connected!")
|
||||
|
||||
// First poll immediately, then on ticker
|
||||
poll(c)
|
||||
|
||||
ticker := time.NewTicker(time.Duration(pollMinutes) * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Keep connection alive by reading (we don't need to react to events)
|
||||
go func() {
|
||||
for {
|
||||
_, _, err := c.ReadMessage()
|
||||
if err != nil {
|
||||
log.Println("ws read:", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for range ticker.C {
|
||||
poll(c)
|
||||
}
|
||||
}
|
||||
|
||||
func poll(c *websocket.Conn) {
|
||||
log.Println("Polling Steam for free games...")
|
||||
|
||||
games, err := fetchFreeGames()
|
||||
if err != nil {
|
||||
log.Println("fetch error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
newCount := 0
|
||||
for _, g := range games {
|
||||
if seen[strconv.Itoa(g.ID)] {
|
||||
continue
|
||||
}
|
||||
seen[strconv.Itoa(g.ID)] = true
|
||||
newCount++
|
||||
|
||||
msg := formatGame(g)
|
||||
sendMessage(c, channelID, msg)
|
||||
log.Printf("Posted: %s (was $%.2f, now FREE)", g.Name, float64(g.OriginalPrice)/100)
|
||||
time.Sleep(500 * time.Millisecond) // be polite to the WS
|
||||
}
|
||||
|
||||
if newCount == 0 {
|
||||
log.Println("No new free games found")
|
||||
} else {
|
||||
log.Printf("Posted %d new free games", newCount)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchFreeGames() ([]FeaturedItem, 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, fmt.Errorf("steam api: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("steam api: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
var cats FeaturedCategories
|
||||
if err := json.Unmarshal(body, &cats); err != nil {
|
||||
return nil, fmt.Errorf("parse json: %w", err)
|
||||
}
|
||||
|
||||
// Filter: 100% discount and actually had a price (not permanent free-to-play)
|
||||
var free []FeaturedItem
|
||||
for _, item := range cats.Specials.Items {
|
||||
if item.DiscountPct == 100 && item.OriginalPrice > 0 {
|
||||
free = append(free, item)
|
||||
}
|
||||
}
|
||||
|
||||
return free, nil
|
||||
}
|
||||
|
||||
func formatGame(g FeaturedItem) string {
|
||||
storeURL := fmt.Sprintf("https://store.steampowered.com/app/%d", g.ID)
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "🎮 **FREE ON STEAM** 🎮\n")
|
||||
fmt.Fprintf(&b, "**%s**\n", g.Name)
|
||||
fmt.Fprintf(&b, "~~$%.2f~~ → **FREE**\n", float64(g.OriginalPrice)/100)
|
||||
fmt.Fprintf(&b, "%s", storeURL)
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func sendMessage(c *websocket.Conn, channelID, content string) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "SEND_MESSAGE",
|
||||
"payload": map[string]string{
|
||||
"channel_id": channelID,
|
||||
"content": content,
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
c.WriteMessage(websocket.TextMessage, data)
|
||||
}
|
||||
Reference in New Issue
Block a user