| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223 |
- 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
- },
- }
- }
|