Files
neoirc/internal/cli/api/client.go
T
clawbot 229c89e1f8
check / check (push) Waiting to run
Open a channel only when the server accepts the join (closes #197)
When the server refuses a JOIN, POST /api/v1/messages answers 200
with status "error" and sends the error numeric to the queue. Both
clients opened the channel anyway.

JoinChannel in internal/cli/api now returns an error unless the
status is "joined". cmdJoin then keeps its buffer and target and
prints its usual "Join failed" line. The web client opens no tab for
a refused join, whether it came from /join or from the saved
channels a fresh session joins again.

Model: opus-5-5
2026-10-27 05:09:01 +01:00

376 lines
7.3 KiB
Go

// Package neoircapi provides a client for the neoirc server API.
package neoircapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/url"
"strconv"
"strings"
"time"
"sneak.berlin/go/neoirc/pkg/irc"
)
const (
httpTimeout = 30 * time.Second
pollExtraTime = 5
httpErrThreshold = 400
)
var (
errHTTP = errors.New("HTTP error")
errJoinRefused = errors.New("the server refused the join")
)
// Client wraps HTTP calls to the neoirc server API.
type Client struct {
BaseURL string
HTTPClient *http.Client
}
// NewClient creates a new API client with a cookie jar
// for automatic auth cookie management.
func NewClient(baseURL string) *Client {
jar, _ := cookiejar.New(nil)
return &Client{
BaseURL: baseURL,
HTTPClient: &http.Client{ //nolint:exhaustruct // defaults fine
Timeout: httpTimeout,
Jar: loopbackJar{CookieJar: jar},
},
}
}
// loopbackJar also sends the server's auth cookie, which is
// always Secure, over plain HTTP to a server on localhost, as
// curl does. Go 1.24's cookie jar sends a Secure cookie only
// over HTTPS.
type loopbackJar struct {
http.CookieJar
}
// Cookies returns the cookies to send to target, treating a
// plain-HTTP target on localhost as HTTPS.
func (jar loopbackJar) Cookies(target *url.URL) []*http.Cookie {
host := target.Hostname()
loopback := host == "localhost" || net.ParseIP(host).IsLoopback()
if target.Scheme == "http" && loopback {
secure := *target
secure.Scheme = "https"
target = &secure
}
return jar.CookieJar.Cookies(target)
}
// CreateSession creates a new session on the server.
// If the server requires hashcash proof-of-work, it
// automatically fetches the difficulty and computes a
// valid stamp.
func (client *Client) CreateSession(
nick string,
) (*SessionResponse, error) {
// Fetch server info to check for hashcash requirement.
info, err := client.GetServerInfo()
var hashcashStamp string
if err == nil && info.HashcashBits > 0 {
resource := info.Name
if resource == "" {
resource = "neoirc"
}
hashcashStamp = MintHashcash(info.HashcashBits, resource)
}
data, err := client.do(
http.MethodPost,
"/api/v1/session",
&SessionRequest{Nick: nick, Hashcash: hashcashStamp},
)
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)
}
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,
Jar: client.HTTPClient.Jar,
}
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)
}
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. It returns an error unless the
// server answers that the session joined. When the server
// refuses the join, it sends the reason to the message queue as
// an error numeric.
func (client *Client) JoinChannel(channel string) error {
data, err := client.do(
http.MethodPost, "/api/v1/messages",
&Message{ //nolint:exhaustruct // only command+to needed
Command: irc.CmdJoin, To: channel,
},
)
if err != nil {
return err
}
var resp struct {
Status string `json:"status"`
}
err = json.Unmarshal(data, &resp)
if err != nil {
return fmt.Errorf("decode join: %w", err)
}
if resp.Status != "joined" {
return errJoinRefused
}
return nil
}
// PartChannel leaves a channel.
func (client *Client) PartChannel(channel string) error {
return client.SendMessage(
&Message{ //nolint:exhaustruct // only command+to needed
Command: irc.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,
) ([]Member, 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 []Member
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",
)
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
}