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) }