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{ 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( 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) } c.Token = resp.Token return &resp, nil } // GetState returns the current user state. func (c *Client) GetState() (*StateResponse, error) { data, err := c.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 (c *Client) SendMessage(msg *Message) error { _, err := c.do( http.MethodPost, "/api/v1/messages", msg, ) return err } // PollMessages long-polls for new messages. func (c *Client) PollMessages( afterID int64, timeout int, ) (*PollResult, error) { client := &http.Client{ 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() req, err := http.NewRequestWithContext( context.Background(), http.MethodGet, c.BaseURL+path, nil, ) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+c.Token) resp, err := client.Do(req) 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), ) } 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 (c *Client) JoinChannel(channel string) error { return c.SendMessage( &Message{Command: "JOIN", To: channel}, ) } // PartChannel leaves a channel. 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( 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, err } return channels, nil } // GetMembers returns members of a channel. func (c *Client) GetMembers( channel string, ) ([]string, error) { name := strings.TrimPrefix(channel, "#") data, err := c.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 (c *Client) GetServerInfo() (*ServerInfo, error) { data, err := c.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, 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.NewRequestWithContext( context.Background(), 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) 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 }