This commit was merged in pull request #8.
This commit is contained in:
204
cmd/chat-cli/api/client.go
Normal file
204
cmd/chat-cli/api/client.go
Normal file
@@ -0,0 +1,204 @@
|
||||
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
|
||||
}
|
||||
83
cmd/chat-cli/api/types.go
Normal file
83
cmd/chat-cli/api/types.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package api
|
||||
|
||||
import "time"
|
||||
|
||||
// SessionRequest is the body for POST /api/v1/session.
|
||||
type SessionRequest struct {
|
||||
Nick string `json:"nick"`
|
||||
}
|
||||
|
||||
// SessionResponse is the response from POST /api/v1/session.
|
||||
type SessionResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
Nick string `json:"nick"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// StateResponse is the response from GET /api/v1/state.
|
||||
type StateResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
Nick string `json:"nick"`
|
||||
Channels []string `json:"channels"`
|
||||
}
|
||||
|
||||
// Message represents a chat message envelope.
|
||||
type Message struct {
|
||||
Command string `json:"command"`
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
Params []string `json:"params,omitempty"`
|
||||
Body interface{} `json:"body,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
TS string `json:"ts,omitempty"`
|
||||
Meta interface{} `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// BodyLines returns the body as a slice of strings (for text messages).
|
||||
func (m *Message) BodyLines() []string {
|
||||
switch v := m.Body.(type) {
|
||||
case []interface{}:
|
||||
lines := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
lines = append(lines, s)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
case []string:
|
||||
return v
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Channel represents a channel in the list response.
|
||||
type Channel struct {
|
||||
Name string `json:"name"`
|
||||
Topic string `json:"topic"`
|
||||
Members int `json:"members"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// ServerInfo is the response from GET /api/v1/server.
|
||||
type ServerInfo struct {
|
||||
Name string `json:"name"`
|
||||
MOTD string `json:"motd"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// MessagesResponse wraps polling results.
|
||||
type MessagesResponse struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
|
||||
// ParseTS parses the message timestamp.
|
||||
func (m *Message) ParseTS() time.Time {
|
||||
t, err := time.Parse(time.RFC3339Nano, m.TS)
|
||||
if err != nil {
|
||||
return time.Now()
|
||||
}
|
||||
return t
|
||||
}
|
||||
Reference in New Issue
Block a user