chatto.go 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. package main
  2. import (
  3. "context"
  4. "fmt"
  5. "os"
  6. "strings"
  7. "github.com/joho/godotenv"
  8. "github.com/spf13/cobra"
  9. "gogs.netdome.biz/paul/cheapo/internal/chatto"
  10. "gogs.netdome.biz/paul/cheapo/internal/game"
  11. )
  12. // loadEnv reads a .env file from the working directory if present (godotenv).
  13. // Existing environment variables take precedence over .env values.
  14. func loadEnv() {
  15. _ = godotenv.Load()
  16. }
  17. // chattoClientFromEnv builds a Chatto client from the CHATTO_TOKEN
  18. // environment variable (or .env file); CHATTO_BASE_URL defaults to the public
  19. // Chatto server (https://chat.chatto.run). The token is provided by the
  20. // operator; this tool never fetches credentials itself.
  21. func chattoClientFromEnv() (*chatto.Client, error) {
  22. loadEnv()
  23. base := os.Getenv("CHATTO_BASE_URL")
  24. if base == "" {
  25. base = "https://chat.chatto.run"
  26. }
  27. token := os.Getenv("CHATTO_TOKEN")
  28. if token == "" {
  29. return nil, fmt.Errorf("CHATTO_TOKEN must be set (base URL: %s, overridable via CHATTO_BASE_URL)", base)
  30. }
  31. return chatto.NewClient(base, token), nil
  32. }
  33. // formatGames renders games as a Chatto Markdown message body. mode is ""
  34. // (free now), "upcoming" or "deals".
  35. func formatGames(games []game.Game, mode string) string {
  36. if len(games) == 0 {
  37. switch mode {
  38. case "upcoming":
  39. return "🗓 **Upcoming free games:** none announced."
  40. case "deals":
  41. return "🏷 **Current deals:** none found."
  42. default:
  43. return "🎮 **Current free games:** none on offer."
  44. }
  45. }
  46. var b strings.Builder
  47. switch mode {
  48. case "upcoming":
  49. b.WriteString("## 🗓 Upcoming free games\n\n")
  50. case "deals":
  51. b.WriteString("## 🏷 Current deals\n\n")
  52. default:
  53. b.WriteString("## 🎮 Current free games\n\n")
  54. }
  55. // One table per store keeps columns uniform (dates only for epic).
  56. b.WriteString("| Game | Store | Price | Period |\n")
  57. b.WriteString("|---|---|---|---|\n")
  58. for _, g := range games {
  59. period := "—"
  60. if g.Kind == "upcoming" && !g.StartDate.IsZero() && !g.EndDate.IsZero() {
  61. period = fmt.Sprintf("from %s, until %s",
  62. g.StartDate.Local().Format("02.01."),
  63. g.EndDate.Local().Format("02.01."))
  64. } else if !g.EndDate.IsZero() {
  65. period = "until " + g.EndDate.Local().Format("02.01.")
  66. }
  67. fmt.Fprintf(&b, "| **%s** | [%s](%s) | %s | %s |\n",
  68. escapePipe(g.Title), g.Store, g.URL, priceCell(g), period)
  69. }
  70. b.WriteString("\n_As of now — free from/until 17:00 Europe/Berlin each._")
  71. return b.String()
  72. }
  73. // priceCell renders the price column: "0 €" for free games, original → final
  74. // with percent for deals.
  75. func priceCell(g game.Game) string {
  76. switch {
  77. case g.DiscountPct > 0 && g.FinalPriceFmt != "":
  78. return fmt.Sprintf("%s → **%s** (−%d %%)", g.PriceFmt, g.FinalPriceFmt, g.DiscountPct)
  79. case g.Kind == "free-now" || g.Kind == "free-to-keep":
  80. return "**0 €**"
  81. case g.PriceFmt != "":
  82. return g.PriceFmt
  83. default:
  84. return "—"
  85. }
  86. }
  87. // escapePipe prevents table cell breakage via titles containing '|'.
  88. func escapePipe(s string) string {
  89. return strings.ReplaceAll(s, "|", "\\|")
  90. }
  91. // fetchDeals queries all selected providers for discounted deals (any
  92. // discount); a failing provider is reported on stderr but does not abort.
  93. func fetchDeals(ctx context.Context) ([]game.Game, error) {
  94. provs, err := newProviders()
  95. if err != nil {
  96. return nil, err
  97. }
  98. var all []game.Game
  99. var hadErr error
  100. for _, p := range provs {
  101. d, ok := p.(interface {
  102. GetDeals(ctx context.Context) ([]game.Game, error)
  103. })
  104. if !ok {
  105. continue // provider has no deals concept (gog)
  106. }
  107. games, err := d.GetDeals(ctx)
  108. if err != nil {
  109. fmt.Fprintf(os.Stderr, "⚠ %s: %v\n", p.Name(), err)
  110. hadErr = err
  111. continue
  112. }
  113. all = append(all, games...)
  114. }
  115. return all, hadErr
  116. }
  117. // chattoSendCmd posts the current free games to a Chatto room.
  118. func chattoSendCmd() *cobra.Command {
  119. var (
  120. roomID string
  121. upcoming bool
  122. deals bool
  123. )
  124. cmd := &cobra.Command{
  125. Use: "chatto-send",
  126. Short: "Post current free games to a Chatto room",
  127. Long: `Fetches the free games and posts them as a message to the Chatto room.
  128. Credentials come from the CHATTO_BASE_URL and CHATTO_TOKEN environment
  129. variables (set by the operator, never fetched automatically).`,
  130. RunE: func(cmd *cobra.Command, args []string) error {
  131. ctx := cmd.Context()
  132. c, err := chattoClientFromEnv()
  133. if err != nil {
  134. return err
  135. }
  136. if roomID == "" {
  137. return fmt.Errorf("--room is required (room ID)")
  138. }
  139. var games []game.Game
  140. switch {
  141. case deals:
  142. games, err = fetchDeals(ctx)
  143. if err != nil && len(games) == 0 {
  144. return err
  145. }
  146. case upcoming:
  147. // Upcoming windows only exist for Epic.
  148. u := newEpic()
  149. games, err = u.GetUpcomingGames(ctx)
  150. if err != nil {
  151. return err
  152. }
  153. default:
  154. games, err = fetchAll(ctx)
  155. if err != nil && len(games) == 0 {
  156. return err
  157. }
  158. }
  159. msg, err := c.CreateMessage(ctx, roomID, formatGames(games, modeOf(upcoming, deals)), "")
  160. if err != nil {
  161. return err
  162. }
  163. fmt.Fprintf(cmd.OutOrStdout(), "✅ Sent: event %s\n", msg.EventID)
  164. return nil
  165. },
  166. }
  167. cmd.Flags().StringVar(&roomID, "room", "", "Chatto room ID (e.g. RtLrcOrNgvv3StA)")
  168. cmd.Flags().BoolVar(&upcoming, "upcoming", false, "Post upcoming instead of current free games (Epic only)")
  169. cmd.Flags().BoolVar(&deals, "deals", false, "Post discounted games (deals) instead of free games")
  170. return cmd
  171. }
  172. // modeOf picks the heading mode for formatGames.
  173. func modeOf(upcoming, deals bool) string {
  174. switch {
  175. case upcoming:
  176. return "upcoming"
  177. case deals:
  178. return "deals"
  179. default:
  180. return ""
  181. }
  182. }
  183. // chattoRoomsCmd lists rooms visible to the token's user — verification helper.
  184. func chattoRoomsCmd() *cobra.Command {
  185. return &cobra.Command{
  186. Use: "chatto-rooms",
  187. Short: "List visible Chatto rooms (client verification)",
  188. RunE: func(cmd *cobra.Command, args []string) error {
  189. c, err := chattoClientFromEnv()
  190. if err != nil {
  191. return err
  192. }
  193. rooms, err := c.ListRooms(cmd.Context())
  194. if err != nil {
  195. return err
  196. }
  197. out := cmd.OutOrStdout()
  198. for _, r := range rooms {
  199. fmt.Fprintf(out, "%s\t%s\t%s\n", r.ID, r.Kind, r.Name)
  200. }
  201. fmt.Fprintf(out, "(%d rooms)\n", len(rooms))
  202. return nil
  203. },
  204. }
  205. }