feat: scaffold IRC-style CLI client (chat-cli)

Adds cmd/chat-cli/ with:
- tview-based irssi-like TUI (message buffer, status bar, input line)
- IRC-style commands: /connect, /nick, /join, /part, /msg, /query,
  /topic, /names, /list, /window, /quit, /help
- Multi-buffer window model with Alt+N switching and unread indicators
- Background long-poll goroutine for message delivery
- Clean API client wrapper in cmd/chat-cli/api/
- Structured types matching the JSON message protocol
This commit is contained in:
clawbot
2026-02-10 11:51:01 -08:00
parent 4b074aafd7
commit f7776f8d3f
6 changed files with 1157 additions and 2 deletions

206
cmd/chat-cli/api/client.go Normal file
View File

@@ -0,0 +1,206 @@
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.
func (c *Client) JoinChannel(channel string) error {
_, err := c.do("POST", "/api/v1/channels/join", map[string]string{"channel": channel})
return err
}
// PartChannel leaves a channel.
func (c *Client) PartChannel(channel string) error {
_, err := c.do("DELETE", "/api/v1/channels/"+url.PathEscape(channel), nil)
return err
}
// ListChannels returns all channels on the server.
func (c *Client) ListChannels() ([]Channel, error) {
data, err := c.do("GET", "/api/v1/channels/all", 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
View 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
}