Files
chat/cmd/chat-cli/api/client.go
clawbot d0656b069d fix: golangci-lint v2 config and lint-clean production code
- Fix .golangci.yml for v2 format (linters-settings -> linters.settings)
- All production code now passes golangci-lint with zero issues
- Line length 88, funlen 80/50, cyclop 15, dupl 100
- Extract shared helpers in db (scanChannels, scanInt64s, scanMessages)
- Split runMigrations into applyMigration/execMigration
- Fix fanOut return signature (remove unused int64)
- Add fanOutSilent helper to avoid dogsled
- Rewrite CLI code for lint compliance (nlreturn, wsl_v5, noctx, etc)
- Rename CLI api package to chatapi to avoid revive var-naming
- Fix all noinlineerr, mnd, perfsprint, funcorder issues
- Fix db tests: extract helpers, add t.Parallel, proper error checks
- Broker tests already clean
- Handler integration tests still have lint issues (next commit)
2026-02-10 18:50:24 -08:00

296 lines
5.1 KiB
Go

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
}