// 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./ // Body: JSON of the request message (proto3 JSON field names) // Auth: Authorization: Bearer // // 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 }