// Package chatapi provides a client for the chat server HTTP API. package chatapi import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "time" ) const ( httpTimeout = 30 * time.Second pollExtraDelay = 5 httpErrThreshold = 400 ) // ErrHTTP is returned for non-2xx responses. var ErrHTTP = errors.New("http error") // ErrUnexpectedFormat is returned when the response format is // not recognised. var ErrUnexpectedFormat = errors.New("unexpected format") // Client wraps HTTP calls to the chat server API. type Client struct { BaseURL string Token string HTTPClient *http.Client } // NewClient creates a new API client. func NewClient(baseURL string) *Client { return &Client{ BaseURL: baseURL, HTTPClient: &http.Client{ Timeout: httpTimeout, }, } } // CreateSession creates a new session on the server. func (c *Client) CreateSession( nick string, ) (*SessionResponse, error) { data, err := c.do( "POST", "/api/v1/session", &SessionRequest{Nick: nick}, ) if err != nil { return nil, err } var resp SessionResponse err = json.Unmarshal(data, &resp) if err != nil { return nil, fmt.Errorf("decode session: %w", err) } c.Token = resp.Token return &resp, nil } // GetState returns the current user state. func (c *Client) GetState() (*StateResponse, error) { data, err := c.do("GET", "/api/v1/state", nil) if err != nil { return nil, err } var resp StateResponse err = json.Unmarshal(data, &resp) if err != nil { return nil, fmt.Errorf("decode state: %w", err) } return &resp, nil } // SendMessage sends a message (any IRC command). func (c *Client) SendMessage(msg *Message) error { _, err := c.do("POST", "/api/v1/messages", msg) return err } // PollMessages long-polls for new messages. func (c *Client) PollMessages( afterID string, timeout int, ) ([]Message, error) { pollTimeout := time.Duration( timeout+pollExtraDelay, ) * time.Second client := &http.Client{Timeout: pollTimeout} params := url.Values{} if afterID != "" { params.Set("after", afterID) } params.Set("timeout", strconv.Itoa(timeout)) path := "/api/v1/messages" if len(params) > 0 { path += "?" + params.Encode() } req, err := http.NewRequest( //nolint:noctx // CLI tool http.MethodGet, c.BaseURL+path, nil, ) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+c.Token) resp, err := client.Do(req) //nolint:gosec // URL from user config if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() data, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode >= httpErrThreshold { return nil, fmt.Errorf( "%w: %d: %s", ErrHTTP, resp.StatusCode, string(data), ) } return decodeMessages(data) } func decodeMessages(data []byte) ([]Message, error) { var msgs []Message err := json.Unmarshal(data, &msgs) if err == nil { return msgs, nil } var wrapped MessagesResponse err2 := json.Unmarshal(data, &wrapped) if err2 != nil { return nil, fmt.Errorf( "decode messages: %w (raw: %s)", err, string(data), ) } return wrapped.Messages, nil } // JoinChannel joins a channel via the unified command // endpoint. func (c *Client) JoinChannel(channel string) error { return c.SendMessage( &Message{Command: "JOIN", To: channel}, ) } // PartChannel leaves a channel via the unified command // endpoint. func (c *Client) PartChannel(channel string) error { return c.SendMessage( &Message{Command: "PART", To: channel}, ) } // ListChannels returns all channels on the server. func (c *Client) ListChannels() ([]Channel, error) { data, err := c.do("GET", "/api/v1/channels", nil) if err != nil { return nil, err } var channels []Channel err = json.Unmarshal(data, &channels) if err != nil { return nil, err } return channels, nil } // GetMembers returns members of a channel. func (c *Client) GetMembers( channel string, ) ([]string, error) { path := "/api/v1/channels/" + url.PathEscape(channel) + "/members" data, err := c.do("GET", path, nil) if err != nil { return nil, err } var members []string err = json.Unmarshal(data, &members) if err != nil { return nil, fmt.Errorf( "%w: members: %s", ErrUnexpectedFormat, string(data), ) } return members, nil } // GetServerInfo returns server info. func (c *Client) GetServerInfo() (*ServerInfo, error) { data, err := c.do("GET", "/api/v1/server", nil) if err != nil { return nil, err } var info ServerInfo err = json.Unmarshal(data, &info) if err != nil { return nil, err } return &info, nil } func (c *Client) do( method, path string, body any, ) ([]byte, error) { var bodyReader io.Reader if body != nil { data, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("marshal: %w", err) } bodyReader = bytes.NewReader(data) } req, err := http.NewRequest( //nolint:noctx // CLI tool method, c.BaseURL+path, bodyReader, ) if err != nil { return nil, fmt.Errorf("request: %w", err) } req.Header.Set("Content-Type", "application/json") if c.Token != "" { req.Header.Set("Authorization", "Bearer "+c.Token) } resp, err := c.HTTPClient.Do(req) //nolint:gosec // URL from user config if err != nil { return nil, fmt.Errorf("http: %w", err) } defer func() { _ = resp.Body.Close() }() data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read body: %w", err) } if resp.StatusCode >= httpErrThreshold { return data, fmt.Errorf( "%w: %d: %s", ErrHTTP, resp.StatusCode, string(data), ) } return data, nil }