feat: Phase F-1 — Plexum-fabric-channel-plugin foundation
Ports the foundation of Fabric.OpenclawPlugin to a native Plexum channel plugin (Go). F-2+ phases (socket.io inbound, wakeup gate, tools, presence, etc.) follow. Layout: internal/identity/ — fabric-identity.json registry (agent → API key) internal/fabric/ — REST client (Center auth + Guild messaging) internal/config/ — channels/<name>.json fabric extension parser cmd/plexum-fabric-register/ — agent registration CLI cmd/plexum-fabric-channel-plugin/— Plexum SDK plugin entry scripts/install.sh — build + install + manifest generator Plugin behavior (F-1): - Reads <profile>/channels/*.json, filters plugin=plexum-fabric-channel, builds (plexum-channel-name → fabric channel-id) index - Validates each bound agent's API key against Center at init (warmSessions); logs warning but doesn't refuse init on bad keys - `send` MCP tool: POST plain text to the bound Fabric channel as the agent user; selects guild endpoint+token from cached session - Manifest channels[] is generated by install.sh from current channels/*.json — re-run with --reset-manifest after adding bindings - Plugin-private config at <profile>/plugins/plexum-fabric-channel/config.json (center_api_base, default http://localhost:7001/api) Live smoke verified: - plexum-fabric-register against running Fabric Center (port 7001): validated fak_..., wrote identity file with user_id + email captured Tests: identity (5) + config (6) = 11 unit tests. F-2 will hook socket.io for inbound + wakeup gating + token refresh. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
274
internal/fabric/client.go
Normal file
274
internal/fabric/client.go
Normal file
@@ -0,0 +1,274 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user