All checks were successful
check / check (push) Successful in 2m15s
Extract cmdLusers, cmdMode, cmdMotd, cmdNames, cmdNotice, cmdPing, cmdPong constants in internal/handlers/api.go. Add corresponding constants in cmd/neoirc-cli/main.go and cmd/neoirc-cli/api/client.go. Replace every bare IRC command string literal in switch cases and command dispatch code with the named constant. Zero bare command strings remain in any dispatch path.
318 lines
5.8 KiB
Go
318 lines
5.8 KiB
Go
// Package neoircapi provides a client for the neoirc server API.
|
|
package neoircapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
httpTimeout = 30 * time.Second
|
|
pollExtraTime = 5
|
|
httpErrThreshold = 400
|
|
|
|
cmdJoin = "JOIN"
|
|
cmdPart = "PART"
|
|
)
|
|
|
|
var errHTTP = errors.New("HTTP error")
|
|
|
|
// Client wraps HTTP calls to the neoirc 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: cmdJoin, To: channel,
|
|
},
|
|
)
|
|
}
|
|
|
|
// PartChannel leaves a channel.
|
|
func (client *Client) PartChannel(channel string) error {
|
|
return client.SendMessage(
|
|
&Message{ //nolint:exhaustruct // only command+to needed
|
|
Command: cmdPart, 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
|
|
}
|