| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208 |
- // 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:"groupId,omitempty"`
- Universal bool `json:"universal,omitempty"`
- }
- type roomWithViewerState struct {
- Room Room `json:"room"`
- }
- type listRoomsRequest struct {
- Page *pageRequest `json:"page,omitempty"`
- }
- type pageRequest struct {
- PageSize int32 `json:"pageSize,omitempty"`
- }
- type listRoomsResponse struct {
- Rooms []roomWithViewerState `json:"rooms"`
- Page struct {
- TotalCount int64 `json:"totalCount,string"`
- HasMore bool `json:"hasMore"`
- } `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[listRoomsRequest, listRoomsResponse](ctx, c,
- "RoomDirectoryService", "ListRooms", &listRoomsRequest{Page: &pageRequest{PageSize: 100}})
- if err != nil {
- return nil, err
- }
- rooms := make([]Room, 0, len(resp.Rooms))
- for _, rw := range resp.Rooms {
- rooms = append(rooms, rw.Room)
- }
- return rooms, nil
- }
- // --- MessageService ---
- type Message struct {
- EventID string `json:"id"`
- RoomID string `json:"roomId"`
- ThreadRoot string `json:"threadRootEventId,omitempty"`
- AuthorID string `json:"actorId,omitempty"`
- Body string `json:"body,omitempty"`
- CreatedAt string `json:"createdAt,omitempty"`
- }
- type createMessageRequest struct {
- RoomID string `json:"roomId"`
- Body string `json:"body"`
- ThreadRootEventID string `json:"threadRootEventId,omitempty"`
- InReplyTo string `json:"inReplyTo,omitempty"`
- AlsoSendToChannel bool `json:"alsoSendToChannel,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:"roomId"`
- EventID string `json:"eventId"`
- }
- 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
- }
|