- JoinChannel/PartChannel now send via POST /messages with command field - ListChannels uses /channels instead of /channels/all - gofmt whitespace fixes
205 lines
4.9 KiB
Go
205 lines
4.9 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
)
|
|
|
|
// 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: 30 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *Client) do(method, path string, body interface{}) ([]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.NewRequest(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 resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read body: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return data, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
// CreateSession creates a new session on the server.
|
|
func (c *Client) CreateSession(nick string) (*SessionResponse, error) {
|
|
data, err := c.do("POST", "/api/v1/session", &SessionRequest{Nick: nick})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var resp SessionResponse
|
|
if err := json.Unmarshal(data, &resp); 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("GET", "/api/v1/state", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var resp StateResponse
|
|
if err := json.Unmarshal(data, &resp); 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("POST", "/api/v1/messages", msg)
|
|
return err
|
|
}
|
|
|
|
// PollMessages long-polls for new messages.
|
|
func (c *Client) PollMessages(afterID string, timeout int) ([]Message, error) {
|
|
// Use a longer HTTP timeout than the server long-poll timeout.
|
|
client := &http.Client{Timeout: time.Duration(timeout+5) * time.Second}
|
|
|
|
params := url.Values{}
|
|
if afterID != "" {
|
|
params.Set("after", afterID)
|
|
}
|
|
params.Set("timeout", fmt.Sprintf("%d", timeout))
|
|
|
|
path := "/api/v1/messages"
|
|
if len(params) > 0 {
|
|
path += "?" + params.Encode()
|
|
}
|
|
|
|
req, err := http.NewRequest("GET", 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 resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
|
|
}
|
|
|
|
// The server may return an array directly or wrapped.
|
|
var msgs []Message
|
|
if err := json.Unmarshal(data, &msgs); err != nil {
|
|
// Try wrapped format.
|
|
var wrapped MessagesResponse
|
|
if err2 := json.Unmarshal(data, &wrapped); err2 != nil {
|
|
return nil, fmt.Errorf("decode messages: %w (raw: %s)", err, string(data))
|
|
}
|
|
msgs = wrapped.Messages
|
|
}
|
|
|
|
return msgs, nil
|
|
}
|
|
|
|
// JoinChannel joins a channel via the unified command endpoint.
|
|
func (c *Client) JoinChannel(channel string) error {
|
|
return c.SendMessage(&Message{Command: "JOIN", To: channel})
|
|
}
|
|
|
|
// PartChannel leaves a channel via the unified command endpoint.
|
|
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("GET", "/api/v1/channels", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var channels []Channel
|
|
if err := json.Unmarshal(data, &channels); err != nil {
|
|
return nil, err
|
|
}
|
|
return channels, nil
|
|
}
|
|
|
|
// GetMembers returns members of a channel.
|
|
func (c *Client) GetMembers(channel string) ([]string, error) {
|
|
data, err := c.do("GET", "/api/v1/channels/"+url.PathEscape(channel)+"/members", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var members []string
|
|
if err := json.Unmarshal(data, &members); err != nil {
|
|
// Try object format.
|
|
var obj map[string]interface{}
|
|
if err2 := json.Unmarshal(data, &obj); err2 != nil {
|
|
return nil, err
|
|
}
|
|
// Extract member names from whatever format.
|
|
return nil, fmt.Errorf("unexpected members format: %s", string(data))
|
|
}
|
|
return members, nil
|
|
}
|
|
|
|
// GetServerInfo returns server info.
|
|
func (c *Client) GetServerInfo() (*ServerInfo, error) {
|
|
data, err := c.do("GET", "/api/v1/server", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var info ServerInfo
|
|
if err := json.Unmarshal(data, &info); err != nil {
|
|
return nil, err
|
|
}
|
|
return &info, nil
|
|
}
|