Files
hzhang f8d43ae70e 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>
2026-05-31 15:13:34 +01:00

112 lines
3.6 KiB
Go

// Package config parses the per-channel binding files at
// <profile>/channels/<name>.json. Plexum's channel.Registry already
// parses the {agent_id, plugin} core; this package additionally pulls
// out the plugin-specific `fabric` extension block describing which
// Fabric (guild, channelId) the Plexum channel name maps to.
package config
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
)
// PluginName is what manifest.json declares as the plugin name; we
// only consume channels/*.json entries with this value in `plugin`.
const PluginName = "plexum-fabric-channel"
// FabricBinding is one Plexum-channel ↔ Fabric-channel mapping. Built
// by Load from channels/<name>.json files.
type FabricBinding struct {
// PlexumChannelName: the basename of channels/<name>.json (no .json).
// Also matches the ChannelContract.Name the plugin manifest advertises.
PlexumChannelName string
// AgentID this channel routes to (also recorded in Plexum's channel
// registry; we re-read here for plugin-internal convenience).
AgentID string
// FabricGuildNodeID — which Fabric guild owns the channel.
FabricGuildNodeID string
// FabricChannelID — the channel id within that guild.
FabricChannelID string
}
// On-disk shape; we ignore fields outside the `fabric` block.
type wireConfig struct {
AgentID string `json:"agent_id"`
Plugin string `json:"plugin"`
Fabric struct {
GuildNodeID string `json:"guild_node_id"`
ChannelID string `json:"channel_id"`
} `json:"fabric"`
}
// Load returns all FabricBindings discovered under channelsDir. Files
// whose `plugin` field doesn't match PluginName are silently skipped
// (other channel plugins coexist in the same dir). Files with our
// plugin name but missing fabric.{guild_node_id, channel_id} are
// errors — we won't silently route nowhere.
func Load(channelsDir string) ([]FabricBinding, error) {
entries, err := os.ReadDir(channelsDir)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("fabric/config: read %s: %w", channelsDir, err)
}
var out []FabricBinding
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
path := filepath.Join(channelsDir, e.Name())
raw, rerr := os.ReadFile(path)
if rerr != nil {
return nil, fmt.Errorf("fabric/config: read %s: %w", path, rerr)
}
var w wireConfig
if jerr := json.Unmarshal(raw, &w); jerr != nil {
return nil, fmt.Errorf("fabric/config: parse %s: %w", path, jerr)
}
if w.Plugin != PluginName {
continue
}
name := strings.TrimSuffix(e.Name(), ".json")
if w.AgentID == "" {
return nil, fmt.Errorf("fabric/config: %s missing agent_id", path)
}
if w.Fabric.GuildNodeID == "" || w.Fabric.ChannelID == "" {
return nil, fmt.Errorf("fabric/config: %s missing fabric.{guild_node_id, channel_id}", path)
}
out = append(out, FabricBinding{
PlexumChannelName: name,
AgentID: w.AgentID,
FabricGuildNodeID: w.Fabric.GuildNodeID,
FabricChannelID: w.Fabric.ChannelID,
})
}
return out, nil
}
// ByFabricChannel indexes bindings by (guild_node_id, channel_id) for
// fast inbound lookup. Plugin builds this map once at startup.
type ByFabricChannel map[string]*FabricBinding
// Key composes the index key.
func Key(guildNodeID, channelID string) string {
return guildNodeID + "/" + channelID
}
// Index returns a ready-to-query ByFabricChannel.
func Index(bindings []FabricBinding) ByFabricChannel {
out := make(ByFabricChannel, len(bindings))
for i := range bindings {
b := bindings[i]
out[Key(b.FabricGuildNodeID, b.FabricChannelID)] = &b
}
return out
}