client.go 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  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:"groupId,omitempty"`
  103. Universal bool `json:"universal,omitempty"`
  104. }
  105. type roomWithViewerState struct {
  106. Room Room `json:"room"`
  107. }
  108. type listRoomsRequest struct {
  109. Page *pageRequest `json:"page,omitempty"`
  110. }
  111. type pageRequest struct {
  112. PageSize int32 `json:"pageSize,omitempty"`
  113. }
  114. type listRoomsResponse struct {
  115. Rooms []roomWithViewerState `json:"rooms"`
  116. Page struct {
  117. TotalCount int64 `json:"totalCount,string"`
  118. HasMore bool `json:"hasMore"`
  119. } `json:"page"`
  120. }
  121. // ListRooms returns rooms visible to the token's user (single page, up to 100).
  122. func (c *Client) ListRooms(ctx context.Context) ([]Room, error) {
  123. resp, err := call[listRoomsRequest, listRoomsResponse](ctx, c,
  124. "RoomDirectoryService", "ListRooms", &listRoomsRequest{Page: &pageRequest{PageSize: 100}})
  125. if err != nil {
  126. return nil, err
  127. }
  128. rooms := make([]Room, 0, len(resp.Rooms))
  129. for _, rw := range resp.Rooms {
  130. rooms = append(rooms, rw.Room)
  131. }
  132. return rooms, nil
  133. }
  134. // --- MessageService ---
  135. type Message struct {
  136. EventID string `json:"id"`
  137. RoomID string `json:"roomId"`
  138. ThreadRoot string `json:"threadRootEventId,omitempty"`
  139. AuthorID string `json:"actorId,omitempty"`
  140. Body string `json:"body,omitempty"`
  141. CreatedAt string `json:"createdAt,omitempty"`
  142. }
  143. type createMessageRequest struct {
  144. RoomID string `json:"roomId"`
  145. Body string `json:"body"`
  146. ThreadRootEventID string `json:"threadRootEventId,omitempty"`
  147. InReplyTo string `json:"inReplyTo,omitempty"`
  148. AlsoSendToChannel bool `json:"alsoSendToChannel,omitempty"`
  149. }
  150. type createMessageResponse struct {
  151. Message Message `json:"message"`
  152. }
  153. // CreateMessage posts a message to a room (or thread reply when
  154. // threadRootEventID is set).
  155. func (c *Client) CreateMessage(ctx context.Context, roomID, body string, threadRootEventID string) (*Message, error) {
  156. req := &createMessageRequest{RoomID: roomID, Body: body}
  157. if threadRootEventID != "" {
  158. req.ThreadRootEventID = threadRootEventID
  159. }
  160. resp, err := call[createMessageRequest, createMessageResponse](ctx, c,
  161. "MessageService", "CreateMessage", req)
  162. if err != nil {
  163. return nil, err
  164. }
  165. return &resp.Message, nil
  166. }
  167. type getMessageRequest struct {
  168. RoomID string `json:"roomId"`
  169. EventID string `json:"eventId"`
  170. }
  171. type getMessageResponse struct {
  172. Message Message `json:"message"`
  173. }
  174. // GetMessage fetches a single message.
  175. func (c *Client) GetMessage(ctx context.Context, roomID, eventID string) (*Message, error) {
  176. resp, err := call[getMessageRequest, getMessageResponse](ctx, c,
  177. "MessageService", "GetMessage", &getMessageRequest{RoomID: roomID, EventID: eventID})
  178. if err != nil {
  179. return nil, err
  180. }
  181. return &resp.Message, nil
  182. }