c839e67c47
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.
199 lines
4.2 KiB
Go
199 lines
4.2 KiB
Go
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)
|
|
}
|