// Package chatapi provides a client for the chat server API. package chatapi import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) const ( httpTimeout = 30 * time.Second pollExtraTime = 5 httpErrThreshold = 400 ) var errHTTP = errors.New("HTTP error") // 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{ //nolint:exhaustruct // Token set after CreateSession BaseURL: baseURL, HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine Timeout: httpTimeout, }, } } // CreateSession creates a new session on the server. func (client *Client) CreateSession( nick string, ) (*SessionResponse, error) { data, err := client.do( http.MethodPost, "/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) } client.Token = resp.Token return &resp, nil } // GetState returns the current user state. func (client *Client) GetState() (*StateResponse, error) { data, err := client.do( http.MethodGet, "/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 (client *Client) SendMessage(msg *Message) error { _, err := client.do( http.MethodPost, "/api/v1/messages", msg, ) return err } // PollMessages long-polls for new messages. func (client *Client) PollMessages( afterID int64, timeout int, ) (*PollResult, error) { pollClient := &http.Client{ //nolint:exhaustruct // defaults fine Timeout: time.Duration( timeout+pollExtraTime, ) * time.Second, } params := url.Values{} if afterID > 0 { params.Set( "after", strconv.FormatInt(afterID, 10), ) } params.Set("timeout", strconv.Itoa(timeout)) path := "/api/v1/messages?" + params.Encode() request, err := http.NewRequestWithContext( context.Background(), http.MethodGet, client.BaseURL+path, nil, ) if err != nil { return nil, fmt.Errorf("new request: %w", err) } request.Header.Set( "Authorization", "Bearer "+client.Token, ) resp, err := pollClient.Do(request) if err != nil { return nil, fmt.Errorf("poll request: %w", err) } defer func() { _ = resp.Body.Close() }() data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read poll body: %w", err) } if resp.StatusCode >= httpErrThreshold { return nil, fmt.Errorf( "%w %d: %s", errHTTP, resp.StatusCode, string(data), ) } var wrapped MessagesResponse err = json.Unmarshal(data, &wrapped) if err != nil { return nil, fmt.Errorf( "decode messages: %w", err, ) } return &PollResult{ Messages: wrapped.Messages, LastID: wrapped.LastID, }, nil } // JoinChannel joins a channel. func (client *Client) JoinChannel(channel string) error { return client.SendMessage( &Message{ //nolint:exhaustruct // only command+to needed Command: "JOIN", To: channel, }, ) } // PartChannel leaves a channel. func (client *Client) PartChannel(channel string) error { return client.SendMessage( &Message{ //nolint:exhaustruct // only command+to needed Command: "PART", To: channel, }, ) } // ListChannels returns all channels on the server. func (client *Client) ListChannels() ( []Channel, error, ) { data, err := client.do( http.MethodGet, "/api/v1/channels", nil, ) if err != nil { return nil, err } var channels []Channel err = json.Unmarshal(data, &channels) if err != nil { return nil, fmt.Errorf( "decode channels: %w", err, ) } return channels, nil } // GetMembers returns members of a channel. func (client *Client) GetMembers( channel string, ) ([]string, error) { name := strings.TrimPrefix(channel, "#") data, err := client.do( http.MethodGet, "/api/v1/channels/"+url.PathEscape(name)+ "/members", nil, ) if err != nil { return nil, err } var members []string err = json.Unmarshal(data, &members) if err != nil { return nil, fmt.Errorf( "unexpected members format: %w", err, ) } return members, nil } // GetServerInfo returns server info. func (client *Client) GetServerInfo() ( *ServerInfo, error, ) { data, err := client.do( http.MethodGet, "/api/v1/server", nil, ) if err != nil { return nil, err } var info ServerInfo err = json.Unmarshal(data, &info) if err != nil { return nil, fmt.Errorf( "decode server info: %w", err, ) } return &info, nil } func (client *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) } request, err := http.NewRequestWithContext( context.Background(), method, client.BaseURL+path, bodyReader, ) if err != nil { return nil, fmt.Errorf("request: %w", err) } request.Header.Set( "Content-Type", "application/json", ) if client.Token != "" { request.Header.Set( "Authorization", "Bearer "+client.Token, ) } resp, err := client.HTTPClient.Do(request) 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 }