From 3d31054626d966283cc454d89f0bacb2a8948cd4 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Wed, 15 Jul 2026 13:05:28 -0400 Subject: [PATCH] =?UTF-8?q?feat(bot):=20SteamFree=20bot=20=E2=80=94=20post?= =?UTF-8?q?s=20free=20Steam=20games?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- examples/steamfree/main.go | 198 +++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 examples/steamfree/main.go diff --git a/examples/steamfree/main.go b/examples/steamfree/main.go new file mode 100644 index 0000000..ca26533 --- /dev/null +++ b/examples/steamfree/main.go @@ -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) +}