F-3 refinements:
- internal/inbound: replace fixed 3s reconnect wait with exponential
backoff (1s → 60s, ×2, reset when prior session lasted >30s); proxy
for "healthy" vs "flapping" and avoids hot reconnect loops when the
server is sick
F-4 agent tool surface (port of openclaw plugin's tools.ts):
- internal/tools/tools.go (~370 LOC): Registry binds Deps {Client,
Tokens, Identities} and exposes 8 agent-facing tools:
fabric-send-message post a normal message to any channel
fabric-send-sys-msg post a kind=sys message (bypasses turn engine)
fabric-channel-list list channels visible in a guild
fabric-guild-list list guilds the agent is in
fabric-message-history paginate channel messages by seq
fabric-channel-set-purpose PATCH the channel's purpose
fabric-channel fetch metadata + members for one channel
fabric-canvas get/share/update/remove channel canvas
- internal/tools/contracts.go: static ToolContract list — kept in sync
with install.sh's manifest emitter
- Every agent-scoped tool requires agent_id in input args (Plexum SDK
doesn't propagate calling agent id through CallTool today)
- guild_node_id defaults to agent's first guild for fabric-send-message
internal/fabric/client.go: new REST methods needed by tools —
PostSystemMessage, CreateChannel, CloseChannel, JoinChannel,
LeaveChannel, SetChannelPurpose, GetCanvas, ShareCanvas, UpdateCanvas,
RemoveCanvas, SyncCommands.
cmd/plexum-fabric-channel-plugin/main.go:
- Manifest declares the tool surface via tools.New(...).Contracts()
- CallTool dispatches "send" to handleSend (outbound for channel
manager), everything else to tools.Registry.Handler(name)
scripts/install.sh:
- Manifest tools[] now lists all 9 tools with schemas — matches what
internal/tools/contracts.go advertises
Live verified against running Fabric stack:
$ plexum plugin-call fabric-guild-list '{"agent_id":"fabrictester"}'
→ "guilds for agent fabrictester (1): test-guild2 @ http://localhost:7003"
$ plexum plugin-call fabric-channel-list '{...,"guild_node_id":"test-guild2"}'
→ 2 channels listed
$ plexum plugin-call fabric-message-history '{...,"limit":5}'
→ 5 messages with timestamps + authors
F-5+ deferred:
- create-{chat,work,report,discussion}-channel (batch 2)
- sub-discussion family (state store + 3 tools)
- presence-sync + command-sync
- attachments
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
439 lines
14 KiB
Go
439 lines
14 KiB
Go
// Package fabric is a thin Go port of Fabric.OpenclawPlugin's
|
|
// fabric-client.ts — Center auth + Guild REST. v0.1 covers what
|
|
// F-1 needs (auth/login, refresh, me/guilds, postMessage, listChannels,
|
|
// listMessages, channelMembers); the canvas / commands / sub-discussion
|
|
// surfaces arrive in later phases as their tools land.
|
|
package fabric
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
)
|
|
|
|
// Session is what /auth/agent/login returns.
|
|
type Session struct {
|
|
AccessToken string `json:"accessToken"`
|
|
RefreshToken string `json:"refreshToken"`
|
|
User SessionUser `json:"user"`
|
|
Guilds []GuildInfo `json:"guilds"`
|
|
GuildAccessTokens []GuildAccessToken `json:"guildAccessTokens"`
|
|
}
|
|
|
|
// SessionUser is the user metadata baked into the session.
|
|
type SessionUser struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// GuildInfo describes one guild this user belongs to.
|
|
type GuildInfo struct {
|
|
NodeID string `json:"nodeId"`
|
|
Name string `json:"name"`
|
|
Endpoint string `json:"endpoint"`
|
|
Status string `json:"status"`
|
|
Purpose *string `json:"purpose,omitempty"`
|
|
}
|
|
|
|
// GuildAccessToken pairs a per-guild short-lived JWT with the guild node.
|
|
type GuildAccessToken struct {
|
|
GuildNodeID string `json:"guildNodeId"`
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
// Client is a thin wrapper around net/http.Client.
|
|
type Client struct {
|
|
CenterAPIBase string // e.g. "http://localhost:7001/api"
|
|
HTTP *http.Client
|
|
}
|
|
|
|
// New constructs a Client with a sensible default http client (30s timeout).
|
|
func New(centerAPIBase string) *Client {
|
|
return &Client{
|
|
CenterAPIBase: centerAPIBase,
|
|
HTTP: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
}
|
|
|
|
// ---- low-level helpers ----
|
|
|
|
func (c *Client) do(ctx context.Context, method, url string, auth string, body any, extraHeaders map[string]string) ([]byte, error) {
|
|
var reader io.Reader
|
|
if body != nil {
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fabric: marshal: %w", err)
|
|
}
|
|
reader = bytes.NewReader(raw)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, url, reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("content-type", "application/json")
|
|
}
|
|
if auth != "" {
|
|
req.Header.Set("authorization", "Bearer "+auth)
|
|
}
|
|
for k, v := range extraHeaders {
|
|
req.Header.Set(k, v)
|
|
}
|
|
resp, err := c.HTTP.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fabric: %s %s: %w", method, url, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("fabric: %s %s -> %d: %s", method, url, resp.StatusCode, string(raw))
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func (c *Client) postJSON(ctx context.Context, url string, body any, auth string, out any) error {
|
|
raw, err := c.do(ctx, http.MethodPost, url, auth, body, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if out == nil || len(raw) == 0 {
|
|
return nil
|
|
}
|
|
return json.Unmarshal(raw, out)
|
|
}
|
|
|
|
func (c *Client) getJSON(ctx context.Context, url, auth string, out any) error {
|
|
raw, err := c.do(ctx, http.MethodGet, url, auth, nil, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if out == nil || len(raw) == 0 {
|
|
return nil
|
|
}
|
|
return json.Unmarshal(raw, out)
|
|
}
|
|
|
|
// ---- Center: auth ----
|
|
|
|
// AgentLogin exchanges an API key for a fresh session + guild tokens.
|
|
func (c *Client) AgentLogin(ctx context.Context, apiKey string) (*Session, error) {
|
|
var s Session
|
|
if err := c.postJSON(ctx, c.CenterAPIBase+"/auth/agent/login",
|
|
map[string]string{"apiKey": apiKey}, "", &s); err != nil {
|
|
return nil, err
|
|
}
|
|
return &s, nil
|
|
}
|
|
|
|
// Refresh trades a refresh token for a fresh access token (guild
|
|
// tokens are re-fetched separately via MeGuilds).
|
|
func (c *Client) Refresh(ctx context.Context, refreshToken string) (*RefreshResponse, error) {
|
|
var out RefreshResponse
|
|
if err := c.postJSON(ctx, c.CenterAPIBase+"/auth/refresh",
|
|
map[string]string{"refreshToken": refreshToken}, "", &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// RefreshResponse is the shape returned by /auth/refresh.
|
|
type RefreshResponse struct {
|
|
AccessToken string `json:"accessToken"`
|
|
RefreshToken string `json:"refreshToken"`
|
|
}
|
|
|
|
// MeGuilds returns the calling user's guild list + fresh per-guild tokens.
|
|
func (c *Client) MeGuilds(ctx context.Context, accessToken string) (*MeGuildsResponse, error) {
|
|
var out MeGuildsResponse
|
|
if err := c.getJSON(ctx, c.CenterAPIBase+"/auth/me/guilds", accessToken, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// MeGuildsResponse subset of Session.
|
|
type MeGuildsResponse struct {
|
|
Guilds []GuildInfo `json:"guilds"`
|
|
GuildAccessTokens []GuildAccessToken `json:"guildAccessTokens"`
|
|
}
|
|
|
|
// ---- Guild: messaging ----
|
|
|
|
// PostMessage posts plain content to a channel as authorUserID.
|
|
func (c *Client) PostMessage(ctx context.Context, guildEndpoint, guildToken, channelID, content, authorUserID string) error {
|
|
_, err := c.do(ctx, http.MethodPost,
|
|
guildEndpoint+"/api/channels/"+url.PathEscape(channelID)+"/messages",
|
|
guildToken,
|
|
map[string]string{"content": content, "authorUserId": authorUserID},
|
|
nil)
|
|
return err
|
|
}
|
|
|
|
// PostSystemMessage posts a system-kind message. Guild routes
|
|
// kind=sys differently (not subject to turn engine, doesn't wake
|
|
// agents); useful for narrator-style narration from a plugin tool.
|
|
func (c *Client) PostSystemMessage(ctx context.Context, guildEndpoint, guildToken, channelID, content, authorUserID string) error {
|
|
_, err := c.do(ctx, http.MethodPost,
|
|
guildEndpoint+"/api/channels/"+url.PathEscape(channelID)+"/messages",
|
|
guildToken,
|
|
map[string]any{"content": content, "authorUserId": authorUserID, "kind": "sys"},
|
|
nil)
|
|
return err
|
|
}
|
|
|
|
// CreateChannelOpts is the payload to POST /api/channels.
|
|
type CreateChannelOpts struct {
|
|
GuildID string `json:"guildId"`
|
|
Name string `json:"name"`
|
|
XType string `json:"xType"`
|
|
IsPublic bool `json:"isPublic,omitempty"`
|
|
MemberUserIDs []string `json:"memberUserIds,omitempty"`
|
|
OnDuty string `json:"onDuty,omitempty"`
|
|
Listeners []string `json:"listeners,omitempty"`
|
|
Purpose string `json:"purpose,omitempty"`
|
|
}
|
|
|
|
// CreateChannel creates a new channel in a guild. Returns the new channel id.
|
|
func (c *Client) CreateChannel(ctx context.Context, guildEndpoint, guildToken string, opts CreateChannelOpts) (string, error) {
|
|
var out struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := c.postJSON(ctx, guildEndpoint+"/api/channels", opts, guildToken, &out); err != nil {
|
|
return "", err
|
|
}
|
|
return out.ID, nil
|
|
}
|
|
|
|
// CloseChannel closes a channel (one-way for most x_types).
|
|
func (c *Client) CloseChannel(ctx context.Context, guildEndpoint, guildToken, channelID string) error {
|
|
_, err := c.do(ctx, http.MethodPost,
|
|
guildEndpoint+"/api/channels/"+url.PathEscape(channelID)+"/close",
|
|
guildToken, map[string]any{}, nil)
|
|
return err
|
|
}
|
|
|
|
// JoinChannel adds the calling user to a public channel.
|
|
func (c *Client) JoinChannel(ctx context.Context, guildEndpoint, guildToken, channelID string) error {
|
|
_, err := c.do(ctx, http.MethodPost,
|
|
guildEndpoint+"/api/channels/"+url.PathEscape(channelID)+"/join",
|
|
guildToken, map[string]any{}, nil)
|
|
return err
|
|
}
|
|
|
|
// LeaveChannel removes the calling user from a channel.
|
|
func (c *Client) LeaveChannel(ctx context.Context, guildEndpoint, guildToken, channelID string) error {
|
|
_, err := c.do(ctx, http.MethodPost,
|
|
guildEndpoint+"/api/channels/"+url.PathEscape(channelID)+"/leave",
|
|
guildToken, map[string]any{}, nil)
|
|
return err
|
|
}
|
|
|
|
// SetChannelPurpose PATCHes the channel's purpose (free-form text).
|
|
func (c *Client) SetChannelPurpose(ctx context.Context, guildEndpoint, guildToken, channelID, purpose string) error {
|
|
_, err := c.do(ctx, http.MethodPatch,
|
|
guildEndpoint+"/api/channels/"+url.PathEscape(channelID),
|
|
guildToken, map[string]string{"purpose": purpose}, nil)
|
|
return err
|
|
}
|
|
|
|
// ---- channel canvas ----
|
|
|
|
// CanvasFormat is "md" | "html" | "text".
|
|
type CanvasFormat string
|
|
|
|
// Canvas is the shape returned by GET /api/channels/<id>/canvas.
|
|
type Canvas struct {
|
|
ChannelID string `json:"channelId"`
|
|
SharerUserID string `json:"sharerUserId"`
|
|
Title string `json:"title"`
|
|
Format CanvasFormat `json:"format"`
|
|
Source string `json:"source"`
|
|
UpdatedAt string `json:"updatedAt,omitempty"`
|
|
}
|
|
|
|
// GetCanvas returns the canvas or nil if no canvas is set.
|
|
func (c *Client) GetCanvas(ctx context.Context, guildEndpoint, guildToken, channelID string) (*Canvas, error) {
|
|
raw, err := c.do(ctx, http.MethodGet,
|
|
guildEndpoint+"/api/channels/"+url.PathEscape(channelID)+"/canvas",
|
|
guildToken, nil, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(raw) == 0 {
|
|
return nil, nil
|
|
}
|
|
var out Canvas
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// ShareCanvas replaces (or initializes) the channel canvas. Caller
|
|
// becomes the sharer.
|
|
func (c *Client) ShareCanvas(ctx context.Context, guildEndpoint, guildToken, channelID, title string, format CanvasFormat, source string) (*Canvas, error) {
|
|
body := map[string]any{"title": title, "format": format, "source": source}
|
|
raw, err := c.do(ctx, http.MethodPut,
|
|
guildEndpoint+"/api/channels/"+url.PathEscape(channelID)+"/canvas",
|
|
guildToken, body, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out Canvas
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// UpdateCanvas updates fields in place (original sharer only; else 403).
|
|
func (c *Client) UpdateCanvas(ctx context.Context, guildEndpoint, guildToken, channelID string, title, source string, format CanvasFormat) (*Canvas, error) {
|
|
body := map[string]any{}
|
|
if title != "" {
|
|
body["title"] = title
|
|
}
|
|
if source != "" {
|
|
body["source"] = source
|
|
}
|
|
if format != "" {
|
|
body["format"] = format
|
|
}
|
|
raw, err := c.do(ctx, http.MethodPatch,
|
|
guildEndpoint+"/api/channels/"+url.PathEscape(channelID)+"/canvas",
|
|
guildToken, body, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out Canvas
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// RemoveCanvas closes the canvas.
|
|
func (c *Client) RemoveCanvas(ctx context.Context, guildEndpoint, guildToken, channelID string) error {
|
|
_, err := c.do(ctx, http.MethodDelete,
|
|
guildEndpoint+"/api/channels/"+url.PathEscape(channelID)+"/canvas",
|
|
guildToken, nil, nil)
|
|
return err
|
|
}
|
|
|
|
// SyncCommands PUTs the agent's slash-command catalog onto the guild
|
|
// (idempotent full replace). Needs the guild's commands-sync key, which
|
|
// the operator sources from the guild config.
|
|
func (c *Client) SyncCommands(ctx context.Context, guildEndpoint, guildToken string, commands []any, syncKey string) error {
|
|
headers := map[string]string{}
|
|
if syncKey != "" {
|
|
headers["x-commands-sync-key"] = syncKey
|
|
}
|
|
_, err := c.do(ctx, http.MethodPut,
|
|
guildEndpoint+"/api/commands", guildToken,
|
|
map[string]any{"commands": commands}, headers)
|
|
return err
|
|
}
|
|
|
|
// ChannelMembers lists members of a channel.
|
|
type ChannelMember struct {
|
|
UserID string `json:"userId"`
|
|
Bypass bool `json:"bypass,omitempty"`
|
|
}
|
|
|
|
func (c *Client) ChannelMembers(ctx context.Context, guildEndpoint, guildToken, channelID string) ([]ChannelMember, error) {
|
|
var out []ChannelMember
|
|
if err := c.getJSON(ctx,
|
|
guildEndpoint+"/api/channels/"+url.PathEscape(channelID)+"/members",
|
|
guildToken, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ---- Guild: channel discovery + history ----
|
|
|
|
// Channel is the wire shape returned by /api/channels list/get.
|
|
type Channel struct {
|
|
ID string `json:"id"`
|
|
GuildID string `json:"guildId"`
|
|
Name string `json:"name"`
|
|
XType string `json:"xType"`
|
|
Kind string `json:"kind"`
|
|
IsPublic bool `json:"isPublic"`
|
|
Closed bool `json:"closed"`
|
|
LastSeq int `json:"lastSeq"`
|
|
CreatedAt string `json:"createdAt"`
|
|
Purpose *string `json:"purpose,omitempty"`
|
|
}
|
|
|
|
// ListChannels lists all channels in a guild visible to the calling user.
|
|
func (c *Client) ListChannels(ctx context.Context, guildEndpoint, guildToken, guildNodeID string) ([]Channel, error) {
|
|
var out []Channel
|
|
u := guildEndpoint + "/api/channels?guildId=" + url.QueryEscape(guildNodeID)
|
|
if err := c.getJSON(ctx, u, guildToken, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Message is the wire shape of one message in history.
|
|
type Message struct {
|
|
MessageID string `json:"messageId"`
|
|
Seq int `json:"seq"`
|
|
Content string `json:"content"`
|
|
AuthorUserID string `json:"authorUserId"`
|
|
CreatedAt string `json:"createdAt"`
|
|
EditedAt *string `json:"editedAt"`
|
|
DeletedAt *string `json:"deletedAt"`
|
|
IsDeleted bool `json:"isDeleted"`
|
|
}
|
|
|
|
// MessagePage wraps a window of messages + pagination metadata.
|
|
type MessagePage struct {
|
|
Items []Message `json:"items"`
|
|
Page struct {
|
|
SeqFrom int `json:"seqFrom"`
|
|
SeqTo int `json:"seqTo"`
|
|
Limit int `json:"limit"`
|
|
Returned int `json:"returned"`
|
|
HasMore bool `json:"hasMore"`
|
|
NextExpectedSeq int `json:"nextExpectedSeq"`
|
|
HighestCommittedSeq int `json:"highestCommittedSeq"`
|
|
} `json:"page"`
|
|
}
|
|
|
|
// ListMessages fetches a window of messages by seq.
|
|
func (c *Client) ListMessages(ctx context.Context, guildEndpoint, guildToken, channelID string, opts ListMessagesOpts) (*MessagePage, error) {
|
|
qs := url.Values{}
|
|
if opts.SeqFrom > 0 {
|
|
qs.Set("seq_from", fmt.Sprint(opts.SeqFrom))
|
|
}
|
|
if opts.SeqTo > 0 {
|
|
qs.Set("seq_to", fmt.Sprint(opts.SeqTo))
|
|
}
|
|
if opts.Limit > 0 {
|
|
qs.Set("limit", fmt.Sprint(opts.Limit))
|
|
}
|
|
u := guildEndpoint + "/api/channels/" + url.PathEscape(channelID) + "/messages"
|
|
if encoded := qs.Encode(); encoded != "" {
|
|
u += "?" + encoded
|
|
}
|
|
var out MessagePage
|
|
if err := c.getJSON(ctx, u, guildToken, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// ListMessagesOpts is the optional paging window.
|
|
type ListMessagesOpts struct {
|
|
SeqFrom int
|
|
SeqTo int
|
|
Limit int
|
|
}
|