client.go 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. // Package chatto implements a minimal ConnectRPC (Connect protocol, JSON
  2. // codec) client for Chatto servers (tested against 0.5.0-beta.27+).
  3. //
  4. // Why hand-built: the Chatto server repository (github.com/chattocorp/chatto) keeps its
  5. // generated protobuf code and Connect handlers under internal/ — they cannot
  6. // be imported from outside the module. But the server exposes the same API as
  7. // plain Connect JSON over HTTP:
  8. //
  9. // POST {base}/api/connect/chatto.api.v1.<Service>/<Method>
  10. // Body: JSON of the request message (proto3 JSON field names)
  11. // Auth: Authorization: Bearer <token>
  12. //
  13. // See proto/chatto/api/v1/*.proto in the Chatto repository for the message
  14. // schemas; only the subset needed by cheapo is modelled here.
  15. package chatto
  16. import (
  17. "bytes"
  18. "context"
  19. "encoding/json"
  20. "fmt"
  21. "io"
  22. "net/http"
  23. "strings"
  24. "time"
  25. )
  26. // Client talks to a Chatto server via the Connect JSON protocol.
  27. type Client struct {
  28. BaseURL string // e.g. https://chat.example.com
  29. Token string // bearer token
  30. HTTP *http.Client
  31. }
  32. // NewClient returns a client with a 30 s HTTP timeout.
  33. func NewClient(baseURL, token string) *Client {
  34. return &Client{
  35. BaseURL: strings.TrimRight(baseURL, "/"),
  36. Token: token,
  37. HTTP: &http.Client{Timeout: 30 * time.Second},
  38. }
  39. }
  40. // connectError models the Connect error envelope
  41. // ({"code": "...", "message": "..."} with HTTP != 200).
  42. type connectError struct {
  43. Code string `json:"code"`
  44. Message string `json:"message"`
  45. }
  46. func (e *connectError) Error() string {
  47. if e.Message == "" {
  48. return "chatto: connect error " + e.Code
  49. }
  50. return fmt.Sprintf("chatto: %s: %s", e.Code, e.Message)
  51. }
  52. // call performs one Connect unary call with JSON codec.
  53. func call[Req any, Resp any](ctx context.Context, c *Client, service, method string, req *Req) (*Resp, error) {
  54. url := fmt.Sprintf("%s/api/connect/chatto.api.v1.%s/%s", c.BaseURL, service, method)
  55. body, err := json.Marshal(req)
  56. if err != nil {
  57. return nil, fmt.Errorf("chatto: encode request: %w", err)
  58. }
  59. httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
  60. if err != nil {
  61. return nil, fmt.Errorf("chatto: %w", err)
  62. }
  63. httpReq.Header.Set("Content-Type", "application/json")
  64. httpReq.Header.Set("Connect-Protocol-Version", "1")
  65. if c.Token != "" {
  66. httpReq.Header.Set("Authorization", "Bearer "+c.Token)
  67. }
  68. resp, err := c.HTTP.Do(httpReq)
  69. if err != nil {
  70. return nil, fmt.Errorf("chatto: request failed: %w", err)
  71. }
  72. defer resp.Body.Close()
  73. raw, err := io.ReadAll(resp.Body)
  74. if err != nil {
  75. return nil, fmt.Errorf("chatto: read response: %w", err)
  76. }
  77. if resp.StatusCode != http.StatusOK {
  78. var ce connectError
  79. if json.Unmarshal(raw, &ce) == nil && ce.Code != "" {
  80. return nil, &ce
  81. }
  82. // Some error paths return HTML (e.g. SPA shell on wrong path).
  83. snippet := string(raw)
  84. if len(snippet) > 120 {
  85. snippet = snippet[:120]
  86. }
  87. return nil, fmt.Errorf("chatto: HTTP %s: %s", resp.Status, snippet)
  88. }
  89. var out Resp
  90. if err := json.Unmarshal(raw, &out); err != nil {
  91. return nil, fmt.Errorf("chatto: decode response: %w", err)
  92. }
  93. return &out, nil
  94. }
  95. // --- RoomDirectoryService ---
  96. type Room struct {
  97. ID string `json:"id"`
  98. Kind string `json:"kind"`
  99. Name string `json:"name"`
  100. Description string `json:"description,omitempty"`
  101. Archived bool `json:"archived,omitempty"`
  102. GroupID string `json:"group_id,omitempty"`
  103. Universal bool `json:"universal,omitempty"`
  104. }
  105. type listRoomsRequest struct {
  106. Page *pageRequest `json:"page,omitempty"`
  107. }
  108. type pageRequest struct {
  109. PageSize int32 `json:"pageSize,omitempty"`
  110. }
  111. type listRoomsResponse struct {
  112. Rooms []Room `json:"rooms"`
  113. Page struct {
  114. TotalCount int64 `json:"total_count"`
  115. HasMore bool `json:"has_more"`
  116. } `json:"page"`
  117. }
  118. // ListRooms returns rooms visible to the token's user (single page, up to 100).
  119. func (c *Client) ListRooms(ctx context.Context) ([]Room, error) {
  120. resp, err := call[struct{}, listRoomsResponse](ctx, c,
  121. "RoomDirectoryService", "ListRooms", &struct{}{})
  122. if err != nil {
  123. return nil, err
  124. }
  125. return resp.Rooms, nil
  126. }
  127. // --- MessageService ---
  128. type Message struct {
  129. EventID string `json:"event_id"`
  130. RoomID string `json:"room_id"`
  131. ThreadRoot string `json:"thread_root_event_id,omitempty"`
  132. AuthorUserID string `json:"author_user_id,omitempty"`
  133. Body string `json:"body,omitempty"`
  134. CreatedAt string `json:"created_at,omitempty"`
  135. }
  136. type createMessageRequest struct {
  137. RoomID string `json:"room_id"`
  138. Body string `json:"body"`
  139. ThreadRootEventID string `json:"thread_root_event_id,omitempty"`
  140. InReplyTo string `json:"in_reply_to,omitempty"`
  141. AlsoSendToChannel bool `json:"also_send_to_channel,omitempty"`
  142. }
  143. type createMessageResponse struct {
  144. Message Message `json:"message"`
  145. }
  146. // CreateMessage posts a message to a room (or thread reply when
  147. // threadRootEventID is set).
  148. func (c *Client) CreateMessage(ctx context.Context, roomID, body string, threadRootEventID string) (*Message, error) {
  149. req := &createMessageRequest{RoomID: roomID, Body: body}
  150. if threadRootEventID != "" {
  151. req.ThreadRootEventID = threadRootEventID
  152. }
  153. resp, err := call[createMessageRequest, createMessageResponse](ctx, c,
  154. "MessageService", "CreateMessage", req)
  155. if err != nil {
  156. return nil, err
  157. }
  158. return &resp.Message, nil
  159. }
  160. type getMessageRequest struct {
  161. RoomID string `json:"room_id"`
  162. EventID string `json:"event_id"`
  163. }
  164. type getMessageResponse struct {
  165. Message Message `json:"message"`
  166. }
  167. // GetMessage fetches a single message.
  168. func (c *Client) GetMessage(ctx context.Context, roomID, eventID string) (*Message, error) {
  169. resp, err := call[getMessageRequest, getMessageResponse](ctx, c,
  170. "MessageService", "GetMessage", &getMessageRequest{RoomID: roomID, EventID: eventID})
  171. if err != nil {
  172. return nil, err
  173. }
  174. return &resp.Message, nil
  175. }