Files
chat/cmd/chat-cli/api/client.go
clawbot d2bc467581
Some checks failed
check / check (push) Failing after 1m3s
fix: resolve lint issues — rename api package, fix nolint directives
2026-02-26 07:45:37 -08:00

293 lines
5.5 KiB
Go

// 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
}