ソースを参照

Add hand-built Chatto Connect-JSON client (chatto-rooms, chatto-send)

Paul Klumpp 3 日 前
親
コミット
d09fd1a76a
5 ファイル変更、360 行追加、2 行削除
  1. 5 0
      AGENTS.md
  2. 27 0
      README.md
  3. 126 0
      cmd/cheapo/chatto.go
  4. 2 2
      cmd/cheapo/main.go
  5. 200 0
      internal/chatto/client.go

+ 5 - 0
AGENTS.md

@@ -84,4 +84,9 @@ How it gets the info:
   `game.Provider` (`Name()` + `GetFreeGames(ctx) ([]game.Game, error)`); the
   shared type is `game.Game` (`internal/game`). New stores (e.g. GOG)
   follow this pattern and are registered in `newProviders()` (`cmd/cheapo/main.go`).
+- **Chatto client:** `internal/chatto` is a hand-built Connect-JSON client
+  (Chatto's generated code is `internal/` and not importable). Credentials ONLY via
+  `CHATTO_BASE_URL`/`CHATTO_TOKEN` env from the operator — the tool never
+  fetches credentials itself, and the agent does not use its own plugin
+  credentials for it.
 - New store platforms (Prime, GOG, …) follow the provider pattern (above).

+ 27 - 0
README.md

@@ -107,8 +107,35 @@ internal/epic/    Epic client (freeGamesPromotions, public JSON endpoint)
 internal/steam/   Steam client (search scrape + featuredcategories API)
 internal/gog/     GOG client (homepage section JSON, gogcom-store-state)
 internal/watch/   Polling/eventing across all providers: reports only new games
+internal/chatto/  Hand-built ConnectRPC client (JSON) for Chatto servers
 ```
 
+## Chatto integration
+
+The package `internal/chatto/` is a **hand-built ConnectRPC client
+(JSON codec)** for Chatto (tested against 0.5.0-beta.27+): `POST
+{base}/api/connect/chatto.api.v1.<Service>/<Method>` with bearer token. The
+Chatto Go codebase (github.com/chattocorp/chatto) keeps generated code and handlers under `internal/`
+— not importable from outside, hence the own client. Message schemas
+come from `proto/chatto/api/v1/` of the Chatto repo (field numbers
+compatible, only the JSON subset is modelled).
+
+CLI commands (credentials deliberately provided by the operator, never
+fetched automatically):
+
+```bash
+export CHATTO_BASE_URL="https://chat.example.com"
+export CHATTO_TOKEN="<token>"        # set by the operator
+
+cheapo chatto-rooms                     # list rooms (client verification)
+cheapo chatto-send --room <room_id>     # post current free games
+cheapo chatto-send --room <room_id> --upcoming   # upcoming ones (Epic)
+```
+
+**Still open (roadmap):** a watch-mode daemon that posts to the chat on new
+free games (state signature like free-games-notifier); MCP server;
+Discord-freestuff-like server-side integration (just an idea).
+
 ## Reference: how the original project works
 
 The reference project (vogler/free-games-claimer) automates a real browser

+ 126 - 0
cmd/cheapo/chatto.go

@@ -0,0 +1,126 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/spf13/cobra"
+
+	"gogs.netdome.biz/paul/cheapo/internal/chatto"
+	"gogs.netdome.biz/paul/cheapo/internal/game"
+)
+
+// chattoClientFromEnv builds a Chatto client from CHATTO_BASE_URL and
+// CHATTO_TOKEN environment variables. The token is provided by the operator;
+// this tool never fetches credentials itself.
+func chattoClientFromEnv() (*chatto.Client, error) {
+	base := os.Getenv("CHATTO_BASE_URL")
+	token := os.Getenv("CHATTO_TOKEN")
+	if base == "" || token == "" {
+		return nil, fmt.Errorf("CHATTO_BASE_URL and CHATTO_TOKEN must be set")
+	}
+	return chatto.NewClient(base, token), nil
+}
+
+// formatGames renders games as a compact Chatto message body.
+func formatGames(games []game.Game, upcoming bool) string {
+	if len(games) == 0 {
+		if upcoming {
+			return "No upcoming free games announced."
+		}
+		return "No free games on offer right now."
+	}
+	var b strings.Builder
+	if upcoming {
+		b.WriteString("🗓 *Upcoming free games (Epic):*\n")
+	} else {
+		b.WriteString("🎮 *Current free games:*\n")
+	}
+	for _, g := range games {
+		fmt.Fprintf(&b, "• [%s] %s", g.Store, g.Title)
+		if !g.EndDate.IsZero() {
+			if g.Kind == "upcoming" && !g.StartDate.IsZero() {
+				fmt.Fprintf(&b, " — from %s", g.StartDate.Local().Format("02.01."))
+			}
+			fmt.Fprintf(&b, " (until %s)", g.EndDate.Local().Format("02.01."))
+		}
+		fmt.Fprintf(&b, "\n  %s\n", g.URL)
+	}
+	return b.String()
+}
+
+// chattoSendCmd posts the current free games to a Chatto room.
+func chattoSendCmd() *cobra.Command {
+	var (
+		roomID   string
+		upcoming 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
+			if upcoming {
+				// Upcoming windows only exist for Epic.
+				u := newEpic()
+				games, err = u.GetUpcomingGames(ctx)
+				if err != nil {
+					return err
+				}
+			} else {
+				games, err = fetchAll(ctx)
+				if err != nil && len(games) == 0 {
+					return err
+				}
+			}
+
+			msg, err := c.CreateMessage(ctx, roomID, formatGames(games, upcoming), "")
+			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)")
+	return cmd
+}
+
+// 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
+		},
+	}
+}

+ 2 - 2
cmd/cheapo/main.go

@@ -45,9 +45,9 @@ References: https://github.com/vogler/free-games-claimer
 	root.PersistentFlags().StringVar(&flagCountry, "country", "DE", "ISO country code for store region (epic)")
 	root.PersistentFlags().StringVar(&flagLocale, "locale", "de", "store locale (e.g. de, en-US)")
 	root.PersistentFlags().BoolVar(&flagJSON, "json", false, "output as JSON")
-	root.PersistentFlags().StringVar(&flagStore, "store", "all", "which store(s): epic, steam, all")
+	root.PersistentFlags().StringVar(&flagStore, "store", "all", "which store(s): epic, steam, gog, all")
 
-	root.AddCommand(listCmd(), watchCmd())
+	root.AddCommand(listCmd(), watchCmd(), chattoSendCmd(), chattoRoomsCmd())
 
 	if err := root.Execute(); err != nil {
 		os.Exit(1)

+ 200 - 0
internal/chatto/client.go

@@ -0,0 +1,200 @@
+// Package chatto implements a minimal ConnectRPC (Connect protocol, JSON
+// codec) client for Chatto servers (tested against 0.5.0-beta.27+).
+//
+// Why hand-built: the Chatto server repository (github.com/chattocorp/chatto) keeps its
+// generated protobuf code and Connect handlers under internal/ — they cannot
+// be imported from outside the module. But the server exposes the same API as
+// plain Connect JSON over HTTP:
+//
+//	POST {base}/api/connect/chatto.api.v1.<Service>/<Method>
+//	Body: JSON of the request message (proto3 JSON field names)
+//	Auth: Authorization: Bearer <token>
+//
+// See proto/chatto/api/v1/*.proto in the Chatto repository for the message
+// schemas; only the subset needed by cheapo is modelled here.
+package chatto
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+)
+
+// Client talks to a Chatto server via the Connect JSON protocol.
+type Client struct {
+	BaseURL string // e.g. https://chat.example.com
+	Token   string // bearer token
+	HTTP    *http.Client
+}
+
+// NewClient returns a client with a 30 s HTTP timeout.
+func NewClient(baseURL, token string) *Client {
+	return &Client{
+		BaseURL: strings.TrimRight(baseURL, "/"),
+		Token:   token,
+		HTTP:    &http.Client{Timeout: 30 * time.Second},
+	}
+}
+
+// connectError models the Connect error envelope
+// ({"code": "...", "message": "..."} with HTTP != 200).
+type connectError struct {
+	Code    string `json:"code"`
+	Message string `json:"message"`
+}
+
+func (e *connectError) Error() string {
+	if e.Message == "" {
+		return "chatto: connect error " + e.Code
+	}
+	return fmt.Sprintf("chatto: %s: %s", e.Code, e.Message)
+}
+
+// call performs one Connect unary call with JSON codec.
+func call[Req any, Resp any](ctx context.Context, c *Client, service, method string, req *Req) (*Resp, error) {
+	url := fmt.Sprintf("%s/api/connect/chatto.api.v1.%s/%s", c.BaseURL, service, method)
+
+	body, err := json.Marshal(req)
+	if err != nil {
+		return nil, fmt.Errorf("chatto: encode request: %w", err)
+	}
+	httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
+	if err != nil {
+		return nil, fmt.Errorf("chatto: %w", err)
+	}
+	httpReq.Header.Set("Content-Type", "application/json")
+	httpReq.Header.Set("Connect-Protocol-Version", "1")
+	if c.Token != "" {
+		httpReq.Header.Set("Authorization", "Bearer "+c.Token)
+	}
+
+	resp, err := c.HTTP.Do(httpReq)
+	if err != nil {
+		return nil, fmt.Errorf("chatto: request failed: %w", err)
+	}
+	defer resp.Body.Close()
+
+	raw, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("chatto: read response: %w", err)
+	}
+
+	if resp.StatusCode != http.StatusOK {
+		var ce connectError
+		if json.Unmarshal(raw, &ce) == nil && ce.Code != "" {
+			return nil, &ce
+		}
+		// Some error paths return HTML (e.g. SPA shell on wrong path).
+		snippet := string(raw)
+		if len(snippet) > 120 {
+			snippet = snippet[:120]
+		}
+		return nil, fmt.Errorf("chatto: HTTP %s: %s", resp.Status, snippet)
+	}
+
+	var out Resp
+	if err := json.Unmarshal(raw, &out); err != nil {
+		return nil, fmt.Errorf("chatto: decode response: %w", err)
+	}
+	return &out, nil
+}
+
+// --- RoomDirectoryService ---
+
+type Room struct {
+	ID          string `json:"id"`
+	Kind        string `json:"kind"`
+	Name        string `json:"name"`
+	Description string `json:"description,omitempty"`
+	Archived    bool   `json:"archived,omitempty"`
+	GroupID     string `json:"group_id,omitempty"`
+	Universal   bool   `json:"universal,omitempty"`
+}
+
+type listRoomsRequest struct {
+	Page *pageRequest `json:"page,omitempty"`
+}
+
+type pageRequest struct {
+	PageSize int32 `json:"pageSize,omitempty"`
+}
+
+type listRoomsResponse struct {
+	Rooms []Room `json:"rooms"`
+	Page  struct {
+		TotalCount int64 `json:"total_count"`
+		HasMore    bool  `json:"has_more"`
+	} `json:"page"`
+}
+
+// ListRooms returns rooms visible to the token's user (single page, up to 100).
+func (c *Client) ListRooms(ctx context.Context) ([]Room, error) {
+	resp, err := call[struct{}, listRoomsResponse](ctx, c,
+		"RoomDirectoryService", "ListRooms", &struct{}{})
+	if err != nil {
+		return nil, err
+	}
+	return resp.Rooms, nil
+}
+
+// --- MessageService ---
+
+type Message struct {
+	EventID      string `json:"event_id"`
+	RoomID       string `json:"room_id"`
+	ThreadRoot   string `json:"thread_root_event_id,omitempty"`
+	AuthorUserID string `json:"author_user_id,omitempty"`
+	Body         string `json:"body,omitempty"`
+	CreatedAt    string `json:"created_at,omitempty"`
+}
+
+type createMessageRequest struct {
+	RoomID            string `json:"room_id"`
+	Body              string `json:"body"`
+	ThreadRootEventID string `json:"thread_root_event_id,omitempty"`
+	InReplyTo         string `json:"in_reply_to,omitempty"`
+	AlsoSendToChannel bool   `json:"also_send_to_channel,omitempty"`
+}
+
+type createMessageResponse struct {
+	Message Message `json:"message"`
+}
+
+// CreateMessage posts a message to a room (or thread reply when
+// threadRootEventID is set).
+func (c *Client) CreateMessage(ctx context.Context, roomID, body string, threadRootEventID string) (*Message, error) {
+	req := &createMessageRequest{RoomID: roomID, Body: body}
+	if threadRootEventID != "" {
+		req.ThreadRootEventID = threadRootEventID
+	}
+	resp, err := call[createMessageRequest, createMessageResponse](ctx, c,
+		"MessageService", "CreateMessage", req)
+	if err != nil {
+		return nil, err
+	}
+	return &resp.Message, nil
+}
+
+type getMessageRequest struct {
+	RoomID  string `json:"room_id"`
+	EventID string `json:"event_id"`
+}
+
+type getMessageResponse struct {
+	Message Message `json:"message"`
+}
+
+// GetMessage fetches a single message.
+func (c *Client) GetMessage(ctx context.Context, roomID, eventID string) (*Message, error) {
+	resp, err := call[getMessageRequest, getMessageResponse](ctx, c,
+		"MessageService", "GetMessage", &getMessageRequest{RoomID: roomID, EventID: eventID})
+	if err != nil {
+		return nil, err
+	}
+	return &resp.Message, nil
+}