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:
2026-07-15 13:05:28 -04:00
parent 7c70082f37
commit 3d31054626
+198
View File
@@ -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)
}