- 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)
296 lines
5.1 KiB
Go
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
|
|
}
|