package main import ( "context" "fmt" "os" "strings" "github.com/joho/godotenv" "github.com/spf13/cobra" "gogs.netdome.biz/paul/cheapo/internal/chatto" "gogs.netdome.biz/paul/cheapo/internal/game" ) // loadEnv reads a .env file from the working directory if present (godotenv). // Existing environment variables take precedence over .env values. func loadEnv() { _ = godotenv.Load() } // chattoClientFromEnv builds a Chatto client from the CHATTO_TOKEN // environment variable (or .env file); CHATTO_BASE_URL defaults to the public // Chatto server (https://chat.chatto.run). The token is provided by the // operator; this tool never fetches credentials itself. func chattoClientFromEnv() (*chatto.Client, error) { loadEnv() base := os.Getenv("CHATTO_BASE_URL") if base == "" { base = "https://chat.chatto.run" } token := os.Getenv("CHATTO_TOKEN") if token == "" { return nil, fmt.Errorf("CHATTO_TOKEN must be set (base URL: %s, overridable via CHATTO_BASE_URL)", base) } return chatto.NewClient(base, token), nil } // formatGames renders games as a Chatto Markdown message body. mode is "" // (free now), "upcoming" or "deals". func formatGames(games []game.Game, mode string) string { if len(games) == 0 { switch mode { case "upcoming": return "🗓 **Upcoming free games:** none announced." case "deals": return "🏷 **Current deals:** none found." default: return "🎮 **Current free games:** none on offer." } } var b strings.Builder switch mode { case "upcoming": b.WriteString("## 🗓 Upcoming free games\n\n") case "deals": b.WriteString("## 🏷 Current deals\n\n") default: b.WriteString("## 🎮 Current free games\n\n") } // One table per store keeps columns uniform (dates only for epic). b.WriteString("| Game | Store | Price | Period |\n") b.WriteString("|---|---|---|---|\n") for _, g := range games { period := "—" if g.Kind == "upcoming" && !g.StartDate.IsZero() && !g.EndDate.IsZero() { period = fmt.Sprintf("from %s, until %s", g.StartDate.Local().Format("02.01."), g.EndDate.Local().Format("02.01.")) } else if !g.EndDate.IsZero() { period = "until " + g.EndDate.Local().Format("02.01.") } fmt.Fprintf(&b, "| **%s** | [%s](%s) | %s | %s |\n", escapePipe(g.Title), g.Store, g.URL, priceCell(g), period) } b.WriteString("\n_As of now — free from/until 17:00 Europe/Berlin each._") return b.String() } // priceCell renders the price column: "0 €" for free games, original → final // with percent for deals. func priceCell(g game.Game) string { switch { case g.DiscountPct > 0 && g.FinalPriceFmt != "": return fmt.Sprintf("%s → **%s** (−%d %%)", g.PriceFmt, g.FinalPriceFmt, g.DiscountPct) case g.Kind == "free-now" || g.Kind == "free-to-keep": return "**0 €**" case g.PriceFmt != "": return g.PriceFmt default: return "—" } } // escapePipe prevents table cell breakage via titles containing '|'. func escapePipe(s string) string { return strings.ReplaceAll(s, "|", "\\|") } // fetchDeals queries all selected providers for discounted deals (any // discount); a failing provider is reported on stderr but does not abort. func fetchDeals(ctx context.Context) ([]game.Game, error) { provs, err := newProviders() if err != nil { return nil, err } var all []game.Game var hadErr error for _, p := range provs { d, ok := p.(interface { GetDeals(ctx context.Context) ([]game.Game, error) }) if !ok { continue // provider has no deals concept (gog) } games, err := d.GetDeals(ctx) if err != nil { fmt.Fprintf(os.Stderr, "⚠ %s: %v\n", p.Name(), err) hadErr = err continue } all = append(all, games...) } return all, hadErr } // chattoSendCmd posts the current free games to a Chatto room. func chattoSendCmd() *cobra.Command { var ( roomID string upcoming bool deals bool ) cmd := &cobra.Command{ Use: "chatto-send", Short: "Post current free games to a Chatto room", Long: `Fetches the free games and posts them as a message to the Chatto room. Credentials come from the CHATTO_BASE_URL and CHATTO_TOKEN environment variables (set by the operator, never fetched automatically).`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() c, err := chattoClientFromEnv() if err != nil { return err } if roomID == "" { return fmt.Errorf("--room is required (room ID)") } var games []game.Game switch { case deals: games, err = fetchDeals(ctx) if err != nil && len(games) == 0 { return err } case upcoming: // Upcoming windows only exist for Epic. u := newEpic() games, err = u.GetUpcomingGames(ctx) if err != nil { return err } default: games, err = fetchAll(ctx) if err != nil && len(games) == 0 { return err } } msg, err := c.CreateMessage(ctx, roomID, formatGames(games, modeOf(upcoming, deals)), "") if err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "✅ Sent: event %s\n", msg.EventID) return nil }, } cmd.Flags().StringVar(&roomID, "room", "", "Chatto room ID (e.g. RtLrcOrNgvv3StA)") cmd.Flags().BoolVar(&upcoming, "upcoming", false, "Post upcoming instead of current free games (Epic only)") cmd.Flags().BoolVar(&deals, "deals", false, "Post discounted games (deals) instead of free games") return cmd } // modeOf picks the heading mode for formatGames. func modeOf(upcoming, deals bool) string { switch { case upcoming: return "upcoming" case deals: return "deals" default: return "" } } // chattoRoomsCmd lists rooms visible to the token's user — verification helper. func chattoRoomsCmd() *cobra.Command { return &cobra.Command{ Use: "chatto-rooms", Short: "List visible Chatto rooms (client verification)", RunE: func(cmd *cobra.Command, args []string) error { c, err := chattoClientFromEnv() if err != nil { return err } rooms, err := c.ListRooms(cmd.Context()) if err != nil { return err } out := cmd.OutOrStdout() for _, r := range rooms { fmt.Fprintf(out, "%s\t%s\t%s\n", r.ID, r.Kind, r.Name) } fmt.Fprintf(out, "(%d rooms)\n", len(rooms)) return nil }, } }