check / check (push) Waiting to run
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
376 lines
7.3 KiB
Go
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
|
|
}
|