All checks were successful
check / check (push) Successful in 4s
- Rename Go module path: git.eeqj.de/sneak/chat -> git.eeqj.de/sneak/neoirc - Rename binary: chatd -> neoircd, chat-cli -> neoirc-cli - Rename cmd directories: cmd/chatd -> cmd/neoircd, cmd/chat-cli -> cmd/neoirc-cli - Rename Go package: chatapi -> neoircapi - Update Makefile: binary name, build targets, docker image tag, clean target - Update Dockerfile: binary paths, user/group names, ENTRYPOINT - Update .gitignore and .dockerignore - Update all Go imports and doc comments - Update default server name fallback: chat -> neoirc - Update web client: localStorage keys, page title, default server name - Update all schema $id URLs and example hostnames - Update README.md: project name, binary references, examples, directory tree - Update AGENTS.md: build command reference - Update test fixtures: app name and channel names
315 lines
5.7 KiB
Go
315 lines
5.7 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
|
|
)
|
|
|
|
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: "JOIN", To: channel,
|
|
},
|
|
)
|
|
}
|
|
|
|
// PartChannel leaves a channel.
|
|
func (client *Client) PartChannel(channel string) error {
|
|
return client.SendMessage(
|
|
&Message{ //nolint:exhaustruct // only command+to needed
|
|
Command: "PART", 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
|
|
}
|