|
|
@@ -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
|
|
|
+}
|